Repository: DIAGNijmegen/rse-panimg (language: python)

Issue:
Update python version support and check if this works with PyDicom 3



Discussion / hints:

Fix patch:
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 7f831dc..43a026e 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -8,7 +8,7 @@ on:
       - published
 
 env:
-  MINIMUM_PYTHON_VERSION: '3.8'
+  MINIMUM_PYTHON_VERSION: '3.9'
 
 concurrency:
   group: ${{ github.head_ref || github.run_id }}
@@ -36,7 +36,7 @@ jobs:
     runs-on: ubuntu-latest
     strategy:
       matrix:
-        python-version: ["3.8", "3.9", "3.10", "3.11"]
+        python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"]
     steps:
       - uses: actions/checkout@v3
       - name: Set up Python ${{ matrix.python-version }}
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 5ee87c2..9b19860 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -1,6 +1,6 @@
 repos:
   - repo: https://github.com/pre-commit/pre-commit-hooks
-    rev: v4.5.0
+    rev: v5.0.0
     hooks:
       - id: check-docstring-first
       - id: debug-statements
@@ -8,22 +8,22 @@ repos:
       - id: mixed-line-ending
       - id: trailing-whitespace
   - repo: https://github.com/asottile/pyupgrade
-    rev: v3.15.0
+    rev: v3.18.0
     hooks:
       - id: pyupgrade
         language: python
-        args: [--py38-plus]
+        args: [--py39-plus]
   - repo: https://github.com/pycqa/isort
-    rev: 5.12.0
+    rev: 5.13.2
     hooks:
       - id: isort
   - repo: https://github.com/ambv/black
-    rev: 23.9.1
+    rev: 24.10.0
     hooks:
       - id: black
         language: python
   - repo: https://github.com/PyCQA/flake8
-    rev: 6.1.0
+    rev: 7.1.1
     hooks:
       - id: flake8
         language: python
@@ -35,7 +35,7 @@ repos:
           - yesqa
         exclude: ^panimg/contrib/
   - repo: https://github.com/pre-commit/mirrors-mypy
-    rev: 'v1.6.0'
+    rev: 'v1.11.2'
     hooks:
       - id: mypy
         additional_dependencies:
diff --git a/HISTORY.md b/HISTORY.md
index 1066521..ff41653 100644
--- a/HISTORY.md
+++ b/HISTORY.md
@@ -1,5 +1,10 @@
 # History
 
+## 0.14.0 (2024-10-15)
+
+* Removed support for Python 3.8
+* Added support for Python 3.12 and 3.13
+
 ## 0.13.2 (2023-10-23)
 
 * Fix DICOM-WSI conversion issue where not all levels were converted correctly
diff --git a/README.md b/README.md
index 8677f01..200565f 100644
--- a/README.md
+++ b/README.md
@@ -9,7 +9,7 @@
 **NOT FOR CLINICAL USE**
 
 Conversion of medical images to MHA and TIFF.
-Requires Python 3.8, 3.9, 3.10 or 3.11.
+Requires Python 3.9, 3.10, 3.11, 3.12 or 3.13.
 `libvips-dev` and `libopenslide-dev` must be installed on your system.
 
 Under the hood we use:
diff --git a/panimg/exceptions.py b/panimg/exceptions.py
index a2dab03..50569c2 100644
--- a/panimg/exceptions.py
+++ b/panimg/exceptions.py
@@ -1,5 +1,4 @@
 from pathlib import Path
-from typing import Dict, List
 
 
 class ValidationError(Exception):
@@ -13,6 +12,6 @@ class UnconsumedFilesException(Exception):  # noqa: N818
     the unconsumed file.
     """
 
-    def __init__(self, *args, file_errors: Dict[Path, List[str]]):
+    def __init__(self, *args, file_errors: dict[Path, list[str]]):
         super().__init__(*args)
         self.file_errors = file_errors
diff --git a/panimg/image_builders/__init__.py b/panimg/image_builders/__init__.py
index dd6d479..c4ee238 100644
--- a/panimg/image_builders/__init__.py
+++ b/panimg/image_builders/__init__.py
@@ -1,4 +1,4 @@
-from typing import Iterable
+from collections.abc import Iterable
 
 from panimg.image_builders.dicom import image_builder_dicom
 from panimg.image_builders.fallback import image_builder_fallback
diff --git a/panimg/image_builders/dicom.py b/panimg/image_builders/dicom.py
index 0e1d7d8..38bd4f8 100644
--- a/panimg/image_builders/dicom.py
+++ b/panimg/image_builders/dicom.py
@@ -1,8 +1,9 @@
 import logging
 from collections import defaultdict
+from collections.abc import Iterator
 from math import isclose
 from pathlib import Path
-from typing import Any, DefaultDict, Dict, Iterator, List, Set, Tuple
+from typing import Any, DefaultDict
 
 import numpy as np
 import pydicom
@@ -248,7 +249,7 @@ def _read_pixel_values(self, filename: Path, rescale: bool) -> np.ndarray:
         else:
             return pixel_array
 
-    def _shape(self, samples_per_pixel: int) -> Tuple[int, ...]:
+    def _shape(self, samples_per_pixel: int) -> tuple[int, ...]:
         pixel_dims = [
             self.n_slices,
             self.ref_header.Rows,
@@ -375,7 +376,7 @@ def read(self) -> SimpleITKImage:
 
 
 def get_dicom_headers_by_study(
-    files: Set[Path], file_errors: DefaultDict[Path, List[str]]
+    files: set[Path], file_errors: DefaultDict[Path, list[str]]
 ):
     """
     Gets all headers from dicom files found in path.
