From 01b930e1c57274192844c7459e56e8013a9f2796 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 11 Mar 2026 13:19:50 +0000 Subject: [PATCH 1/7] Rename epoch/probe/element functions to match MATLAB source of truth MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MATLAB function names are the canonical reference. Renames: - save_to_file → savetofile (epochprobemap_daqsystem) - load_from_file → loadfromfile (epochprobemap_daqsystem) - clear_cache → resetepochtable (epochset) - find_epoch_node → findepochnode (epoch/functions) - init_probe_type_map → initProbeTypeMap (probe) - get_probe_type_map → getProbeTypeMap (probe) - spikes_for_probe → spikesForProbe (element/functions) Also adds probestring() method to Probe class (exists in MATLAB probe.m but was missing from Python port), and updates PYTHON_PORTING_GUIDE.md with the "MATLAB function names are the source of truth" instruction. https://claude.ai/code/session_016oPH9EoxCLcsUSTpgN9iZp --- MATLAB_MAPPING.md | 4 +-- PYTHON_PORTING_GUIDE.md | 5 +-- src/ndi/element/__init__.py | 2 +- src/ndi/element/functions.py | 4 +-- src/ndi/epoch/__init__.py | 4 +-- src/ndi/epoch/epochprobemap_daqsystem.py | 4 +-- src/ndi/epoch/epochset.py | 7 ++-- src/ndi/epoch/functions.py | 6 ++-- src/ndi/probe/__init__.py | 17 +++++++-- tests/matlab_tests/MATLAB_TEST_MAPPING.md | 2 +- tests/matlab_tests/test_element.py | 14 ++++---- tests/matlab_tests/test_probe.py | 24 ++++++------- tests/test_batch_a.py | 18 +++++----- tests/test_batch_e.py | 42 +++++++++++------------ tests/test_element.py | 6 ++-- tests/test_phase2_gaps.py | 20 +++++------ 16 files changed, 97 insertions(+), 82 deletions(-) diff --git a/MATLAB_MAPPING.md b/MATLAB_MAPPING.md index 2e7a643..75c50d5 100644 --- a/MATLAB_MAPPING.md +++ b/MATLAB_MAPPING.md @@ -56,8 +56,8 @@ Complete reference mapping every MATLAB NDI function/class to its Python equival | `ndi.probe.timeseries` | `ndi.probe.ProbeTimeseries` | `ndi.probe.timeseries` | | `ndi.probe.timeseries.mfdaq` | `ndi.probe.ProbeTimeseriesMFDAQ` | `ndi.probe.timeseries_mfdaq` | | `ndi.probe.timeseries.stimulator` | `ndi.probe.ProbeTimeseriesStimulator` | `ndi.probe.timeseries_stimulator` | -| `ndi.probe.fun.initProbeTypeMap` | `ndi.probe.init_probe_type_map()` | `ndi.probe` | -| `ndi.probe.fun.getProbeTypeMap` | `ndi.probe.get_probe_type_map()` | `ndi.probe` | +| `ndi.probe.fun.initProbeTypeMap` | `ndi.probe.initProbeTypeMap()` | `ndi.probe` | +| `ndi.probe.fun.getProbeTypeMap` | `ndi.probe.getProbeTypeMap()` | `ndi.probe` | ## Session & Dataset diff --git a/PYTHON_PORTING_GUIDE.md b/PYTHON_PORTING_GUIDE.md index c49bbb4..1a20235 100644 --- a/PYTHON_PORTING_GUIDE.md +++ b/PYTHON_PORTING_GUIDE.md @@ -6,11 +6,12 @@ The MATLAB codebase is the **Source of Truth**. The Python version is a "faithfu ## 2. Function & Variable Naming (The "Strict Mirror" Rule) -Do **not** attempt to "translate" MATLAB names into Python PEP 8 (`snake_case`). +**MATLAB function names are the source of truth.** Do **not** attempt to "translate" MATLAB names into Python PEP 8 (`snake_case`). - **Exact Match:** Every function name in Python must be an identical string match to the MATLAB function name. -- **Case Sensitivity:** If the MATLAB function is `ListAllDocuments`, the Python function must be `ListAllDocuments`. If the MATLAB function is `get_dataset_id`, the Python function must be `get_dataset_id`. +- **Case Sensitivity:** If the MATLAB function is `ListAllDocuments`, the Python function must be `ListAllDocuments`. If the MATLAB function is `get_dataset_id`, the Python function must be `get_dataset_id`. If the MATLAB function is `savetofile`, the Python method must be `savetofile` (not `save_to_file`). - **No Aliasing:** Do not create `snake_case` aliases unless explicitly requested. The user should be able to copy-paste function names between environments. +- **Verification:** When porting or reviewing code, always check the MATLAB source at [VH-Lab/NDI-matlab](https://github.com/VH-Lab/NDI-matlab) to confirm the exact function name. The MATLAB `.m` file's `function` line is the canonical reference. ## 3. Namespace and Directory Structure diff --git a/src/ndi/element/__init__.py b/src/ndi/element/__init__.py index 2eecb2c..246e294 100644 --- a/src/ndi/element/__init__.py +++ b/src/ndi/element/__init__.py @@ -356,7 +356,7 @@ def addepoch( self._session.database_add(doc) # Clear cache - self.clear_cache() + self.resetepochtable() return self, doc diff --git a/src/ndi/element/functions.py b/src/ndi/element/functions.py index fe78ba4..a37d312 100644 --- a/src/ndi/element/functions.py +++ b/src/ndi/element/functions.py @@ -102,7 +102,7 @@ def oneepoch( return elem_out -def spikes_for_probe( +def spikesForProbe( session: Any, probe: Any, name: str, @@ -129,7 +129,7 @@ def spikes_for_probe( ... {'epochid': 'epoch_001', 'spiketimes': [0.1, 0.5, 1.2]}, ... {'epochid': 'epoch_002', 'spiketimes': [0.3, 0.8]}, ... ] - >>> neuron = spikes_for_probe(session, probe, 'unit1', 1, spikes) + >>> neuron = spikesForProbe(session, probe, 'unit1', 1, spikes) Note: This creates the structural framework for a neuron element. diff --git a/src/ndi/epoch/__init__.py b/src/ndi/epoch/__init__.py index 21ea84a..f75e0aa 100644 --- a/src/ndi/epoch/__init__.py +++ b/src/ndi/epoch/__init__.py @@ -14,7 +14,7 @@ from .epochprobemap import EpochProbeMap from .epochprobemap_daqsystem import EpochProbeMapDAQSystem from .epochset import EpochSet -from .functions import epochrange, find_epoch_node +from .functions import epochrange, findepochnode __all__ = [ "Epoch", @@ -22,5 +22,5 @@ "EpochProbeMap", "EpochProbeMapDAQSystem", "epochrange", - "find_epoch_node", + "findepochnode", ] diff --git a/src/ndi/epoch/epochprobemap_daqsystem.py b/src/ndi/epoch/epochprobemap_daqsystem.py index 8a2e7ab..25a4fc3 100644 --- a/src/ndi/epoch/epochprobemap_daqsystem.py +++ b/src/ndi/epoch/epochprobemap_daqsystem.py @@ -84,7 +84,7 @@ def serialize(self) -> str: ] ) - def save_to_file(self, filename: str) -> None: + def savetofile(self, filename: str) -> None: """ Write this epoch probe map to a file. @@ -122,7 +122,7 @@ def decode(cls, s: str) -> EpochProbeMapDAQSystem: ) @classmethod - def load_from_file(cls, filename: str) -> list[EpochProbeMapDAQSystem]: + def loadfromfile(cls, filename: str) -> list[EpochProbeMapDAQSystem]: """ Load epoch probe maps from a file. diff --git a/src/ndi/epoch/epochset.py b/src/ndi/epoch/epochset.py index 80eb5b0..4dc9b62 100644 --- a/src/ndi/epoch/epochset.py +++ b/src/ndi/epoch/epochset.py @@ -312,7 +312,10 @@ def epochgraph(self) -> list[dict[str, Any]]: return nodes - def clear_cache(self) -> None: - """Clear the epoch table cache.""" + def resetepochtable(self) -> None: + """Reset (clear) the epoch table cache. + + MATLAB equivalent: ndi.epoch.epochset.resetepochtable + """ self._epochtable_cache = None self._epochtable_hash = None diff --git a/src/ndi/epoch/functions.py b/src/ndi/epoch/functions.py index 02835b9..0df33d4 100644 --- a/src/ndi/epoch/functions.py +++ b/src/ndi/epoch/functions.py @@ -118,7 +118,7 @@ def _resolve_epoch_index( raise ValueError(f"Epoch ID '{epoch}' not found") -def find_epoch_node( +def findepochnode( epoch_node: dict[str, Any], epoch_node_array: list[dict[str, Any]], ) -> list[int]: @@ -153,9 +153,9 @@ def find_epoch_node( ... {'epoch_id': 'e1', 'objectname': 'probe1'}, ... {'epoch_id': 'e2', 'objectname': 'probe2'}, ... ] - >>> find_epoch_node({'epoch_id': 'e1'}, nodes) + >>> findepochnode({'epoch_id': 'e1'}, nodes) [0] - >>> find_epoch_node({}, nodes) # empty = match all + >>> findepochnode({}, nodes) # empty = match all [0, 1] """ STRING_FIELDS = ( diff --git a/src/ndi/probe/__init__.py b/src/ndi/probe/__init__.py index 786f47b..43e2003 100644 --- a/src/ndi/probe/__init__.py +++ b/src/ndi/probe/__init__.py @@ -234,6 +234,17 @@ def issyncgraphroot(self) -> bool: # Probe-specific Methods # ========================================================================= + def probestring(self) -> str: + """ + Format probe as human-readable string. + + MATLAB equivalent: ndi.probe.probestring + + Returns: + String in format "name | reference | type" + """ + return f"{self._name} | {self._reference} | {self._type}" + def epochprobemapmatch( self, epochprobemap: Any, @@ -428,7 +439,7 @@ def __repr__(self) -> str: _PROBE_TYPE_MAP: dict[str, str] | None = None -def init_probe_type_map() -> dict[str, str]: +def initProbeTypeMap() -> dict[str, str]: """Load the probe type→class mapping from ``probetype2object.json``. MATLAB equivalent: ndi.probe.fun.initProbeTypeMap @@ -462,7 +473,7 @@ def init_probe_type_map() -> dict[str, str]: return result -def get_probe_type_map() -> dict[str, str]: +def getProbeTypeMap() -> dict[str, str]: """Return the cached probe type→class mapping. MATLAB equivalent: ndi.probe.fun.getProbeTypeMap @@ -474,5 +485,5 @@ def get_probe_type_map() -> dict[str, str]: """ global _PROBE_TYPE_MAP if _PROBE_TYPE_MAP is None: - _PROBE_TYPE_MAP = init_probe_type_map() + _PROBE_TYPE_MAP = initProbeTypeMap() return _PROBE_TYPE_MAP diff --git a/tests/matlab_tests/MATLAB_TEST_MAPPING.md b/tests/matlab_tests/MATLAB_TEST_MAPPING.md index 88e23fa..856e58c 100644 --- a/tests/matlab_tests/MATLAB_TEST_MAPPING.md +++ b/tests/matlab_tests/MATLAB_TEST_MAPPING.md @@ -170,7 +170,7 @@ All cloud tests are **dual-mode**: mocked by default, live when `NDI_CLOUD_USERN | MATLAB File | Python Class | Key Tests | |------------|-------------|-----------| -| `+probe/ProbeMapTest.m` | `TestProbeMap` | `test_init_probe_type_map`, `test_map_contains_expected_types`, `test_map_ntrode_class` | +| `+probe/ProbeMapTest.m` | `TestProbeMap` | `test_initProbeTypeMap`, `test_map_contains_expected_types`, `test_map_ntrode_class` | | `+probe/ProbeTest.m` | `TestProbe` | `test_probe_instantiation`, `test_probe_with_session`, `test_probe_issyncgraphroot`, `test_probe_epochsetname` | --- diff --git a/tests/matlab_tests/test_element.py b/tests/matlab_tests/test_element.py index e832261..2962ae9 100644 --- a/tests/matlab_tests/test_element.py +++ b/tests/matlab_tests/test_element.py @@ -327,12 +327,12 @@ def test_missing_epochs_both_empty(self): class TestSpikesForProbe: - """Tests for ndi.element.functions.spikes_for_probe.""" + """Tests for ndi.element.functions.spikesForProbe.""" def test_creates_spike_element(self): - """spikes_for_probe creates an element of type 'spikes'.""" + """spikesForProbe creates an element of type 'spikes'.""" from ndi.element import Element - from ndi.element.functions import spikes_for_probe + from ndi.element.functions import spikesForProbe session = MagicMock() probe = MagicMock() @@ -344,13 +344,13 @@ def test_creates_spike_element(self): {"epochid": "epoch_001", "spiketimes": [0.1, 0.5]}, ] - result = spikes_for_probe(session, probe, "unit1", 1, spikedata) + result = spikesForProbe(session, probe, "unit1", 1, spikedata) assert isinstance(result, Element) assert result._type == "spikes" def test_invalid_epoch_raises(self): - """spikes_for_probe raises ValueError for unknown epoch.""" - from ndi.element.functions import spikes_for_probe + """spikesForProbe raises ValueError for unknown epoch.""" + from ndi.element.functions import spikesForProbe session = MagicMock() probe = MagicMock() @@ -363,7 +363,7 @@ def test_invalid_epoch_raises(self): ] with pytest.raises(ValueError, match="not found"): - spikes_for_probe(session, probe, "unit1", 1, spikedata) + spikesForProbe(session, probe, "unit1", 1, spikedata) # =========================================================================== diff --git a/tests/matlab_tests/test_probe.py b/tests/matlab_tests/test_probe.py index 16bf5f1..06f8201 100644 --- a/tests/matlab_tests/test_probe.py +++ b/tests/matlab_tests/test_probe.py @@ -6,11 +6,11 @@ +probe/ProbeTest.m -> TestProbe Tests for: -- ndi.probe.init_probe_type_map() / get_probe_type_map() +- ndi.probe.initProbeTypeMap() / getProbeTypeMap() - ndi.probe.Probe instantiation and basic interface """ -from ndi.probe import Probe, get_probe_type_map, init_probe_type_map +from ndi.probe import Probe, getProbeTypeMap, initProbeTypeMap # =========================================================================== # TestProbeMap @@ -25,22 +25,22 @@ class TestProbeMap: from probetype2object.json. """ - def test_init_probe_type_map(self): - """init_probe_type_map() returns a non-empty dict. + def test_initProbeTypeMap(self): + """initProbeTypeMap() returns a non-empty dict. MATLAB equivalent: ProbeMapTest.testInitProbeTypeMap """ - probe_map = init_probe_type_map() + probe_map = initProbeTypeMap() assert isinstance(probe_map, dict) assert len(probe_map) > 0, "Probe type map should be non-empty" - def test_get_probe_type_map(self): - """get_probe_type_map() returns the cached version, same result. + def test_getProbeTypeMap(self): + """getProbeTypeMap() returns the cached version, same result. MATLAB equivalent: ProbeMapTest.testGetProbeTypeMap """ - map_init = init_probe_type_map() - map_cached = get_probe_type_map() + map_init = initProbeTypeMap() + map_cached = getProbeTypeMap() assert isinstance(map_cached, dict) assert map_cached == map_init, "Cached map should equal freshly loaded map" @@ -50,7 +50,7 @@ def test_map_contains_expected_types(self): MATLAB equivalent: ProbeMapTest.testMapContents """ - probe_map = get_probe_type_map() + probe_map = getProbeTypeMap() # These types are defined in ndi_common/probe/probetype2object.json expected_types = ["n-trode", "patch", "sharp", "stimulator"] @@ -63,7 +63,7 @@ def test_map_values_are_strings(self): MATLAB equivalent: ProbeMapTest (implicit) """ - probe_map = get_probe_type_map() + probe_map = getProbeTypeMap() for key, value in probe_map.items(): assert isinstance(key, str), f"Key should be string, got {type(key)}" assert isinstance(value, str), f"Value should be string, got {type(value)}" @@ -74,7 +74,7 @@ def test_map_ntrode_class(self): MATLAB equivalent: ProbeMapTest (spot check) """ - probe_map = get_probe_type_map() + probe_map = getProbeTypeMap() assert probe_map["n-trode"] == "ndi.probe.timeseries.mfdaq" diff --git a/tests/test_batch_a.py b/tests/test_batch_a.py index a970dee..9f8b817 100644 --- a/tests/test_batch_a.py +++ b/tests/test_batch_a.py @@ -212,8 +212,8 @@ def test_file_io(self, tmp_path): subjectstring="mouse001", ) filepath = str(tmp_path / "test_epm.txt") - epm.save_to_file(filepath) - loaded = EpochProbeMapDAQSystem.load_from_file(filepath) + epm.savetofile(filepath) + loaded = EpochProbeMapDAQSystem.loadfromfile(filepath) assert len(loaded) == 1 assert loaded[0].name == "probe1" @@ -712,11 +712,11 @@ class TestElementFunctions: """Tests for ndi.element.functions.""" def test_import(self): - from ndi.element.functions import missingepochs, oneepoch, spikes_for_probe + from ndi.element.functions import missingepochs, oneepoch, spikesForProbe assert missingepochs is not None assert oneepoch is not None - assert spikes_for_probe is not None + assert spikesForProbe is not None def test_missingepochs_none_missing(self): from ndi.element.functions import missingepochs @@ -779,8 +779,8 @@ def test_missingepochs_with_dicts(self): assert missing assert ids == ["b"] - def test_spikes_for_probe_validates_epochs(self, tmp_path): - from ndi.element.functions import spikes_for_probe + def test_spikesForProbe_validates_epochs(self, tmp_path): + from ndi.element.functions import spikesForProbe from ndi.session import DirSession session_path = tmp_path / "test_session" @@ -796,7 +796,7 @@ def epochtable(self): ] with pytest.raises(ValueError, match="not found"): - spikes_for_probe(session, MockProbe(), "unit1", 1, spikedata) + spikesForProbe(session, MockProbe(), "unit1", 1, spikedata) # ============================================================================ @@ -848,8 +848,8 @@ def test_probe_timeseries_mfdaq(self): assert ProbeTimeseriesMFDAQ is not None def test_element_functions(self): - from ndi.element.functions import missingepochs, oneepoch, spikes_for_probe + from ndi.element.functions import missingepochs, oneepoch, spikesForProbe assert callable(missingepochs) assert callable(oneepoch) - assert callable(spikes_for_probe) + assert callable(spikesForProbe) diff --git a/tests/test_batch_e.py b/tests/test_batch_e.py index b1bbd6a..11bea5c 100644 --- a/tests/test_batch_e.py +++ b/tests/test_batch_e.py @@ -1,7 +1,7 @@ """ Tests for Batch E: Minor remaining MATLAB gaps. -Tests find_epoch_node(), SessionTable, TuningFit. +Tests findepochnode(), SessionTable, TuningFit. """ from pathlib import Path @@ -14,17 +14,17 @@ # --------------------------------------------------------------------------- # Imports # --------------------------------------------------------------------------- -from ndi.epoch.functions import find_epoch_node +from ndi.epoch.functions import findepochnode from ndi.session.sessiontable import SessionTable class TestImports: """Verify all Batch E items are importable.""" - def test_import_find_epoch_node_from_epoch(self): - from ndi.epoch import find_epoch_node as fen + def test_import_findepochnode_from_epoch(self): + from ndi.epoch import findepochnode as fen - assert fen is find_epoch_node + assert fen is findepochnode def test_import_session_table_from_session(self): from ndi.session import SessionTable as ST @@ -38,7 +38,7 @@ def test_import_tuning_fit_from_calc(self): # =========================================================================== -# find_epoch_node +# findepochnode # =========================================================================== @@ -77,58 +77,58 @@ def sample_nodes(self): def test_empty_search_matches_all(self, sample_nodes): """Empty search node = wildcard, matches everything.""" - result = find_epoch_node({}, sample_nodes) + result = findepochnode({}, sample_nodes) assert result == [0, 1, 2] def test_search_by_epoch_id(self, sample_nodes): - result = find_epoch_node({"epoch_id": "e2"}, sample_nodes) + result = findepochnode({"epoch_id": "e2"}, sample_nodes) assert result == [1] def test_search_by_objectname(self, sample_nodes): - result = find_epoch_node({"objectname": "probe1"}, sample_nodes) + result = findepochnode({"objectname": "probe1"}, sample_nodes) assert result == [0, 2] def test_search_by_objectclass(self, sample_nodes): - result = find_epoch_node({"objectclass": "Probe"}, sample_nodes) + result = findepochnode({"objectclass": "Probe"}, sample_nodes) assert result == [0, 1] def test_search_by_session_id(self, sample_nodes): - result = find_epoch_node({"epoch_session_id": "s2"}, sample_nodes) + result = findepochnode({"epoch_session_id": "s2"}, sample_nodes) assert result == [2] def test_search_by_multiple_fields(self, sample_nodes): - result = find_epoch_node( + result = findepochnode( {"objectname": "probe1", "objectclass": "Probe"}, sample_nodes, ) assert result == [0] def test_search_no_match(self, sample_nodes): - result = find_epoch_node({"epoch_id": "e99"}, sample_nodes) + result = findepochnode({"epoch_id": "e99"}, sample_nodes) assert result == [] def test_search_by_epoch_clock(self, sample_nodes): - result = find_epoch_node({"epoch_clock": "utc"}, sample_nodes) + result = findepochnode({"epoch_clock": "utc"}, sample_nodes) assert result == [2] def test_search_by_time_value_in_range(self, sample_nodes): - result = find_epoch_node({"time_value": 5.0}, sample_nodes) + result = findepochnode({"time_value": 5.0}, sample_nodes) # 5.0 is in [0, 10] (node 0) and [0, 100] (node 2) assert result == [0, 2] def test_search_by_time_value_boundary(self, sample_nodes): - result = find_epoch_node({"time_value": 10.0}, sample_nodes) + result = findepochnode({"time_value": 10.0}, sample_nodes) # 10.0 is at boundary of node 0 [0,10] and node 1 [10,20] and node 2 [0,100] assert 0 in result assert 1 in result assert 2 in result def test_search_by_time_value_out_of_range(self, sample_nodes): - result = find_epoch_node({"time_value": 200.0}, sample_nodes) + result = findepochnode({"time_value": 200.0}, sample_nodes) assert result == [] def test_combined_string_and_time(self, sample_nodes): - result = find_epoch_node( + result = findepochnode( {"objectname": "probe1", "time_value": 5.0}, sample_nodes, ) @@ -136,15 +136,15 @@ def test_combined_string_and_time(self, sample_nodes): assert result == [0, 2] def test_empty_node_array(self): - result = find_epoch_node({"epoch_id": "e1"}, []) + result = findepochnode({"epoch_id": "e1"}, []) assert result == [] def test_none_field_treated_as_wildcard(self, sample_nodes): - result = find_epoch_node({"epoch_id": None}, sample_nodes) + result = findepochnode({"epoch_id": None}, sample_nodes) assert result == [0, 1, 2] def test_empty_string_treated_as_wildcard(self, sample_nodes): - result = find_epoch_node({"objectname": ""}, sample_nodes) + result = findepochnode({"objectname": ""}, sample_nodes) assert result == [0, 1, 2] diff --git a/tests/test_element.py b/tests/test_element.py index 1008982..db51e63 100644 --- a/tests/test_element.py +++ b/tests/test_element.py @@ -234,13 +234,13 @@ def test_epochnumber_not_found(self): with pytest.raises(ValueError): es.epochnumber("unknown") - def test_clear_cache(self): - """Test cache clearing.""" + def test_resetepochtable(self): + """Test cache clearing via resetepochtable.""" es = ConcreteEpochSet([{"epoch_number": 1, "epoch_id": "ep1"}]) es.epochtable() # Populate cache assert es._epochtable_cache is not None - es.clear_cache() + es.resetepochtable() assert es._epochtable_cache is None diff --git a/tests/test_phase2_gaps.py b/tests/test_phase2_gaps.py index 52d40ab..f1b31cd 100644 --- a/tests/test_phase2_gaps.py +++ b/tests/test_phase2_gaps.py @@ -4,7 +4,7 @@ Covers: - Batch 1: stimulus_tuningcurve_log, t0_t1cell2array, ontologyTableRowVars - Batch 2: database_to_json, copy_doc_file_to_temp, extract_docs_files -- Batch 3: get_probe_type_map, init_probe_type_map +- Batch 3: getProbeTypeMap, initProbeTypeMap - Batch 4: uploadSingleFile - Batch 5: openminds_convert (4 functions) """ @@ -305,33 +305,33 @@ def test_creates_temp_dir_if_none(self): class TestProbeTypeMap: - """Tests for ndi.probe.init_probe_type_map and get_probe_type_map.""" + """Tests for ndi.probe.initProbeTypeMap and getProbeTypeMap.""" - def test_init_probe_type_map_returns_dict(self): - from ndi.probe import init_probe_type_map + def test_initProbeTypeMap_returns_dict(self): + from ndi.probe import initProbeTypeMap - result = init_probe_type_map() + result = initProbeTypeMap() assert isinstance(result, dict) # probetype2object.json exists in the repo if result: assert "n-trode" in result assert result["n-trode"] == "ndi.probe.timeseries.mfdaq" - def test_get_probe_type_map_cached(self): + def test_getProbeTypeMap_cached(self): import ndi.probe as probe_mod # Reset cache probe_mod._PROBE_TYPE_MAP = None - result1 = probe_mod.get_probe_type_map() - result2 = probe_mod.get_probe_type_map() + result1 = probe_mod.getProbeTypeMap() + result2 = probe_mod.getProbeTypeMap() # Should be same object (cached) assert result1 is result2 def test_map_has_expected_types(self): - from ndi.probe import init_probe_type_map + from ndi.probe import initProbeTypeMap - m = init_probe_type_map() + m = initProbeTypeMap() if m: assert "stimulator" in m assert "patch" in m From a1336fda2a60fc68ea3b0353161b7214db8fd4d5 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 11 Mar 2026 14:11:18 +0000 Subject: [PATCH 2/7] Document ndi.cloud.api coverage status in porting guide - Add Namespace Coverage Status section to PYTHON_PORTING_GUIDE.md with verified per-submodule coverage table for ndi.cloud.api (100% across datasets, documents, files, users, compute, auth) - Fix MATLAB_MAPPING.md: undeleteDataset, listDeletedDatasets, and listDeletedDocuments exist in MATLAB (not Python-only) - Fix MATLAB_MAPPING.md: duplicateDocuments is ported (internal.py) https://claude.ai/code/session_016oPH9EoxCLcsUSTpgN9iZp --- MATLAB_MAPPING.md | 9 ++++----- PYTHON_PORTING_GUIDE.md | 37 +++++++++++++++++++++++++++++++++++++ 2 files changed, 41 insertions(+), 5 deletions(-) diff --git a/MATLAB_MAPPING.md b/MATLAB_MAPPING.md index 75c50d5..53501c4 100644 --- a/MATLAB_MAPPING.md +++ b/MATLAB_MAPPING.md @@ -290,8 +290,8 @@ Auth functions are re-exported from `ndi.cloud.__init__` so `from ndi.cloud impo | `submitDataset` | `submitDataset(dataset_id)` | | | `createDatasetBranch` | `createDatasetBranch(dataset_id)` | | | `getBranches` | `getBranches(dataset_id)` | | -| — | `undeleteDataset(dataset_id)` | Soft-delete API | -| — | `listDeletedDatasets(...)` | Soft-delete API | +| `undeleteDataset` | `undeleteDataset(dataset_id)` | | +| `listDeletedDatasets` | `listDeletedDatasets(...)` | | ### Documents API (`ndi.cloud.api.documents`) @@ -311,7 +311,7 @@ Auth functions are re-exported from `ndi.cloud.__init__` so `from ndi.cloud impo | `ndiquery` | `ndiquery(scope, search_structure, ...)` | | | `ndiqueryAll` | `ndiqueryAll(scope, search_structure, ...)` | | | — | `bulkUpload(dataset_id, zip_path)` | Python-only | -| — | `listDeletedDocuments(dataset_id, ...)` | Soft-delete API | +| `listDeletedDocuments` | `listDeletedDocuments(dataset_id, ...)` | | ### Files API (`ndi.cloud.api.files`) @@ -416,7 +416,7 @@ from ndi.cloud import downloadDataset, uploadDataset, syncDataset, uploadSingleF | `+internal/getUploadedDocumentIds` | — | Via `listRemoteDocumentIds()` | | `+internal/getUploadedFileIds` | — | Via `listFiles()` | | `+internal/dropDuplicateDocsFromJsonDecode` | — | Not needed (Python JSON is exact) | -| `+internal/duplicateDocuments` | — | Not yet ported | +| `+internal/duplicateDocuments` | `internal.duplicateDocuments()` | | | `+sync/+internal/listLocalDocuments` | `internal.listLocalDocuments()` | | | `+sync/+internal/getFileUidsFromDocuments` | `internal.getFileUidsFromDocuments()` | | | `+sync/+internal/filesNotYetUploaded` | `internal.filesNotYetUploaded()` | | @@ -453,7 +453,6 @@ from ndi.cloud import downloadDataset, uploadDataset, syncDataset, uploadSingleF | `ndi.cloud.ui.dialog.selectCloudDataset` | MATLAB GUI dialog | | `ndi.cloud.utility.createCloudMetadataStruct` | MATLAB struct validator; `CloudConfig` replaces | | `ndi.cloud.utility.mustBeValidMetadata` | MATLAB struct validator; type hints replace | -| `+internal/duplicateDocuments` | Not yet ported | ## Ontology diff --git a/PYTHON_PORTING_GUIDE.md b/PYTHON_PORTING_GUIDE.md index 1a20235..86199c6 100644 --- a/PYTHON_PORTING_GUIDE.md +++ b/PYTHON_PORTING_GUIDE.md @@ -41,3 +41,40 @@ To replicate the robustness of the MATLAB `arguments` block, use Pydantic for al - Include the original MATLAB documentation in the Python docstring. - Note any Python-specific requirements (like specific library dependencies) at the bottom of the docstring. + +--- + +## Namespace Coverage Status + +Verified coverage of each MATLAB namespace against the Python port. See +[MATLAB_MAPPING.md](MATLAB_MAPPING.md) for the full function-by-function mapping. + +### `ndi.cloud.api` — Fully Ported + +**Verified:** 2026-03-11 against `VH-Lab/NDI-matlab` branch `main`. + +| Submodule | MATLAB funcs | Python funcs | Coverage | Notes | +|-----------|:------------:|:------------:|:--------:|-------| +| `+datasets` (14) | 14 | 14 + 2 | **100 %** | Python adds `listAllDatasets` (auto-paginator), `listDeletedDatasets` | +| `+documents` (15) | 15 | 15 + 1 | **100 %** | `countDocuments` subsumes MATLAB's `documentCount`; Python adds `bulkUpload` | +| `+files` (6) | 6 | 6 + 2 | **100 %** | Python adds `putFileBytes`, `getBulkUploadURL` | +| `+users` (3) | 3 | 3 | **100 %** | | +| `+compute` (6) | 6 | 6 | **100 %** | | +| `+auth` (8) | 8 | 8 | **100 %** | `loginOriginal`/`logoutOriginal` (legacy) intentionally skipped; auth funcs live in `ndi.cloud.auth` | +| `call.m` / `url.m` | 2 | — | **N/A** | Replaced by `CloudClient` + `CloudConfig` (architectural change) | +| `+implementation/*` (50 classes) | 50 | — | **N/A** | Eliminated; single `CloudClient` replaces all impl classes | + +**Architectural differences from MATLAB:** + +- MATLAB uses an abstract `call` base class with 50 concrete implementation classes (one per endpoint). Python replaces this with `CloudClient`, a thin `requests.Session` wrapper with `get`/`post`/`put`/`delete` methods. +- MATLAB `url.m` builds endpoint URLs from a name→template dictionary. Python uses `CloudConfig.api_url` + path templates in each function. +- All Python API functions use `@pydantic.validate_call` for input validation (matching MATLAB `arguments` blocks) and `@_auto_client` to make the `client` parameter optional. + +**Not ported (intentional):** + +| MATLAB | Reason | +|--------|--------| +| `ndi.cloud.uilogin` | MATLAB GUI | +| `ndi.cloud.ui.dialog.selectCloudDataset` | MATLAB GUI dialog | +| `ndi.cloud.utility.createCloudMetadataStruct` | MATLAB struct validator; `CloudConfig` replaces | +| `ndi.cloud.utility.mustBeValidMetadata` | MATLAB struct validator; type hints replace | From 738e087567c71b35a68236d5b3e778d3a2717e20 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 11 Mar 2026 14:28:12 +0000 Subject: [PATCH 3/7] Port ndi.validators (11 functions) and ndi.util (8 functions) from MATLAB ndi.validators: All 11 MATLAB argument-block validators ported 1:1 - mustBeCellArrayOfClass, mustBeCellArrayOfNdiSessions, mustBeCellArrayOfNonEmptyCharacterArrays, mustBeClassnameOfType, mustBeEpochInput, mustBeID, mustBeNumericClass, mustBeTextLike, mustHaveFields, mustHaveRequiredColumns, mustMatchRegex ndi.util: All 8 portable utility functions ported 1:1 - datestamp2datetime, downsampleTimeseries, hexDiff, hexDiffBytes, getHexDiffFromFileObj, hexDump, rehydrateJSONNanNull, unwrapTableCellContent - GUI-only (choosefile, choosefileordir) and MATLAB-specific (toolboxdir) intentionally skipped Also updates MATLAB_MAPPING.md and PYTHON_PORTING_GUIDE.md with coverage tables for both namespaces. https://claude.ai/code/session_016oPH9EoxCLcsUSTpgN9iZp --- MATLAB_MAPPING.md | 42 ++++++ PYTHON_PORTING_GUIDE.md | 30 +++++ src/ndi/__init__.py | 4 +- src/ndi/util/__init__.py | 34 +++++ src/ndi/util/datestamp2datetime.py | 44 +++++++ src/ndi/util/downsampleTimeseries.py | 90 +++++++++++++ src/ndi/util/getHexDiffFromFileObj.py | 68 ++++++++++ src/ndi/util/hexDiff.py | 121 ++++++++++++++++++ src/ndi/util/hexDiffBytes.py | 65 ++++++++++ src/ndi/util/hexDump.py | 94 ++++++++++++++ src/ndi/util/rehydrateJSONNanNull.py | 59 +++++++++ src/ndi/util/unwrapTableCellContent.py | 52 ++++++++ src/ndi/validators/__init__.py | 37 ++++++ src/ndi/validators/mustBeCellArrayOfClass.py | 40 ++++++ .../mustBeCellArrayOfNdiSessions.py | 44 +++++++ ...ustBeCellArrayOfNonEmptyCharacterArrays.py | 47 +++++++ src/ndi/validators/mustBeClassnameOfType.py | 61 +++++++++ src/ndi/validators/mustBeEpochInput.py | 44 +++++++ src/ndi/validators/mustBeID.py | 61 +++++++++ src/ndi/validators/mustBeNumericClass.py | 59 +++++++++ src/ndi/validators/mustBeTextLike.py | 41 ++++++ src/ndi/validators/mustHaveFields.py | 42 ++++++ src/ndi/validators/mustHaveRequiredColumns.py | 53 ++++++++ src/ndi/validators/mustMatchRegex.py | 43 +++++++ 24 files changed, 1274 insertions(+), 1 deletion(-) create mode 100644 src/ndi/util/__init__.py create mode 100644 src/ndi/util/datestamp2datetime.py create mode 100644 src/ndi/util/downsampleTimeseries.py create mode 100644 src/ndi/util/getHexDiffFromFileObj.py create mode 100644 src/ndi/util/hexDiff.py create mode 100644 src/ndi/util/hexDiffBytes.py create mode 100644 src/ndi/util/hexDump.py create mode 100644 src/ndi/util/rehydrateJSONNanNull.py create mode 100644 src/ndi/util/unwrapTableCellContent.py create mode 100644 src/ndi/validators/__init__.py create mode 100644 src/ndi/validators/mustBeCellArrayOfClass.py create mode 100644 src/ndi/validators/mustBeCellArrayOfNdiSessions.py create mode 100644 src/ndi/validators/mustBeCellArrayOfNonEmptyCharacterArrays.py create mode 100644 src/ndi/validators/mustBeClassnameOfType.py create mode 100644 src/ndi/validators/mustBeEpochInput.py create mode 100644 src/ndi/validators/mustBeID.py create mode 100644 src/ndi/validators/mustBeNumericClass.py create mode 100644 src/ndi/validators/mustBeTextLike.py create mode 100644 src/ndi/validators/mustHaveFields.py create mode 100644 src/ndi/validators/mustHaveRequiredColumns.py create mode 100644 src/ndi/validators/mustMatchRegex.py diff --git a/MATLAB_MAPPING.md b/MATLAB_MAPPING.md index 53501c4..4d1ad68 100644 --- a/MATLAB_MAPPING.md +++ b/MATLAB_MAPPING.md @@ -454,6 +454,45 @@ from ndi.cloud import downloadDataset, uploadDataset, syncDataset, uploadSingleF | `ndi.cloud.utility.createCloudMetadataStruct` | MATLAB struct validator; `CloudConfig` replaces | | `ndi.cloud.utility.mustBeValidMetadata` | MATLAB struct validator; type hints replace | +## Validators (`ndi.validators.*`) + +| MATLAB | Python | Module | +|--------|--------|--------| +| `ndi.validators.mustBeCellArrayOfClass` | `ndi.validators.mustBeCellArrayOfClass()` | `ndi.validators.mustBeCellArrayOfClass` | +| `ndi.validators.mustBeCellArrayOfNdiSessions` | `ndi.validators.mustBeCellArrayOfNdiSessions()` | `ndi.validators.mustBeCellArrayOfNdiSessions` | +| `ndi.validators.mustBeCellArrayOfNonEmptyCharacterArrays` | `ndi.validators.mustBeCellArrayOfNonEmptyCharacterArrays()` | `ndi.validators.mustBeCellArrayOfNonEmptyCharacterArrays` | +| `ndi.validators.mustBeClassnameOfType` | `ndi.validators.mustBeClassnameOfType()` | `ndi.validators.mustBeClassnameOfType` | +| `ndi.validators.mustBeEpochInput` | `ndi.validators.mustBeEpochInput()` | `ndi.validators.mustBeEpochInput` | +| `ndi.validators.mustBeID` | `ndi.validators.mustBeID()` | `ndi.validators.mustBeID` | +| `ndi.validators.mustBeNumericClass` | `ndi.validators.mustBeNumericClass()` | `ndi.validators.mustBeNumericClass` | +| `ndi.validators.mustBeTextLike` | `ndi.validators.mustBeTextLike()` | `ndi.validators.mustBeTextLike` | +| `ndi.validators.mustHaveFields` | `ndi.validators.mustHaveFields()` | `ndi.validators.mustHaveFields` | +| `ndi.validators.mustHaveRequiredColumns` | `ndi.validators.mustHaveRequiredColumns()` | `ndi.validators.mustHaveRequiredColumns` | +| `ndi.validators.mustMatchRegex` | `ndi.validators.mustMatchRegex()` | `ndi.validators.mustMatchRegex` | + +## Utilities (`ndi.util.*`) + +| MATLAB | Python | Module | +|--------|--------|--------| +| `ndi.util.datestamp2datetime` | `ndi.util.datestamp2datetime()` | `ndi.util.datestamp2datetime` | +| `ndi.util.downsampleTimeseries` | `ndi.util.downsampleTimeseries()` | `ndi.util.downsampleTimeseries` | +| `ndi.util.hexDiff` | `ndi.util.hexDiff()` | `ndi.util.hexDiff` | +| `ndi.util.hexDiffBytes` | `ndi.util.hexDiffBytes()` | `ndi.util.hexDiffBytes` | +| `ndi.util.getHexDiffFromFileObj` | `ndi.util.getHexDiffFromFileObj()` | `ndi.util.getHexDiffFromFileObj` | +| `ndi.util.hexDump` | `ndi.util.hexDump()` | `ndi.util.hexDump` | +| `ndi.util.rehydrateJSONNanNull` | `ndi.util.rehydrateJSONNanNull()` | `ndi.util.rehydrateJSONNanNull` | +| `ndi.util.unwrapTableCellContent` | `ndi.util.unwrapTableCellContent()` | `ndi.util.unwrapTableCellContent` | +| `ndi.util.openminds.find_instance_name` | `ndi.openminds_convert.find_controlled_instance()` | `ndi.openminds_convert` | +| `ndi.util.openminds.find_techniques_names` | `ndi.openminds_convert.find_technique_names()` | `ndi.openminds_convert` | + +### Util: Not Ported + +| MATLAB | Reason | +|--------|--------| +| `ndi.util.choosefile` | MATLAB GUI dialog | +| `ndi.util.choosefileordir` | MATLAB GUI dialog | +| `ndi.util.toolboxdir` | MATLAB-specific path resolution | + ## Ontology | MATLAB | Python | Module | @@ -499,3 +538,6 @@ The following MATLAB components were intentionally not ported (GUI, MATLAB-speci | `ndi.fun.convertoldnsd2ndi` | Legacy NSD→NDI migration | | `ndi.fun.run_Linux_checks` | MATLAB Linux environment checks | | `ndi.fun.plot_extracellular_spikeshapes` | MATLAB GUI plotting | +| `ndi.util.choosefile` | MATLAB GUI dialog | +| `ndi.util.choosefileordir` | MATLAB GUI dialog | +| `ndi.util.toolboxdir` | MATLAB-specific path resolution | diff --git a/PYTHON_PORTING_GUIDE.md b/PYTHON_PORTING_GUIDE.md index 86199c6..fddabeb 100644 --- a/PYTHON_PORTING_GUIDE.md +++ b/PYTHON_PORTING_GUIDE.md @@ -78,3 +78,33 @@ Verified coverage of each MATLAB namespace against the Python port. See | `ndi.cloud.ui.dialog.selectCloudDataset` | MATLAB GUI dialog | | `ndi.cloud.utility.createCloudMetadataStruct` | MATLAB struct validator; `CloudConfig` replaces | | `ndi.cloud.utility.mustBeValidMetadata` | MATLAB struct validator; type hints replace | + +### `ndi.validators` — Fully Ported + +**Verified:** 2026-03-11 against `VH-Lab/NDI-matlab` branch `main`. + +| MATLAB | Python | Coverage | +|--------|--------|:--------:| +| 11 functions | 11 functions | **100 %** | + +All 11 MATLAB `arguments`-block validators ported 1:1 with matching +function names. Python equivalents accept Python types (``list`` for +cell array, ``dict`` for struct, ``pd.DataFrame`` for table). + +### `ndi.util` — Fully Ported + +**Verified:** 2026-03-11 against `VH-Lab/NDI-matlab` branch `main`. + +| Category | MATLAB funcs | Python funcs | Coverage | Notes | +|----------|:------------:|:------------:|:--------:|-------| +| Data/time utilities (8) | 8 | 8 | **100 %** | | +| `+openminds` (2) | 2 | 2 | **100 %** | Ported in `ndi.openminds_convert` | +| GUI / MATLAB-specific (3) | 3 | — | **N/A** | `choosefile`, `choosefileordir`, `toolboxdir` | + +**Not ported (intentional):** + +| MATLAB | Reason | +|--------|--------| +| `ndi.util.choosefile` | MATLAB GUI dialog (`inputdlg`) | +| `ndi.util.choosefileordir` | MATLAB GUI dialog (`inputdlg`) | +| `ndi.util.toolboxdir` | MATLAB-specific path resolution | diff --git a/src/ndi/__init__.py b/src/ndi/__init__.py index 3486e71..585a4ef 100644 --- a/src/ndi/__init__.py +++ b/src/ndi/__init__.py @@ -32,7 +32,7 @@ # Import epoch module (Phase 6) # Import Phase 10: Cloud API client # Import Phase 11: Schema validation -from . import calc, cloud, daq, epoch, file, session, time, validate +from . import calc, cloud, daq, epoch, file, session, time, util, validate, validators # Import Phase 9: App framework and calculators from .app import App @@ -123,6 +123,8 @@ def version() -> tuple: "Calculator", "calc", "cloud", + "util", "validate", + "validators", "version", ] diff --git a/src/ndi/util/__init__.py b/src/ndi/util/__init__.py new file mode 100644 index 0000000..fa076d6 --- /dev/null +++ b/src/ndi/util/__init__.py @@ -0,0 +1,34 @@ +""" +ndi.util - General utility functions for NDI. + +MATLAB equivalent: +ndi/+util/ + +Provides utility functions for datestamp conversion, timeseries +downsampling, hex diffing/dumping, JSON rehydration, and table +cell unwrapping. + +MATLAB GUI utilities (``choosefile``, ``choosefileordir``) and +MATLAB-specific helpers (``toolboxdir``) are intentionally not ported. +The ``+openminds`` sub-package is ported separately in +``ndi.openminds_convert``. +""" + +from .datestamp2datetime import datestamp2datetime +from .downsampleTimeseries import downsampleTimeseries +from .getHexDiffFromFileObj import getHexDiffFromFileObj +from .hexDiff import hexDiff +from .hexDiffBytes import hexDiffBytes +from .hexDump import hexDump +from .rehydrateJSONNanNull import rehydrateJSONNanNull +from .unwrapTableCellContent import unwrapTableCellContent + +__all__ = [ + "datestamp2datetime", + "downsampleTimeseries", + "getHexDiffFromFileObj", + "hexDiff", + "hexDiffBytes", + "hexDump", + "rehydrateJSONNanNull", + "unwrapTableCellContent", +] diff --git a/src/ndi/util/datestamp2datetime.py b/src/ndi/util/datestamp2datetime.py new file mode 100644 index 0000000..68c513f --- /dev/null +++ b/src/ndi/util/datestamp2datetime.py @@ -0,0 +1,44 @@ +""" +ndi.util.datestamp2datetime + +MATLAB equivalent: +ndi/+util/datestamp2datetime.m + +Converts an NDI datestamp string to a Python ``datetime`` object. +""" + +from __future__ import annotations + +from datetime import datetime, timezone + + +def datestamp2datetime(datestampStr: str) -> datetime: + """Convert an NDI datestamp string to a :class:`datetime.datetime`. + + MATLAB equivalent: ``ndi.util.datestamp2datetime(datestampStr)`` + + The expected input format is ISO 8601 with milliseconds and timezone + offset, e.g. ``'2023-01-01T12:00:00.000+00:00'``. The returned + datetime is always in UTC. + + Parameters + ---------- + datestampStr : str + An ISO 8601 datestamp string. + + Returns + ------- + datetime.datetime + A timezone-aware datetime in UTC. + + Raises + ------ + TypeError + If *datestampStr* is not a string. + ValueError + If the string cannot be parsed. + """ + if not isinstance(datestampStr, str): + raise TypeError("Input must be a string.") + + dt = datetime.fromisoformat(datestampStr) + return dt.astimezone(timezone.utc) diff --git a/src/ndi/util/downsampleTimeseries.py b/src/ndi/util/downsampleTimeseries.py new file mode 100644 index 0000000..a8f65db --- /dev/null +++ b/src/ndi/util/downsampleTimeseries.py @@ -0,0 +1,90 @@ +""" +ndi.util.downsampleTimeseries + +MATLAB equivalent: +ndi/+util/downsampleTimeseries.m + +Downsamples a timeseries after applying a low-pass anti-aliasing filter. +""" + +from __future__ import annotations + +import warnings + +import numpy as np + + +def downsampleTimeseries( + t_in: np.ndarray, + d_in: np.ndarray, + LP: float, +) -> tuple[np.ndarray, np.ndarray]: + """Downsample a timeseries with anti-aliasing. + + MATLAB equivalent: + ``[t_out, d_out] = ndi.util.downsampleTimeseries(t_in, d_in, LP)`` + + If the sampling frequency is greater than ``2 * LP``, a 4th-order + Chebyshev Type I low-pass filter is applied before downsampling. + Otherwise the data is returned unchanged. + + Parameters + ---------- + t_in : numpy.ndarray + 1-D array of time values (seconds). Samples are assumed to be + equally spaced. + d_in : numpy.ndarray + Data matrix. Each column is a channel; rows correspond to samples + in *t_in*. + LP : float + Low-pass cutoff frequency in Hz. + + Returns + ------- + t_out : numpy.ndarray + Downsampled time vector. + d_out : numpy.ndarray + Downsampled (and filtered) data matrix. + + Raises + ------ + ValueError + If *t_in* and *d_in* have incompatible shapes, or *LP* is not + positive. + """ + from scipy.signal import cheby1, filtfilt + + t_in = np.asarray(t_in, dtype=float) + d_in = np.asarray(d_in, dtype=float) + + if t_in.ndim != 1: + raise ValueError("t_in must be a 1-D array.") + if d_in.ndim == 1: + d_in = d_in[:, np.newaxis] + if d_in.shape[0] != t_in.shape[0]: + raise ValueError( + "The number of rows in d_in must equal the length of t_in." + ) + if LP <= 0: + raise ValueError("LP must be positive.") + + dt = np.median(np.diff(t_in)) + fs = 1.0 / dt + + if fs > 2 * LP: + b, a = cheby1(4, 0.8, LP / (fs / 2), btype="low") + d_filtered = filtfilt(b, a, d_in, axis=0) + + t_out = np.arange(t_in[0], t_in[-1] + 1e-12, 1.0 / (2 * LP)) + d_out = np.empty((len(t_out), d_filtered.shape[1])) + for ch in range(d_filtered.shape[1]): + d_out[:, ch] = np.interp(t_out, t_in, d_filtered[:, ch]) + else: + warnings.warn( + "Sampling frequency is not greater than twice the low-pass " + "frequency. No downsampling or filtering performed.", + stacklevel=2, + ) + t_out = t_in.copy() + d_out = d_in.copy() + + return t_out, d_out diff --git a/src/ndi/util/getHexDiffFromFileObj.py b/src/ndi/util/getHexDiffFromFileObj.py new file mode 100644 index 0000000..ee79ffa --- /dev/null +++ b/src/ndi/util/getHexDiffFromFileObj.py @@ -0,0 +1,68 @@ +""" +ndi.util.getHexDiffFromFileObj + +MATLAB equivalent: +ndi/+util/getHexDiffFromFileObj.m + +Compare two file-like objects chunk by chunk for equality. +""" + +from __future__ import annotations + +from typing import IO + +from .hexDiffBytes import hexDiffBytes + + +def getHexDiffFromFileObj( + file_obj1: IO[bytes], + file_obj2: IO[bytes], + *, + chunkSize: int = 1024 * 1024, +) -> tuple[bool, str]: + """Compare two file-like objects chunk by chunk. + + MATLAB equivalent: + ``[are_identical, diff_output] = ndi.util.getHexDiffFromFileObj(f1, f2)`` + + Parameters + ---------- + file_obj1, file_obj2 : file-like (binary mode) + Open file objects to compare. + chunkSize : int, optional + Number of bytes to read per chunk (default 1 MiB). + + Returns + ------- + are_identical : bool + ``True`` if files are identical. + diff_output : str + Empty when identical; hex diff of the first mismatched chunk + otherwise. + """ + file_obj1.seek(0, 2) + size1 = file_obj1.tell() + file_obj2.seek(0, 2) + size2 = file_obj2.tell() + + file_obj1.seek(0) + file_obj2.seek(0) + + if size1 != size2: + d1 = file_obj1.read(chunkSize) + d2 = file_obj2.read(chunkSize) + msg = f"Files have different sizes ({size1} bytes vs {size2} bytes)." + if d1 != d2: + msg += "\nHexdiff of the start of the files:\n" + hexDiffBytes(d1, d2) + return False, msg + + offset = 0 + while True: + d1 = file_obj1.read(chunkSize) + d2 = file_obj2.read(chunkSize) + if not d1 and not d2: + break + if d1 != d2: + return False, hexDiffBytes(d1, d2, StartOffset=offset) + offset += len(d1) + + return True, "" diff --git a/src/ndi/util/hexDiff.py b/src/ndi/util/hexDiff.py new file mode 100644 index 0000000..8b049ac --- /dev/null +++ b/src/ndi/util/hexDiff.py @@ -0,0 +1,121 @@ +""" +ndi.util.hexDiff + +MATLAB equivalent: +ndi/+util/hexDiff.m + +Compares two files and prints the 16-byte lines where they differ. +""" + +from __future__ import annotations + +from pathlib import Path + + +def hexDiff( + filename1: str | Path, + filename2: str | Path, + *, + StartByte: int = 0, + StopByte: int | None = None, +) -> None: + """Compare two files and print differing 16-byte lines. + + MATLAB equivalent: ``ndi.util.hexDiff(filename1, filename2, ...)`` + + Parameters + ---------- + filename1, filename2 : str or Path + Paths to the files to compare. + StartByte : int, optional + Zero-based byte offset to start comparison (default 0). + StopByte : int or None, optional + Zero-based byte offset to end comparison. ``None`` means end of + the longer file. + + Raises + ------ + FileNotFoundError + If either file does not exist. + ValueError + If *StartByte* > *StopByte*. + """ + data1 = Path(filename1).read_bytes() + data2 = Path(filename2).read_bytes() + + max_size = max(len(data1), len(data2)) + + if StopByte is None: + StopByte = max_size - 1 if max_size > 0 else -1 + + if StartByte >= max_size and max_size > 0: + raise ValueError( + f"StartByte ({StartByte}) is beyond the end of both files." + ) + if StartByte > StopByte: + raise ValueError( + f"StartByte ({StartByte}) cannot be greater than " + f"StopByte ({StopByte})." + ) + + print( + f'Comparing "{filename1}" ({len(data1)} bytes) with ' + f'"{filename2}" ({len(data2)} bytes)' + ) + print("Displaying only differing 16-byte lines...") + print("-" * 140) + + differences_found = False + for offset in range(StartByte, StopByte + 1, 16): + chunk1 = data1[offset : offset + 16] + chunk2 = data2[offset : offset + 16] + + if chunk1 != chunk2: + if not differences_found: + _print_header() + differences_found = True + _print_diff_line(offset, chunk1, chunk2) + + if not differences_found: + print("Files are identical in the specified range.") + print("-" * 140) + + +def _print_header() -> None: + h1 = ( + " Offset(h) 00 01 02 03 04 05 06 07 " + "08 09 0A 0B 0C 0D 0E 0F |ASCII |" + ) + h2 = ( + " | 00 01 02 03 04 05 06 07 " + "08 09 0A 0B 0C 0D 0E 0F |ASCII |" + ) + print(h1 + h2) + print("-" * 140) + + +def _format_chunk(chunk: bytes) -> str: + hex_parts: list[str] = [] + for k in range(16): + if k < len(chunk): + hex_parts.append(f"{chunk[k]:02X} ") + else: + hex_parts.append(" ") + if k == 7: + hex_parts.append(" ") + + ascii_parts: list[str] = [] + for k in range(16): + if k < len(chunk): + ch = chunk[k] + ascii_parts.append(chr(ch) if 32 <= ch <= 126 else ".") + else: + ascii_parts.append(" ") + + return "".join(hex_parts) + " |" + "".join(ascii_parts) + "|" + + +def _print_diff_line(offset: int, chunk1: bytes, chunk2: bytes) -> None: + print( + f"{offset:08x}: {_format_chunk(chunk1)} | " + f"{_format_chunk(chunk2)}" + ) diff --git a/src/ndi/util/hexDiffBytes.py b/src/ndi/util/hexDiffBytes.py new file mode 100644 index 0000000..a62b3aa --- /dev/null +++ b/src/ndi/util/hexDiffBytes.py @@ -0,0 +1,65 @@ +""" +ndi.util.hexDiffBytes + +MATLAB equivalent: +ndi/+util/hexDiffBytes.m + +Compares two byte sequences and returns a formatted hex diff string. +""" + +from __future__ import annotations + +from .hexDiff import _format_chunk + + +def hexDiffBytes( + data1: bytes, + data2: bytes, + *, + StartOffset: int = 0, +) -> str: + """Compare two byte sequences and return a hex diff string. + + MATLAB equivalent: + ``diff_string = ndi.util.hexDiffBytes(data1, data2, ...)`` + + Parameters + ---------- + data1, data2 : bytes + The byte sequences to compare. + StartOffset : int, optional + Zero-based byte offset at which to start (default 0). + + Returns + ------- + str + A formatted hex diff string. Empty if the sequences are + identical in the compared range. + """ + max_size = max(len(data1), len(data2)) + lines: list[str] = [] + header_added = False + + for offset in range(StartOffset, max_size, 16): + chunk1 = data1[offset : offset + 16] + chunk2 = data2[offset : offset + 16] + + if chunk1 != chunk2: + if not header_added: + h1 = ( + " Offset(h) 00 01 02 03 04 05 06 07 " + "08 09 0A 0B 0C 0D 0E 0F |ASCII |" + ) + h2 = ( + " | 00 01 02 03 04 05 06 07 " + "08 09 0A 0B 0C 0D 0E 0F |ASCII |" + ) + lines.append(h1 + h2) + lines.append("-" * 140) + header_added = True + + lines.append( + f"{offset:08x}: {_format_chunk(chunk1)} | " + f"{_format_chunk(chunk2)}" + ) + + return "\n".join(lines) if lines else "" diff --git a/src/ndi/util/hexDump.py b/src/ndi/util/hexDump.py new file mode 100644 index 0000000..c8dc070 --- /dev/null +++ b/src/ndi/util/hexDump.py @@ -0,0 +1,94 @@ +""" +ndi.util.hexDump + +MATLAB equivalent: +ndi/+util/hexDump.m + +Displays the hexadecimal and ASCII content of a file. +""" + +from __future__ import annotations + +import warnings +from pathlib import Path + +from .hexDiff import _format_chunk + + +def hexDump( + filename: str | Path, + *, + StartByte: int = 0, + StopByte: int | None = None, +) -> None: + """Print a hex dump of a file. + + MATLAB equivalent: ``ndi.util.hexDump(filename, ...)`` + + Parameters + ---------- + filename : str or Path + Path to the file. + StartByte : int, optional + Zero-based byte offset to start (default 0). + StopByte : int or None, optional + Zero-based byte offset to end. ``None`` means end of file. + + Raises + ------ + FileNotFoundError + If the file does not exist. + ValueError + If *StartByte* > *StopByte* or beyond end of file. + """ + path = Path(filename) + data = path.read_bytes() + file_size = len(data) + + if file_size == 0: + print("-" * 77) + print(f" Hex Dump of: {filename}") + print(" File Size: 0 bytes") + print("-" * 77) + print("No data to display in the specified range.") + print("-" * 77) + return + + if StopByte is None: + StopByte = file_size - 1 + + if StartByte >= file_size: + raise ValueError( + f"StartByte ({StartByte}) is beyond the end of the file " + f"(size: {file_size} bytes)." + ) + if StopByte >= file_size: + warnings.warn( + f"StopByte ({StopByte}) is beyond the end of the file. " + f"Adjusting to {file_size - 1}.", + stacklevel=2, + ) + StopByte = file_size - 1 + if StartByte > StopByte: + raise ValueError( + f"StartByte ({StartByte}) cannot be greater than " + f"StopByte ({StopByte})." + ) + + print("-" * 77) + print(f" Hex Dump of: {filename}") + print(f" File Size: {file_size} bytes") + print(f" Displaying bytes {StartByte} through {StopByte}") + print("-" * 77) + print( + " Offset(h) 00 01 02 03 04 05 06 07 " + "08 09 0A 0B 0C 0D 0E 0F |ASCII |" + ) + print("-" * 77) + + region = data[StartByte : StopByte + 1] + for offset in range(0, len(region), 16): + chunk = region[offset : offset + 16] + addr = StartByte + offset + print(f"{addr:08x}: {_format_chunk(chunk)}") + + print("-" * 77) diff --git a/src/ndi/util/rehydrateJSONNanNull.py b/src/ndi/util/rehydrateJSONNanNull.py new file mode 100644 index 0000000..91401f7 --- /dev/null +++ b/src/ndi/util/rehydrateJSONNanNull.py @@ -0,0 +1,59 @@ +""" +ndi.util.rehydrateJSONNanNull + +MATLAB equivalent: +ndi/+util/rehydrateJSONNanNull.m + +Replaces NDI sentinel strings for NaN, Infinity, and -Infinity in JSON +text with Python-compatible representations. +""" + +from __future__ import annotations + + +def rehydrateJSONNanNull( + jsonText: str, + *, + nan_string: str = '"__NDI__NaN__"', + inf_string: str = '"__NDI__Infinity__"', + ninf_string: str = '"__NDI__-Infinity__"', +) -> str: + """Replace NaN/Inf sentinel strings in JSON text. + + MATLAB equivalent: + ``rehydratedJsonText = ndi.util.rehydrateJSONNanNull(jsonText)`` + + By default performs the following replacements: + + * ``"__NDI__NaN__"`` → ``NaN`` + * ``"__NDI__Infinity__"`` → ``Infinity`` + * ``"__NDI__-Infinity__"`` → ``-Infinity`` + + Parameters + ---------- + jsonText : str + The JSON string to process. + nan_string : str, optional + Sentinel for NaN. + inf_string : str, optional + Sentinel for Infinity. + ninf_string : str, optional + Sentinel for -Infinity. + + Returns + ------- + str + The JSON string with sentinels replaced. + + Raises + ------ + TypeError + If *jsonText* is not a string. + """ + if not isinstance(jsonText, str): + raise TypeError("Input must be a string.") + + result = jsonText + result = result.replace(nan_string, "NaN") + result = result.replace(inf_string, "Infinity") + result = result.replace(ninf_string, "-Infinity") + return result diff --git a/src/ndi/util/unwrapTableCellContent.py b/src/ndi/util/unwrapTableCellContent.py new file mode 100644 index 0000000..d9dc502 --- /dev/null +++ b/src/ndi/util/unwrapTableCellContent.py @@ -0,0 +1,52 @@ +""" +ndi.util.unwrapTableCellContent + +MATLAB equivalent: +ndi/+util/unwrapTableCellContent.m + +Recursively unwraps nested lists (MATLAB cell arrays) to retrieve the +core value. +""" + +from __future__ import annotations + +import math +from typing import Any + + +def unwrapTableCellContent(cellValue: Any) -> Any: + """Recursively unwrap nested lists to retrieve the innermost value. + + MATLAB equivalent: + ``unwrappedValue = ndi.util.unwrapTableCellContent(cellValue)`` + + If the innermost value is ``None`` or an empty list, ``float('nan')`` + is returned (matching MATLAB's ``NaN`` for empty cells). + + Parameters + ---------- + cellValue : any + The value to unwrap. In practice this is often a list (from + MATLAB cell arrays) that may be nested. + + Returns + ------- + any + The unwrapped value, or ``float('nan')`` if the value is empty. + """ + current = cellValue + max_unwrap = 10 + + for _ in range(max_unwrap): + if not isinstance(current, list): + break + if len(current) == 0: + return float("nan") + current = current[0] + + if isinstance(current, list) and len(current) == 0: + return float("nan") + + if current is None: + return float("nan") + + return current diff --git a/src/ndi/validators/__init__.py b/src/ndi/validators/__init__.py new file mode 100644 index 0000000..9bc1ba4 --- /dev/null +++ b/src/ndi/validators/__init__.py @@ -0,0 +1,37 @@ +""" +ndi.validators - Custom validation functions for NDI. + +MATLAB equivalent: +ndi/+validators/ + +Provides validation functions that mirror MATLAB's custom validators used +in ``arguments`` blocks. In Python these are typically called explicitly +or via ``@pydantic.validate_call``. +""" + +from .mustBeCellArrayOfClass import mustBeCellArrayOfClass +from .mustBeCellArrayOfNdiSessions import mustBeCellArrayOfNdiSessions +from .mustBeCellArrayOfNonEmptyCharacterArrays import ( + mustBeCellArrayOfNonEmptyCharacterArrays, +) +from .mustBeClassnameOfType import mustBeClassnameOfType +from .mustBeEpochInput import mustBeEpochInput +from .mustBeID import mustBeID +from .mustBeNumericClass import mustBeNumericClass +from .mustBeTextLike import mustBeTextLike +from .mustHaveFields import mustHaveFields +from .mustHaveRequiredColumns import mustHaveRequiredColumns +from .mustMatchRegex import mustMatchRegex + +__all__ = [ + "mustBeCellArrayOfClass", + "mustBeCellArrayOfNdiSessions", + "mustBeCellArrayOfNonEmptyCharacterArrays", + "mustBeClassnameOfType", + "mustBeEpochInput", + "mustBeID", + "mustBeNumericClass", + "mustBeTextLike", + "mustHaveFields", + "mustHaveRequiredColumns", + "mustMatchRegex", +] diff --git a/src/ndi/validators/mustBeCellArrayOfClass.py b/src/ndi/validators/mustBeCellArrayOfClass.py new file mode 100644 index 0000000..d86f594 --- /dev/null +++ b/src/ndi/validators/mustBeCellArrayOfClass.py @@ -0,0 +1,40 @@ +""" +ndi.validators.mustBeCellArrayOfClass + +MATLAB equivalent: +ndi/+validators/mustBeCellArrayOfClass.m + +Validation function that checks if all elements of a list are of a certain class. +""" + +from __future__ import annotations + + +def mustBeCellArrayOfClass(c: list | tuple, className: type) -> None: + """Validate that all elements of a list are instances of *className*. + + MATLAB equivalent: ``ndi.validators.mustBeCellArrayOfClass(c, className)`` + + In MATLAB, *className* is a string naming a class. In Python it is + the class object itself (e.g. ``ndi.session.DirSession``). + + Parameters + ---------- + c : list or tuple + The collection to validate. + className : type + The required type for every element. + + Raises + ------ + TypeError + If *c* is not a list/tuple or any element is not an instance of + *className*. + """ + if not isinstance(c, (list, tuple)): + raise TypeError("Input must be a list or tuple.") + for i, item in enumerate(c): + if not isinstance(item, className): + raise TypeError( + f"All elements must be of class {className.__name__}. " + f"Element {i} is of class {type(item).__name__}." + ) diff --git a/src/ndi/validators/mustBeCellArrayOfNdiSessions.py b/src/ndi/validators/mustBeCellArrayOfNdiSessions.py new file mode 100644 index 0000000..c793ac0 --- /dev/null +++ b/src/ndi/validators/mustBeCellArrayOfNdiSessions.py @@ -0,0 +1,44 @@ +""" +ndi.validators.mustBeCellArrayOfNdiSessions + +MATLAB equivalent: +ndi/+validators/mustBeCellArrayOfNdiSessions.m + +Validates that the input is a list of ``ndi.session.DirSession`` objects. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from collections.abc import Sequence + + +def mustBeCellArrayOfNdiSessions(value: Sequence) -> None: + """Validate that every element is an ``ndi.session.DirSession``. + + MATLAB equivalent: ``ndi.validators.mustBeCellArrayOfNdiSessions(value)`` + + Parameters + ---------- + value : list or tuple + The collection to validate. + + Raises + ------ + TypeError + If *value* is not a list/tuple, or any element is not a + ``DirSession``. + """ + # Import lazily to avoid circular imports. + from ndi.session import DirSession + + if not isinstance(value, (list, tuple)): + raise TypeError("Input must be a list or tuple.") + + for i, item in enumerate(value): + if not isinstance(item, DirSession): + raise TypeError( + f"All elements must be ndi.session.DirSession objects. " + f"Element {i} is of class {type(item).__name__!r}." + ) diff --git a/src/ndi/validators/mustBeCellArrayOfNonEmptyCharacterArrays.py b/src/ndi/validators/mustBeCellArrayOfNonEmptyCharacterArrays.py new file mode 100644 index 0000000..ce602b0 --- /dev/null +++ b/src/ndi/validators/mustBeCellArrayOfNonEmptyCharacterArrays.py @@ -0,0 +1,47 @@ +""" +ndi.validators.mustBeCellArrayOfNonEmptyCharacterArrays + +MATLAB equivalent: +ndi/+validators/mustBeCellArrayOfNonEmptyCharacterArrays.m + +Validates that the input is a list of non-empty strings. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from collections.abc import Sequence + + +def mustBeCellArrayOfNonEmptyCharacterArrays(value: Sequence) -> None: + """Validate that every element is a non-empty string. + + MATLAB equivalent: + ``ndi.validators.mustBeCellArrayOfNonEmptyCharacterArrays(value)`` + + Parameters + ---------- + value : list or tuple + The collection to validate. + + Raises + ------ + TypeError + If *value* is not a list/tuple or any element is not a string. + ValueError + If any element is an empty string. + """ + if not isinstance(value, (list, tuple)): + raise TypeError("Input must be a list or tuple.") + + for i, item in enumerate(value): + if not isinstance(item, str): + raise TypeError( + f"All elements must be non-empty strings. " + f"Element {i} is of type {type(item).__name__!r}." + ) + if not item: + raise ValueError( + f"All elements must be non-empty strings. Element {i} is empty." + ) diff --git a/src/ndi/validators/mustBeClassnameOfType.py b/src/ndi/validators/mustBeClassnameOfType.py new file mode 100644 index 0000000..601509f --- /dev/null +++ b/src/ndi/validators/mustBeClassnameOfType.py @@ -0,0 +1,61 @@ +""" +ndi.validators.mustBeClassnameOfType + +MATLAB equivalent: +ndi/+validators/mustBeClassnameOfType.m + +Validates that a class name string refers to a class that is a subclass +of a required type. +""" + +from __future__ import annotations + +import importlib + + +def mustBeClassnameOfType(classname: str, requiredType: type) -> None: + """Validate that *classname* resolves to a subclass of *requiredType*. + + MATLAB equivalent: + ``ndi.validators.mustBeClassnameOfType(classname, requiredType)`` + + *classname* is a fully-qualified Python class name + (e.g. ``"ndi.session.DirSession"``). The function dynamically imports + the module, looks up the class, and checks ``issubclass``. + + Parameters + ---------- + classname : str + Fully-qualified class name (``"package.module.ClassName"``). + requiredType : type + The base class that *classname* must inherit from. + + Raises + ------ + TypeError + If *classname* is not a string. + ValueError + If the class cannot be found or does not inherit from + *requiredType*. + """ + if not isinstance(classname, str): + raise TypeError("classname must be a string.") + + parts = classname.rsplit(".", 1) + if len(parts) != 2: + raise ValueError(f"Class {classname!r} is not a fully-qualified name.") + + module_path, class_attr = parts + try: + mod = importlib.import_module(module_path) + except ModuleNotFoundError: + raise ValueError(f"Module {module_path!r} does not exist.") from None + + cls = getattr(mod, class_attr, None) + if cls is None or not isinstance(cls, type): + raise ValueError(f"Class {classname!r} does not exist.") + + if not issubclass(cls, requiredType): + raise ValueError( + f"Class {classname!r} must be a subclass of " + f"{requiredType.__name__!r}." + ) diff --git a/src/ndi/validators/mustBeEpochInput.py b/src/ndi/validators/mustBeEpochInput.py new file mode 100644 index 0000000..edd5768 --- /dev/null +++ b/src/ndi/validators/mustBeEpochInput.py @@ -0,0 +1,44 @@ +""" +ndi.validators.mustBeEpochInput + +MATLAB equivalent: +ndi/+validators/mustBeEpochInput.m + +Determines whether an input can describe an epoch (string or positive +integer scalar). +""" + +from __future__ import annotations + + +def mustBeEpochInput(v: str | int) -> None: + """Validate that *v* is a valid epoch identifier. + + MATLAB equivalent: ``ndi.validators.mustBeEpochInput(v)`` + + A valid epoch input is either a non-empty string or a positive integer. + + Parameters + ---------- + v : str or int + The value to validate. + + Raises + ------ + TypeError + If *v* is not a string or integer. + ValueError + If *v* is an integer that is not positive, or an empty string. + """ + if isinstance(v, str): + if not v: + raise ValueError("Epoch input string must not be empty.") + return + + if isinstance(v, (int,)) and not isinstance(v, bool): + if v < 1: + raise ValueError("Epoch input integer must be positive.") + return + + raise TypeError( + "Value must be a string or positive integer scalar." + ) diff --git a/src/ndi/validators/mustBeID.py b/src/ndi/validators/mustBeID.py new file mode 100644 index 0000000..c2f64ce --- /dev/null +++ b/src/ndi/validators/mustBeID.py @@ -0,0 +1,61 @@ +""" +ndi.validators.mustBeID + +MATLAB equivalent: +ndi/+validators/mustBeID.m + +Validates that a string is a correctly formatted NDI ID (33 characters, +underscore at position 17, all other characters alphanumeric). +""" + +from __future__ import annotations + +import re + +_ID_PATTERN = re.compile(r"^[A-Za-z0-9]{16}_[A-Za-z0-9]{16}$") + + +def mustBeID(inputArg: str) -> None: + """Validate that *inputArg* is a correctly formatted NDI ID. + + MATLAB equivalent: ``ndi.validators.mustBeID(inputArg)`` + + Format: exactly 33 characters — 16 alphanumeric, an underscore, then + 16 more alphanumeric characters. + + Parameters + ---------- + inputArg : str + The string to validate. + + Raises + ------ + TypeError + If *inputArg* is not a string. + ValueError + If the format is incorrect. + """ + if not isinstance(inputArg, str): + raise TypeError("Input must be a string.") + + if len(inputArg) != 33: + raise ValueError( + f"Input must be exactly 33 characters long " + f"(actual length was {len(inputArg)})." + ) + + if inputArg[16] != "_": + raise ValueError( + f"Character 17 must be an underscore (_), " + f"but found {inputArg[16]!r}." + ) + + if not _ID_PATTERN.match(inputArg): + # Find first invalid character for a helpful message. + for i, ch in enumerate(inputArg): + if i == 16: + continue + if not ch.isalnum(): + raise ValueError( + f"Characters 1-16 and 18-33 must be alphanumeric. " + f"Found invalid character {ch!r} at position {i + 1}." + ) diff --git a/src/ndi/validators/mustBeNumericClass.py b/src/ndi/validators/mustBeNumericClass.py new file mode 100644 index 0000000..96213c8 --- /dev/null +++ b/src/ndi/validators/mustBeNumericClass.py @@ -0,0 +1,59 @@ +""" +ndi.validators.mustBeNumericClass + +MATLAB equivalent: +ndi/+validators/mustBeNumericClass.m + +Validates that a string names a valid numeric (or logical) dtype. +""" + +from __future__ import annotations + +_VALID_CLASSES = frozenset( + { + "uint8", + "uint16", + "uint32", + "uint64", + "int8", + "int16", + "int32", + "int64", + "single", + "double", + "logical", + # Python/NumPy equivalents accepted as well: + "float32", + "float64", + "bool", + } +) + + +def mustBeNumericClass(className: str) -> None: + """Validate that *className* is a recognised numeric or logical class. + + MATLAB equivalent: ``ndi.validators.mustBeNumericClass(className)`` + + Accepts MATLAB names (``"double"``, ``"single"``, ``"logical"``, etc.) + as well as NumPy equivalents (``"float64"``, ``"float32"``, ``"bool"``). + + Parameters + ---------- + className : str + The class name to validate. + + Raises + ------ + TypeError + If *className* is not a string. + ValueError + If *className* is not one of the recognised numeric/logical types. + """ + if not isinstance(className, str): + raise TypeError("Input must be a string.") + + if className not in _VALID_CLASSES: + raise ValueError( + f"Value must be a valid numeric or logical class name. " + f"Must be one of: {', '.join(sorted(_VALID_CLASSES))}." + ) diff --git a/src/ndi/validators/mustBeTextLike.py b/src/ndi/validators/mustBeTextLike.py new file mode 100644 index 0000000..33d81c3 --- /dev/null +++ b/src/ndi/validators/mustBeTextLike.py @@ -0,0 +1,41 @@ +""" +ndi.validators.mustBeTextLike + +MATLAB equivalent: +ndi/+validators/mustBeTextLike.m + +Validates that input is a string, or a list of strings. +""" + +from __future__ import annotations + + +def mustBeTextLike(value: str | list | tuple) -> None: + """Validate that *value* is text-like. + + MATLAB equivalent: ``ndi.validators.mustBeTextLike(value)`` + + Accepted forms: + + * A ``str`` + * A ``list`` or ``tuple`` where every element is a ``str`` + + Parameters + ---------- + value : str, list, or tuple + The value to validate. + + Raises + ------ + TypeError + If *value* is not a string, or a list/tuple of strings. + """ + if isinstance(value, str): + return + + if isinstance(value, (list, tuple)): + if all(isinstance(item, str) for item in value): + return + + raise TypeError( + "Input must be a string, or a list/tuple of strings." + ) diff --git a/src/ndi/validators/mustHaveFields.py b/src/ndi/validators/mustHaveFields.py new file mode 100644 index 0000000..d046417 --- /dev/null +++ b/src/ndi/validators/mustHaveFields.py @@ -0,0 +1,42 @@ +""" +ndi.validators.mustHaveFields + +MATLAB equivalent: +ndi/+validators/mustHaveFields.m + +Validates that a dict (MATLAB struct) has all required keys. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from collections.abc import Sequence + + +def mustHaveFields(s: dict, fields: Sequence[str]) -> None: + """Validate that dict *s* contains all *fields*. + + MATLAB equivalent: ``ndi.validators.mustHaveFields(s, fields)`` + + Parameters + ---------- + s : dict + The dictionary to check. + fields : list of str + Required key names. + + Raises + ------ + TypeError + If *s* is not a dict. + ValueError + If any required keys are missing. + """ + if not isinstance(s, dict): + raise TypeError("First argument must be a dict.") + missing = [f for f in fields if f not in s] + if missing: + raise ValueError( + f"Dict is missing fields: {', '.join(missing)}" + ) diff --git a/src/ndi/validators/mustHaveRequiredColumns.py b/src/ndi/validators/mustHaveRequiredColumns.py new file mode 100644 index 0000000..43355be --- /dev/null +++ b/src/ndi/validators/mustHaveRequiredColumns.py @@ -0,0 +1,53 @@ +""" +ndi.validators.mustHaveRequiredColumns + +MATLAB equivalent: +ndi/+validators/mustHaveRequiredColumns.m + +Validates that a pandas DataFrame contains the specified columns. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + import pandas as pd + + +def mustHaveRequiredColumns( + t: pd.DataFrame, + requiredCols: str | list[str], +) -> None: + """Validate that DataFrame *t* has all *requiredCols*. + + MATLAB equivalent: + ``ndi.validators.mustHaveRequiredColumns(t, requiredCols)`` + + Parameters + ---------- + t : pandas.DataFrame + The table to check. + requiredCols : str or list of str + Required column name(s). + + Raises + ------ + TypeError + If *t* is not a DataFrame. + ValueError + If any required columns are missing. + """ + import pandas as pd + + if not isinstance(t, pd.DataFrame): + raise TypeError("First argument must be a pandas DataFrame.") + + if isinstance(requiredCols, str): + requiredCols = [requiredCols] + + actual = set(t.columns) + missing = [c for c in requiredCols if c not in actual] + if missing: + raise ValueError( + f"Input table is missing required column(s): {', '.join(missing)}" + ) diff --git a/src/ndi/validators/mustMatchRegex.py b/src/ndi/validators/mustMatchRegex.py new file mode 100644 index 0000000..2ac1f88 --- /dev/null +++ b/src/ndi/validators/mustMatchRegex.py @@ -0,0 +1,43 @@ +""" +ndi.validators.mustMatchRegex + +MATLAB equivalent: +ndi/+validators/mustMatchRegex.m + +Validates that a string fully matches a regular expression pattern. +""" + +from __future__ import annotations + +import re + + +def mustMatchRegex(value: str, pattern: str) -> None: + """Validate that *value* fully matches *pattern*. + + MATLAB equivalent: ``ndi.validators.mustMatchRegex(value, pattern)`` + + The pattern is anchored (must match the entire string). + + Parameters + ---------- + value : str + The string to validate. + pattern : str + The regular expression pattern. + + Raises + ------ + TypeError + If *value* or *pattern* is not a string. + ValueError + If *value* does not match *pattern*. + """ + if not isinstance(value, str): + raise TypeError("Input value must be a string.") + if not isinstance(pattern, str): + raise TypeError("Pattern must be a string.") + + if not re.fullmatch(pattern, value): + raise ValueError( + f'Value "{value}" does not match the required pattern: "{pattern}".' + ) From 524262d4f4db3c3106175b5797630b0ee28f684f Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 11 Mar 2026 15:01:00 +0000 Subject: [PATCH 4/7] Add @pydantic.validate_call to all ndi.util public functions Per PYTHON_PORTING_GUIDE Rule 4, all public-facing API functions must use @pydantic.validate_call to mirror MATLAB arguments blocks. Replaces manual isinstance checks with Pydantic validation. Uses Annotated + pydantic.Field for constraints (ge=0, gt=0) and arbitrary_types_allowed=True where numpy/IO types are needed. https://claude.ai/code/session_016oPH9EoxCLcsUSTpgN9iZp --- src/ndi/util/datestamp2datetime.py | 8 ++++---- src/ndi/util/downsampleTimeseries.py | 15 +++++++++------ src/ndi/util/getHexDiffFromFileObj.py | 16 +++++++++++++--- src/ndi/util/hexDiff.py | 10 ++++++++-- src/ndi/util/hexDiffBytes.py | 12 +++++++++++- src/ndi/util/hexDump.py | 10 ++++++++-- src/ndi/util/rehydrateJSONNanNull.py | 8 ++++---- src/ndi/util/unwrapTableCellContent.py | 4 ++++ 8 files changed, 61 insertions(+), 22 deletions(-) diff --git a/src/ndi/util/datestamp2datetime.py b/src/ndi/util/datestamp2datetime.py index 68c513f..1e59d88 100644 --- a/src/ndi/util/datestamp2datetime.py +++ b/src/ndi/util/datestamp2datetime.py @@ -10,7 +10,10 @@ from datetime import datetime, timezone +import pydantic + +@pydantic.validate_call def datestamp2datetime(datestampStr: str) -> datetime: """Convert an NDI datestamp string to a :class:`datetime.datetime`. @@ -32,13 +35,10 @@ def datestamp2datetime(datestampStr: str) -> datetime: Raises ------ - TypeError + ValidationError If *datestampStr* is not a string. ValueError If the string cannot be parsed. """ - if not isinstance(datestampStr, str): - raise TypeError("Input must be a string.") - dt = datetime.fromisoformat(datestampStr) return dt.astimezone(timezone.utc) diff --git a/src/ndi/util/downsampleTimeseries.py b/src/ndi/util/downsampleTimeseries.py index a8f65db..817d170 100644 --- a/src/ndi/util/downsampleTimeseries.py +++ b/src/ndi/util/downsampleTimeseries.py @@ -9,14 +9,18 @@ from __future__ import annotations import warnings +from typing import Annotated import numpy as np +import pydantic +from pydantic import ConfigDict +@pydantic.validate_call(config=ConfigDict(arbitrary_types_allowed=True)) def downsampleTimeseries( t_in: np.ndarray, d_in: np.ndarray, - LP: float, + LP: Annotated[float, pydantic.Field(gt=0)], ) -> tuple[np.ndarray, np.ndarray]: """Downsample a timeseries with anti-aliasing. @@ -36,7 +40,7 @@ def downsampleTimeseries( Data matrix. Each column is a channel; rows correspond to samples in *t_in*. LP : float - Low-pass cutoff frequency in Hz. + Low-pass cutoff frequency in Hz. Must be positive. Returns ------- @@ -47,9 +51,10 @@ def downsampleTimeseries( Raises ------ + ValidationError + If *LP* is not positive or types are wrong. ValueError - If *t_in* and *d_in* have incompatible shapes, or *LP* is not - positive. + If *t_in* and *d_in* have incompatible shapes. """ from scipy.signal import cheby1, filtfilt @@ -64,8 +69,6 @@ def downsampleTimeseries( raise ValueError( "The number of rows in d_in must equal the length of t_in." ) - if LP <= 0: - raise ValueError("LP must be positive.") dt = np.median(np.diff(t_in)) fs = 1.0 / dt diff --git a/src/ndi/util/getHexDiffFromFileObj.py b/src/ndi/util/getHexDiffFromFileObj.py index ee79ffa..f712b00 100644 --- a/src/ndi/util/getHexDiffFromFileObj.py +++ b/src/ndi/util/getHexDiffFromFileObj.py @@ -8,16 +8,20 @@ from __future__ import annotations -from typing import IO +from typing import IO, Annotated + +import pydantic +from pydantic import ConfigDict from .hexDiffBytes import hexDiffBytes +@pydantic.validate_call(config=ConfigDict(arbitrary_types_allowed=True)) def getHexDiffFromFileObj( file_obj1: IO[bytes], file_obj2: IO[bytes], *, - chunkSize: int = 1024 * 1024, + chunkSize: Annotated[int, pydantic.Field(gt=0)] = 1024 * 1024, ) -> tuple[bool, str]: """Compare two file-like objects chunk by chunk. @@ -29,7 +33,8 @@ def getHexDiffFromFileObj( file_obj1, file_obj2 : file-like (binary mode) Open file objects to compare. chunkSize : int, optional - Number of bytes to read per chunk (default 1 MiB). + Number of bytes to read per chunk (default 1 MiB). Must be + positive. Returns ------- @@ -38,6 +43,11 @@ def getHexDiffFromFileObj( diff_output : str Empty when identical; hex diff of the first mismatched chunk otherwise. + + Raises + ------ + ValidationError + If types are wrong or *chunkSize* is not positive. """ file_obj1.seek(0, 2) size1 = file_obj1.tell() diff --git a/src/ndi/util/hexDiff.py b/src/ndi/util/hexDiff.py index 8b049ac..c04e07a 100644 --- a/src/ndi/util/hexDiff.py +++ b/src/ndi/util/hexDiff.py @@ -9,14 +9,18 @@ from __future__ import annotations from pathlib import Path +from typing import Annotated +import pydantic + +@pydantic.validate_call def hexDiff( filename1: str | Path, filename2: str | Path, *, - StartByte: int = 0, - StopByte: int | None = None, + StartByte: Annotated[int, pydantic.Field(ge=0)] = 0, + StopByte: Annotated[int, pydantic.Field(ge=0)] | None = None, ) -> None: """Compare two files and print differing 16-byte lines. @@ -34,6 +38,8 @@ def hexDiff( Raises ------ + ValidationError + If types are wrong or byte offsets are negative. FileNotFoundError If either file does not exist. ValueError diff --git a/src/ndi/util/hexDiffBytes.py b/src/ndi/util/hexDiffBytes.py index a62b3aa..446d6d0 100644 --- a/src/ndi/util/hexDiffBytes.py +++ b/src/ndi/util/hexDiffBytes.py @@ -8,14 +8,19 @@ from __future__ import annotations +from typing import Annotated + +import pydantic + from .hexDiff import _format_chunk +@pydantic.validate_call def hexDiffBytes( data1: bytes, data2: bytes, *, - StartOffset: int = 0, + StartOffset: Annotated[int, pydantic.Field(ge=0)] = 0, ) -> str: """Compare two byte sequences and return a hex diff string. @@ -34,6 +39,11 @@ def hexDiffBytes( str A formatted hex diff string. Empty if the sequences are identical in the compared range. + + Raises + ------ + ValidationError + If types are wrong or *StartOffset* is negative. """ max_size = max(len(data1), len(data2)) lines: list[str] = [] diff --git a/src/ndi/util/hexDump.py b/src/ndi/util/hexDump.py index c8dc070..c38dab0 100644 --- a/src/ndi/util/hexDump.py +++ b/src/ndi/util/hexDump.py @@ -10,15 +10,19 @@ import warnings from pathlib import Path +from typing import Annotated + +import pydantic from .hexDiff import _format_chunk +@pydantic.validate_call def hexDump( filename: str | Path, *, - StartByte: int = 0, - StopByte: int | None = None, + StartByte: Annotated[int, pydantic.Field(ge=0)] = 0, + StopByte: Annotated[int, pydantic.Field(ge=0)] | None = None, ) -> None: """Print a hex dump of a file. @@ -35,6 +39,8 @@ def hexDump( Raises ------ + ValidationError + If types are wrong or byte offsets are negative. FileNotFoundError If the file does not exist. ValueError diff --git a/src/ndi/util/rehydrateJSONNanNull.py b/src/ndi/util/rehydrateJSONNanNull.py index 91401f7..cb1a79a 100644 --- a/src/ndi/util/rehydrateJSONNanNull.py +++ b/src/ndi/util/rehydrateJSONNanNull.py @@ -9,7 +9,10 @@ from __future__ import annotations +import pydantic + +@pydantic.validate_call def rehydrateJSONNanNull( jsonText: str, *, @@ -46,12 +49,9 @@ def rehydrateJSONNanNull( Raises ------ - TypeError + ValidationError If *jsonText* is not a string. """ - if not isinstance(jsonText, str): - raise TypeError("Input must be a string.") - result = jsonText result = result.replace(nan_string, "NaN") result = result.replace(inf_string, "Infinity") diff --git a/src/ndi/util/unwrapTableCellContent.py b/src/ndi/util/unwrapTableCellContent.py index d9dc502..eafa170 100644 --- a/src/ndi/util/unwrapTableCellContent.py +++ b/src/ndi/util/unwrapTableCellContent.py @@ -12,7 +12,11 @@ import math from typing import Any +import pydantic +from pydantic import ConfigDict + +@pydantic.validate_call(config=ConfigDict(arbitrary_types_allowed=True)) def unwrapTableCellContent(cellValue: Any) -> Any: """Recursively unwrap nested lists to retrieve the innermost value. From 6402e04eb55c93d9ef75386a7b83225dedb3112d Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 11 Mar 2026 15:07:27 +0000 Subject: [PATCH 5/7] Add reusable validators (is_ndarray, is_iso8601) and update porting guide Create ndi.validators.is_ndarray and ndi.validators.is_iso8601 as reusable validation functions following the MATLAB +ndi/+validators/ pattern. Update PYTHON_PORTING_GUIDE Section 4 with new subsection 4a documenting the requirement to centralise custom validators in ndi/validators/ rather than writing inline checks. https://claude.ai/code/session_016oPH9EoxCLcsUSTpgN9iZp --- PYTHON_PORTING_GUIDE.md | 26 ++++++++++++++++++- src/ndi/validators/__init__.py | 4 +++ src/ndi/validators/is_iso8601.py | 43 ++++++++++++++++++++++++++++++++ src/ndi/validators/is_ndarray.py | 37 +++++++++++++++++++++++++++ 4 files changed, 109 insertions(+), 1 deletion(-) create mode 100644 src/ndi/validators/is_iso8601.py create mode 100644 src/ndi/validators/is_ndarray.py diff --git a/PYTHON_PORTING_GUIDE.md b/PYTHON_PORTING_GUIDE.md index fddabeb..dd6d93d 100644 --- a/PYTHON_PORTING_GUIDE.md +++ b/PYTHON_PORTING_GUIDE.md @@ -31,6 +31,23 @@ To replicate the robustness of the MATLAB `arguments` block, use Pydantic for al - MATLAB `char` or `string` → Python `str` - MATLAB `{member1, member2}` → Python `Literal["member1", "member2"]` - **Coercion:** Allow Pydantic's default behavior of casting (e.g., allowing a string `"1"` or integer `1` to satisfy a `bool` type). +- **Arbitrary Types:** When a function accepts types that Pydantic cannot serialise natively (e.g. `numpy.ndarray`, file-like `IO[bytes]`), pass `config=ConfigDict(arbitrary_types_allowed=True)` to the decorator. +- **Constraints:** Use `Annotated[type, pydantic.Field(...)]` to express MATLAB `arguments`-block constraints such as `mustBePositive` (`gt=0`), `mustBeNonnegative` (`ge=0`), and `mustBeInteger`. + +### 4a. Reusable Validators in `ndi.validators` + +MATLAB centralises custom validation functions in the `+ndi/+validators/` namespace. Python must do the same in the `ndi/validators/` package. + +- **When to create a validator:** Any type check, format check, or constraint that appears (or is likely to appear) in more than one function should be extracted into its own module under `ndi/validators/` instead of being written inline. +- **Naming convention:** MATLAB-originated validators keep their exact MATLAB name (e.g. `mustBeID`). Python-specific validators that have no MATLAB counterpart use `snake_case` prefixed with a descriptive verb (e.g. `is_ndarray`, `is_iso8601`). +- **Signature pattern:** Each validator takes a single value, raises `ValueError` on failure, and returns the validated value unchanged: + ```python + def is_ndarray(val: object) -> np.ndarray: + if not isinstance(val, np.ndarray): + raise ValueError("Input must be a numpy.ndarray") + return val + ``` +- **Registration:** Every new validator must be imported and listed in `ndi/validators/__init__.py` so it is accessible as `ndi.validators.`. ## 5. Error Handling @@ -85,12 +102,19 @@ Verified coverage of each MATLAB namespace against the Python port. See | MATLAB | Python | Coverage | |--------|--------|:--------:| -| 11 functions | 11 functions | **100 %** | +| 11 functions | 11 + 2 | **100 %** | All 11 MATLAB `arguments`-block validators ported 1:1 with matching function names. Python equivalents accept Python types (``list`` for cell array, ``dict`` for struct, ``pd.DataFrame`` for table). +Python adds two reusable validators with no direct MATLAB counterpart: + +| Python | Purpose | +|--------|---------| +| `is_ndarray` | Validates value is a `numpy.ndarray` | +| `is_iso8601` | Validates string is parseable ISO 8601 | + ### `ndi.util` — Fully Ported **Verified:** 2026-03-11 against `VH-Lab/NDI-matlab` branch `main`. diff --git a/src/ndi/validators/__init__.py b/src/ndi/validators/__init__.py index 9bc1ba4..3338c81 100644 --- a/src/ndi/validators/__init__.py +++ b/src/ndi/validators/__init__.py @@ -8,6 +8,8 @@ or via ``@pydantic.validate_call``. """ +from .is_iso8601 import is_iso8601 +from .is_ndarray import is_ndarray from .mustBeCellArrayOfClass import mustBeCellArrayOfClass from .mustBeCellArrayOfNdiSessions import mustBeCellArrayOfNdiSessions from .mustBeCellArrayOfNonEmptyCharacterArrays import ( @@ -23,6 +25,8 @@ from .mustMatchRegex import mustMatchRegex __all__ = [ + "is_iso8601", + "is_ndarray", "mustBeCellArrayOfClass", "mustBeCellArrayOfNdiSessions", "mustBeCellArrayOfNonEmptyCharacterArrays", diff --git a/src/ndi/validators/is_iso8601.py b/src/ndi/validators/is_iso8601.py new file mode 100644 index 0000000..3aa06ae --- /dev/null +++ b/src/ndi/validators/is_iso8601.py @@ -0,0 +1,43 @@ +""" +ndi.validators.is_iso8601 + +MATLAB equivalent: +ndi/+validators/ (custom; mirrors the ``arguments`` +block format check in ``datestamp2datetime.m``) + +Validates that a string is a parseable ISO 8601 datestamp. +""" + +from __future__ import annotations + +from datetime import datetime + + +def is_iso8601(val: object) -> str: + """Validate that *val* is a parseable ISO 8601 datestamp string. + + MATLAB equivalent: format validation in the ``arguments`` block of + ``ndi.util.datestamp2datetime`` (input format + ``'yyyy-MM-dd''T''HH:mm:ss.SSSXXX'``). + + Parameters + ---------- + val : object + The value to check. + + Returns + ------- + str + The validated string (unchanged). + + Raises + ------ + ValueError + If *val* is not a string or cannot be parsed as ISO 8601. + """ + if not isinstance(val, str): + raise ValueError("Input must be a string.") + try: + datetime.fromisoformat(val) + except (ValueError, TypeError) as exc: + raise ValueError(f"String is not valid ISO 8601: {val!r}") from exc + return val diff --git a/src/ndi/validators/is_ndarray.py b/src/ndi/validators/is_ndarray.py new file mode 100644 index 0000000..2540640 --- /dev/null +++ b/src/ndi/validators/is_ndarray.py @@ -0,0 +1,37 @@ +""" +ndi.validators.is_ndarray + +MATLAB equivalent: +ndi/+validators/ (custom; mirrors ``mustBeA(val, 'numeric')``) + +Validates that an input is a numpy ndarray. +""" + +from __future__ import annotations + +import numpy as np + + +def is_ndarray(val: object) -> np.ndarray: + """Validate that *val* is a :class:`numpy.ndarray`. + + MATLAB equivalent: type checking inside ``arguments`` blocks + (e.g. ``mustBeA(val, 'numeric')``). + + Parameters + ---------- + val : object + The value to check. + + Returns + ------- + numpy.ndarray + The validated array (unchanged). + + Raises + ------ + ValueError + If *val* is not a ``numpy.ndarray``. + """ + if not isinstance(val, np.ndarray): + raise ValueError("Input must be a numpy.ndarray") + return val From dab40b40786050b33018b57cdefdcab9c59bae6f Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 11 Mar 2026 15:16:03 +0000 Subject: [PATCH 6/7] Add code style section to porting guide and run black on all files Add Section 5 (Code Style & Linting) to PYTHON_PORTING_GUIDE requiring black --check before every commit. Run black to fix the 12 files that were out of compliance. https://claude.ai/code/session_016oPH9EoxCLcsUSTpgN9iZp --- PYTHON_PORTING_GUIDE.md | 13 ++++++++-- src/ndi/util/downsampleTimeseries.py | 4 +-- src/ndi/util/hexDiff.py | 25 +++++-------------- src/ndi/util/hexDiffBytes.py | 10 ++------ src/ndi/util/hexDump.py | 11 +++----- ...ustBeCellArrayOfNonEmptyCharacterArrays.py | 4 +-- src/ndi/validators/mustBeClassnameOfType.py | 3 +-- src/ndi/validators/mustBeEpochInput.py | 4 +-- src/ndi/validators/mustBeID.py | 8 ++---- src/ndi/validators/mustBeTextLike.py | 4 +-- src/ndi/validators/mustHaveFields.py | 4 +-- src/ndi/validators/mustHaveRequiredColumns.py | 4 +-- src/ndi/validators/mustMatchRegex.py | 4 +-- 13 files changed, 32 insertions(+), 66 deletions(-) diff --git a/PYTHON_PORTING_GUIDE.md b/PYTHON_PORTING_GUIDE.md index dd6d93d..fb0d2d3 100644 --- a/PYTHON_PORTING_GUIDE.md +++ b/PYTHON_PORTING_GUIDE.md @@ -49,12 +49,21 @@ MATLAB centralises custom validation functions in the `+ndi/+validators/` namesp ``` - **Registration:** Every new validator must be imported and listed in `ndi/validators/__init__.py` so it is accessible as `ndi.validators.`. -## 5. Error Handling +## 5. Code Style & Linting + +All Python code must pass **`black`** formatting before being committed. + +- **Formatter:** [Black](https://black.readthedocs.io/) is the project's sole code formatter. +- **Before committing:** Always run `black --check src/ tests/` to verify formatting. If any files would be reformatted, run `black src/ tests/` to fix them before committing. +- **Line length:** Use Black's default (88 characters). +- **No manual formatting overrides:** Do not use `# fmt: off` / `# fmt: on` unless absolutely necessary. + +## 6. Error Handling - If a MATLAB function throws an error for a specific condition, the Python version must raise a corresponding Exception (`ValueError`, `TypeError`, or a custom `NDIError`). - The goal is to ensure that a user providing bad input gets a **"Hard Fail"** at the function entry point in both languages. -## 6. Documentation (Docstring Symmetry) +## 7. Documentation (Docstring Symmetry) - Include the original MATLAB documentation in the Python docstring. - Note any Python-specific requirements (like specific library dependencies) at the bottom of the docstring. diff --git a/src/ndi/util/downsampleTimeseries.py b/src/ndi/util/downsampleTimeseries.py index 817d170..3face52 100644 --- a/src/ndi/util/downsampleTimeseries.py +++ b/src/ndi/util/downsampleTimeseries.py @@ -66,9 +66,7 @@ def downsampleTimeseries( if d_in.ndim == 1: d_in = d_in[:, np.newaxis] if d_in.shape[0] != t_in.shape[0]: - raise ValueError( - "The number of rows in d_in must equal the length of t_in." - ) + raise ValueError("The number of rows in d_in must equal the length of t_in.") dt = np.median(np.diff(t_in)) fs = 1.0 / dt diff --git a/src/ndi/util/hexDiff.py b/src/ndi/util/hexDiff.py index c04e07a..a1d4acb 100644 --- a/src/ndi/util/hexDiff.py +++ b/src/ndi/util/hexDiff.py @@ -54,18 +54,14 @@ def hexDiff( StopByte = max_size - 1 if max_size > 0 else -1 if StartByte >= max_size and max_size > 0: - raise ValueError( - f"StartByte ({StartByte}) is beyond the end of both files." - ) + raise ValueError(f"StartByte ({StartByte}) is beyond the end of both files.") if StartByte > StopByte: raise ValueError( - f"StartByte ({StartByte}) cannot be greater than " - f"StopByte ({StopByte})." + f"StartByte ({StartByte}) cannot be greater than " f"StopByte ({StopByte})." ) print( - f'Comparing "{filename1}" ({len(data1)} bytes) with ' - f'"{filename2}" ({len(data2)} bytes)' + f'Comparing "{filename1}" ({len(data1)} bytes) with ' f'"{filename2}" ({len(data2)} bytes)' ) print("Displaying only differing 16-byte lines...") print("-" * 140) @@ -87,14 +83,8 @@ def hexDiff( def _print_header() -> None: - h1 = ( - " Offset(h) 00 01 02 03 04 05 06 07 " - "08 09 0A 0B 0C 0D 0E 0F |ASCII |" - ) - h2 = ( - " | 00 01 02 03 04 05 06 07 " - "08 09 0A 0B 0C 0D 0E 0F |ASCII |" - ) + h1 = " Offset(h) 00 01 02 03 04 05 06 07 " "08 09 0A 0B 0C 0D 0E 0F |ASCII |" + h2 = " | 00 01 02 03 04 05 06 07 " "08 09 0A 0B 0C 0D 0E 0F |ASCII |" print(h1 + h2) print("-" * 140) @@ -121,7 +111,4 @@ def _format_chunk(chunk: bytes) -> str: def _print_diff_line(offset: int, chunk1: bytes, chunk2: bytes) -> None: - print( - f"{offset:08x}: {_format_chunk(chunk1)} | " - f"{_format_chunk(chunk2)}" - ) + print(f"{offset:08x}: {_format_chunk(chunk1)} | " f"{_format_chunk(chunk2)}") diff --git a/src/ndi/util/hexDiffBytes.py b/src/ndi/util/hexDiffBytes.py index 446d6d0..7823983 100644 --- a/src/ndi/util/hexDiffBytes.py +++ b/src/ndi/util/hexDiffBytes.py @@ -59,17 +59,11 @@ def hexDiffBytes( " Offset(h) 00 01 02 03 04 05 06 07 " "08 09 0A 0B 0C 0D 0E 0F |ASCII |" ) - h2 = ( - " | 00 01 02 03 04 05 06 07 " - "08 09 0A 0B 0C 0D 0E 0F |ASCII |" - ) + h2 = " | 00 01 02 03 04 05 06 07 " "08 09 0A 0B 0C 0D 0E 0F |ASCII |" lines.append(h1 + h2) lines.append("-" * 140) header_added = True - lines.append( - f"{offset:08x}: {_format_chunk(chunk1)} | " - f"{_format_chunk(chunk2)}" - ) + lines.append(f"{offset:08x}: {_format_chunk(chunk1)} | " f"{_format_chunk(chunk2)}") return "\n".join(lines) if lines else "" diff --git a/src/ndi/util/hexDump.py b/src/ndi/util/hexDump.py index c38dab0..0ab5872 100644 --- a/src/ndi/util/hexDump.py +++ b/src/ndi/util/hexDump.py @@ -64,8 +64,7 @@ def hexDump( if StartByte >= file_size: raise ValueError( - f"StartByte ({StartByte}) is beyond the end of the file " - f"(size: {file_size} bytes)." + f"StartByte ({StartByte}) is beyond the end of the file " f"(size: {file_size} bytes)." ) if StopByte >= file_size: warnings.warn( @@ -76,8 +75,7 @@ def hexDump( StopByte = file_size - 1 if StartByte > StopByte: raise ValueError( - f"StartByte ({StartByte}) cannot be greater than " - f"StopByte ({StopByte})." + f"StartByte ({StartByte}) cannot be greater than " f"StopByte ({StopByte})." ) print("-" * 77) @@ -85,10 +83,7 @@ def hexDump( print(f" File Size: {file_size} bytes") print(f" Displaying bytes {StartByte} through {StopByte}") print("-" * 77) - print( - " Offset(h) 00 01 02 03 04 05 06 07 " - "08 09 0A 0B 0C 0D 0E 0F |ASCII |" - ) + print(" Offset(h) 00 01 02 03 04 05 06 07 " "08 09 0A 0B 0C 0D 0E 0F |ASCII |") print("-" * 77) region = data[StartByte : StopByte + 1] diff --git a/src/ndi/validators/mustBeCellArrayOfNonEmptyCharacterArrays.py b/src/ndi/validators/mustBeCellArrayOfNonEmptyCharacterArrays.py index ce602b0..dbeaf3e 100644 --- a/src/ndi/validators/mustBeCellArrayOfNonEmptyCharacterArrays.py +++ b/src/ndi/validators/mustBeCellArrayOfNonEmptyCharacterArrays.py @@ -42,6 +42,4 @@ def mustBeCellArrayOfNonEmptyCharacterArrays(value: Sequence) -> None: f"Element {i} is of type {type(item).__name__!r}." ) if not item: - raise ValueError( - f"All elements must be non-empty strings. Element {i} is empty." - ) + raise ValueError(f"All elements must be non-empty strings. Element {i} is empty.") diff --git a/src/ndi/validators/mustBeClassnameOfType.py b/src/ndi/validators/mustBeClassnameOfType.py index 601509f..47f3dbe 100644 --- a/src/ndi/validators/mustBeClassnameOfType.py +++ b/src/ndi/validators/mustBeClassnameOfType.py @@ -56,6 +56,5 @@ def mustBeClassnameOfType(classname: str, requiredType: type) -> None: if not issubclass(cls, requiredType): raise ValueError( - f"Class {classname!r} must be a subclass of " - f"{requiredType.__name__!r}." + f"Class {classname!r} must be a subclass of " f"{requiredType.__name__!r}." ) diff --git a/src/ndi/validators/mustBeEpochInput.py b/src/ndi/validators/mustBeEpochInput.py index edd5768..8d8e85a 100644 --- a/src/ndi/validators/mustBeEpochInput.py +++ b/src/ndi/validators/mustBeEpochInput.py @@ -39,6 +39,4 @@ def mustBeEpochInput(v: str | int) -> None: raise ValueError("Epoch input integer must be positive.") return - raise TypeError( - "Value must be a string or positive integer scalar." - ) + raise TypeError("Value must be a string or positive integer scalar.") diff --git a/src/ndi/validators/mustBeID.py b/src/ndi/validators/mustBeID.py index c2f64ce..6a88721 100644 --- a/src/ndi/validators/mustBeID.py +++ b/src/ndi/validators/mustBeID.py @@ -39,15 +39,11 @@ def mustBeID(inputArg: str) -> None: if len(inputArg) != 33: raise ValueError( - f"Input must be exactly 33 characters long " - f"(actual length was {len(inputArg)})." + f"Input must be exactly 33 characters long " f"(actual length was {len(inputArg)})." ) if inputArg[16] != "_": - raise ValueError( - f"Character 17 must be an underscore (_), " - f"but found {inputArg[16]!r}." - ) + raise ValueError(f"Character 17 must be an underscore (_), " f"but found {inputArg[16]!r}.") if not _ID_PATTERN.match(inputArg): # Find first invalid character for a helpful message. diff --git a/src/ndi/validators/mustBeTextLike.py b/src/ndi/validators/mustBeTextLike.py index 33d81c3..a12e7bd 100644 --- a/src/ndi/validators/mustBeTextLike.py +++ b/src/ndi/validators/mustBeTextLike.py @@ -36,6 +36,4 @@ def mustBeTextLike(value: str | list | tuple) -> None: if all(isinstance(item, str) for item in value): return - raise TypeError( - "Input must be a string, or a list/tuple of strings." - ) + raise TypeError("Input must be a string, or a list/tuple of strings.") diff --git a/src/ndi/validators/mustHaveFields.py b/src/ndi/validators/mustHaveFields.py index d046417..e5f9433 100644 --- a/src/ndi/validators/mustHaveFields.py +++ b/src/ndi/validators/mustHaveFields.py @@ -37,6 +37,4 @@ def mustHaveFields(s: dict, fields: Sequence[str]) -> None: raise TypeError("First argument must be a dict.") missing = [f for f in fields if f not in s] if missing: - raise ValueError( - f"Dict is missing fields: {', '.join(missing)}" - ) + raise ValueError(f"Dict is missing fields: {', '.join(missing)}") diff --git a/src/ndi/validators/mustHaveRequiredColumns.py b/src/ndi/validators/mustHaveRequiredColumns.py index 43355be..73023d8 100644 --- a/src/ndi/validators/mustHaveRequiredColumns.py +++ b/src/ndi/validators/mustHaveRequiredColumns.py @@ -48,6 +48,4 @@ def mustHaveRequiredColumns( actual = set(t.columns) missing = [c for c in requiredCols if c not in actual] if missing: - raise ValueError( - f"Input table is missing required column(s): {', '.join(missing)}" - ) + raise ValueError(f"Input table is missing required column(s): {', '.join(missing)}") diff --git a/src/ndi/validators/mustMatchRegex.py b/src/ndi/validators/mustMatchRegex.py index 2ac1f88..1a000d6 100644 --- a/src/ndi/validators/mustMatchRegex.py +++ b/src/ndi/validators/mustMatchRegex.py @@ -38,6 +38,4 @@ def mustMatchRegex(value: str, pattern: str) -> None: raise TypeError("Pattern must be a string.") if not re.fullmatch(pattern, value): - raise ValueError( - f'Value "{value}" does not match the required pattern: "{pattern}".' - ) + raise ValueError(f'Value "{value}" does not match the required pattern: "{pattern}".') From 263f9179347135187a6189859832f47ee6533291 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 11 Mar 2026 15:17:35 +0000 Subject: [PATCH 7/7] Add ruff linting to porting guide and fix unused import Add ruff check requirement alongside black in Section 5 of PYTHON_PORTING_GUIDE. Fix unused `math` import in unwrapTableCellContent.py caught by ruff. https://claude.ai/code/session_016oPH9EoxCLcsUSTpgN9iZp --- PYTHON_PORTING_GUIDE.md | 9 ++++++--- src/ndi/util/unwrapTableCellContent.py | 1 - 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/PYTHON_PORTING_GUIDE.md b/PYTHON_PORTING_GUIDE.md index fb0d2d3..5dee328 100644 --- a/PYTHON_PORTING_GUIDE.md +++ b/PYTHON_PORTING_GUIDE.md @@ -51,12 +51,15 @@ MATLAB centralises custom validation functions in the `+ndi/+validators/` namesp ## 5. Code Style & Linting -All Python code must pass **`black`** formatting before being committed. +All Python code must pass **`black`** formatting and **`ruff`** linting before being committed. - **Formatter:** [Black](https://black.readthedocs.io/) is the project's sole code formatter. -- **Before committing:** Always run `black --check src/ tests/` to verify formatting. If any files would be reformatted, run `black src/ tests/` to fix them before committing. +- **Linter:** [Ruff](https://docs.astral.sh/ruff/) is the project's linter. +- **Before committing:** Always run both checks and fix any issues: + 1. `ruff check src/ tests/` — fix any lint errors (unused imports, undefined names, etc.). Use `ruff check --fix src/ tests/` for auto-fixable issues. + 2. `black --check src/ tests/` — verify formatting. Run `black src/ tests/` to fix. - **Line length:** Use Black's default (88 characters). -- **No manual formatting overrides:** Do not use `# fmt: off` / `# fmt: on` unless absolutely necessary. +- **No manual formatting overrides:** Do not use `# fmt: off` / `# fmt: on` or `# noqa` unless absolutely necessary. ## 6. Error Handling diff --git a/src/ndi/util/unwrapTableCellContent.py b/src/ndi/util/unwrapTableCellContent.py index eafa170..ce35126 100644 --- a/src/ndi/util/unwrapTableCellContent.py +++ b/src/ndi/util/unwrapTableCellContent.py @@ -9,7 +9,6 @@ from __future__ import annotations -import math from typing import Any import pydantic