Skip to content

Commit e9d2151

Browse files
committed
File overwrite protection w/safety-first behavior
* fixes issue where tofile() has inconsistent behavior * add `overwrite` kwarg to all file write methods * New SigMFFileExistsError * add --overwrite CLI flag * increment to v1.7.0 for API change
1 parent 4c16fd0 commit e9d2151

12 files changed

Lines changed: 297 additions & 40 deletions

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.6.2"
8+
__version__ = "1.7.0"
99
# matching version of the SigMF specification
1010
__specification__ = "1.2.6"
1111

sigmf/archive.py

Lines changed: 29 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
import tempfile
1313
from pathlib import Path
1414

15-
from .error import SigMFFileError
15+
from .error import SigMFFileError, SigMFFileExistsError
1616

1717
SIGMF_ARCHIVE_EXT = ".sigmf"
1818
SIGMF_METADATA_EXT = ".sigmf-meta"
@@ -22,11 +22,7 @@
2222

2323
class SigMFArchive:
2424
"""
25-
Archive a SigMFFile
26-
27-
A `.sigmf` file must include both valid metadata and data.
28-
If `self.data_file` is not set or the requested output file
29-
is not writable, raises `SigMFFileError`.
25+
Archive a SigMFFile into a tar file.
3026
3127
Parameters
3228
----------
@@ -35,7 +31,7 @@ class SigMFArchive:
3531
A SigMFFile object with valid metadata and data_file.
3632
3733
name : PathLike | str | bytes
38-
Path to archive file to create. If file exists, overwrite.
34+
Path to archive file to create.
3935
If `name` doesn't end in .sigmf, it will be appended.
4036
For example: if `name` == "/tmp/archive1", then the
4137
following archive will be created:
@@ -56,12 +52,21 @@ class SigMFArchive:
5652
- archive1/
5753
- archive1.sigmf-meta
5854
- archive1.sigmf-data
55+
56+
overwrite : bool, default False
57+
If False, raise exception if archive file already exists.
58+
59+
Raises
60+
------
61+
SigMFFileError
62+
If `sigmffile` has no data_file set, or if `name` is not writable.
63+
5964
"""
6065

61-
def __init__(self, sigmffile, name=None, fileobj=None):
66+
def __init__(self, sigmffile, name=None, fileobj=None, overwrite=False):
6267
is_buffer = fileobj is not None
6368
self.sigmffile = sigmffile
64-
self.path, arcname, fileobj = self._resolve(name, fileobj)
69+
self.path, arcname, fileobj = self._resolve(name, fileobj, overwrite)
6570

6671
self._ensure_data_file_set()
6772
self._validate()
@@ -106,13 +111,22 @@ def _ensure_data_file_set(self):
106111
def _validate(self):
107112
self.sigmffile.validate()
108113