@@ -393,9 +394,9 @@ def get_dicom_headers_by_study(
     A dictionary of headers for all dicom image files found within path,
     grouped by study id.
     """
-    study_key_type = Tuple[str, ...]
-    studies: Dict[study_key_type, Dict[str, Any]] = {}
-    indices: Dict[str, Dict[study_key_type, int]] = {}
+    study_key_type = tuple[str, ...]
+    studies: dict[study_key_type, dict[str, Any]] = {}
+    indices: dict[str, dict[study_key_type, int]] = {}
 
     for file in files:
         if not file.is_file():
@@ -443,8 +444,8 @@ def get_dicom_headers_by_study(
 
 
 def _find_valid_dicom_files(
-    files: Set[Path], file_errors: DefaultDict[Path, List[str]]
-) -> List[DicomDataset]:
+    files: set[Path], file_errors: DefaultDict[Path, list[str]]
+) -> list[DicomDataset]:
     """
     Gets the headers for all dicom files on path and validates them.
 
@@ -529,7 +530,7 @@ def _find_valid_dicom_files(
     return result
 
 
-def image_builder_dicom(*, files: Set[Path]) -> Iterator[SimpleITKImage]:
+def image_builder_dicom(*, files: set[Path]) -> Iterator[SimpleITKImage]:
     """
     Constructs image objects by inspecting files in a directory.
 
@@ -546,7 +547,7 @@ def image_builder_dicom(*, files: Set[Path]) -> Iterator[SimpleITKImage]:
      - a list files associated with the detected images
      - path->error message map describing what is wrong with a given file
     """
-    file_errors: DefaultDict[Path, List[str]] = defaultdict(list)
+    file_errors: DefaultDict[Path, list[str]] = defaultdict(list)
 
     studies = _find_valid_dicom_files(files=files, file_errors=file_errors)
     for dicom_ds in studies:
diff --git a/panimg/image_builders/fallback.py b/panimg/image_builders/fallback.py
index 818ad4d..d55d1f4 100644
--- a/panimg/image_builders/fallback.py
+++ b/panimg/image_builders/fallback.py
@@ -1,6 +1,7 @@
 from collections import defaultdict
+from collections.abc import Iterator
 from pathlib import Path
-from typing import DefaultDict, Iterator, List, Set
+from typing import DefaultDict
 
 import numpy as np
 import SimpleITK
@@ -15,7 +16,7 @@ def format_error(message: str) -> str:
     return f"Fallback image builder: {message}"
 
 
-def image_builder_fallback(*, files: Set[Path]) -> Iterator[SimpleITKImage]:
+def image_builder_fallback(*, files: set[Path]) -> Iterator[SimpleITKImage]:
     """
     Constructs image objects by inspecting files in a directory.
 
@@ -33,7 +34,7 @@ def image_builder_fallback(*, files: Set[Path]) -> Iterator[SimpleITKImage]:
      - a list files associated with the detected images
      - path->error message map describing what is wrong with a given file
     """
-    file_errors: DefaultDict[Path, List[str]] = defaultdict(list)
+    file_errors: DefaultDict[Path, list[str]] = defaultdict(list)
 
     for file in files:
         try:
diff --git a/panimg/image_builders/metaio_mhd_mha.py b/panimg/image_builders/metaio_mhd_mha.py
index 0f148f0..b42ab40 100644
--- a/panimg/image_builders/metaio_mhd_mha.py
+++ b/panimg/image_builders/metaio_mhd_mha.py
@@ -3,9 +3,11 @@
 
 See: https://itk.org/Wiki/MetaIO/Documentation
 """
+
 from collections import defaultdict
+from collections.abc import Iterator, Mapping
 from pathlib import Path
-from typing import DefaultDict, Dict, Iterator, List, Mapping, Set, Union
+from typing import DefaultDict, Union
 
 from panimg.exceptions import UnconsumedFilesException, ValidationError
 from panimg.image_builders.metaio_utils import load_sitk_image, parse_mh_header
@@ -13,7 +15,7 @@
 
 
 def image_builder_mhd(  # noqa: C901
-    *, files: Set[Path]
+    *, files: set[Path]
 ) -> Iterator[SimpleITKImage]:
     """
     Constructs image objects by inspecting files in a directory.
@@ -31,11 +33,11 @@ def image_builder_mhd(  # noqa: C901
      - files associated with the detected images
      - path->error message map describing what is wrong with a given file
     """
-    file_errors: DefaultDict[Path, List[str]] = defaultdict(list)
+    file_errors: DefaultDict[Path, list[str]] = defaultdict(list)
 
     element_data_file_key = "ElementDataFile"
 
-    def detect_mhd_file(headers: Dict[str, str], path: Path) -> bool:
+    def detect_mhd_file(headers: dict[str, str], path: Path) -> bool:
         try:
             data_file = headers[element_data_file_key]
         except KeyError:
diff --git a/panimg/image_builders/metaio_nifti.py b/panimg/image_builders/metaio_nifti.py
index defc9ae..429aa94 100644
--- a/panimg/image_builders/metaio_nifti.py
+++ b/panimg/image_builders/metaio_nifti.py
@@ -1,6 +1,7 @@
 from collections import defaultdict
+from collections.abc import Iterator
 from pathlib import Path
-from typing import DefaultDict, Iterator, List, Set
+from typing import DefaultDict
 
 from panimg.exceptions import UnconsumedFilesException, ValidationError
 from panimg.image_builders.metaio_utils import load_sitk_image
@@ -11,7 +12,7 @@ def format_error(message: str) -> str:
     return f"NifTI image builder: {message}"
 
 
-def image_builder_nifti(*, files: Set[Path]) -> Iterator[SimpleITKImage]:
+def image_builder_nifti(*, files: set[Path]) -> Iterator[SimpleITKImage]:
     """
     Constructs image objects from files in NifTI format (nii/nii.gz)
 
@@ -28,7 +29,7 @@ def image_builder_nifti(*, files: Set[Path]) -> Iterator[SimpleITKImage]:
      - a list files associated with the detected images
      - path->error message map describing what is wrong with a given file
     """
-    file_errors: DefaultDict[Path, List[str]] = defaultdict(list)
+    file_errors: DefaultDict[Path, list[str]] = defaultdict(list)
 
     for file in files:
         if not (file.name.endswith(".nii") or file.name.endswith(".nii.gz")):
diff --git a/panimg/image_builders/metaio_nrrd.py b/panimg/image_builders/metaio_nrrd.py
index a6b7550..cef43e7 100644
--- a/panimg/image_builders/metaio_nrrd.py
+++ b/panimg/image_builders/metaio_nrrd.py
@@ -1,7 +1,8 @@
 import re
 from collections import defaultdict
+from collections.abc import Iterator
 from pathlib import Path
-from typing import DefaultDict, Iterator, List, Set
+from typing import DefaultDict
 
 from panimg.exceptions import UnconsumedFilesException, ValidationError
 from panimg.image_builders.metaio_utils import load_sitk_image
@@ -70,7 +71,7 @@ def format_error(message: str) -> str:
     return f"NRRD image builder: {message}"
 
 
-def image_builder_nrrd(*, files: Set[Path]) -> Iterator[SimpleITKImage]:
+def image_builder_nrrd(*, files: set[Path]) -> Iterator[SimpleITKImage]:
     """
     Constructs image objects from files in NRRD format (nrrd)
 
@@ -92,7 +93,7 @@ def image_builder_nrrd(*, files: Set[Path]) -> Iterator[SimpleITKImage]:
      - a list files associated with the detected images
      - path->error message map describing what is wrong with a given file
     """
-    file_errors: DefaultDict[Path, List[str]] = defaultdict(list)
+    file_errors: DefaultDict[Path, list[str]] = defaultdict(list)
 
     for file in files:
         try:
diff --git a/panimg/image_builders/metaio_utils.py b/panimg/image_builders/metaio_utils.py
index 07aba09..32b0cf0 100644
--- a/panimg/image_builders/metaio_utils.py
+++ b/panimg/image_builders/metaio_utils.py
@@ -1,6 +1,6 @@
 import re
 from pathlib import Path
-from typing import Any, Dict, List
+from typing import Any
 
 import SimpleITK
 
@@ -70,18 +70,18 @@
     **{md.keyword: md.match_pattern for md in EXTRA_METADATA},
 }
 
-HEADERS_MATCHING_NUM_TIMEPOINTS: List[str] = ["Exposures", "ContentTimes"]
+HEADERS_MATCHING_NUM_TIMEPOINTS: list[str] = ["Exposures", "ContentTimes"]
 
-HEADERS_MATCHING_WINDOW_SETTINGS: List[str] = ["WindowCenter", "WindowWidth"]
+HEADERS_MATCHING_WINDOW_SETTINGS: list[str] = ["WindowCenter", "WindowWidth"]
 
-HEADERS_WITH_LISTING: List[str] = [
+HEADERS_WITH_LISTING: list[str] = [
     "TransformMatrix",
     "Offset",
     "CenterOfRotation",
     "ElementSpacing",
 ]
 
-EXPECTED_HEADERS: List[str] = [
+EXPECTED_HEADERS: list[str] = [
     "ObjectType",
     "NDims",
     "BinaryData",
@@ -100,7 +100,7 @@
 ]
 
 
-def parse_mh_header(file: Path) -> Dict[str, str]:
+def parse_mh_header(file: Path) -> dict[str, str]:
     """
     Attempts to parse the headers of an mhd file.
 
@@ -125,7 +125,7 @@ def parse_mh_header(file: Path) -> Dict[str, str]:
     # attempt to limit number of read headers to prevent overflow attacks
     read_line_limit = 10000
 
-    result: Dict[str, str] = {}
+    result: dict[str, str] = {}
     with file.open("rb") as f:
         lines = True
         while lines:
@@ -155,7 +155,7 @@ def parse_mh_header(file: Path) -> Dict[str, str]:
     return result
 
 
-def extract_key_value_pairs(line: str) -> Dict[str, str]:
+def extract_key_value_pairs(line: str) -> dict[str, str]:
     line = line.rstrip("\n\r")
     if line.strip() and "=" in line:
         key, value = line.split("=", 1)
@@ -165,13 +165,13 @@ def extract_key_value_pairs(line: str) -> Dict[str, str]:
 
 
 def extract_header_listing(
-    property: str, headers: Dict[str, str], dtype: type = float
-) -> List[Any]:
+    property: str, headers: dict[str, str], dtype: type = float
+) -> list[Any]:
     return [dtype(e) for e in headers[property].strip().split(" ")]
 
 
 def resolve_mh_data_file_path(
-    headers: Dict[str, str], is_mha: bool, mhd_file: Path
+    headers: dict[str, str], is_mha: bool, mhd_file: Path
 ) -> Path:
     if is_mha:
         data_file_path = mhd_file
@@ -186,7 +186,7 @@ def resolve_mh_data_file_path(
 
 def validate_and_clean_additional_mh_headers(
     reader: SimpleITK.ImageFileReader,
-) -> Dict[str, str]:
+) -> dict[str, str]:
     cleaned_headers = {}
     for key in reader.GetMetaDataKeys():
         value = reader.GetMetaData(key)
@@ -255,7 +255,7 @@ def validate_list_data_matches_num_timepoints(
 
 
 def add_additional_mh_headers_to_sitk_image(
-    sitk_image: SimpleITK.Image, cleaned_headers: Dict[str, str]
+    sitk_image: SimpleITK.Image, cleaned_headers: dict[str, str]
 ):
     for header in ADDITIONAL_HEADERS:
         if header in cleaned_headers:
diff --git a/panimg/image_builders/oct.py b/panimg/image_builders/oct.py
index b1b6ae3..e51ebe1 100644
--- a/panimg/image_builders/oct.py
+++ b/panimg/image_builders/oct.py
@@ -1,15 +1,7 @@
 from collections import defaultdict
+from collections.abc import Iterable, Iterator
 from pathlib import Path
-from typing import (
-    Any,
-    DefaultDict,
-    Iterable,
-    Iterator,
-    List,
-    Set,
-    Tuple,
-    Union,
-)
+from typing import Any, DefaultDict, Union
 
 import numpy as np
 import numpy.typing as npt
@@ -46,7 +38,7 @@ class OctDimensions(BaseModel):
 
 def _create_itk_images(
     *,
-    file: Tuple[Path, str],
+    file: tuple[Path, str],
     oct_volumes: Iterable[OCTVolumeWithMetaData],
     fundus_images: Iterable[FundusImageWithMetaData],
     oct_slice_size: OctDimensions,
@@ -85,7 +77,7 @@ def _create_itk_images(
 def _create_itk_oct_volume(
     *,
     file: Path,
-    volume: List[npt.NDArray[Any]],
+    volume: list[npt.NDArray[Any]],
     oct_slice_size: OctDimensions,
     eye_choice: EyeChoice,
 ) -> SimpleITKImage:
@@ -148,13 +140,11 @@ def _extract_slice_size(*, img: Union[FDS, FDA]) -> OctDimensions:
         )
 
 
-def _get_image(
-    *, file: Path
-) -> Tuple[
+def _get_image(*, file: Path) -> tuple[
     Iterable[OCTVolumeWithMetaData],
     Iterable[FundusImageWithMetaData],
     OctDimensions,
-    Tuple[Path, str],
+    tuple[Path, str],
 ]:
     with open(file, "rb") as f:
         header = f.read(7)
@@ -200,7 +190,7 @@ def _get_image(
             raise ValueError
 
 
-def image_builder_oct(*, files: Set[Path]) -> Iterator[SimpleITKImage]:
+def image_builder_oct(*, files: set[Path]) -> Iterator[SimpleITKImage]:
     """
     Constructs OCT image objects by inspecting files in a directory by using
     OCT-converter PyPI library.
@@ -219,7 +209,7 @@ def image_builder_oct(*, files: Set[Path]) -> Iterator[SimpleITKImage]:
      - a list files associated with the detected images
      - path->error message map describing what is wrong with a given file
     """
-    file_errors: DefaultDict[Path, List[str]] = defaultdict(list)
+    file_errors: DefaultDict[Path, list[str]] = defaultdict(list)
 
     for file in files:
         try:
diff --git a/panimg/image_builders/tiff.py b/panimg/image_builders/tiff.py
index bc4a3d6..8ec24a9 100644
--- a/panimg/image_builders/tiff.py
+++ b/panimg/image_builders/tiff.py
@@ -1,19 +1,11 @@
 import os
 import re
 from collections import defaultdict
+from collections.abc import Iterator
 from dataclasses import dataclass, field
 from pathlib import Path
 from tempfile import TemporaryDirectory
-from typing import (
-    Callable,
-    DefaultDict,
-    Dict,
-    FrozenSet,
-    Iterator,
-    List,
-    Optional,
-    Set,
-)
+from typing import Callable, DefaultDict, Optional
 from uuid import UUID, uuid4
 
 import tifffile
@@ -53,7 +45,7 @@ class GrandChallengeTiffFile:
     color_space: Optional[ColorSpace] = None
     voxel_width_mm: float = 0
     voxel_height_mm: float = 0
-    associated_files: List[Path] = field(default_factory=list)
+    associated_files: list[Path] = field(default_factory=list)
     min_voxel_value: Optional[int] = None
     max_voxel_value: Optional[int] = None
 
@@ -84,7 +76,7 @@ def validate(self) -> None:
             )
 
     @property
-    def segments(self) -> Optional[FrozenSet[int]]:
+    def segments(self) -> Optional[frozenset[int]]:
         if self.min_voxel_value is None or self.max_voxel_value is None:
             return None
         if (
@@ -326,14 +318,14 @@ def _get_vms_files(vms_file: Path):
 
 def _convert(
     *,
-    files: List[Path],
-    associated_files_getter: Optional[Callable[[Path], List[Path]]],
+    files: list[Path],
+    associated_files_getter: Optional[Callable[[Path], list[Path]]],
     converter,
     output_directory: Path,
-    file_errors: Dict[Path, List[str]],
-) -> List[GrandChallengeTiffFile]:
-    converted_files: List[GrandChallengeTiffFile] = []
-    associated_files: List[Path] = []
+    file_errors: dict[Path, list[str]],
+) -> list[GrandChallengeTiffFile]:
+    converted_files: list[GrandChallengeTiffFile] = []
+    associated_files: list[Path] = []
 
     for file in files:
         try:
@@ -404,7 +396,7 @@ def _convert_dicom_wsi_dir(
     gc_file: GrandChallengeTiffFile,
     file: Path,
     output_directory: Path,
-    file_errors: Dict[Path, List[str]],
+    file_errors: dict[Path, list[str]],
 ):
     wsidicom_dir = file.parent
     try:
@@ -427,7 +419,7 @@ def _convert_dicom_wsi_dir(
 
 
 def _find_valid_dicom_wsi_files(
-    files: Set[Path], file_errors: DefaultDict[Path, List[str]]
+    files: set[Path], file_errors: DefaultDict[Path, list[str]]
 ):
     """
     Gets the headers for all dicom files on path and validates them.
@@ -449,7 +441,7 @@ def _find_valid_dicom_wsi_files(
     # Try and get dicom files; ignore errors
     dicom_errors = file_errors.copy()
     studies = get_dicom_headers_by_study(files=files, file_errors=dicom_errors)
-    result: Dict[Path, List[Path]] = {}
+    result: dict[Path, list[Path]] = {}
 
     for key in studies:
         headers = studies[key]["headers"]
@@ -477,12 +469,12 @@ def associated_files(file_path: Path):
 
 def _load_gc_files(
     *,
-    files: Set[Path],
+    files: set[Path],
     converter,
     output_directory: Path,
-    file_errors: DefaultDict[Path, List[str]],
-) -> List[GrandChallengeTiffFile]:
-    loaded_files: List[GrandChallengeTiffFile] = []
+    file_errors: DefaultDict[Path, list[str]],
+) -> list[GrandChallengeTiffFile]:
+    loaded_files: list[GrandChallengeTiffFile] = []
 
     complex_file_handlers = {
         ".mrxs": _get_mrxs_files,
@@ -531,7 +523,7 @@ def _load_gc_files(
 
 
 def image_builder_tiff(  # noqa: C901
-    *, files: Set[Path]
+    *, files: set[Path]
 ) -> Iterator[TIFFImage]:
     if openslide is False:
         raise ImportError(
@@ -547,7 +539,7 @@ def image_builder_tiff(  # noqa: C901
             f"is installed or remove {__name__} from your list of builders."
         )
 
-    file_errors: DefaultDict[Path, List[str]] = defaultdict(list)
+    file_errors: DefaultDict[Path, list[str]] = defaultdict(list)
 
     with TemporaryDirectory() as output_directory:
         loaded_files = _load_gc_files(
diff --git a/panimg/models.py b/panimg/models.py
index 1da3842..67b395a 100644
--- a/panimg/models.py
+++ b/panimg/models.py
@@ -5,7 +5,7 @@
 from enum import Enum
 from functools import cached_property
 from pathlib import Path
-from typing import Any, Dict, FrozenSet, List, NamedTuple, Optional, Set, Tuple
+from typing import Any, NamedTuple, Optional
 from uuid import UUID, uuid4
 
 import numpy as np
@@ -152,7 +152,7 @@ class PanImg:
     series_instance_uid: str = ""
     study_description: str = ""
     series_description: str = ""
-    segments: Optional[FrozenSet[int]] = None
+    segments: Optional[frozenset[int]] = None
 
 
 @dataclass(frozen=True)
@@ -165,22 +165,22 @@ class PanImgFile:
 
 @dataclass
 class PanImgResult:
-    new_images: Set[PanImg]
-    new_image_files: Set[PanImgFile]
-    consumed_files: Set[Path]
-    file_errors: Dict[Path, List[str]]
+    new_images: set[PanImg]
+    new_image_files: set[PanImgFile]
+    consumed_files: set[Path]
+    file_errors: dict[Path, list[str]]
 
 
 @dataclass
 class PostProcessorResult:
-    new_image_files: Set[PanImgFile]
+    new_image_files: set[PanImgFile]
 
 
 class SimpleITKImage(BaseModel):
     image: Image
 
     name: str
-    consumed_files: Set[Path]
+    consumed_files: set[Path]
 
     spacing_valid: bool
     eye_choice: EyeChoice = EyeChoice.NOT_APPLICABLE
@@ -265,7 +265,7 @@ def add_value_range_meta_data(cls, image: Image):  # noqa: B902, N805
         return image
 
     @cached_property
-    def segments(self) -> Optional[FrozenSet[int]]:
+    def segments(self) -> Optional[frozenset[int]]:
         if (
             self.image.GetNumberOfComponentsPerPixel() != 1
             or self.image.GetPixelIDValue() not in MASK_TYPE_PIXEL_IDS
@@ -328,7 +328,7 @@ def voxel_depth_mm(self) -> Optional[float]:
         else:
             return None
 
-    def generate_extra_metadata(self) -> Dict[str, Any]:
+    def generate_extra_metadata(self) -> dict[str, Any]:
         extra_metadata = {
             md.field_name: md.default_value for md in EXTRA_METADATA
         }
@@ -352,7 +352,7 @@ def generate_extra_metadata(self) -> Dict[str, Any]:
                     )
         return extra_metadata
 
-    def save(self, output_directory: Path) -> Tuple[PanImg, Set[PanImgFile]]:
+    def save(self, output_directory: Path) -> tuple[PanImg, set[PanImgFile]]:
         pk = uuid4()
 
         work_dir = Path(output_directory) / self.name
@@ -397,7 +397,7 @@ class TIFFImage(BaseModel):
     file: Path
 
     name: str
-    consumed_files: Set[Path]
+    consumed_files: set[Path]
 
     width: int
     height: int
@@ -406,11 +406,11 @@ class TIFFImage(BaseModel):
     resolution_levels: int
     color_space: ColorSpace
     eye_choice: EyeChoice = EyeChoice.NOT_APPLICABLE
-    segments: Optional[FrozenSet[int]] = None
+    segments: Optional[frozenset[int]] = None
 
     model_config = ConfigDict(frozen=True)
 
-    def save(self, output_directory: Path) -> Tuple[PanImg, Set[PanImgFile]]:
+    def save(self, output_directory: Path) -> tuple[PanImg, set[PanImgFile]]:
         pk = uuid4()
 
         output_file = output_directory / self.name / f"{pk}{self.file.suffix}"
diff --git a/panimg/panimg.py b/panimg/panimg.py
index 32b58e9..bd27474 100644
--- a/panimg/panimg.py
+++ b/panimg/panimg.py
@@ -1,7 +1,8 @@
 import logging
 from collections import defaultdict
+from collections.abc import Iterable
 from pathlib import Path
-from typing import DefaultDict, Dict, Iterable, List, Optional, Set
+from typing import DefaultDict, Optional
 
 from panimg.exceptions import UnconsumedFilesException
 from panimg.image_builders import DEFAULT_IMAGE_BUILDERS
@@ -20,10 +21,10 @@ def convert(
     post_processors: Optional[Iterable[PostProcessor]] = None,
     recurse_subdirectories: bool = True,
 ) -> PanImgResult:
-    new_images: Set[PanImg] = set()
-    new_image_files: Set[PanImgFile] = set()
-    consumed_files: Set[Path] = set()
-    file_errors: DefaultDict[Path, List[str]] = defaultdict(list)
+    new_images: set[PanImg] = set()
+    new_image_files: set[PanImgFile] = set()
+    consumed_files: set[Path] = set()
+    file_errors: DefaultDict[Path, list[str]] = defaultdict(list)
 
     builders = builders if builders is not None else DEFAULT_IMAGE_BUILDERS
 
@@ -43,9 +44,11 @@ def convert(
 
     result = post_process(
         image_files=new_image_files,
-        post_processors=post_processors
-        if post_processors is not None
-        else DEFAULT_POST_PROCESSORS,
+        post_processors=(
+            post_processors
+            if post_processors is not None
+            else DEFAULT_POST_PROCESSORS
+        ),
     )
     new_image_files |= result.new_image_files
 
@@ -62,10 +65,10 @@ def _convert_directory(
     input_directory: Path,
     output_directory: Path,
     builders: Iterable[ImageBuilder],
-    consumed_files: Set[Path],
-    new_images: Set[PanImg],
-    new_image_files: Set[PanImgFile],
-    file_errors: DefaultDict[Path, List[str]],
+    consumed_files: set[Path],
+    new_images: set[PanImg],
+    new_image_files: set[PanImgFile],
+    file_errors: DefaultDict[Path, list[str]],
     recurse_subdirectories: bool = True,
 ):
     input_directory = Path(input_directory).resolve()
@@ -124,12 +127,12 @@ def _convert_directory(
 
 
 def _build_files(
-    *, builder: ImageBuilder, files: Set[Path], output_directory: Path
+    *, builder: ImageBuilder, files: set[Path], output_directory: Path
 ) -> PanImgResult:
     new_images = set()
-    new_image_files: Set[PanImgFile] = set()
-    consumed_files: Set[Path] = set()
-    file_errors: Dict[Path, List[str]] = {}
+    new_image_files: set[PanImgFile] = set()
+    consumed_files: set[Path] = set()
+    file_errors: dict[Path, list[str]] = {}
 
     try:
         for result in builder(files=files):
@@ -153,7 +156,7 @@ def _build_files(
 
 
 def post_process(
-    *, image_files: Set[PanImgFile], post_processors: Iterable[PostProcessor]
+    *, image_files: set[PanImgFile], post_processors: Iterable[PostProcessor]
 ) -> PostProcessorResult:
     """
     Run a set of post processors on a set of image files
@@ -162,7 +165,7 @@ def post_process(
     such as DZI creation for TIFF images, or thumbnail generation.
     They do not produce new image entities.
     """
-    new_image_files: Set[PanImgFile] = set()
+    new_image_files: set[PanImgFile] = set()
 
     logger.info(f"Post processing {len(image_files)} image(s)")
 
diff --git a/panimg/post_processors/tiff_to_dzi.py b/panimg/post_processors/tiff_to_dzi.py
index 813a338..121f937 100644
--- a/panimg/post_processors/tiff_to_dzi.py
+++ b/panimg/post_processors/tiff_to_dzi.py
@@ -1,5 +1,4 @@
 import logging
-from typing import Set
 
 from panimg.models import ImageType, PanImgFile, PostProcessorResult
 from panimg.settings import DZI_TILE_SIZE
@@ -12,7 +11,7 @@
 logger = logging.getLogger(__name__)
 
 
-def tiff_to_dzi(*, image_files: Set[PanImgFile]) -> PostProcessorResult:
+def tiff_to_dzi(*, image_files: set[PanImgFile]) -> PostProcessorResult:
     if pyvips is False:
         raise ImportError(
             f"Could not import pyvips, which is required for the "
@@ -21,7 +20,7 @@ def tiff_to_dzi(*, image_files: Set[PanImgFile]) -> PostProcessorResult:
             f"processors."
         )
 
-    new_image_files: Set[PanImgFile] = set()
+    new_image_files: set[PanImgFile] = set()
 
     for file in image_files:
         if file.image_type == ImageType.TIFF:
diff --git a/panimg/types.py b/panimg/types.py
index cc7829e..04232c6 100644
--- a/panimg/types.py
+++ b/panimg/types.py
@@ -1,5 +1,6 @@
+from collections.abc import Iterator
 from pathlib import Path
-from typing import Iterator, Protocol, Set, Union
+from typing import Protocol, Union
 
 from panimg.models import (
     PanImgFile,
@@ -10,12 +11,12 @@
 
 
 class ImageBuilder(Protocol):
-    def __call__(
-        self, *, files: Set[Path]
-    ) -> Union[Iterator[SimpleITKImage], Iterator[TIFFImage]]:
-        ...
+    def __call__(  # noqa: E704
+        self, *, files: set[Path]
+    ) -> Union[Iterator[SimpleITKImage], Iterator[TIFFImage]]: ...
 
 
 class PostProcessor(Protocol):
-    def __call__(self, *, image_files: Set[PanImgFile]) -> PostProcessorResult:
-        ...
+    def __call__(  # noqa: E704
+        self, *, image_files: set[PanImgFile]
+    ) -> PostProcessorResult: ...
diff --git a/pyproject.toml b/pyproject.toml
index f7f7235..8696541 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,6 +1,6 @@
 [tool.poetry]
 name = "panimg"
-version = "0.13.2"
+version = "0.14.0"
 description = "Conversion of medical images to MHA and TIFF."
 license = "Apache-2.0"
 authors = ["James Meakin <panimg@jmsmkn.com>"]
@@ -17,7 +17,7 @@ classifiers = [
 panimg = "panimg.cli:cli"
 
 [tool.poetry.dependencies]
-python = "^3.8"
+python = "^3.9"
 pydantic = ">=2"
 numpy = ">=1.22"
 # Exclude 2.1.1.1 due to
@@ -68,14 +68,15 @@ addopts = "--strict-markers --showlocals -n auto --dist loadscope"
 legacy_tox_ini = """
 [tox]
 isolated_build = True
-envlist = py38, py39, py310, py311
+envlist = py39, py310, py311, py312, py313
 
 [gh-actions]
 python =
-    3.8: py38
     3.9: py39
     3.10: py310
     3.11: py311
+    3.12: py312
+    3.13: py313
 
 [testenv]
 allowlist_externals = poetry


Test patch:
diff --git a/tests/test_mhd.py b/tests/test_mhd.py
index 6987251..6fcabfa 100644
--- a/tests/test_mhd.py
+++ b/tests/test_mhd.py
@@ -1,7 +1,7 @@
 import shutil
 import zlib
 from pathlib import Path
-from typing import List, Union
+from typing import Union
 
 import pytest
 import SimpleITK
@@ -198,8 +198,8 @@ def test_load_sitk_image_with_additional_meta_data(tmpdir, test_img: str):
 )
 def test_load_sitk_image_with_various_window_formats(
     test_img: str,
-    center: Union[float, List[float]],
-    width: Union[float, List[float]],
+    center: Union[float, list[float]],
+    width: Union[float, list[float]],
 ):
     src = MHD_WINDOW_DIR / test_img
     sitk_image = load_sitk_image(src)
diff --git a/tests/test_nrrd.py b/tests/test_nrrd.py
index 951c67a..080bc36 100644
--- a/tests/test_nrrd.py
+++ b/tests/test_nrrd.py
@@ -2,7 +2,6 @@
 import shutil
 from pathlib import Path
 from tempfile import TemporaryDirectory
-from typing import List
 
 import pytest
 
@@ -61,7 +60,7 @@ def test_image_builder_nrrd(tmpdir_factory, srcfile: Path):
     ),
 )
 def test_image_builder_nrrd_detached_header(
-    tmpdir_factory, srcfiles: List[Path]
+    tmpdir_factory, srcfiles: list[Path]
 ):
     dest = Path(tmpdir_factory.mktemp("input"))
 
diff --git a/tests/test_post_processors.py b/tests/test_post_processors.py
index b74a1b6..df3825a 100644
--- a/tests/test_post_processors.py
+++ b/tests/test_post_processors.py
@@ -1,6 +1,5 @@
 import shutil
 from pathlib import Path
-from typing import Set
 from uuid import uuid4
 
 import pytest
@@ -46,7 +45,7 @@ def test_dzi_creation(tmpdir_factory, post_processors):
         )
 
 
-def bad_post_processor(*, image_files: Set[PanImgFile]) -> PostProcessorResult:
+def bad_post_processor(*, image_files: set[PanImgFile]) -> PostProcessorResult:
     good_files = {
         PanImgFile(
             image_id=f.image_id, image_type=f.image_type, file=Path("foo")
diff --git a/tests/test_wsi.py b/tests/test_wsi.py
index 2b76620..7c9beae 100644
--- a/tests/test_wsi.py
+++ b/tests/test_wsi.py
@@ -1,6 +1,6 @@
 from pathlib import Path
 from tempfile import TemporaryDirectory
-from typing import Any, Dict
+from typing import Any
 
 import pytest
 
@@ -63,7 +63,7 @@
     ),
 )
 def test_image_builder_wsi(
-    src: Path, expected_image: Dict[str, Any], errors: int
+    src: Path, expected_image: dict[str, Any], errors: int
 ):
     files = {f for f in src.rglob("*") if f.is_file()}
     with TemporaryDirectory() as output: