diff --git a/docs/source/advanced.rst b/docs/source/advanced.rst index 4f097a3..862c343 100644 --- a/docs/source/advanced.rst +++ b/docs/source/advanced.rst @@ -104,6 +104,26 @@ You can control whether fixed-point samples are automatically scaled: handle_raw = sigmf.fromfile("fixed_point_data.sigmf", autoscale=False) raw_samples = handle_raw.read_samples() # Returns original integer types +--------------------------------------------- +Access the Declared Specification Version +--------------------------------------------- + +When loading a SigMF file, the library automatically normalizes the metadata to +the current specification version. You can access the originally declared spec +version using the ``declared_version`` property: + +.. code-block:: python + + import sigmf + + # Load a recording (possibly created with an older SigMF spec version) + meta = sigmf.fromfile("legacy_recording.sigmf") + + # meta.version always reports the current library spec version + print(f"Current spec version: {meta.version}") + # meta.declared_version preserves the spec version declared in the loaded file + print(f"Declared spec version: {meta.declared_version}") + ------------------------------ Iterate over SigMF Annotations ------------------------------ @@ -114,14 +134,13 @@ the recording of the SigMF logo used in this example `from the specification .. code-block:: python - from sigmf import SigMFFile, sigmffile + import sigmf # Load a dataset - path = "logo/sigmf_logo" # extension is optional - signal = sigmffile.fromfile(path) + signal = sigmf.fromfile("logo/sigmf_logo.sigmf") # Get some metadata and all annotations - sample_rate = signal.get_global_field(sigmf.SAMPLE_RATE_KEY) + sample_rate = signal.sample_rate sample_count = signal.sample_count signal_duration = sample_count / sample_rate annotations = signal.get_annotations() @@ -131,7 +150,7 @@ the recording of the SigMF logo used in this example `from the specification annotation_start_idx = annotation[sigmf.SAMPLE_START_KEY] annotation_length = annotation[sigmf.SAMPLE_COUNT_KEY] annotation_comment = annotation.get( - sigmf.COMMENT_KEY, "[annotation {}]".format(adx) + sigmf.COMMENT_KEY, f"[annotation {adx}]" ) # Get capture info associated with the start of annotation @@ -157,10 +176,8 @@ First, create a single SigMF Recording and save it to disk: .. code-block:: python - import datetime as dt import numpy as np import sigmf - from sigmf import SigMFFile from sigmf.utils import get_data_type_str, get_sigmf_iso8601_datetime_now # suppose we have a complex timeseries signal @@ -170,7 +187,7 @@ First, create a single SigMF Recording and save it to disk: data.tofile("example_cf32.sigmf-data") # create the metadata - meta = SigMFFile( + meta = sigmf.SigMFFile( data_file="example_cf32.sigmf-data", # extension is optional global_info={ sigmf.DATATYPE_KEY: get_data_type_str(data), # in this case, 'cf32_le' @@ -207,7 +224,8 @@ Now lets add another SigMF Recording and associate them with a SigMF Collection: .. code-block:: python - from sigmf import SigMFFile, SigMFCollection + import numpy as np + import sigmf data_ci16 = np.zeros(1024, dtype=np.complex64) @@ -216,7 +234,7 @@ Now lets add another SigMF Recording and associate them with a SigMF Collection: data_ci16.view(np.float32).astype(np.int16).tofile("example_ci16.sigmf-data") # create the metadata for the second file - meta_ci16 = SigMFFile( + meta_ci16 = sigmf.SigMFFile( data_file="example_ci16.sigmf-data", # extension is optional global_info={ sigmf.DATATYPE_KEY: "ci16_le", # get_data_type_str() is only valid for numpy types @@ -227,12 +245,12 @@ Now lets add another SigMF Recording and associate them with a SigMF Collection: meta_ci16.add_capture(0, metadata=meta.get_capture_info(0)) meta_ci16.tofile("example_ci16.sigmf-meta") - collection = SigMFCollection( + collection = sigmf.SigMFCollection( ["example_cf32.sigmf-meta", "example_ci16.sigmf-meta"], metadata={ "collection": { - SigMFCollection.AUTHOR_KEY: "sigmf@sigmf.org", - SigMFCollection.DESCRIPTION_KEY: "Collection of two all zero files.", + sigmf.AUTHOR_KEY: "sigmf@sigmf.org", + sigmf.DESCRIPTION_KEY: "Collection of two all zero files.", } }, ) diff --git a/docs/source/developers.rst b/docs/source/developers.rst index e154684..5127903 100644 --- a/docs/source/developers.rst +++ b/docs/source/developers.rst @@ -14,7 +14,7 @@ To install from source: $ git clone https://github.com/sigmf/sigmf-python.git $ cd sigmf-python - $ pip install .[test] + $ pip install --editable .[test] ------- Testing diff --git a/docs/source/quickstart.rst b/docs/source/quickstart.rst index b2f7ec2..3c43525 100644 --- a/docs/source/quickstart.rst +++ b/docs/source/quickstart.rst @@ -90,7 +90,7 @@ For full control over global fields, captures, and annotations: .. code-block:: python import numpy as np - from sigmf import SigMFFile + import sigmf from sigmf.utils import get_data_type_str, get_sigmf_iso8601_datetime_now # suppose we have a complex timeseries signal @@ -100,7 +100,7 @@ For full control over global fields, captures, and annotations: data.tofile("example.sigmf-data") # create the metadata - meta = SigMFFile( + meta = sigmf.SigMFFile( data_file="example.sigmf-data", # extension is optional global_info={ sigmf.DATATYPE_KEY: get_data_type_str(data), # in this case, "cf32_le" @@ -146,7 +146,7 @@ method-based approach. import sigmf # read some recording - meta = sigmf.SigMFFile("sigmf_logo") + meta = sigmf.fromfile("sigmf_logo.sigmf") # read global metadata print(f"Sample rate: {meta.sample_rate}") diff --git a/docs/source/siggen.rst b/docs/source/siggen.rst index 6f0b6b3..f6e295d 100644 --- a/docs/source/siggen.rst +++ b/docs/source/siggen.rst @@ -17,10 +17,10 @@ Basic Usage .. code-block:: python - from sigmf.siggen import SigMFGenerator + import sigmf # generate a 1 kHz tone at 48 kHz sample rate for 1 second - signal = SigMFGenerator().tone(1000).sample_rate(48000).duration(1.0).generate() + signal = sigmf.SigMFGenerator().tone(1000).sample_rate(48000).duration(1.0).generate() signal.read_samples() # complex64 numpy array The returned object is a standard :class:`~sigmf.sigmffile.SigMFFile` backed by @@ -36,8 +36,10 @@ label. .. code-block:: python + import sigmf + signal = ( - SigMFGenerator() + sigmf.SigMFGenerator() .tone(1000) .tone(-2500) .sweep(500, 4000) @@ -60,8 +62,10 @@ A seed ensures reproducibility across runs. .. code-block:: python + import sigmf + # deterministic random signal - signal = SigMFGenerator(seed=0xDEADBEEF).generate() + signal = sigmf.SigMFGenerator(seed=0xDEADBEEF).generate() # the number and type of components are randomly chosen print(signal.description) # e.g. "synthetic signal with 3 tones and 2 sweeps" @@ -79,8 +83,10 @@ author, and comment can be set via the builder. .. code-block:: python + import sigmf + signal = ( - SigMFGenerator() + sigmf.SigMFGenerator() .tone(440) .sample_rate(44100) .duration(1.0) diff --git a/sigmf/__init__.py b/sigmf/__init__.py index a720a60..c809a4f 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.12.0" +__version__ = "1.13.0" # matching version of the SigMF specification __specification__ = "1.2.6" diff --git a/sigmf/sigmffile.py b/sigmf/sigmffile.py index d36e3e5..cfab878 100644 --- a/sigmf/sigmffile.py +++ b/sigmf/sigmffile.py @@ -7,6 +7,7 @@ """SigMFFile Object""" import codecs +import copy import io import json import warnings @@ -88,10 +89,10 @@ class SigMFMetafile(metaclass=_SigMFDeprecatingMeta): VALID_KEYS: dict[str, list[str]] = {} def __init__(self): - self.version = None self.schema = None - self._metadata = None self.shape = None + self._metadata = None + self._declared_version = None def __str__(self): return self.dumps() @@ -459,25 +460,28 @@ def get_schema(self): """ Return a schema object valid for the current metadata """ - current_metadata_version = self.get_global_info().get(keys.VERSION_KEY) - if self.version != current_metadata_version or self.schema is None: - self.version = current_metadata_version - self.schema = schema.get_schema(self.version) - if not isinstance(self.schema, dict): - raise SigMFError("SigMF schema expects a dict (key, value pairs)") + if self.schema is None: + version = self.get_global_field(keys.VERSION_KEY) + self.schema = schema.get_schema(version) return self.schema def set_metadata(self, metadata): """ Read provided metadata as either None (empty), string, bytes, or dictionary. + + If an existing core:version is present, it is preserved in the declared_version + property, but the metadata will always report the current library version. """ if metadata is None: # Create empty self._metadata = {self.GLOBAL_KEY: {}, self.CAPTURE_KEY: [], self.ANNOTATION_KEY: []} + self._declared_version = None elif isinstance(metadata, dict): - self._metadata = metadata + self._metadata = copy.deepcopy(metadata) + self._declared_version = self._metadata.get(self.GLOBAL_KEY, {}).get(keys.VERSION_KEY) elif isinstance(metadata, (str, bytes)): self._metadata = json.loads(metadata) + self._declared_version = self._metadata.get(self.GLOBAL_KEY, {}).get(keys.VERSION_KEY) else: raise SigMFError("Unable to interpret provided metadata.") @@ -487,7 +491,7 @@ def set_metadata(self, metadata): if self.get_global_field(keys.OFFSET_KEY) is None: self.set_global_field(keys.OFFSET_KEY, 0) - # set version to current implementation + # set version to current (object operates per current spec and always writes current version) self.set_global_field(keys.VERSION_KEY, __specification__) def set_global_info(self, new_global): @@ -517,6 +521,14 @@ def get_global_field(self, key, default=None): """ return self._metadata[self.GLOBAL_KEY].get(key, default) + @property + def declared_version(self) -> str | None: + """ + Return the core:version that may have been present in the metadata when this + SigMFFile was loaded, before normalization to the current spec. + """ + return self._declared_version + def add_capture(self, start_index, metadata=None): """ Insert capture info for sample starting at start_index. diff --git a/tests/test_archive.py b/tests/test_archive.py index 23d2aa5..247798e 100644 --- a/tests/test_archive.py +++ b/tests/test_archive.py @@ -7,7 +7,6 @@ """Tests for SigMFArchive""" import codecs -import copy import json import shutil import tarfile @@ -34,7 +33,7 @@ def setUp(self): self.temp_path_meta = self.temp_dir / "trash.sigmf-meta" self.temp_path_archive = self.temp_dir / "test.sigmf" TEST_FLOAT32_DATA.tofile(self.temp_path_data) - self.sigmf_object = SigMFFile(copy.deepcopy(TEST_METADATA), data_file=self.temp_path_data) + self.sigmf_object = SigMFFile(TEST_METADATA, data_file=self.temp_path_data) self.sigmf_object.tofile(self.temp_path_meta) self.sigmf_object.tofile(self.temp_path_archive) self.sigmf_tarfile = tarfile.open(self.temp_path_archive, mode="r", format=tarfile.PAX_FORMAT) @@ -188,7 +187,7 @@ def setUp(self): self.temp_dir = Path(tempfile.mkdtemp()) self.temp_path_data = self.temp_dir / "test.sigmf-data" TEST_FLOAT32_DATA.tofile(self.temp_path_data) - self.sigmf_object = SigMFFile(copy.deepcopy(TEST_METADATA), data_file=self.temp_path_data) + self.sigmf_object = SigMFFile(TEST_METADATA, data_file=self.temp_path_data) self.original_samples = self.sigmf_object.read_samples() def tearDown(self): @@ -318,7 +317,7 @@ def test_data_buffer_writes_data_file(self): data_buffer.write(TEST_FLOAT32_DATA.tobytes()) data_buffer.seek(0) - meta = SigMFFile(copy.deepcopy(TEST_METADATA)) + meta = SigMFFile(TEST_METADATA) meta.set_data_file(data_buffer=data_buffer) # tofile without archive extension should create separate files diff --git a/tests/test_attributes.py b/tests/test_attributes.py index a6c7b49..6578a3e 100644 --- a/tests/test_attributes.py +++ b/tests/test_attributes.py @@ -1,8 +1,9 @@ """Tests for dynamic attribute access functionality.""" -import copy import unittest +from packaging.version import parse + import sigmf from sigmf import SigMFFile from sigmf.error import SigMFAccessError @@ -19,7 +20,7 @@ class TestDynamicAttributeAccess(unittest.TestCase): def setUp(self): """create test sigmf file with some initial metadata""" - self.meta = SigMFFile(copy.deepcopy(TEST_METADATA)) + self.meta = SigMFFile(TEST_METADATA) def test_getter_existing_fields(self): """test attribute getters for existing core fields""" @@ -88,3 +89,50 @@ def test_existing_properties_unaffected(self): # test that existing properties like data_file still work self.meta.data_file = None # this should work normally self.assertIsNone(self.meta.data_file) + + +class TestDeclaredVersion(unittest.TestCase): + """Test declared_version property behavior""" + + def test_declared_version_preserved(self): + """declared_version preserves the declared version from loaded metadata""" + # create metadata with old version + meta_with_old_version = dict(TEST_METADATA) + meta_with_old_version[SigMFFile.GLOBAL_KEY] = dict(TEST_METADATA[SigMFFile.GLOBAL_KEY]) + meta_with_old_version[SigMFFile.GLOBAL_KEY][sigmf.VERSION_KEY] = "0.1.0" + + meta = SigMFFile(metadata=meta_with_old_version) + + # declared version is preserved + self.assertEqual(meta.declared_version, "0.1.0") + # object reports current version (from library) + self.assertEqual(meta.version, sigmf.__specification__) + # declared is older than current (using proper version comparison) + self.assertLess(parse(meta.declared_version), parse(meta.version)) + + def test_declared_version_none_for_new_files(self): + """declared_version is None for newly created files""" + meta = SigMFFile() + self.assertIsNone(meta.declared_version) + # object reports current library version + self.assertEqual(meta.version, sigmf.__specification__) + + def test_declared_version_matches_current_when_loaded_from_current(self): + """declared_version matches current version when loading file with current version""" + # TEST_METADATA gets current version injected + meta = SigMFFile(TEST_METADATA) + # both should be current library version + self.assertEqual(meta.declared_version, sigmf.__specification__) + self.assertEqual(meta.version, sigmf.__specification__) + + def test_dict_not_mutated(self): + """initialization dict is deepcopied, not mutated""" + meta_copy = dict(TEST_METADATA) + meta_copy[SigMFFile.GLOBAL_KEY] = dict(TEST_METADATA[SigMFFile.GLOBAL_KEY]) + meta_copy[SigMFFile.GLOBAL_KEY][sigmf.VERSION_KEY] = "0.1.0" + declared_version = meta_copy[SigMFFile.GLOBAL_KEY][sigmf.VERSION_KEY] + + SigMFFile(metadata=meta_copy) + + # caller's dict should be unchanged + self.assertEqual(meta_copy[SigMFFile.GLOBAL_KEY][sigmf.VERSION_KEY], declared_version) diff --git a/tests/test_collection.py b/tests/test_collection.py index 0a4ee7e..70f9e9a 100644 --- a/tests/test_collection.py +++ b/tests/test_collection.py @@ -6,7 +6,6 @@ """Tests for collections""" -import copy import os import shutil import tempfile @@ -53,7 +52,7 @@ def test_load_collection(self, subdir: str) -> None: TEST_FLOAT32_DATA.tofile(data_path2) # create metadata files - metadata = copy.deepcopy(TEST_METADATA) + metadata = TEST_METADATA meta1 = SigMFFile(metadata=metadata, data_file=data_path1) meta2 = SigMFFile(metadata=metadata, data_file=data_path2) meta1.tofile(meta_path1, overwrite=True) diff --git a/tests/test_ncd.py b/tests/test_ncd.py index c4629d5..6138d62 100644 --- a/tests/test_ncd.py +++ b/tests/test_ncd.py @@ -45,7 +45,7 @@ def test_load_ncd(self, subdir: str) -> None: TEST_FLOAT32_DATA.tofile(data_path) # create metadata file - ncd_metadata = copy.deepcopy(TEST_METADATA) + ncd_metadata = TEST_METADATA meta = SigMFFile(metadata=ncd_metadata, data_file=data_path) meta.tofile(meta_path, overwrite=True) diff --git a/tests/test_sigmffile.py b/tests/test_sigmffile.py index 6b9f137..e558a46 100644 --- a/tests/test_sigmffile.py +++ b/tests/test_sigmffile.py @@ -87,7 +87,7 @@ def test_checksum(self): def test_equality(self): """Ensure __eq__ working as expected""" - other = SigMFFile(copy.deepcopy(TEST_METADATA)) + other = SigMFFile(TEST_METADATA) self.assertEqual(self.sigmf_object, other) # different after changing any part of metadata other.add_annotation(start_index=0, metadata={"a": 0}) @@ -97,7 +97,7 @@ def test_equality(self): class TestAnnotationHandling(unittest.TestCase): def test_get_annotations_with_index(self): """Test that only annotations containing index are returned from get_annotations()""" - meta = SigMFFile(copy.deepcopy(TEST_METADATA)) + meta = SigMFFile(TEST_METADATA) meta.add_annotation(start_index=1) meta.add_annotation(start_index=4, length=4) annotations_idx10 = meta.get_annotations(index=10) @@ -111,7 +111,7 @@ def test_get_annotations_with_index(self): def test_sample_count_from_annotations(self): """Make sure sample count from annotations use correct end index""" - meta = SigMFFile(copy.deepcopy(TEST_METADATA)) + meta = SigMFFile(TEST_METADATA) meta.add_annotation(start_index=0, length=32) meta.add_annotation(start_index=4, length=4) sample_count = meta._count_samples() @@ -122,7 +122,7 @@ def test_set_data_file_without_annotations(self): Make sure setting data_file with no annotations registered does not raise any errors """ - meta = SigMFFile(copy.deepcopy(TEST_METADATA)) + meta = SigMFFile(TEST_METADATA) meta._metadata[SigMFFile.ANNOTATION_KEY].clear() with tempfile.TemporaryDirectory() as tmpdir: temp_path_data = Path(tmpdir) / "datafile" @@ -137,7 +137,7 @@ def test_set_data_file_with_annotations(self): count from data_file and issue a warning if annotations have end indices bigger than file end index """ - meta = SigMFFile(copy.deepcopy(TEST_METADATA)) + meta = SigMFFile(TEST_METADATA) meta.add_annotation(start_index=0, length=32) with tempfile.TemporaryDirectory() as tmpdir: temp_path_data = Path(tmpdir) / "datafile" diff --git a/tests/test_validation.py b/tests/test_validation.py index 9029f50..c90a676 100644 --- a/tests/test_validation.py +++ b/tests/test_validation.py @@ -24,7 +24,7 @@ class NominalCases(unittest.TestCase): def test_nominal(self): """nominal case should pass""" - SigMFFile(copy.deepcopy(TEST_METADATA)).validate() + SigMFFile(TEST_METADATA).validate() class CommandLineValidator(unittest.TestCase): @@ -36,7 +36,7 @@ def setUp(self): self.tmp_path = tmp_path = Path(self.tmp_dir.name) junk_path = tmp_path / "junk" TEST_FLOAT32_DATA.tofile(junk_path) - some_meta = SigMFFile(copy.deepcopy(TEST_METADATA), data_file=junk_path) + some_meta = SigMFFile(TEST_METADATA, data_file=junk_path) some_meta.tofile(tmp_path / "a") some_meta.tofile(tmp_path / "b") some_meta.tofile(tmp_path / "c.sigmf") @@ -83,7 +83,7 @@ def setUp(self): def test_no_version(self): """version key must be present""" - meta = SigMFFile(copy.deepcopy(self.metadata)) + meta = SigMFFile(self.metadata) del meta._metadata[SigMFFile.GLOBAL_KEY][sigmf.VERSION_KEY] with self.assertRaises(ValidationError): meta.validate()