109-
def _resolve(self, name, fileobj):
114+
def _resolve(self, name, fileobj, overwrite=False):
110115
"""
111116
Resolve both (name, fileobj) into (path, arcname, fileobj) given either or both.
112117
118+
Parameters
119+
----------
120+
name : PathLike | str | bytes | None
121+
Path to archive file to create.
122+
fileobj : BufferedWriter | None
123+
Open file handle object.
124+
overwrite : bool, default False
125+
If False, raise exception if archive file already exists.
126+
113127
Returns
114128
-------
115-
path : PathLike
129+
path : Path
116130
Path of the archive file.
117131
arcname : str
118132
Name of the sigmf object within the archive.
@@ -144,6 +158,10 @@ def _resolve(self, name, fileobj):
144158
raise SigMFFileError(f"Invalid extension ({path.suffix} != {SIGMF_ARCHIVE_EXT}).")
145159
arcname = path.stem
146160

161+
# check if file exists and overwrite is disabled
162+
if not overwrite and path.exists():
163+
raise SigMFFileExistsError(path, "Archive file")
164+
147165
try:
148166
fileobj = open(path, "wb")
149167
except (OSError, IOError) as exc:

sigmf/convert/__main__.py

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ def main() -> None:
6060
exclusive_group.add_argument(
6161
"--ncd", action="store_true", help="Output .sigmf-meta only and process as a Non-Conforming Dataset (NCD)"
6262
)
63+
parser.add_argument("--overwrite", action="store_true", help="Overwrite existing output files")
6364
parser.add_argument("--version", action="version", version=f"%(prog)s v{toolversion}")
6465
args = parser.parse_args()
6566

@@ -89,11 +90,23 @@ def main() -> None:
8990

9091
if magic_bytes == b"RIFF":
9192
# WAV file
92-
_ = wav_to_sigmf(wav_path=input_path, out_path=output_path, create_archive=args.archive, create_ncd=args.ncd)
93+
_ = wav_to_sigmf(
94+
wav_path=input_path,
95+
out_path=output_path,
96+
create_archive=args.archive,
97+
create_ncd=args.ncd,
98+
overwrite=args.overwrite,
99+
)
93100

94101
elif magic_bytes == b"BLUE":
95102
# BLUE file
96-
_ = blue_to_sigmf(blue_path=input_path, out_path=output_path, create_archive=args.archive, create_ncd=args.ncd)
103+
_ = blue_to_sigmf(
104+
blue_path=input_path,
105+
out_path=output_path,
106+
create_archive=args.archive,
107+
create_ncd=args.ncd,
108+
overwrite=args.overwrite,
109+
)
97110

98111
else:
99112
raise SigMFConversionError(

sigmf/convert/blue.py

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -498,8 +498,9 @@ def _build_common_metadata(
498498
tuple[dict, dict]
499499
(global_info, capture_info) dictionaries.
500500
"""
501-
# helper to look up extended header values by tag
501+
502502
def get_tag(tag):
503+
"""helper to look up extended header values by tag"""
503504
for entry in h_extended:
504505
if entry["tag"] == tag:
505506
return entry["value"]
@@ -670,6 +671,7 @@ def construct_sigmf(
670671
h_extended: list,
671672
is_metadata_only: bool = False,
672673
create_archive: bool = False,
674+
overwrite: bool = False,
673675
) -> SigMFFile:
674676
"""
675677
Built & write a SigMF object from BLUE metadata.
@@ -688,6 +690,8 @@ def construct_sigmf(
688690
If True, creates a metadata-only SigMF file.
689691
create_archive : bool, optional
690692
When True, package output as SigMF archive instead of a meta/data pair.
693+
overwrite : bool, optional
694+
If False, raise exception if output files already exist.
691695
692696
Returns
693697
-------
@@ -723,12 +727,12 @@ def construct_sigmf(
723727
meta.add_capture(0, metadata=capture_info)
724728

725729
if create_archive:
726-
meta.tofile(filenames["archive_fn"], toarchive=True)
730+
meta.tofile(filenames["archive_fn"], toarchive=True, overwrite=overwrite)
727731
log.info("wrote SigMF archive to %s", filenames["archive_fn"])
728732
# metadata returned should be for this archive
729733
meta = fromfile(filenames["archive_fn"])
730734
else:
731-
meta.tofile(filenames["meta_fn"], toarchive=False)
735+
meta.tofile(filenames["meta_fn"], toarchive=False, overwrite=overwrite)
732736
log.info("wrote SigMF metadata to %s", filenames["meta_fn"])
733737

734738
log.debug("created %r", meta)
@@ -790,6 +794,7 @@ def blue_to_sigmf(
790794
out_path: Optional[str] = None,
791795
create_archive: bool = False,
792796
create_ncd: bool = False,
797+
overwrite: bool = False,
793798
) -> SigMFFile:
794799
"""
795800
Read a MIDAS Bluefile, optionally write SigMF, return associated SigMF object.
@@ -804,6 +809,8 @@ def blue_to_sigmf(
804809
When True, package output as a .sigmf archive.
805810
create_ncd : bool, optional
806811
When True, create Non-Conforming Dataset with header_bytes and trailing_bytes.
812+
overwrite : bool, optional
813+
If False, raise exception if output files already exist.
807814
808815
Returns
809816
-------
@@ -846,7 +853,7 @@ def blue_to_sigmf(
846853

847854
# write NCD metadata to specified output path if provided
848855
if out_path is not None:
849-
ncd_meta.tofile(filenames["meta_fn"])
856+
ncd_meta.tofile(filenames["meta_fn"], overwrite=overwrite)
850857
log.info("wrote SigMF non-conforming metadata to %s", filenames["meta_fn"])
851858

852859
return ncd_meta
@@ -872,6 +879,7 @@ def blue_to_sigmf(
872879
h_extended=h_extended,
873880
is_metadata_only=metadata_only,
874881
create_archive=create_archive,
882+
overwrite=overwrite,
875883
)
876884

877885
log.debug(">>>>>>>>> Fixed Header")

sigmf/convert/wav.py

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
from .. import SigMFFile
2020
from .. import __version__ as toolversion
2121
from .. import fromfile
22+
from ..error import SigMFFileExistsError
2223
from ..sigmffile import get_sigmf_filenames
2324
from ..utils import SIGMF_DATETIME_ISO8601_FMT, get_data_type_str
2425

@@ -78,6 +79,7 @@ def wav_to_sigmf(
7879
out_path: Optional[str] = None,
7980
create_archive: bool = False,
8081
create_ncd: bool = False,
82+
overwrite: bool = False,
8183
) -> SigMFFile:
8284
"""
8385
Read a wav, optionally write sigmf, return associated SigMF object.
@@ -92,6 +94,8 @@ def wav_to_sigmf(
9294
When True, package output as a .sigmf archive.
9395
create_ncd : bool, optional
9496
When True, create Non-Conforming Dataset with header_bytes and trailing_bytes.
97+
overwrite : bool, optional
98+
If False, raise exception if output files already exist.
9599
96100
Returns
97101
-------
@@ -172,7 +176,7 @@ def wav_to_sigmf(
172176
filenames = get_sigmf_filenames(out_path)
173177
output_dir = filenames["meta_fn"].parent
174178
output_dir.mkdir(parents=True, exist_ok=True)
175-
meta.tofile(filenames["meta_fn"], toarchive=False)
179+
meta.tofile(filenames["meta_fn"], toarchive=False, overwrite=overwrite)
176180
log.info("wrote SigMF non-conforming metadata to %s", filenames["meta_fn"])
177181

178182
log.debug("created %r", meta)
@@ -197,20 +201,25 @@ def wav_to_sigmf(
197201
meta = SigMFFile(data_file=data_path, global_info=global_info)
198202
meta.add_capture(0, metadata=capture_info)
199203

200-
meta.tofile(filenames["archive_fn"], toarchive=True)
204+
meta.tofile(filenames["archive_fn"], toarchive=True, overwrite=overwrite)
201205
log.info("wrote SigMF archive to %s", filenames["archive_fn"])
202206
# metadata returned should be for this archive
203207
meta = fromfile(filenames["archive_fn"])
204208
else:
205209
# write separate meta and data files
206210
data_path = filenames["data_fn"]
211+
212+
# check if data file exists when overwrite is disabled
213+
if not overwrite and data_path.exists():
214+
raise SigMFFileExistsError(data_path, "Data file")
215+
207216
wav_data.tofile(data_path)
208217
log.info("wrote SigMF dataset to %s", data_path)
209218

210219
meta = SigMFFile(data_file=data_path, global_info=global_info)
211220
meta.add_capture(0, metadata=capture_info)
212221

213-
meta.tofile(filenames["meta_fn"], toarchive=False)
222+
meta.tofile(filenames["meta_fn"], toarchive=False, overwrite=overwrite)
214223
log.info("wrote SigMF metadata to %s", filenames["meta_fn"])
215224

216225
log.debug("created %r", meta)

sigmf/error.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,5 +24,14 @@ class SigMFFileError(SigMFError):
2424
"""Exceptions related to reading or writing SigMF files or archives."""
2525

2626

27+
class SigMFFileExistsError(SigMFFileError):
28+
"""Exception raised when a file already exists and overwrite is disabled."""
29+
30+
def __init__(self, file_path, file_type="File"):
31+
super().__init__(f"{file_type} {file_path} already exists. Use overwrite=True to overwrite.")
32+
self.file_path = file_path
33+
self.file_type = file_type
34+
35+
2736
class SigMFConversionError(SigMFError):
2837
"""Exceptions related to converting to SigMF format."""

sigmf/sigmffile.py

Lines changed: 36 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,13 @@
2323
SIGMF_METADATA_EXT,
2424
SigMFArchive,
2525
)
26-
from .error import SigMFAccessError, SigMFConversionError, SigMFError, SigMFFileError
26+
from .error import (
27+
SigMFAccessError,
28+
SigMFConversionError,
29+
SigMFError,
30+
SigMFFileError,
31+
SigMFFileExistsError,
32+
)
2733
from .utils import dict_merge, get_magic_bytes
2834

2935

@@ -790,16 +796,24 @@ def validate(self):
790796
"""
791797
validate.validate(self._metadata, self.get_schema())
792798

793-
def archive(self, name=None, fileobj=None):
799+
def archive(self, name=None, fileobj=None, overwrite=False):
794800
"""Dump contents to SigMF archive format.
795801
796802
`name` and `fileobj` are passed to SigMFArchive and are defined there.
797803
798-
"""
799-
archive = SigMFArchive(self, name, fileobj)
804+
Parameters
805+
----------
806+
name : str, optional
807+
Name of the archive file to create. If None, a temporary file will be created.
808+
fileobj : file-like object, optional
809+
A file-like object to write the archive to. If None, a file will be created at `name`.
810+
overwrite : bool, default False
811+
If False, raise exception if archive file already exists.
812+
"""
813+
archive = SigMFArchive(self, name, fileobj, overwrite=overwrite)
800814
return archive.path
801815

802-
def tofile(self, file_path, pretty=True, toarchive=False, skip_validate=False):
816+
def tofile(self, file_path, pretty=True, toarchive=False, skip_validate=False, overwrite=False):
803817
"""
804818
Write metadata file or full archive containing metadata & dataset.
805819
@@ -812,13 +826,21 @@ def tofile(self, file_path, pretty=True, toarchive=False, skip_validate=False):
812826
toarchive : bool, default False
813827
If True will write both dataset & metadata into SigMF archive format as a single `tar` file.
814828
If False will only write metadata to `sigmf-meta`.
829+
skip_validate : bool, default False
830+
Skip validation of metadata before writing.
831+
overwrite : bool, default False
832+
If False, raise exception if output file already exists.
815833
"""
816834
if not skip_validate:
817835
self.validate()
818836
fns = get_sigmf_filenames(file_path)
837+
819838
if toarchive:
820-
self.archive(fns["archive_fn"])
839+
self.archive(fns["archive_fn"], overwrite=overwrite)
821840
else:
841+
# check if metadata file exists
842+
if not overwrite and fns["meta_fn"].exists():
843+
raise SigMFFileExistsError(fns["meta_fn"], "Metadata file")
822844
with open(fns["meta_fn"], "w") as fp:
823845
self.dump(fp, pretty=pretty)
824846
fp.write("\n") # text files should end in carriage return
@@ -1076,7 +1098,7 @@ def get_collection_field(self, key: str, default=None):
10761098
"""
10771099
return self._metadata[self.COLLECTION_KEY].get(key, default)
10781100

1079-
def tofile(self, file_path, pretty: bool = True) -> None:
1101+
def tofile(self, file_path, pretty: bool = True, overwrite: bool = False) -> None:
10801102
"""
10811103
Write metadata file
10821104
@@ -1086,8 +1108,15 @@ def tofile(self, file_path, pretty: bool = True) -> None:
10861108
Location to save.
10871109
pretty : bool, default True
10881110
When True will write more human-readable output, otherwise will be flat JSON.
1111+
overwrite : bool, default False
1112+
If False, raise exception if collection file already exists.
10891113
"""
10901114
filenames = get_sigmf_filenames(file_path)
1115+
1116+
# check if collection file exists
1117+
if not overwrite and filenames["collection_fn"].exists():
1118+
raise SigMFFileExistsError(filenames["collection_fn"], "Collection file")
1119+
10911120
with open(filenames["collection_fn"], "w") as handle:
10921121
self.dump(handle, pretty=pretty)
10931122
handle.write("\n") # text files should end in carriage return

tests/test_archive.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -47,13 +47,13 @@ def test_archive_creation_requires_data_file(self):
4747
"""Test that archiving without data file raises error"""
4848
self.sigmf_object.data_file = None
4949
with self.assertRaises(error.SigMFFileError):
50-
self.sigmf_object.archive(name=self.temp_path_archive)
50+
self.sigmf_object.archive(name=self.temp_path_archive, overwrite=True)
5151

5252
def test_archive_creation_validates_metadata(self):
5353
"""Test that invalid metadata raises error"""
5454
del self.sigmf_object._metadata["global"]["core:datatype"] # required field
5555
with self.assertRaises(jsonschema.exceptions.ValidationError):
56-
self.sigmf_object.archive(name=self.temp_path_archive)
56+
self.sigmf_object.archive(name=self.temp_path_archive, overwrite=True)
5757

5858
def test_archive_creation_validates_extension(self):
5959
"""Test that wrong extension raises error"""

0 commit comments

Comments
 (0)