Skip to content

Commit 815c56b

Browse files
committed
Get&Set global fields via properties
* created new get/set dynamic interface for global fields * fixed bug where (data_doi, meta_doi) was incorrectly (data-doi, meta_doi) * increment API version * Thanks to @gregparkes for base implementation
1 parent 4cdc35e commit 815c56b

File tree

5 files changed

+255
-13
lines changed

5 files changed

+255
-13
lines changed

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,3 +22,6 @@ htmlcov/*
2222
# docs related
2323
docs/_build/
2424
docs/source/_autosummary/
25+
26+
# dev related
27+
.vscode/

docs/source/quickstart.rst

Lines changed: 49 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,11 +24,14 @@ Read a SigMF Recording
2424
2525
import sigmf
2626
handle = sigmf.fromfile("example.sigmf")
27-
handle.read_samples() # returns all timeseries data
27+
# reading data
28+
handle.read_samples() # read all timeseries data
29+
handle[10:50] # read memory slice of samples 10 through 50
30+
# accessing metadata
31+
handle.sample_rate # get sample rate attribute
2832
handle.get_global_info() # returns 'global' dictionary
2933
handle.get_captures() # returns list of 'captures' dictionaries
3034
handle.get_annotations() # returns list of all annotations
31-
handle[10:50] # return memory slice of samples 10 through 50
3235
3336
-----------------------------------
3437
Verify SigMF Integrity & Compliance
@@ -80,3 +83,47 @@ Save a Numpy array as a SigMF Recording
8083
8184
# check for mistakes & write to disk
8285
meta.tofile('example_cf32.sigmf-meta') # extension is optional
86+
87+
----------------------------------
88+
Attribute Access for Global Fields
89+
----------------------------------
90+
91+
SigMF-Python provides convenient attribute-style access for core global metadata fields,
92+
allowing you to read and write metadata using simple dot notation alongside the traditional
93+
method-based approach.
94+
95+
.. code-block:: python
96+
97+
import numpy as np
98+
from sigmf import SigMFFile
99+
100+
# create a new recording
101+
meta = SigMFFile()
102+
103+
# set global metadata using attributes
104+
meta.sample_rate = 48000
105+
meta.author = '[email protected]'
106+
meta.datatype = 'cf32_le'
107+
meta.description = 'Example recording with attribute access'
108+
meta.license = 'MIT'
109+
meta.recorder = 'hackrf_one'
110+
111+
# read global metadata using attributes
112+
print(f"Sample rate: {meta.sample_rate}")
113+
print(f"Author: {meta.author}")
114+
print(f"License: {meta.license}")
115+
116+
# method-based approach
117+
meta.set_global_field(SigMFFile.HW_KEY, 'SDR Hardware v1.2')
118+
hw_info = meta.get_global_field(SigMFFile.HW_KEY)
119+
print(f"Hardware: {hw_info}")
120+
121+
# attribute and method access are equivalent
122+
meta.set_global_field(SigMFFile.RECORDER_KEY, 'usrp_b210')
123+
print(f"Recorder via attribute: {meta.recorder}") # prints: usrp_b210
124+
125+
.. note::
126+
127+
Only core **global** fields support attribute access. Capture and annotation
128+
fields must still be accessed using the traditional ``add_capture()`` and
129+
``add_annotation()`` methods.

sigmf/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
# SPDX-License-Identifier: LGPL-3.0-or-later
66

77
# version of this python module
8-
__version__ = "1.2.12"
8+
__version__ = "1.3.0"
99
# matching version of the SigMF specification
1010
__specification__ = "1.2.5"
1111

sigmf/sigmffile.py

Lines changed: 108 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -125,8 +125,8 @@ class SigMFFile(SigMFMetafile):
125125
COMMENT_KEY = "core:comment"
126126
DESCRIPTION_KEY = "core:description"
127127
AUTHOR_KEY = "core:author"
128-
META_DOI_KEY = "core:meta-doi"
129-
DATA_DOI_KEY = "core:data-doi"
128+
META_DOI_KEY = "core:meta_doi"
129+
DATA_DOI_KEY = "core:data_doi"
130130
GENERATOR_KEY = "core:generator"
131131
LABEL_KEY = "core:label"
132132
RECORDER_KEY = "core:recorder"
@@ -146,14 +146,38 @@ class SigMFFile(SigMFMetafile):
146146
CAPTURE_KEY = "captures"
147147
ANNOTATION_KEY = "annotations"
148148
VALID_GLOBAL_KEYS = [
149-
AUTHOR_KEY, COLLECTION_KEY, DATASET_KEY, DATATYPE_KEY, DATA_DOI_KEY, DESCRIPTION_KEY, EXTENSIONS_KEY,
150-
GEOLOCATION_KEY, HASH_KEY, HW_KEY, LICENSE_KEY, META_DOI_KEY, METADATA_ONLY_KEY, NUM_CHANNELS_KEY, RECORDER_KEY,
151-
SAMPLE_RATE_KEY, START_OFFSET_KEY, TRAILING_BYTES_KEY, VERSION_KEY
149+
AUTHOR_KEY,
150+
COLLECTION_KEY,
151+
DATASET_KEY,
152+
DATATYPE_KEY,
153+
DATA_DOI_KEY,
154+
DESCRIPTION_KEY,
155+
EXTENSIONS_KEY,
156+
GEOLOCATION_KEY,
157+
HASH_KEY,
158+
HW_KEY,
159+
LICENSE_KEY,
160+
META_DOI_KEY,
161+
METADATA_ONLY_KEY,
162+
NUM_CHANNELS_KEY,
163+
RECORDER_KEY,
164+
SAMPLE_RATE_KEY,
165+
START_OFFSET_KEY,
166+
TRAILING_BYTES_KEY,
167+
VERSION_KEY,
152168
]
153169
VALID_CAPTURE_KEYS = [DATETIME_KEY, FREQUENCY_KEY, HEADER_BYTES_KEY, GLOBAL_INDEX_KEY, START_INDEX_KEY]
154170
VALID_ANNOTATION_KEYS = [
155-
COMMENT_KEY, FHI_KEY, FLO_KEY, GENERATOR_KEY, LABEL_KEY, LAT_KEY, LENGTH_INDEX_KEY, LON_KEY, START_INDEX_KEY,
156-
UUID_KEY
171+
COMMENT_KEY,
172+
FHI_KEY,
173+
FLO_KEY,
174+
GENERATOR_KEY,
175+
LABEL_KEY,
176+
LAT_KEY,
177+
LENGTH_INDEX_KEY,
178+
LON_KEY,
179+
START_INDEX_KEY,
180+
UUID_KEY,
157181
]
158182
VALID_KEYS = {GLOBAL_KEY: VALID_GLOBAL_KEYS, CAPTURE_KEY: VALID_CAPTURE_KEYS, ANNOTATION_KEY: VALID_ANNOTATION_KEYS}
159183

@@ -200,6 +224,75 @@ def __eq__(self, other):
200224
return self._metadata == other._metadata
201225
return False
202226

227+
def __getattr__(self, name):
228+
"""
229+
Enable dynamic attribute access for core global metadata fields.
230+
231+
Allows convenient access to core metadata fields using attribute notation:
232+
- `sigmf_file.sample_rate` returns `sigmf_file._metadata["global"]["core:sample_rate"]
233+
- `sigmf_file.author` returns `sigmf_file._metadata["global"]["core:author"]
234+
235+
Parameters
236+
----------
237+
name : str
238+
Attribute name corresponding to a core field (without "core:" prefix).
239+
240+
Returns
241+
-------
242+
value
243+
The value of the core field from global metadata, or None if not set.
244+
245+
Raises
246+
------
247+
SigMFAccessError
248+
If the attribute name doesn't correspond to a valid core global field.
249+
"""
250+
# iterate through valid global keys to find matching core field
251+
for key in self.VALID_GLOBAL_KEYS:
252+
if key.startswith("core:") and key[5:] == name:
253+
field_value = self.get_global_field(key)
254+
if field_value is None:
255+
raise SigMFAccessError(f"Core field '{key}' does not exist in global metadata")
256+
return field_value
257+
258+
# if we get here, the attribute doesn't correspond to a core field
259+
raise AttributeError(f"'{type(self).__name__}' object has no attribute '{name}'")
260+
261+
def __setattr__(self, name, value):
262+
"""
263+
Enable dynamic attribute setting for core global metadata fields.
264+
265+
Allows convenient setting of core metadata fields using attribute notation:
266+
- `sigmf_file.sample_rate = 1000000` sets `sigmf_file._metadata["global"]["core:sample_rate"]
267+
- `sigmf_file.author = "[email protected]"` sets `sigmf_file._metadata["global"]["core:author"]
268+
269+
Parameters
270+
----------
271+
name : str
272+
Attribute name. If it corresponds to a core field (without "core:" prefix),
273+
the value will be set in global metadata. Otherwise, normal attribute setting occurs.
274+
value
275+
The value to set for the field.
276+
"""
277+
# handle regular instance attributes, existing properties, or during initialization
278+
if (
279+
name.startswith("_")
280+
or hasattr(type(self), name)
281+
or not hasattr(self, "_metadata")
282+
or self._metadata is None
283+
):
284+
super().__setattr__(name, value)
285+
return
286+
287+
# check if this corresponds to a core global field
288+
for key in self.VALID_GLOBAL_KEYS:
289+
if key.startswith("core:") and key[5:] == name:
290+
self.set_global_field(key, value)
291+
return
292+
293+
# fall back to normal attribute setting for non-core attributes
294+
super().__setattr__(name, value)
295+
203296
def __next__(self):
204297
"""get next batch of samples"""
205298
if self.iter_position < len(self):
@@ -768,7 +861,9 @@ class SigMFCollection(SigMFMetafile):
768861
]
769862
VALID_KEYS = {COLLECTION_KEY: VALID_COLLECTION_KEYS}
770863

771-
def __init__(self, metafiles: list = None, metadata: dict = None, base_path=None, skip_checksums: bool = False) -> None:
864+
def __init__(
865+
self, metafiles: list = None, metadata: dict = None, base_path=None, skip_checksums: bool = False
866+
) -> None:
772867
"""
773868
Create a SigMF Collection object.
774869
@@ -1046,6 +1141,7 @@ def fromarchive(archive_path, dir=None, skip_checksum=False):
10461141
access SigMF archives without extracting them.
10471142
"""
10481143
from .archivereader import SigMFArchiveReader
1144+
10491145
return SigMFArchiveReader(archive_path, skip_checksum=skip_checksum).sigmffile
10501146

10511147

@@ -1119,8 +1215,10 @@ def get_sigmf_filenames(filename):
11191215
# suffix, because the filename might contain '.' characters which are part
11201216
# of the filename rather than an extension.
11211217
sigmf_suffixes = [
1122-
SIGMF_DATASET_EXT, SIGMF_METADATA_EXT,
1123-
SIGMF_ARCHIVE_EXT, SIGMF_COLLECTION_EXT,
1218+
SIGMF_DATASET_EXT,
1219+
SIGMF_METADATA_EXT,
1220+
SIGMF_ARCHIVE_EXT,
1221+
SIGMF_COLLECTION_EXT,
11241222
]
11251223
if stem_path.suffix in sigmf_suffixes:
11261224
with_suffix_path = stem_path

tests/test_attributes.py

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
"""Tests for dynamic attribute access functionality."""
2+
3+
import copy
4+
import unittest
5+
6+
import numpy as np
7+
8+
from sigmf import SigMFFile
9+
from sigmf.error import SigMFAccessError
10+
11+
from .testdata import TEST_METADATA
12+
13+
SOME_LICENSE = "CC0-1.0"
14+
SOME_RECORDER = "HackRF Pro"
15+
SOME_DOI = "10.1000/182"
16+
17+
18+
class TestDynamicAttributeAccess(unittest.TestCase):
19+
"""Test dynamic attribute access for core global metadata fields."""
20+
21+
def setUp(self):
22+
"""create test sigmf file with some initial metadata"""
23+
self.meta = SigMFFile(copy.deepcopy(TEST_METADATA))
24+
25+
def test_getter_existing_fields(self):
26+
"""test attribute getters for existing core fields"""
27+
# test common core fields
28+
# self.assertEqual(self.meta.sample_rate, self.meta.get_global_field(SigMFFile.SAMPLE_RATE_KEY))
29+
# self.assertEqual(self.meta.author, self.meta.get_global_field(SigMFFile.AUTHOR_KEY))
30+
self.assertEqual(self.meta.datatype, self.meta.get_global_field(SigMFFile.DATATYPE_KEY))
31+
self.assertEqual(self.meta.sha512, self.meta.get_global_field(SigMFFile.HASH_KEY))
32+
# self.assertEqual(self.meta.description, self.meta.get_global_field(SigMFFile.DESCRIPTION_KEY))
33+
34+
def test_getter_missing_core_fields(self):
35+
"""test that getter raises SigMFAccessError for missing core fields"""
36+
with self.assertRaises(SigMFAccessError) as context:
37+
_ = self.meta.license
38+
self.assertIn(SigMFFile.LICENSE_KEY, str(context.exception))
39+
40+
def test_getter_nonexistent_attribute(self):
41+
"""test that getter raises AttributeError for non-core attributes"""
42+
with self.assertRaises(AttributeError) as context:
43+
_ = self.meta.nonexistent_field
44+
self.assertIn("nonexistent_field", str(context.exception))
45+
46+
def test_setter_new_fields(self):
47+
"""test that attribute setters work for new core fields"""
48+
# set various core global fields using attribute notation
49+
self.meta.license = SOME_LICENSE
50+
self.meta.meta_doi = SOME_DOI
51+
self.meta.recorder = SOME_RECORDER
52+
53+
# verify they were set correctly
54+
self.assertEqual(self.meta.license, SOME_LICENSE)
55+
self.assertEqual(self.meta.meta_doi, SOME_DOI)
56+
self.assertEqual(self.meta.recorder, SOME_RECORDER)
57+
58+
def test_setter_overwrite_fields(self):
59+
"""test that attribute setters can overwrite existing fields"""
60+
self.meta.sha512 = "effec7"
61+
self.assertEqual(self.meta.sha512, "effec7")
62+
63+
def test_setter_noncore_attributes(self):
64+
"""test that setter works for non-core object attributes"""
65+
# set a regular attribute
66+
self.meta.custom_attribute = "test value"
67+
68+
# verify it was set as a regular attribute
69+
self.assertEqual(self.meta.custom_attribute, "test value")
70+
71+
# verify it doesn't appear in metadata
72+
self.assertNotIn("custom_attribute", self.meta.get_global_info())
73+
74+
def test_method_vs_attribute_equivalence(self):
75+
"""test that method-based and attribute-based access are equivalent"""
76+
# set via method, access via attribute
77+
self.meta.set_global_field(SigMFFile.LICENSE_KEY, SOME_LICENSE)
78+
self.assertEqual(self.meta.license, SOME_LICENSE)
79+
80+
# set via attribute, access via method
81+
self.meta.recorder = SOME_RECORDER
82+
self.assertEqual(self.meta.get_global_field(SigMFFile.RECORDER_KEY), SOME_RECORDER)
83+
84+
def test_private_attributes_unaffected(self):
85+
"""test that private attributes work normally"""
86+
# private attributes should not trigger dynamic behavior
87+
self.meta._test_private = "private_value"
88+
self.assertEqual(self.meta._test_private, "private_value")
89+
90+
def test_existing_properties_unaffected(self):
91+
"""test that existing class properties work normally"""
92+
# test that existing properties like data_file still work
93+
self.meta.data_file = None # this should work normally
94+
self.assertIsNone(self.meta.data_file)

0 commit comments

Comments
 (0)