From c5d194d5e659def926d25737baa7b6cbbb4887bd Mon Sep 17 00:00:00 2001 From: Teque5 Date: Wed, 14 Feb 2024 10:36:45 -0800 Subject: [PATCH] Drop3.6 & move all configuration into pyproject.toml (#49) * move all configuration into pyproject.toml * tox configuration simplified and consolidated to pyproject.toml * default configuration for common tools (black, pytype, coverage) * add entry point for sigmf_convert_wav * slightly improve sigmf_convert_wav * increment to v1.2.0 * move tools/ to apps/ * move gui.py to apps/ * drop support for python 3.6 * add support for python 3.12 * distribution previously made with setup.py can be created w/python3 -m build * upgrade logo to SVG version * pin PySimpleGUI version --- .github/workflows/main.yml | 9 ++- .gitignore | 3 +- README.md | 27 +++++--- pyproject.toml | 102 ++++++++++++++++++++++++++++++ setup.cfg | 2 - setup.py | 47 -------------- sigmf/__init__.py | 2 +- sigmf/{tools => apps}/__init__.py | 0 sigmf/apps/convert_wav.py | 78 +++++++++++++++++++++++ sigmf/{ => apps}/gui.py | 4 +- sigmf/sigmffile.py | 12 ++-- sigmf/tools/wav2sigmf.py | 56 ---------------- sigmf/validate.py | 2 +- tox.ini | 22 ------- 14 files changed, 214 insertions(+), 152 deletions(-) create mode 100644 pyproject.toml delete mode 100644 setup.cfg delete mode 100755 setup.py rename sigmf/{tools => apps}/__init__.py (100%) create mode 100755 sigmf/apps/convert_wav.py rename sigmf/{ => apps}/gui.py (99%) delete mode 100755 sigmf/tools/wav2sigmf.py delete mode 100644 tox.ini diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index b37b254..d7193d7 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -1,6 +1,6 @@ name: Python package -on: +on: push: pull_request: types: [opened, synchronize] @@ -10,7 +10,7 @@ jobs: runs-on: ubuntu-20.04 strategy: matrix: - python-version: ["3.6"] + python-version: ["3.7", "3.9", "3.12"] steps: - uses: actions/checkout@v3 - name: Set up Python ${{ matrix.python-version }} @@ -20,8 +20,7 @@ jobs: - name: Install dependencies run: | python -m pip install --upgrade pip - pip install pytest - pip install . + pip install .[test,apps] - name: Test with pytest run: | - pytest + coverage run diff --git a/.gitignore b/.gitignore index 1ec0199..756579b 100644 --- a/.gitignore +++ b/.gitignore @@ -9,8 +9,9 @@ build/* .eggs/* SigMF.egg-info/* -# pytest & coverage related +# test related .coverage pytest.xml coverage.xml +.tox/ htmlcov/* diff --git a/README.md b/README.md index 790a8ff..56242e8 100644 --- a/README.md +++ b/README.md @@ -1,21 +1,21 @@ -

+

Rendered SigMF Logo

This python module makes it easy to interact with Signal Metadata Format -(SigMF) objects. This module works with Python 3.6+ and is distributed freely -under the terms GNU Lesser GPL v3 License. +(SigMF) recordings. This module works with Python 3.7+ and is distributed +freely under the terms GNU Lesser GPL v3 License. The [SigMF specification document](https://github.com/sigmf/SigMF/blob/HEAD/sigmf-spec.md) is located in the [SigMF](https://github.com/gnuradio/SigMF) repository. # Installation -To install the latest release, install from pip: +To install the latest PyPi release, install from pip: ```bash pip install sigmf ``` -To install the latest development version, build from source: +To install the latest git release, build from source: ```bash git clone https://github.com/sigmf/sigmf-python.git @@ -23,12 +23,19 @@ cd sigmf-python pip install . ``` -To run the included QA tests: +Testing can be run with a variety of tools: + ```bash -# basic -python3 -m pytest tests/ -# fancy -coverage run --a --source sigmf -m pytest --doctest-modules +# pytest and coverage run locally +pytest +coverage run +# run coverage in a venv +tox run +# other useful tools +pylint sigmf tests +pytype +black +flake8 ``` # Examples diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..ee425f7 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,102 @@ +[project] +name = "SigMF" +description = "Easily interact with Signal Metadata Format (SigMF) recordings." +keywords = ["gnuradio"] +classifiers = [ + "Development Status :: 5 - Production/Stable", + "License :: OSI Approved :: GNU Lesser General Public License v3 or later (LGPLv3+)", + "Operating System :: OS Independent", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.7", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", +] +dynamic = ["version", "readme"] +requires-python = ">=3.7" +dependencies = [ + "numpy", # for vector math + "jsonschema", # for spec validation +] + [project.urls] + repository = "https://github.com/sigmf/sigmf-python" + [project.scripts] + sigmf_validate = "sigmf.validate:main" + sigmf_gui = "sigmf.apps.gui:main [apps]" + sigmf_convert_wav = "sigmf.apps.convert_wav:main [apps]" + [project.optional-dependencies] + test = [ + "pylint", + "pytest", + "pytest-cov", + "hypothesis", # next-gen testing framework + ] + apps = [ + "scipy", # for wav i/o + # FIXME: PySimpleGUI 2024-02-12 v5.0.0 release seems to have a bug. Unpin version when possible. + "PySimpleGUI < 5.0.0", # for gui interface + ] + +[tool.setuptools] +packages = ["sigmf"] + [tool.setuptools.dynamic] + version = {attr = "sigmf.__version__"} + readme = {file = ["README.md"], content-type = "text/markdown"} + [tool.setuptools.package-data] + sigmf = ["*.json"] + +[build-system] +requires = ["setuptools>=65.0", "setuptools-scm"] +build-backend = "setuptools.build_meta" + +[tool.coverage.run] +branch = true +source = ["sigmf", "tests"] +# -rA captures stdout from all tests and places it after the pytest summary +command_line = "-m pytest -rA --doctest-modules --junitxml=pytest.xml" + +[tool.pytest.ini_options] +addopts = "--doctest-modules" + +[tool.pylint] + [tool.pylint.main] + load-plugins = [ + "pylint.extensions.typing", + "pylint.extensions.docparams", + ] + exit-zero = true + [tool.pylint.messages_control] + disable = [ + "logging-not-lazy", + "missing-module-docstring", + "import-error", + "unspecified-encoding", + ] + max-line-length = 120 + [tool.pylint.REPORTS] + # omit from the similarity reports + ignore-comments = 'yes' + ignore-docstrings = 'yes' + ignore-imports = 'yes' + ignore-signatures = 'yes' + min-similarity-lines = 4 + +[tool.pytype] +inputs = ['sigmf', 'tests'] + +[tool.black] +line-length = 120 + +[tool.tox] +legacy_tox_ini = ''' + [tox] + skip_missing_interpreters = True + envlist = py{37,38,39,310,311,312} + + [testenv] + usedevelop = True + deps = .[test,apps] + commands = coverage run +''' diff --git a/setup.cfg b/setup.cfg deleted file mode 100644 index b7e4789..0000000 --- a/setup.cfg +++ /dev/null @@ -1,2 +0,0 @@ -[aliases] -test=pytest diff --git a/setup.py b/setup.py deleted file mode 100755 index 1d43b36..0000000 --- a/setup.py +++ /dev/null @@ -1,47 +0,0 @@ -#!/usr/bin/env python3 -from setuptools import setup -import os -import re - -short_description = "Python module for interacting with SigMF recordings." - -with open("README.md", encoding="utf-8") as handle: - long_description = handle.read() - -with open(os.path.join("sigmf", "__init__.py"), encoding="utf-8") as handle: - version = re.search(r'__version__\s*=\s*[\'"]([^\'"]*)[\'"]', handle.read()).group(1) - -setup( - name="SigMF", - version=version, - description=short_description, - long_description=long_description, - long_description_content_type="text/markdown", - url="https://github.com/sigmf/sigmf-python", - license="GNU Lesser General Public License v3 or later (LGPLv3+)", - classifiers=[ - "License :: OSI Approved :: GNU Lesser General Public License v3 or later (LGPLv3+)", - "Operating System :: OS Independent", - "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.6", - "Programming Language :: Python :: 3.7", - "Programming Language :: Python :: 3.8", - "Programming Language :: Python :: 3.9", - "Programming Language :: Python :: 3.10", - ], - entry_points={ - "console_scripts": [ - "sigmf_validate = sigmf.validate:main", - "sigmf_gui = sigmf.gui:main [gui]", - ] - }, - packages=["sigmf"], - package_data={ - "sigmf": ["*.json"], - }, - install_requires=["numpy", "jsonschema"], - extras_require={"gui": "pysimplegui==4.0.0"}, - setup_requires=["pytest-runner"], - tests_require=["pytest>3", "hypothesis"], - zip_safe=False, -) diff --git a/sigmf/__init__.py b/sigmf/__init__.py index fa4275e..e11844a 100644 --- a/sigmf/__init__.py +++ b/sigmf/__init__.py @@ -4,7 +4,7 @@ # # SPDX-License-Identifier: LGPL-3.0-or-later -__version__ = "1.1.5" +__version__ = "1.2.0" from .archive import SigMFArchive from .sigmffile import SigMFFile, SigMFCollection diff --git a/sigmf/tools/__init__.py b/sigmf/apps/__init__.py similarity index 100% rename from sigmf/tools/__init__.py rename to sigmf/apps/__init__.py diff --git a/sigmf/apps/convert_wav.py b/sigmf/apps/convert_wav.py new file mode 100755 index 0000000..638bd46 --- /dev/null +++ b/sigmf/apps/convert_wav.py @@ -0,0 +1,78 @@ +# Copyright: Multiple Authors +# +# This file is part of SigMF. https://github.com/sigmf/sigmf-python +# +# SPDX-License-Identifier: LGPL-3.0-or-later + +"""converter for wav containers""" + +import os +import tempfile +import datetime +import pathlib +import argparse +import getpass + +from scipy.io import wavfile + +from .. import archive +from ..sigmffile import SigMFFile +from ..utils import get_data_type_str + + +def convert_wav(input_wav_filename, archive_filename=None, start_datetime=None, author=None): + """ + read a .wav and write a .sigmf archive + """ + samp_rate, wav_data = wavfile.read(input_wav_filename) + + global_info = { + SigMFFile.AUTHOR_KEY: getpass.getuser() if author is None else author, + SigMFFile.DATATYPE_KEY: get_data_type_str(wav_data), + SigMFFile.DESCRIPTION_KEY: f"Converted from {input_wav_filename}", + SigMFFile.NUM_CHANNELS_KEY: 1 if len(wav_data.shape) < 2 else wav_data.shape[1], + SigMFFile.RECORDER_KEY: os.path.basename(__file__), + SigMFFile.SAMPLE_RATE_KEY: samp_rate, + } + + if start_datetime is None: + fname = pathlib.Path(input_wav_filename) + mtime = datetime.datetime.fromtimestamp(fname.stat().st_mtime) + start_datetime = mtime.isoformat() + "Z" + + capture_info = {SigMFFile.START_INDEX_KEY: 0} + if start_datetime is not None: + capture_info[SigMFFile.DATETIME_KEY] = start_datetime + + tmpdir = tempfile.mkdtemp() + sigmf_data_filename = input_wav_filename + archive.SIGMF_DATASET_EXT + sigmf_data_path = os.path.join(tmpdir, sigmf_data_filename) + wav_data.tofile(sigmf_data_path) + + meta = SigMFFile(data_file=sigmf_data_path, global_info=global_info) + meta.add_capture(0, metadata=capture_info) + + if archive_filename is None: + archive_filename = os.path.basename(input_wav_filename) + archive.SIGMF_ARCHIVE_EXT + meta.tofile(archive_filename, toarchive=True) + return os.path.abspath(archive_filename) + + +def main(): + """ + entry-point for sigmf_convert_wav + """ + parser = argparse.ArgumentParser(description="Convert .wav to .sigmf container.") + parser.add_argument("input", type=str, help="Wavfile path") + parser.add_argument("--author", type=str, default=None, help=f"set {SigMFFile.AUTHOR_KEY} metadata") + args = parser.parse_args() + + out_fname = convert_wav( + input_wav_filename=args.input, + author=args.author, + ) + print("Wrote", out_fname) + + +if __name__ == "__main__": + main() diff --git a/sigmf/gui.py b/sigmf/apps/gui.py similarity index 99% rename from sigmf/gui.py rename to sigmf/apps/gui.py index 7d44b86..ac51da3 100644 --- a/sigmf/gui.py +++ b/sigmf/apps/gui.py @@ -10,8 +10,8 @@ import logging from PySimpleGUI import * -from .sigmffile import SigMFFile, fromarchive, dtype_info -from .archive import SIGMF_ARCHIVE_EXT +from ..sigmffile import SigMFFile, fromarchive, dtype_info +from ..archive import SIGMF_ARCHIVE_EXT log = logging.getLogger() diff --git a/sigmf/sigmffile.py b/sigmf/sigmffile.py index bd68118..9e72d01 100644 --- a/sigmf/sigmffile.py +++ b/sigmf/sigmffile.py @@ -202,7 +202,7 @@ def __next__(self): def __getitem__(self, sli): mem = self._memmap[sli] # matches behavior of numpy.ndarray.__getitem__() - + if self._return_type is None: return mem @@ -229,13 +229,15 @@ def get_num_channels(self): def _is_conforming_dataset(self): """ - Returns `True` if the dataset is conforming to SigMF, `False` otherwise - The dataset is non-conforming if the datafile contains non-sample bytes which means global trailing_bytes field is zero or not set, all captures `header_bytes` fields are zero or not set. Because we do not necessarily know the filename no means of verifying the meta/data filename roots match, but this will also check that a data file exists. + + Returns + ------- + `True` if the dataset is conforming to SigMF, `False` otherwise """ if self.get_global_field(self.TRAILING_BYTES_KEY, 0): return False @@ -405,7 +407,7 @@ def get_annotations(self, index=None): annotations = self._metadata.get(self.ANNOTATION_KEY, []) if index is None: return annotations - + annotations_including_index = [] for annotation in annotations: if index < annotation[self.START_INDEX_KEY]: @@ -416,7 +418,7 @@ def get_annotations(self, index=None): if index >= annotation[self.START_INDEX_KEY] + annotation[self.LENGTH_INDEX_KEY]: # index is after annotation end -> skip continue - + annotations_including_index.append(annotation) return annotations_including_index diff --git a/sigmf/tools/wav2sigmf.py b/sigmf/tools/wav2sigmf.py deleted file mode 100755 index 99e9ee3..0000000 --- a/sigmf/tools/wav2sigmf.py +++ /dev/null @@ -1,56 +0,0 @@ -#!/usr/bin/env python3 - -import os, tempfile -from scipy.io import wavfile -import sigmf -from sigmf import SigMFFile, SigMFArchive -from sigmf.utils import get_data_type_str - -def writeSigMFArchiveFromWave(input_wav_filename, archive_filename=None, start_datetime=None, author=None): - samplerate, wav_data = wavfile.read(input_wav_filename) - - global_info = { - SigMFFile.DATATYPE_KEY: get_data_type_str(wav_data), - SigMFFile.SAMPLE_RATE_KEY: samplerate, - SigMFFile.DESCRIPTION_KEY: 'Converted from ' + input_wav_filename + '.', - SigMFFile.NUM_CHANNELS_KEY: 1 if len(wav_data.shape) < 2 else wav_data.shape[1], - SigMFFile.RECORDER_KEY: os.path.basename(__file__), - } - if author is None: - try: - import getpass - except: - pass - else: - author = getpass.getuser() - if author is not None: - global_info[SigMFFile.AUTHOR_KEY]: author - - if start_datetime is None: - import datetime, pathlib - fname = pathlib.Path(input_wav_filename) - mtime = datetime.datetime.fromtimestamp(fname.stat().st_mtime) - start_datetime = mtime.isoformat() + 'Z' - - capture_info = {SigMFFile.START_INDEX_KEY: 0} - if start_datetime is not None: - capture_info[SigMFFile.DATETIME_KEY] = start_datetime - - tmpdir = tempfile.mkdtemp() - sigmf_data_filename = input_wav_filename + sigmf.archive.SIGMF_DATASET_EXT - sigmf_data_path = os.path.join(tmpdir, sigmf_data_filename) - wav_data.tofile(sigmf_data_path) - - meta = sigmf.SigMFFile(data_file=sigmf_data_path, global_info=global_info) - meta.add_capture(0, metadata=capture_info) - - if archive_filename is None: - archive_filename = os.path.basename(input_wav_filename) + sigmf.archive.SIGMF_ARCHIVE_EXT - meta.tofile(archive_filename, toarchive=True) - return os.path.abspath(archive_filename) - -if __name__ == '__main__': - import sys - input_wav_filename = sys.argv[1] # produces an understandable error if nothing was provided on command line - out_fname = writeSigMFArchiveFromWave(input_wav_filename) - print("Wrote", out_fname) diff --git a/sigmf/validate.py b/sigmf/validate.py index a251bdd..ce18fcc 100644 --- a/sigmf/validate.py +++ b/sigmf/validate.py @@ -124,5 +124,5 @@ def main(): log.info('Validation OK!') -if __name__ == '__main__': +if __name__ == "__main__": main() diff --git a/tox.ini b/tox.ini deleted file mode 100644 index 02a42f5..0000000 --- a/tox.ini +++ /dev/null @@ -1,22 +0,0 @@ -[tox] -skip_missing_interpreters = True -envlist = py36, py37, py38, py39, py310 - -[testenv] -usedevelop = True -deps = - pytest - flake8 -commands = - pytest - - flake8 - -[testenv:coverage] -deps = - pytest-cov -commands = py.test --cov-report term-missing --cov=sigmf tests - -[flake8] -max-line-length = 120 -[pycodestyle] -max-line-length = 120