diff --git a/.github/workflows/tox.yaml b/.github/workflows/tox.yaml index 2cf3db71..3a9c211b 100644 --- a/.github/workflows/tox.yaml +++ b/.github/workflows/tox.yaml @@ -31,19 +31,19 @@ jobs: fail-fast: false matrix: os: - - ubuntu-20.04 + - ubuntu-22.04 py: - - "3.8" - "3.9" - "3.10" - "3.11" + - "3.12" steps: - name: Set up Python ${{ matrix.py }} - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: python-version: ${{ matrix.py }} - name: Checkout scos-actions - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: Install tox-gh run: python -m pip install tox-gh - name: Set up test suite diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 0b4ae5b3..52848726 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,8 +1,6 @@ -default_language_version: - python: python3.10 repos: - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v4.6.0 + rev: v5.0.0 hooks: - id: check-ast types: [file, python] @@ -18,10 +16,10 @@ repos: - id: end-of-file-fixer - id: trailing-whitespace - repo: https://github.com/asottile/pyupgrade - rev: v3.16.0 + rev: v3.19.0 hooks: - id: pyupgrade - args: ["--py38-plus"] + args: ["--py39-plus"] - repo: https://github.com/pycqa/isort rev: 5.13.2 hooks: @@ -30,12 +28,12 @@ repos: types: [file, python] args: ["--profile", "black", "--filter-files", "--gitignore"] - repo: https://github.com/psf/black - rev: 24.4.2 + rev: 24.10.0 hooks: - id: black types: [file, python] - repo: https://github.com/igorshubovych/markdownlint-cli - rev: v0.41.0 + rev: v0.42.0 hooks: - id: markdownlint types: [file, markdown] diff --git a/README.md b/README.md index 37b521c1..64fd2265 100644 --- a/README.md +++ b/README.md @@ -102,7 +102,7 @@ if the new functionality can be supported by most signal analyzers. ### Requirements and Configuration Set up a development environment using a tool like [Conda](https://docs.conda.io/en/latest/) -or [venv](https://docs.python.org/3/library/venv.html#module-venv), with `python>=3.8`. Then, +or [venv](https://docs.python.org/3/library/venv.html#module-venv), with `python>=3.9`. Then, from the cloned directory, install the development dependencies by running: ```bash diff --git a/pyproject.toml b/pyproject.toml index 61ee7e34..6e69ef93 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,7 +7,7 @@ name = "scos-actions" dynamic = ["version"] description = "The base plugin providing common actions and interfaces for SCOS Sensor plugins" readme = "README.md" -requires-python = ">=3.8" +requires-python = ">=3.9" license = { file = "LICENSE.md" } authors = [ @@ -35,32 +35,32 @@ classifiers = [ "Environment :: Plugins", "Programming Language :: Python", "Programming Language :: Python :: 3", - "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", ] dependencies = [ "environs>=9.5.0", - "django>=4.2,<5.0", + "django>=4.2.8,<5.0", # As of 4.2.8, Python 3.13 is not supported. "its_preselector @ git+https://github.com/NTIA/Preselector@3.1.0", "msgspec>=0.16.0,<1.0.0", "numexpr>=2.8.3", - "numpy>=1.22.0", + "numpy>=1.25.0", "psutil>=5.9.4", "python-dateutil>=2.0", "ray>=2.10.0", "ruamel.yaml>=0.15", - "scipy>=1.8.0", + "scipy>=1.11.4", "sigmf @ git+https://github.com/NTIA/SigMF@multi-recording-archive", ] [project.optional-dependencies] test = [ - "pytest>=7.3.1,<8.0", - "pytest-cov>=4.0.0,<5.0", - "tox>=4.5.1,<5.0", + "pytest>=8.0,<9.0", + "pytest-cov>=6.0,<7.0", + "tox>=4.5.1,<5.0", # Keep in sync with min_version in tox.ini ] dev = [ "hatchling>=1.14.1,<2.0", diff --git a/scos_actions/actions/acquire_sea_data_product.py b/scos_actions/actions/acquire_sea_data_product.py index 15fb5715..5e72550d 100644 --- a/scos_actions/actions/acquire_sea_data_product.py +++ b/scos_actions/actions/acquire_sea_data_product.py @@ -26,7 +26,6 @@ import sys from enum import EnumMeta from time import perf_counter -from typing import Tuple import numpy as np import psutil @@ -261,7 +260,7 @@ def __init__( self.detector = detector self.impedance_ohms = impedance_ohms - def run(self, iq: ray.ObjectRef) -> Tuple[np.ndarray, np.ndarray]: + def run(self, iq: ray.ObjectRef) -> tuple[np.ndarray, np.ndarray]: """ Compute power versus time results from IQ samples. @@ -399,7 +398,7 @@ class IQProcessor: The ``run`` method can be called to filter and process IQ samples. Filtering happens before the remote workers are called, which run concurrently. The ``run`` method returns Ray object references - immediately, which can be later used to retrieve the procesed results. + immediately, which can be later used to retrieve the processed results. """ def __init__(self, params: dict, iir_sos: np.ndarray): diff --git a/scos_actions/calibration/interfaces/calibration.py b/scos_actions/calibration/interfaces/calibration.py index f3ea522c..917b7d25 100644 --- a/scos_actions/calibration/interfaces/calibration.py +++ b/scos_actions/calibration/interfaces/calibration.py @@ -3,7 +3,7 @@ import logging from abc import abstractmethod from pathlib import Path -from typing import List, get_origin +from typing import get_origin from scos_actions.calibration.utils import ( CalibrationParametersMissingException, @@ -28,7 +28,7 @@ class Calibration: updates (if allowed) will be saved. """ - calibration_parameters: List[str] + calibration_parameters: list[str] calibration_data: dict calibration_reference: str file_path: Path @@ -43,7 +43,7 @@ def __post_init__(self): def _validate_fields(self) -> None: """Loosely check that the input types are as expected.""" for f_name, f_def in self.__dataclass_fields__.items(): - # Note that nested types are not checked: i.e., "List[str]" + # Note that nested types are not checked: i.e., "list[str]" # will surely be a list, but may not be filled with strings. f_type = get_origin(f_def.type) or f_def.type actual_value = getattr(self, f_name) diff --git a/scos_actions/calibration/tests/test_calibration.py b/scos_actions/calibration/tests/test_calibration.py index 9c74d9a1..d445c9f8 100644 --- a/scos_actions/calibration/tests/test_calibration.py +++ b/scos_actions/calibration/tests/test_calibration.py @@ -3,7 +3,6 @@ import dataclasses import json from pathlib import Path -from typing import List import pytest @@ -57,7 +56,7 @@ def test_calibration_dataclass_fields(self): fields = {f.name: f.type for f in dataclasses.fields(Calibration)} # Note: does not check field order assert fields == { - "calibration_parameters": List[str], + "calibration_parameters": list[str], "calibration_reference": str, "calibration_data": dict, "file_path": Path, diff --git a/scos_actions/hardware/sensor.py b/scos_actions/hardware/sensor.py index c9eaa2a8..fad49e2c 100644 --- a/scos_actions/hardware/sensor.py +++ b/scos_actions/hardware/sensor.py @@ -2,7 +2,7 @@ import hashlib import json import logging -from typing import Any, Dict, List, Optional +from typing import Any, Optional import numpy as np from its_preselector.preselector import Preselector @@ -38,7 +38,7 @@ def __init__( capabilities: dict, gps: Optional[GPSInterface] = None, preselector: Optional[Preselector] = None, - switches: Optional[Dict[str, WebRelay]] = {}, + switches: Optional[dict[str, WebRelay]] = {}, location: Optional[dict] = None, sensor_cal: Optional[SensorCalibration] = None, differential_cal: Optional[DifferentialCalibration] = None, @@ -93,7 +93,7 @@ def preselector(self, preselector: Preselector): self._preselector = preselector @property - def switches(self) -> Dict[str, WebRelay]: + def switches(self) -> dict[str, WebRelay]: """ Dictionary of WebRelays, indexed by name. WebRelays may enable/disable other components within the sensor and/or provide a variety of sensors. @@ -101,7 +101,7 @@ def switches(self) -> Dict[str, WebRelay]: return self._switches @switches.setter - def switches(self, switches: Dict[str, WebRelay]): + def switches(self, switches: dict[str, WebRelay]): self._switches = switches @property @@ -213,12 +213,12 @@ def last_calibration_time(self) -> str: ) @property - def sensor_calibration_data(self) -> Dict[str, Any]: + def sensor_calibration_data(self) -> dict[str, Any]: """Sensor calibration data for the current sensor settings.""" return self._sensor_calibration_data @property - def differential_calibration_data(self) -> Dict[str, float]: + def differential_calibration_data(self) -> dict[str, float]: """Differential calibration data for the current sensor settings.""" return self._differential_calibration_data diff --git a/scos_actions/hardware/sigan_iface.py b/scos_actions/hardware/sigan_iface.py index 23497414..0a0edea9 100644 --- a/scos_actions/hardware/sigan_iface.py +++ b/scos_actions/hardware/sigan_iface.py @@ -1,7 +1,7 @@ import logging import time from abc import ABC, abstractmethod -from typing import Dict, Optional +from typing import Optional from its_preselector.web_relay import WebRelay @@ -13,7 +13,7 @@ class SignalAnalyzerInterface(ABC): def __init__( self, - switches: Optional[Dict[str, WebRelay]] = None, + switches: Optional[dict[str, WebRelay]] = None, ): self._model = "Unknown" self._api_version = "Unknown" diff --git a/scos_actions/hardware/utils.py b/scos_actions/hardware/utils.py index f01250f8..1eb3a8ff 100644 --- a/scos_actions/hardware/utils.py +++ b/scos_actions/hardware/utils.py @@ -1,6 +1,6 @@ import logging import subprocess -from typing import Dict, Tuple, Union +from typing import Union import psutil from its_preselector.web_relay import WebRelay @@ -127,7 +127,7 @@ def get_max_cpu_temperature(fahrenheit: bool = False) -> float: raise e -def power_cycle_sigan(switches: Dict[str, WebRelay]): +def power_cycle_sigan(switches: dict[str, WebRelay]): """ Performs a hard power cycle of the signal analyzer. This method requires power to the signal analyzer is controlled by a Web_Relay (see https://www.github.com/ntia/Preselector) and that the switch id of that diff --git a/scos_actions/metadata/sigmf_builder.py b/scos_actions/metadata/sigmf_builder.py index 1e6106df..1fdac1f6 100644 --- a/scos_actions/metadata/sigmf_builder.py +++ b/scos_actions/metadata/sigmf_builder.py @@ -1,5 +1,5 @@ import json -from typing import List, Union +from typing import Union import msgspec from sigmf import SigMFFile @@ -268,7 +268,7 @@ def set_collection(self, collection: str) -> None: ### ntia-algorithm v2.0.1 ### - def set_data_products(self, data_products: List[Graph]) -> None: + def set_data_products(self, data_products: list[Graph]) -> None: """ Set the value of the Global "ntia-algorithm:data_products" field. @@ -276,7 +276,7 @@ def set_data_products(self, data_products: List[Graph]) -> None: """ self.sigmf_md.set_global_field("ntia-algorithm:data_products", data_products) - def set_processing(self, processing: List[str]) -> None: + def set_processing(self, processing: list[str]) -> None: """ Set the value of the Global "ntia-algorithm:processing" field. @@ -286,7 +286,7 @@ def set_processing(self, processing: List[str]) -> None: self.sigmf_md.set_global_field("ntia-algorithm:processing", processing) def set_processing_info( - self, processing_info: List[Union[DigitalFilter, DFT]] + self, processing_info: list[Union[DigitalFilter, DFT]] ) -> None: """ Set the value of the Global "ntia-algorithm:processing_info" field. @@ -324,7 +324,7 @@ def set_diagnostics(self, diagnostics: Diagnostics) -> None: ### ntia-nasctn-sea v0.6.0 ### def set_max_of_max_channel_powers( - self, max_of_max_channel_powers: List[float] + self, max_of_max_channel_powers: list[float] ) -> None: """ Set the value of the Global "ntia-nasctn-sea:max_of_max_channel_powers" field. @@ -335,7 +335,7 @@ def set_max_of_max_channel_powers( "ntia-nasctn-sea:max_of_max_channel_powers", max_of_max_channel_powers ) - def set_mean_channel_powers(self, mean_channel_powers: List[float]) -> None: + def set_mean_channel_powers(self, mean_channel_powers: list[float]) -> None: """ Set the value of the Global "ntia-nasctn-sea:mean_channel_powers" field. @@ -345,7 +345,7 @@ def set_mean_channel_powers(self, mean_channel_powers: List[float]) -> None: "ntia-nasctn-sea:mean_channel_powers", mean_channel_powers ) - def set_median_channel_powers(self, median_channel_powers: List[float]) -> None: + def set_median_channel_powers(self, median_channel_powers: list[float]) -> None: """ Set the value of the Global "ntia-nasctn-sea:median_channel_powers" field. @@ -356,7 +356,7 @@ def set_median_channel_powers(self, median_channel_powers: List[float]) -> None: ) def set_median_of_mean_channel_powers( - self, median_of_mean_channel_powers: List[float] + self, median_of_mean_channel_powers: list[float] ) -> None: """ Set the value of the Global "ntia-nasctn-sea:median_of_mean_channel_powers" field. diff --git a/scos_actions/metadata/structs/ntia_algorithm.py b/scos_actions/metadata/structs/ntia_algorithm.py index eacf6baf..ad9b8209 100644 --- a/scos_actions/metadata/structs/ntia_algorithm.py +++ b/scos_actions/metadata/structs/ntia_algorithm.py @@ -1,5 +1,5 @@ from enum import Enum -from typing import List, Optional, Union +from typing import Optional, Union import msgspec @@ -16,7 +16,7 @@ class DigitalFilter(msgspec.Struct, tag=True, **SIGMF_OBJECT_KWARGS): Interface for generating `ntia-algorithm` `DigitalFilter` objects. :param id: Unique ID of the filter. - :param filter_type: Type of the digital fitler, given by the `FilterType` + :param filter_type: Type of the digital filter, given by the `FilterType` enum. :param feedforward_coefficients: Coefficients that define the feedforward filter stage. @@ -32,8 +32,8 @@ class DigitalFilter(msgspec.Struct, tag=True, **SIGMF_OBJECT_KWARGS): id: str filter_type: FilterType - feedforward_coefficients: Optional[List[float]] = None - feedback_coefficients: Optional[List[float]] = None + feedforward_coefficients: Optional[list[float]] = None + feedback_coefficients: Optional[list[float]] = None attenuation_cutoff: Optional[float] = None frequency_cutoff: Optional[float] = None description: Optional[str] = None @@ -49,19 +49,19 @@ class Graph(msgspec.Struct, **SIGMF_OBJECT_KWARGS): """ name: str - series: Optional[List[str]] = None + series: Optional[list[str]] = None length: Optional[int] = None x_units: Optional[str] = None - x_axis: Optional[List[Union[int, float, str]]] = None - x_start: Optional[List[float]] = None - x_stop: Optional[List[float]] = None - x_step: Optional[List[float]] = None + x_axis: Optional[list[Union[int, float, str]]] = None + x_start: Optional[list[float]] = None + x_stop: Optional[list[float]] = None + x_step: Optional[list[float]] = None y_units: Optional[str] = None - y_axis: Optional[List[Union[int, float, str]]] = None - y_start: Optional[List[float]] = None - y_stop: Optional[List[float]] = None - y_step: Optional[List[float]] = None - processing: Optional[List[str]] = None + y_axis: Optional[list[Union[int, float, str]]] = None + y_start: Optional[list[float]] = None + y_stop: Optional[list[float]] = None + y_step: Optional[list[float]] = None + processing: Optional[list[str]] = None reference: Optional[str] = None description: Optional[str] = None diff --git a/scos_actions/metadata/structs/ntia_core.py b/scos_actions/metadata/structs/ntia_core.py index 531f2a6d..2e8f80f4 100644 --- a/scos_actions/metadata/structs/ntia_core.py +++ b/scos_actions/metadata/structs/ntia_core.py @@ -1,4 +1,4 @@ -from typing import List, Optional +from typing import Optional import msgspec @@ -57,8 +57,8 @@ class Antenna(msgspec.Struct, rename={"antenna_type": "type"}, **SIGMF_OBJECT_KW polarization: Optional[float] = None cross_polar_discrimination: Optional[float] = None gain: Optional[float] = None - horizontal_gain_pattern: Optional[List[float]] = None - vertical_gain_pattern: Optional[List[float]] = None + horizontal_gain_pattern: Optional[list[float]] = None + vertical_gain_pattern: Optional[list[float]] = None horizontal_beamwidth: Optional[float] = None vertical_beamwidth: Optional[float] = None voltage_standing_wave_ratio: Optional[float] = None diff --git a/scos_actions/metadata/structs/ntia_diagnostics.py b/scos_actions/metadata/structs/ntia_diagnostics.py index a0ef7c8d..8b6f851b 100644 --- a/scos_actions/metadata/structs/ntia_diagnostics.py +++ b/scos_actions/metadata/structs/ntia_diagnostics.py @@ -1,4 +1,4 @@ -from typing import List, Optional +from typing import Optional import msgspec @@ -13,7 +13,7 @@ class Preselector(msgspec.Struct, **SIGMF_OBJECT_KWARGS): :param noise_diode_temp: Temperature of the noise diode, in degrees Celsius. :param noise_diode_powered: Boolean indicating if the noise diode is powered. :param lna_powered: Boolean indicating if the lna is powered. - :param lna_temp: Temparature of the low noise amplifier, in degrees Celsius. + :param lna_temp: Temperature of the low noise amplifier, in degrees Celsius. :param antenna_path_enabled: Boolean indicating if the antenna path is enabled. :param noise_diode_path_enabled: Boolean indicating if the noise diode path is enabled. :param humidity: Relative humidity inside the preselector enclosure, as a percentage. @@ -80,9 +80,9 @@ class SPU(msgspec.Struct, **SIGMF_OBJECT_KWARGS): ups_healthy: Optional[bool] = None replace_battery: Optional[bool] = None - temperature_sensors: Optional[List[DiagnosticSensor]] = None - humidity_sensors: Optional[List[DiagnosticSensor]] = None - power_sensors: Optional[List[DiagnosticSensor]] = None + temperature_sensors: Optional[list[DiagnosticSensor]] = None + humidity_sensors: Optional[list[DiagnosticSensor]] = None + power_sensors: Optional[list[DiagnosticSensor]] = None door_closed: Optional[bool] = None @@ -97,7 +97,7 @@ class SsdSmartData(msgspec.Struct, **SIGMF_OBJECT_KWARGS): :param available_spare: Normalized percentage (0 to 100) of the remaining spare capacity available. :param available_spare_threshold: When the `available_spare` falls below - this threshold, an aynchronous event completion may occur. Indicated as + this threshold, an asynchronous event completion may occur. Indicated as a normalized percentage (0 to 100). :param percentage_used: Contains a vendor specific estimate of the percentage of NVM subsystem life used based on the actual usage and the manufacturer’s diff --git a/scos_actions/metadata/structs/ntia_scos.py b/scos_actions/metadata/structs/ntia_scos.py index b3d786e5..df0e4f83 100644 --- a/scos_actions/metadata/structs/ntia_scos.py +++ b/scos_actions/metadata/structs/ntia_scos.py @@ -1,4 +1,4 @@ -from typing import List, Optional +from typing import Optional import msgspec @@ -43,4 +43,4 @@ class ScheduleEntry(msgspec.Struct, **SIGMF_OBJECT_KWARGS): stop: Optional[str] = None interval: Optional[int] = None priority: Optional[int] = None - roles: Optional[List[str]] = None + roles: Optional[list[str]] = None diff --git a/scos_actions/metadata/structs/ntia_sensor.py b/scos_actions/metadata/structs/ntia_sensor.py index c57f605f..5a2cfb0a 100644 --- a/scos_actions/metadata/structs/ntia_sensor.py +++ b/scos_actions/metadata/structs/ntia_sensor.py @@ -1,5 +1,5 @@ import logging -from typing import List, Optional +from typing import Optional import msgspec @@ -110,10 +110,10 @@ class Preselector(msgspec.Struct, **SIGMF_OBJECT_KWARGS): """ preselector_spec: Optional[HardwareSpec] = None - cal_sources: Optional[List[CalSource]] = None - amplifiers: Optional[List[Amplifier]] = None - filters: Optional[List[Filter]] = None - rf_paths: Optional[List[RFPath]] = None + cal_sources: Optional[list[CalSource]] = None + amplifiers: Optional[list[Amplifier]] = None + filters: Optional[list[Filter]] = None + rf_paths: Optional[list[RFPath]] = None class Calibration( diff --git a/scos_actions/signal_processing/apd.py b/scos_actions/signal_processing/apd.py index 2047f19e..b1aba99a 100644 --- a/scos_actions/signal_processing/apd.py +++ b/scos_actions/signal_processing/apd.py @@ -1,5 +1,3 @@ -from typing import Tuple - import numexpr as ne import numpy as np @@ -12,7 +10,7 @@ def get_apd( min_bin: float = None, max_bin: float = None, impedance_ohms: float = None, -) -> Tuple[np.ndarray, np.ndarray]: +) -> tuple[np.ndarray, np.ndarray]: """Estimate the APD by sampling the CCDF. The size of the output depends on ``bin_size_dB``, which diff --git a/scos_actions/signal_processing/calibration.py b/scos_actions/signal_processing/calibration.py index e1a4c847..03b28f35 100644 --- a/scos_actions/signal_processing/calibration.py +++ b/scos_actions/signal_processing/calibration.py @@ -1,5 +1,5 @@ import logging -from typing import Optional, Tuple +from typing import Optional import numpy as np from its_preselector.preselector import Preselector @@ -23,7 +23,7 @@ def y_factor( enr_linear: float, enbw_hz: float, temp_kelvins: float = 300.0, -) -> Tuple[float, float]: +) -> tuple[float, float]: """ Perform Y-Factor calculations of noise figure and gain. @@ -105,7 +105,7 @@ def get_linear_enr( def get_temperature( preselector: Preselector, sensor_idx: Optional[int] = None -) -> Tuple[float, float, float]: +) -> tuple[float, float, float]: """ Get the temperature from a preselector sensor. diff --git a/scos_actions/signal_processing/filtering.py b/scos_actions/signal_processing/filtering.py index ae6bb527..3d2133de 100644 --- a/scos_actions/signal_processing/filtering.py +++ b/scos_actions/signal_processing/filtering.py @@ -1,4 +1,4 @@ -from typing import Tuple, Union +from typing import Union import numpy as np from scipy.signal import ellip, ellipord, firwin, kaiserord, sos2zpk, sosfreqz @@ -74,7 +74,7 @@ def generate_fir_low_pass_filter( :param width_Hz: Width of the transition region, in Hz. :param cutoff_Hz: Filter cutoff frequency, in Hz. :param sample_rate_Hz: Sampling rate, in Hz. - :return: Coeffiecients of the FIR low pass filter. + :return: Coefficients of the FIR low pass filter. """ ord, beta = kaiserord( ripple=attenuation_dB, width=width_Hz / (0.5 * sample_rate_Hz) @@ -93,7 +93,7 @@ def generate_fir_low_pass_filter( def get_iir_frequency_response( sos: np.ndarray, worN: Union[int, np.ndarray], sample_rate_Hz: float -) -> Tuple[np.ndarray, np.ndarray]: +) -> tuple[np.ndarray, np.ndarray]: """ Get the frequency response of an IIR filter. @@ -113,7 +113,7 @@ def get_iir_frequency_response( def get_iir_phase_response( sos: np.ndarray, worN: Union[int, np.ndarray], sample_rate_Hz: float -) -> Tuple[np.ndarray, np.ndarray]: +) -> tuple[np.ndarray, np.ndarray]: """ Get the phase response of an IIR filter. diff --git a/tox.ini b/tox.ini index f04c3a61..de57049f 100644 --- a/tox.ini +++ b/tox.ini @@ -1,9 +1,9 @@ [tox] env_list = - py38 py39 py310 py311 + py312 min_version = 4.5.1 skip_missing_interpreters = true no_package = false @@ -17,7 +17,7 @@ commands = pytest --cov-report term-missing --no-cov-on-fail --cov {posargs} [gh] ; GitHub Actions CI with tox-gh python = - 3.8 = py38 3.9 = py39 3.10 = py310 3.11 = py311 + 3.12 = py312