diff --git a/.gitignore b/.gitignore index 85bad5a..4cb6761 100644 --- a/.gitignore +++ b/.gitignore @@ -22,3 +22,6 @@ htmlcov/* # docs related docs/_build/ docs/source/_autosummary/ + +# dev related +.vscode/ diff --git a/docs/source/quickstart.rst b/docs/source/quickstart.rst index 92c023b..6fbf8c0 100644 --- a/docs/source/quickstart.rst +++ b/docs/source/quickstart.rst @@ -24,11 +24,14 @@ Read a SigMF Recording import sigmf handle = sigmf.fromfile("example.sigmf") - handle.read_samples() # returns all timeseries data + # reading data + handle.read_samples() # read all timeseries data + handle[10:50] # read memory slice of samples 10 through 50 + # accessing metadata + handle.sample_rate # get sample rate attribute handle.get_global_info() # returns 'global' dictionary handle.get_captures() # returns list of 'captures' dictionaries handle.get_annotations() # returns list of all annotations - handle[10:50] # return memory slice of samples 10 through 50 ----------------------------------- Verify SigMF Integrity & Compliance @@ -52,16 +55,16 @@ Save a Numpy array as a SigMF Recording data = np.zeros(1024, dtype=np.complex64) # write those samples to file in cf32_le - data.tofile('example_cf32.sigmf-data') + data.tofile("example.sigmf-data") # create the metadata meta = SigMFFile( - data_file='example_cf32.sigmf-data', # extension is optional + data_file="example.sigmf-data", # extension is optional global_info = { - SigMFFile.DATATYPE_KEY: get_data_type_str(data), # in this case, 'cf32_le' + SigMFFile.DATATYPE_KEY: get_data_type_str(data), # in this case, "cf32_le" SigMFFile.SAMPLE_RATE_KEY: 48000, - SigMFFile.AUTHOR_KEY: 'jane.doe@domain.org', - SigMFFile.DESCRIPTION_KEY: 'All zero complex float32 example file.', + SigMFFile.AUTHOR_KEY: "jane.doe@domain.org", + SigMFFile.DESCRIPTION_KEY: "All zero complex float32 example file.", } ) @@ -75,8 +78,40 @@ Save a Numpy array as a SigMF Recording meta.add_annotation(100, 200, metadata = { SigMFFile.FLO_KEY: 914995000.0, SigMFFile.FHI_KEY: 915005000.0, - SigMFFile.COMMENT_KEY: 'example annotation', + SigMFFile.COMMENT_KEY: "example annotation", }) - # check for mistakes & write to disk - meta.tofile('example_cf32.sigmf-meta') # extension is optional + # validate & write to disk + meta.tofile("example.sigmf-meta") # extension is optional + +---------------------------------- +Attribute Access for Global Fields +---------------------------------- + +SigMF-Python provides convenient attribute read/write access for core global +metadata fields, allowing you use simple dot notation alongside the traditional +method-based approach. + +.. code-block:: python + + import sigmf + + # read some recording + meta = sigmf.SigMFFile("sigmf_logo") + + # read global metadata + print(f"Sample rate: {meta.sample_rate}") + print(f"License: {meta.license}") + + # set global metadata + meta.description = "Updated description via attribute access" + meta.author = "jane.doe@domain.org" + + # validate & write changes to disk + meta.tofile("sigmf_logo_updated") + +.. note:: + + Only core **global** fields support attribute access. Capture and annotation + fields must still be accessed using the traditional ``get_captures()`` and + ``get_annotations()`` methods. diff --git a/sigmf/__init__.py b/sigmf/__init__.py index b23ff4d..b4667ac 100644 --- a/sigmf/__init__.py +++ b/sigmf/__init__.py @@ -5,7 +5,7 @@ # SPDX-License-Identifier: LGPL-3.0-or-later # version of this python module -__version__ = "1.2.12" +__version__ = "1.3.0" # matching version of the SigMF specification __specification__ = "1.2.5" diff --git a/sigmf/sigmffile.py b/sigmf/sigmffile.py index 1203c53..8082d70 100644 --- a/sigmf/sigmffile.py +++ b/sigmf/sigmffile.py @@ -125,8 +125,8 @@ class SigMFFile(SigMFMetafile): COMMENT_KEY = "core:comment" DESCRIPTION_KEY = "core:description" AUTHOR_KEY = "core:author" - META_DOI_KEY = "core:meta-doi" - DATA_DOI_KEY = "core:data-doi" + META_DOI_KEY = "core:meta_doi" + DATA_DOI_KEY = "core:data_doi" GENERATOR_KEY = "core:generator" LABEL_KEY = "core:label" RECORDER_KEY = "core:recorder" @@ -146,14 +146,38 @@ class SigMFFile(SigMFMetafile): CAPTURE_KEY = "captures" ANNOTATION_KEY = "annotations" VALID_GLOBAL_KEYS = [ - AUTHOR_KEY, COLLECTION_KEY, DATASET_KEY, DATATYPE_KEY, DATA_DOI_KEY, DESCRIPTION_KEY, EXTENSIONS_KEY, - GEOLOCATION_KEY, HASH_KEY, HW_KEY, LICENSE_KEY, META_DOI_KEY, METADATA_ONLY_KEY, NUM_CHANNELS_KEY, RECORDER_KEY, - SAMPLE_RATE_KEY, START_OFFSET_KEY, TRAILING_BYTES_KEY, VERSION_KEY + AUTHOR_KEY, + COLLECTION_KEY, + DATASET_KEY, + DATATYPE_KEY, + DATA_DOI_KEY, + DESCRIPTION_KEY, + EXTENSIONS_KEY, + GEOLOCATION_KEY, + HASH_KEY, + HW_KEY, + LICENSE_KEY, + META_DOI_KEY, + METADATA_ONLY_KEY, + NUM_CHANNELS_KEY, + RECORDER_KEY, + SAMPLE_RATE_KEY, + START_OFFSET_KEY, + TRAILING_BYTES_KEY, + VERSION_KEY, ] VALID_CAPTURE_KEYS = [DATETIME_KEY, FREQUENCY_KEY, HEADER_BYTES_KEY, GLOBAL_INDEX_KEY, START_INDEX_KEY] VALID_ANNOTATION_KEYS = [ - COMMENT_KEY, FHI_KEY, FLO_KEY, GENERATOR_KEY, LABEL_KEY, LAT_KEY, LENGTH_INDEX_KEY, LON_KEY, START_INDEX_KEY, - UUID_KEY + COMMENT_KEY, + FHI_KEY, + FLO_KEY, + GENERATOR_KEY, + LABEL_KEY, + LAT_KEY, + LENGTH_INDEX_KEY, + LON_KEY, + START_INDEX_KEY, + UUID_KEY, ] VALID_KEYS = {GLOBAL_KEY: VALID_GLOBAL_KEYS, CAPTURE_KEY: VALID_CAPTURE_KEYS, ANNOTATION_KEY: VALID_ANNOTATION_KEYS} @@ -200,6 +224,75 @@ def __eq__(self, other): return self._metadata == other._metadata return False + def __getattr__(self, name): + """ + Enable dynamic attribute access for core global metadata fields. + + Allows convenient access to core metadata fields using attribute notation: + - `sigmf_file.sample_rate` returns `sigmf_file._metadata["global"]["core:sample_rate"] + - `sigmf_file.author` returns `sigmf_file._metadata["global"]["core:author"] + + Parameters + ---------- + name : str + Attribute name corresponding to a core field (without "core:" prefix). + + Returns + ------- + value + The value of the core field from global metadata, or None if not set. + + Raises + ------ + SigMFAccessError + If the attribute name doesn't correspond to a valid core global field. + """ + # iterate through valid global keys to find matching core field + for key in self.VALID_GLOBAL_KEYS: + if key.startswith("core:") and key[5:] == name: + field_value = self.get_global_field(key) + if field_value is None: + raise SigMFAccessError(f"Core field '{key}' does not exist in global metadata") + return field_value + + # if we get here, the attribute doesn't correspond to a core field + raise AttributeError(f"'{type(self).__name__}' object has no attribute '{name}'") + + def __setattr__(self, name, value): + """ + Enable dynamic attribute setting for core global metadata fields. + + Allows convenient setting of core metadata fields using attribute notation: + - `sigmf_file.sample_rate = 1000000` sets `sigmf_file._metadata["global"]["core:sample_rate"] + - `sigmf_file.author = "jane.doe@domain.org"` sets `sigmf_file._metadata["global"]["core:author"] + + Parameters + ---------- + name : str + Attribute name. If it corresponds to a core field (without "core:" prefix), + the value will be set in global metadata. Otherwise, normal attribute setting occurs. + value + The value to set for the field. + """ + # handle regular instance attributes, existing properties, or during initialization + if ( + name.startswith("_") + or hasattr(type(self), name) + or not hasattr(self, "_metadata") + or self._metadata is None + ): + super().__setattr__(name, value) + return + + # check if this corresponds to a core global field + for key in self.VALID_GLOBAL_KEYS: + if key.startswith("core:") and key[5:] == name: + self.set_global_field(key, value) + return + + # fall back to normal attribute setting for non-core attributes + super().__setattr__(name, value) + def __next__(self): """get next batch of samples""" if self.iter_position < len(self): @@ -229,15 +322,15 @@ def __getitem__(self, sli): raise ValueError("unhandled ndim in SigMFFile.__getitem__(); this shouldn't happen") return ray[0] if isinstance(sli, int) else ray # return element instead of 1-element array - def _get_start_offset(self): - """ - Return the offset of the first sample. - """ - return self.get_global_field(self.START_OFFSET_KEY, 0) - def get_num_channels(self): - """Returns integer number of channels if present, otherwise 1""" - return self.get_global_field(self.NUM_CHANNELS_KEY, 1) + """Return integer number of channels.""" + warnings.warn( + "get_num_channels() is deprecated and will be removed in a future version of sigmf. " + "Use the 'num_channels' attribute instead.", + DeprecationWarning, + stacklevel=2, + ) + return self.num_channels def _is_conforming_dataset(self): """ @@ -287,9 +380,11 @@ def set_metadata(self, metadata): else: raise SigMFError("Unable to interpret provided metadata.") - # if num_channels missing, default to 1 + # ensure fields required for parsing are present or use defaults if self.get_global_field(self.NUM_CHANNELS_KEY) is None: self.set_global_field(self.NUM_CHANNELS_KEY, 1) + if self.get_global_field(self.START_OFFSET_KEY) is None: + self.set_global_field(self.START_OFFSET_KEY, 0) # set version to current implementation self.set_global_field(self.VERSION_KEY, __specification__) @@ -327,7 +422,8 @@ def add_capture(self, start_index, metadata=None): If there is already capture info for this index, metadata will be merged with the existing metadata, overwriting keys if they were previously set. """ - assert start_index >= self._get_start_offset() + if start_index < self.offset: + raise SigMFFileError("Capture start_index cannot be less than dataset start offset.") capture_list = self._metadata[self.CAPTURE_KEY] new_capture = metadata or {} new_capture[self.START_INDEX_KEY] = start_index @@ -353,12 +449,13 @@ def get_captures(self): def get_capture_info(self, index): """ - Returns a dictionary containing all the capture information at sample - 'index'. + Returns a dictionary containing all the capture information at sample index. """ - assert index >= self._get_start_offset() + if index < self.offset: + raise SigMFFileError("Sample index cannot be less than dataset start offset.") captures = self._metadata.get(self.CAPTURE_KEY, []) - assert len(captures) > 0 + if len(captures) == 0: + raise SigMFFileError("No captures in metadata.") cap_info = captures[0] for capture in captures: if capture[self.START_INDEX_KEY] > index: @@ -391,9 +488,7 @@ def get_capture_byte_boundarys(self, index): prev_start_sample = 0 for ii, capture in enumerate(self.get_captures()): start_byte += capture.get(self.HEADER_BYTES_KEY, 0) - start_byte += ( - (self.get_capture_start(ii) - prev_start_sample) * self.get_sample_size() * self.get_num_channels() - ) + start_byte += (self.get_capture_start(ii) - prev_start_sample) * self.get_sample_size() * self.num_channels prev_start_sample = self.get_capture_start(ii) if ii >= index: break @@ -405,7 +500,7 @@ def get_capture_byte_boundarys(self, index): end_byte += ( (self.get_capture_start(index + 1) - self.get_capture_start(index)) * self.get_sample_size() - * self.get_num_channels() + * self.num_channels ) return (start_byte, end_byte) @@ -413,7 +508,8 @@ def add_annotation(self, start_index, length=None, metadata=None): """ Insert annotation at start_index with length (if != None). """ - assert start_index >= self._get_start_offset() + if start_index < self.offset: + raise SigMFFileError("Annotation start_index cannot be less than dataset start offset.") new_annot = metadata or {} new_annot[self.START_INDEX_KEY] = start_index @@ -466,7 +562,7 @@ def get_sample_size(self): Determines the size of a sample, in bytes, from the datatype of this set. For complex data, a 'sample' includes both the real and imaginary part. """ - return dtype_info(self.get_global_field(self.DATATYPE_KEY))["sample_size"] + return dtype_info(self.datatype)["sample_size"] def _count_samples(self): """ @@ -483,7 +579,7 @@ def _count_samples(self): file_size = self.data_file.stat().st_size if self.data_size_bytes is None else self.data_size_bytes file_data_size = file_size - self.get_global_field(self.TRAILING_BYTES_KEY, 0) - header_bytes # bytes sample_size = self.get_sample_size() # size of a sample in bytes - num_channels = self.get_num_channels() + num_channels = self.num_channels sample_count = file_data_size // sample_size // num_channels if file_data_size % (sample_size * num_channels) != 0: warnings.warn( @@ -560,7 +656,7 @@ def set_data_file( dtype = dtype_info(self.get_global_field(self.DATATYPE_KEY)) self.is_complex_data = dtype["is_complex"] - num_channels = self.get_num_channels() + num_channels = self.num_channels self.ndim = 1 if (num_channels < 2) else 2 complex_int_separates = dtype["is_complex"] and dtype["is_fixedpoint"] @@ -656,7 +752,7 @@ def read_samples_in_capture(self, index=0, autoscale=True): Samples are returned as an array of float or complex, with number of dimensions equal to NUM_CHANNELS_KEY. """ cb = self.get_capture_byte_boundarys(index) - if (cb[1] - cb[0]) % (self.get_sample_size() * self.get_num_channels()): + if (cb[1] - cb[0]) % (self.get_sample_size() * self.num_channels): warnings.warn( f"Capture `{index}` in `{self.data_file}` does not contain " "an integer number of samples across channels. It may be invalid." @@ -695,11 +791,11 @@ def read_samples(self, start_index=0, count=-1, autoscale=True, raw_components=F raise SigMFFileError("Cannot read samples from a metadata only distribution.") else: raise SigMFFileError("No signal data file has been associated with the metadata.") - first_byte = start_index * self.get_sample_size() * self.get_num_channels() + first_byte = start_index * self.get_sample_size() * self.num_channels if not self._is_conforming_dataset(): warnings.warn(f"Recording dataset appears non-compliant, resulting data may be erroneous") - return self._read_datafile(first_byte, count * self.get_num_channels(), autoscale, False) + return self._read_datafile(first_byte, count * self.num_channels, autoscale, False) def _read_datafile(self, first_byte, nitems, autoscale, raw_components): """ @@ -714,7 +810,7 @@ def _read_datafile(self, first_byte, nitems, autoscale, raw_components): component_size = dtype["component_size"] data_type_out = np.dtype("f4") if not self.is_complex_data else np.dtype("f4, f4") - num_channels = self.get_num_channels() + num_channels = self.num_channels if self.data_file is not None: fp = open(self.data_file, "rb") @@ -768,7 +864,9 @@ class SigMFCollection(SigMFMetafile): ] VALID_KEYS = {COLLECTION_KEY: VALID_COLLECTION_KEYS} - def __init__(self, metafiles: list = None, metadata: dict = None, base_path=None, skip_checksums: bool = False) -> None: + def __init__( + self, metafiles: list = None, metadata: dict = None, base_path=None, skip_checksums: bool = False + ) -> None: """ Create a SigMF Collection object. @@ -1046,6 +1144,7 @@ def fromarchive(archive_path, dir=None, skip_checksum=False): access SigMF archives without extracting them. """ from .archivereader import SigMFArchiveReader + return SigMFArchiveReader(archive_path, skip_checksum=skip_checksum).sigmffile @@ -1119,8 +1218,10 @@ def get_sigmf_filenames(filename): # suffix, because the filename might contain '.' characters which are part # of the filename rather than an extension. sigmf_suffixes = [ - SIGMF_DATASET_EXT, SIGMF_METADATA_EXT, - SIGMF_ARCHIVE_EXT, SIGMF_COLLECTION_EXT, + SIGMF_DATASET_EXT, + SIGMF_METADATA_EXT, + SIGMF_ARCHIVE_EXT, + SIGMF_COLLECTION_EXT, ] if stem_path.suffix in sigmf_suffixes: with_suffix_path = stem_path diff --git a/tests/test_attributes.py b/tests/test_attributes.py new file mode 100644 index 0000000..f6a1f61 --- /dev/null +++ b/tests/test_attributes.py @@ -0,0 +1,94 @@ +"""Tests for dynamic attribute access functionality.""" + +import copy +import unittest + +import numpy as np + +from sigmf import SigMFFile +from sigmf.error import SigMFAccessError + +from .testdata import TEST_METADATA + +SOME_LICENSE = "CC0-1.0" +SOME_RECORDER = "HackRF Pro" +SOME_DOI = "10.1000/182" + + +class TestDynamicAttributeAccess(unittest.TestCase): + """Test dynamic attribute access for core global metadata fields.""" + + def setUp(self): + """create test sigmf file with some initial metadata""" + self.meta = SigMFFile(copy.deepcopy(TEST_METADATA)) + + def test_getter_existing_fields(self): + """test attribute getters for existing core fields""" + # test common core fields + # self.assertEqual(self.meta.sample_rate, self.meta.get_global_field(SigMFFile.SAMPLE_RATE_KEY)) + # self.assertEqual(self.meta.author, self.meta.get_global_field(SigMFFile.AUTHOR_KEY)) + self.assertEqual(self.meta.datatype, self.meta.get_global_field(SigMFFile.DATATYPE_KEY)) + self.assertEqual(self.meta.sha512, self.meta.get_global_field(SigMFFile.HASH_KEY)) + # self.assertEqual(self.meta.description, self.meta.get_global_field(SigMFFile.DESCRIPTION_KEY)) + + def test_getter_missing_core_fields(self): + """test that getter raises SigMFAccessError for missing core fields""" + with self.assertRaises(SigMFAccessError) as context: + _ = self.meta.license + self.assertIn(SigMFFile.LICENSE_KEY, str(context.exception)) + + def test_getter_nonexistent_attribute(self): + """test that getter raises AttributeError for non-core attributes""" + with self.assertRaises(AttributeError) as context: + _ = self.meta.nonexistent_field + self.assertIn("nonexistent_field", str(context.exception)) + + def test_setter_new_fields(self): + """test that attribute setters work for new core fields""" + # set various core global fields using attribute notation + self.meta.license = SOME_LICENSE + self.meta.meta_doi = SOME_DOI + self.meta.recorder = SOME_RECORDER + + # verify they were set correctly + self.assertEqual(self.meta.license, SOME_LICENSE) + self.assertEqual(self.meta.meta_doi, SOME_DOI) + self.assertEqual(self.meta.recorder, SOME_RECORDER) + + def test_setter_overwrite_fields(self): + """test that attribute setters can overwrite existing fields""" + self.meta.sha512 = "effec7" + self.assertEqual(self.meta.sha512, "effec7") + + def test_setter_noncore_attributes(self): + """test that setter works for non-core object attributes""" + # set a regular attribute + self.meta.custom_attribute = "test value" + + # verify it was set as a regular attribute + self.assertEqual(self.meta.custom_attribute, "test value") + + # verify it doesn't appear in metadata + self.assertNotIn("custom_attribute", self.meta.get_global_info()) + + def test_method_vs_attribute_equivalence(self): + """test that method-based and attribute-based access are equivalent""" + # set via method, access via attribute + self.meta.set_global_field(SigMFFile.LICENSE_KEY, SOME_LICENSE) + self.assertEqual(self.meta.license, SOME_LICENSE) + + # set via attribute, access via method + self.meta.recorder = SOME_RECORDER + self.assertEqual(self.meta.get_global_field(SigMFFile.RECORDER_KEY), SOME_RECORDER) + + def test_private_attributes_unaffected(self): + """test that private attributes work normally""" + # private attributes should not trigger dynamic behavior + self.meta._test_private = "private_value" + self.assertEqual(self.meta._test_private, "private_value") + + def test_existing_properties_unaffected(self): + """test that existing class properties work normally""" + # test that existing properties like data_file still work + self.meta.data_file = None # this should work normally + self.assertIsNone(self.meta.data_file) diff --git a/tests/testdata.py b/tests/testdata.py index b91ad67..d773d69 100644 --- a/tests/testdata.py +++ b/tests/testdata.py @@ -19,6 +19,7 @@ SigMFFile.DATATYPE_KEY: "rf32_le", SigMFFile.HASH_KEY: "f4984219b318894fa7144519185d1ae81ea721c6113243a52b51e444512a39d74cf41a4cec3c5d000bd7277cc71232c04d7a946717497e18619bdbe94bfeadd6", SigMFFile.NUM_CHANNELS_KEY: 1, + SigMFFile.START_OFFSET_KEY: 0, SigMFFile.VERSION_KEY: __specification__, }, }