Script 'mail_helper' called by obssrc
Hello community,

here is the log from the commit of package python-roifile for openSUSE:Factory 
checked in at 2026-02-03 21:35:57
++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Comparing /work/SRC/openSUSE:Factory/python-roifile (Old)
 and      /work/SRC/openSUSE:Factory/.python-roifile.new.1995 (New)
++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++

Package is "python-roifile"

Tue Feb  3 21:35:57 2026 rev:10 rq:1330813 version:2026.1.22

Changes:
--------
--- /work/SRC/openSUSE:Factory/python-roifile/python-roifile.changes    
2024-11-09 20:59:38.265581977 +0100
+++ /work/SRC/openSUSE:Factory/.python-roifile.new.1995/python-roifile.changes  
2026-02-03 21:36:43.029769442 +0100
@@ -1,0 +2,14 @@
+Tue Jan 27 16:08:15 UTC 2026 - Dirk Müller <[email protected]>
+
+- update to 2026.1.22:
+  * Fix boolean codec in ImagejRoi.properties.
+  * Fix reading ImagejRoi.props.
+  * Add ImagejRoi.properties property to decode and encode
+    ImagejRoi.props.
+  * Improve code quality.
+  * Drop support for Python 3.10.
+  * Move tests to separate module.
+  * Support Python 3.14.
+  * Drop support for Python 3.9.
+
+-------------------------------------------------------------------

Old:
----
  roifile-2024.9.15.tar.gz

New:
----
  roifile-2026.1.22.tar.gz

++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++

Other differences:
------------------
++++++ python-roifile.spec ++++++
--- /var/tmp/diff_new_pack.ixCv79/_old  2026-02-03 21:36:43.505789469 +0100
+++ /var/tmp/diff_new_pack.ixCv79/_new  2026-02-03 21:36:43.509789637 +0100
@@ -1,7 +1,7 @@
 #
 # spec file for package python-roifile
 #
-# Copyright (c) 2024 SUSE LLC
+# Copyright (c) 2026 SUSE LLC and contributors
 #
 # All modifications and additions to the file contributed by third parties
 # remain the property of their copyright owners, unless otherwise agreed
@@ -18,7 +18,7 @@
 
 %define packagename roifile
 Name:           python-roifile
-Version:        2024.9.15
+Version:        2026.1.22
 Release:        0
 Summary:        Read and write ImageJ ROI format
 License:        BSD-3-Clause

++++++ roifile-2024.9.15.tar.gz -> roifile-2026.1.22.tar.gz ++++++
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/roifile-2024.9.15/.gitignore 
new/roifile-2026.1.22/.gitignore
--- old/roifile-2024.9.15/.gitignore    2024-09-15 18:53:51.000000000 +0200
+++ new/roifile-2026.1.22/.gitignore    2026-01-22 21:42:33.000000000 +0100
@@ -32,7 +32,7 @@
 MANIFEST
 setup.cfg
 PKG-INFO
-
+mypy.ini
 # PyInstaller
 #  Usually these files are written by a python script from a template
 #  before PyInstaller builds the exe, so as to inject date/other infos into it.
@@ -56,6 +56,7 @@
 *.py,cover
 .hypothesis/
 .pytest_cache/
+cover/
 
 # Translations
 *.mo
@@ -78,6 +79,7 @@
 docs/_build/
 
 # PyBuilder
+.pybuilder/
 target/
 
 # Jupyter Notebook
@@ -88,6 +90,8 @@
 ipython_config.py
 
 # pyenv
+#   For a library or package, you might want to ignore these files since the 
code is
+#   intended to run in multiple environments; otherwise, check them in:
 .python-version
 
 # pipenv
@@ -97,8 +101,35 @@
 #   install all needed dependencies.
 #Pipfile.lock
 
-# celery beat schedule file
+# UV
+#   Similar to Pipfile.lock, it is generally recommended to include uv.lock in 
version control.
+#   This is especially recommended for binary packages to ensure 
reproducibility, and is more
+#   commonly ignored for libraries.
+uv.lock
+
+# poetry
+#   Similar to Pipfile.lock, it is generally recommended to include 
poetry.lock in version control.
+#   This is especially recommended for binary packages to ensure 
reproducibility, and is more
+#   commonly ignored for libraries.
+#   
https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
+#poetry.lock
+
+# pdm
+#   Similar to Pipfile.lock, it is generally recommended to include pdm.lock 
in version control.
+#pdm.lock
+#   pdm stores project-wide configurations in .pdm.toml, but it is recommended 
to not include it
+#   in version control.
+#   https://pdm.fming.dev/latest/usage/project/#working-with-version-control
+.pdm.toml
+.pdm-python
+.pdm-build/
+
+# PEP 582; used by e.g. github.com/David-OConnor/pyflow and 
github.com/pdm-project/pdm
+__pypackages__/
+
+# Celery stuff
 celerybeat-schedule
+celerybeat.pid
 
 # SageMath parsed files
 *.sage.py
@@ -129,3 +160,19 @@
 
 # Pyre type checker
 .pyre/
+
+# pytype static type analyzer
+.pytype/
+
+# Cython debug symbols
+cython_debug/
+
+# PyCharm
+#  JetBrains specific template is maintained in a separate JetBrains.gitignore 
that can
+#  be found at 
https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
+#  and can be added to the global gitignore or merged into this file.  For a 
more nuclear
+#  option (not recommended) you can uncomment the following to ignore the 
entire idea folder.
+#.idea/
+
+# PyPI configuration file
+.pypirc
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/roifile-2024.9.15/CHANGES.rst 
new/roifile-2026.1.22/CHANGES.rst
--- old/roifile-2024.9.15/CHANGES.rst   2024-09-15 18:53:51.000000000 +0200
+++ new/roifile-2026.1.22/CHANGES.rst   2026-01-22 21:42:33.000000000 +0100
@@ -1,6 +1,32 @@
 Revisions
 ---------
 
+2026.1.22
+
+- Fix boolean codec in ImagejRoi.properties.
+
+2026.1.20
+
+- Fix reading ImagejRoi.props.
+- Add ImagejRoi.properties property to decode and encode ImagejRoi.props.
+
+2026.1.8
+
+- Improve code quality.
+- Drop support for Python 3.10.
+
+2025.12.12
+
+- Move tests to separate module.
+
+2025.5.10
+
+- Support Python 3.14.
+
+2025.2.20
+
+- Drop support for Python 3.9.
+
 2024.9.15
 
 - Improve typing.
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/roifile-2024.9.15/LICENSE 
new/roifile-2026.1.22/LICENSE
--- old/roifile-2024.9.15/LICENSE       2024-09-15 18:53:51.000000000 +0200
+++ new/roifile-2026.1.22/LICENSE       2026-01-22 21:42:33.000000000 +0100
@@ -1,6 +1,6 @@
-BSD 3-Clause License
+BSD-3-Clause license
 
-Copyright (c) 2020-2024, Christoph Gohlke
+Copyright (c) 2020-2026, Christoph Gohlke
 All rights reserved.
 
 Redistribution and use in source and binary forms, with or without
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/roifile-2024.9.15/MANIFEST.in 
new/roifile-2026.1.22/MANIFEST.in
--- old/roifile-2024.9.15/MANIFEST.in   2024-09-15 18:53:51.000000000 +0200
+++ new/roifile-2026.1.22/MANIFEST.in   2026-01-22 21:42:33.000000000 +0100
@@ -1,11 +1,16 @@
 include LICENSE
 include README.rst
 include CHANGES.rst
+include pyproject.toml
 include roifile_demo.py
 
 include roifile/py.typed
 
+exclude .env
 exclude *.cmd
+exclude *.yaml
+exclude mypy.ini
+exclude ruff.toml
 recursive-exclude doc *
 recursive-exclude docs *
 recursive-exclude test *
@@ -14,3 +19,6 @@
 recursive-exclude * __pycache__
 recursive-exclude * *.py[co]
 recursive-exclude * *Copy*
+
+include tests/conftest.py
+include tests/test_roifile.py
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/roifile-2024.9.15/README.rst 
new/roifile-2026.1.22/README.rst
--- old/roifile-2024.9.15/README.rst    2024-09-15 18:53:51.000000000 +0200
+++ new/roifile-2026.1.22/README.rst    2026-01-22 21:42:33.000000000 +0100
@@ -11,8 +11,8 @@
 .. _ImageJ: https://imagej.net
 
 :Author: `Christoph Gohlke <https://www.cgohlke.com>`_
-:License: BSD 3-Clause
-:Version: 2024.9.15
+:License: BSD-3-Clause
+:Version: 2026.1.22
 :DOI: `10.5281/zenodo.6941603 <https://doi.org/10.5281/zenodo.6941603>`_
 
 Quickstart
@@ -38,55 +38,41 @@
 This revision was tested with the following requirements and dependencies
 (other versions may work):
 
-- `CPython <https://www.python.org>`_ 3.10.11, 3.11.9, 3.12.5, 3.13.0rc2
-- `Numpy <https://pypi.org/project/numpy/>`_ 2.2.1
-- `Tifffile <https://pypi.org/project/tifffile/>`_ 2024.8.30 (optional)
-- `Matplotlib <https://pypi.org/project/matplotlib/>`_ 3.9.2 (optional)
+- `CPython <https://www.python.org>`_ 3.11.9, 3.12.10, 3.13.11, 3.14.2 64-bit
+- `NumPy <https://pypi.org/project/numpy>`_ 2.4.1
+- `Tifffile <https://pypi.org/project/tifffile/>`_ 2026.1.14 (optional)
+- `Matplotlib <https://pypi.org/project/matplotlib/>`_ 3.10.8 (optional)
 
 Revisions
 ---------
 
-2024.9.15
-
-- Improve typing.
-- Deprecate Python 3.9, support Python 3.13.
-
-2024.5.24
+2026.1.22
 
-- Fix docstring examples not correctly rendered on GitHub.
+- Fix boolean codec in ImagejRoi.properties.
 
-2024.3.20
+2026.1.20
 
-- Fix writing generator of ROIs (#9).
+- Fix reading ImagejRoi.props.
+- Add ImagejRoi.properties property to decode and encode ImagejRoi.props.
 
-2024.1.10
+2026.1.8
 
-- Support text rotation.
-- Improve text rendering.
-- Avoid array copies.
-- Limit size read from files.
+- Improve code quality.
+- Drop support for Python 3.10.
 
-2023.8.30
+2025.12.12
 
-- Fix linting issues.
-- Add py.typed marker.
+- Move tests to separate module.
 
-2023.5.12
+2025.5.10
 
-- Improve object repr and type hints.
-- Drop support for Python 3.8 and numpy < 1.21 (NEP29).
+- Support Python 3.14.
 
-2023.2.12
+2025.2.20
 
-- Delay import of zipfile.
-- Verify shape of coordinates on write.
+- Drop support for Python 3.9.
 
-2022.9.19
-
-- Fix integer coordinates to -5000..60536 conforming with ImageJ (breaking).
-- Add subpixel_coordinates in frompoints for out-of-range integer coordinates.
-
-2022.7.29
+2024.9.15
 
 - …
 
@@ -170,6 +156,7 @@
 
 .. code-block:: python
 
+    >>> import numpy
     >>> import tifffile
     >>> tifffile.imwrite(
     ...     '_test.tif',
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/roifile-2024.9.15/pyproject.toml 
new/roifile-2026.1.22/pyproject.toml
--- old/roifile-2024.9.15/pyproject.toml        1970-01-01 01:00:00.000000000 
+0100
+++ new/roifile-2026.1.22/pyproject.toml        2026-01-22 21:42:33.000000000 
+0100
@@ -0,0 +1,13 @@
+[build-system]
+requires = ["setuptools"]
+build-backend = "setuptools.build_meta"
+
+[tool.black]
+line-length = 79
+target-version = ["py311", "py312", "py313", "py314"]
+skip-string-normalization = true
+
+[tool.isort]
+known_first_party = ["roifile"]
+profile = "black"
+line_length = 79
\ No newline at end of file
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/roifile-2024.9.15/roifile/__init__.py 
new/roifile-2026.1.22/roifile/__init__.py
--- old/roifile-2024.9.15/roifile/__init__.py   2024-09-15 18:53:51.000000000 
+0200
+++ new/roifile-2026.1.22/roifile/__init__.py   2026-01-22 21:42:33.000000000 
+0100
@@ -1,4 +1,9 @@
 # roifile/__init__.py
 
-from .roifile import __doc__, __all__, __version__
 from .roifile import *
+from .roifile import __all__, __doc__, __version__
+
+# constants are repeated for documentation
+
+__version__ = __version__
+"""Roifile version string."""
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/roifile-2024.9.15/roifile/roifile.py 
new/roifile-2026.1.22/roifile/roifile.py
--- old/roifile-2024.9.15/roifile/roifile.py    2024-09-15 18:53:51.000000000 
+0200
+++ new/roifile-2026.1.22/roifile/roifile.py    2026-01-22 21:42:33.000000000 
+0100
@@ -1,6 +1,6 @@
 # roifile.py
 
-# Copyright (c) 2020-2024, Christoph Gohlke
+# Copyright (c) 2020-2026, Christoph Gohlke
 # All rights reserved.
 #
 # Redistribution and use in source and binary forms, with or without
@@ -38,8 +38,8 @@
 .. _ImageJ: https://imagej.net
 
 :Author: `Christoph Gohlke <https://www.cgohlke.com>`_
-:License: BSD 3-Clause
-:Version: 2024.9.15
+:License: BSD-3-Clause
+:Version: 2026.1.22
 :DOI: `10.5281/zenodo.6941603 <https://doi.org/10.5281/zenodo.6941603>`_
 
 Quickstart
@@ -65,55 +65,41 @@
 This revision was tested with the following requirements and dependencies
 (other versions may work):
 
-- `CPython <https://www.python.org>`_ 3.10.11, 3.11.9, 3.12.5, 3.13.0rc2
-- `Numpy <https://pypi.org/project/numpy/>`_ 2.2.1
-- `Tifffile <https://pypi.org/project/tifffile/>`_ 2024.8.30 (optional)
-- `Matplotlib <https://pypi.org/project/matplotlib/>`_ 3.9.2 (optional)
+- `CPython <https://www.python.org>`_ 3.11.9, 3.12.10, 3.13.11, 3.14.2 64-bit
+- `NumPy <https://pypi.org/project/numpy>`_ 2.4.1
+- `Tifffile <https://pypi.org/project/tifffile/>`_ 2026.1.14 (optional)
+- `Matplotlib <https://pypi.org/project/matplotlib/>`_ 3.10.8 (optional)
 
 Revisions
 ---------
 
-2024.9.15
-
-- Improve typing.
-- Deprecate Python 3.9, support Python 3.13.
-
-2024.5.24
+2026.1.22
 
-- Fix docstring examples not correctly rendered on GitHub.
+- Fix boolean codec in ImagejRoi.properties.
 
-2024.3.20
+2026.1.20
 
-- Fix writing generator of ROIs (#9).
+- Fix reading ImagejRoi.props.
+- Add ImagejRoi.properties property to decode and encode ImagejRoi.props.
 
-2024.1.10
+2026.1.8
 
-- Support text rotation.
-- Improve text rendering.
-- Avoid array copies.
-- Limit size read from files.
+- Improve code quality.
+- Drop support for Python 3.10.
 
-2023.8.30
+2025.12.12
 
-- Fix linting issues.
-- Add py.typed marker.
+- Move tests to separate module.
 
-2023.5.12
+2025.5.10
 
-- Improve object repr and type hints.
-- Drop support for Python 3.8 and numpy < 1.21 (NEP29).
+- Support Python 3.14.
 
-2023.2.12
+2025.2.20
 
-- Delay import of zipfile.
-- Verify shape of coordinates on write.
+- Drop support for Python 3.9.
 
-2022.9.19
-
-- Fix integer coordinates to -5000..60536 conforming with ImageJ (breaking).
-- Add subpixel_coordinates in frompoints for out-of-range integer coordinates.
-
-2022.7.29
+2024.9.15
 
 - …
 
@@ -183,6 +169,7 @@
 
 Write the ROIs to an ImageJ formatted TIFF file:
 
+>>> import numpy
 >>> import tifffile
 >>> tifffile.imwrite(
 ...     '_test.tif',
@@ -206,34 +193,36 @@
 
 from __future__ import annotations
 
-__version__ = '2024.9.15'
+__version__ = '2026.1.22'
 
 __all__ = [
-    'roiread',
-    'roiwrite',
-    'ImagejRoi',
-    'ROI_TYPE',
-    'ROI_SUBTYPE',
+    'ROI_COLOR_NONE',
     'ROI_OPTIONS',
-    'ROI_POINT_TYPE',
     'ROI_POINT_SIZE',
-    'ROI_COLOR_NONE',
+    'ROI_POINT_TYPE',
+    'ROI_SUBTYPE',
+    'ROI_TYPE',
+    'ImagejRoi',
+    '__version__',
+    'roiread',
+    'roiwrite',
 ]
 
-import dataclasses
+import contextlib
 import enum
 import logging
 import os
 import struct
 import sys
 import uuid
+from dataclasses import dataclass
 from typing import TYPE_CHECKING
 
 import numpy
 
 if TYPE_CHECKING:
     from collections.abc import Iterable
-    from typing import Any, Iterator, Literal
+    from typing import Any, Literal
 
     from matplotlib.axes import Axes
     from numpy.typing import ArrayLike, NDArray
@@ -281,11 +270,13 @@
 
     if name is not None:
         if isinstance(name, str):
-            raise ValueError("'name' is not an iterable of str")
+            msg = "'name' is not an iterable of str"
+            raise ValueError(msg)
         name = iter(name)
 
     import zipfile
 
+    assert mode is not None
     with zipfile.ZipFile(filename, mode) as zf:
         for r in roi:
             if name is None:
@@ -328,6 +319,7 @@
 class ROI_OPTIONS(enum.IntFlag):
     """ImageJ ROI options."""
 
+    NONE = 0
     SPLINE_FIT = 1
     DOUBLE_HEADED = 2
     OUTLINE = 4
@@ -366,17 +358,17 @@
 
 
 ROI_COLOR_NONE = b'\x00\x00\x00\x00'
-"""No color or Black."""
+"""No color or black."""
 
 
[email protected]
+@dataclass
 class ImagejRoi:
     """Read and write ImageJ ROI format."""
 
     byteorder: Literal['>', '<'] = '>'
     roitype: ROI_TYPE = ROI_TYPE.POLYGON
     subtype: ROI_SUBTYPE = ROI_SUBTYPE.UNDEFINED
-    options: ROI_OPTIONS = ROI_OPTIONS(0)
+    options: ROI_OPTIONS = ROI_OPTIONS.NONE
     name: str = ''
     props: str = ''
     version: int = 217
@@ -513,18 +505,19 @@
 
             with tifffile.TiffFile(filename) as tif:
                 if tif.imagej_metadata is None:
-                    raise ValueError('file does not contain ImagejRoi')
-                rois = []
+                    msg = 'file does not contain ImagejRoi'
+                    raise ValueError(msg)
+                rois: list[bytes] = []
                 if 'Overlays' in tif.imagej_metadata:
                     overlays = tif.imagej_metadata['Overlays']
-                    if isinstance(overlays, list):
+                    if isinstance(overlays, (list, tuple)):
                         rois.extend(overlays)
                     else:
                         rois.append(overlays)
                 if 'ROI' in tif.imagej_metadata:
                     roi = tif.imagej_metadata['ROI']
-                    if isinstance(roi, list):
-                        overlays.extend(roi)
+                    if isinstance(roi, (list, tuple)):
+                        rois.extend(roi)
                     else:
                         rois.append(roi)
                 return [
@@ -558,7 +551,8 @@
     ) -> ImagejRoi:
         """Return ImagejRoi instance from bytes."""
         if data[:4] != b'Iout':
-            raise ValueError(f'not an ImageJ ROI {data[:4]!r}')
+            msg = f'not an ImageJ ROI {data[:4]!r}'
+            raise ValueError(msg)
 
         self = cls()
 
@@ -607,15 +601,14 @@
         self.options = ROI_OPTIONS(options)
 
         if self.subpixelrect:
-            (self.xd, self.yd, self.widthd, self.heightd) = struct.unpack(
+            self.xd, self.yd, self.widthd, self.heightd = struct.unpack(
                 self.byteorder + 'ffff', data[18:34]
             )
-        elif (
-            self.roitype == ROI_TYPE.LINE
-            or self.roitype == ROI_TYPE.FREEHAND
+        elif self.roitype == ROI_TYPE.LINE or (
+            self.roitype == ROI_TYPE.FREEHAND
             and self.subtype in {ROI_SUBTYPE.ELLIPSE, ROI_SUBTYPE.ROTATED_RECT}
         ):
-            (self.x1, self.y1, self.x2, self.y2) = struct.unpack(
+            self.x1, self.y1, self.x2, self.y2 = struct.unpack(
                 self.byteorder + 'ffff', data[18:34]
             )
         elif self.n_coordinates == 0:
@@ -650,7 +643,7 @@
 
             if roi_props_offset > 0 and roi_props_length > 0:
                 props = data[
-                    roi_props_offset : name_offset + roi_props_length * 2
+                    roi_props_offset : roi_props_offset + roi_props_length * 2
                 ]
                 self.props = props.decode(self.utf16)
 
@@ -732,6 +725,8 @@
     def tofile(
         self,
         filename: os.PathLike[Any] | str,
+        /,
+        *,
         name: str | None = None,
         mode: Literal['r', 'w', 'x', 'a'] | None = None,
     ) -> None:
@@ -750,9 +745,12 @@
                 mode = 'a' if os.path.exists(filename) else 'w'
             import zipfile
 
-            with zipfile.ZipFile(filename, mode) as zf:
-                with zf.open(name, 'w') as fh:
-                    fh.write(self.tobytes())
+            assert mode is not None
+            with (
+                zipfile.ZipFile(filename, mode) as zf,
+                zf.open(name, 'w') as fh,
+            ):
+                fh.write(self.tobytes())
         else:
             with open(filename, 'wb') as fh:
                 fh.write(self.tobytes())
@@ -784,9 +782,8 @@
                     self.heightd,
                 )
             )
-        elif (
-            self.roitype == ROI_TYPE.LINE
-            or self.roitype == ROI_TYPE.FREEHAND
+        elif self.roitype == ROI_TYPE.LINE or (
+            self.roitype == ROI_TYPE.FREEHAND
             and self.subtype in {ROI_SUBTYPE.ELLIPSE, ROI_SUBTYPE.ROTATED_RECT}
         ):
             result.append(
@@ -844,22 +841,24 @@
         ):
             if self.integer_coordinates is not None:
                 if self.integer_coordinates.shape != (self.n_coordinates, 2):
-                    raise ValueError(
+                    msg = (
                         'integer_coordinates.shape '
                         f'{self.integer_coordinates.shape} '
                         f'!= ({self.n_coordinates}, 2)'
                     )
+                    raise ValueError(msg)
                 coord = self.integer_coordinates.astype(
                     self.byteorder + 'i2', copy=False
                 )
                 extradata = coord.tobytes(order='F')
             if self.subpixel_coordinates is not None:
                 if self.subpixel_coordinates.shape != (self.n_coordinates, 2):
-                    raise ValueError(
+                    msg = (
                         'subpixel_coordinates.shape '
                         f'{self.subpixel_coordinates.shape} '
                         f'!= ({self.n_coordinates}, 2)'
                     )
+                    raise ValueError(msg)
                 coord = self.subpixel_coordinates.astype(
                     self.byteorder + 'f4', copy=False
                 )
@@ -931,9 +930,10 @@
         title: str | None = None,
         bounds: bool = False,
         invert_yaxis: bool | None = None,
+        show: bool = True,
         **kwargs: Any,
     ) -> None:
-        """Plot a draft of coordinates using matplotlib."""
+        """Plot draft of coordinates using matplotlib."""
         fig: Any
         roitype = self.roitype
         subtype = self.subtype
@@ -973,7 +973,8 @@
                 roi.plot(ax=ax, **kwargs)
             if invert_yaxis:
                 ax.invert_yaxis()
-            pyplot.show()
+            if show:
+                pyplot.show()
             return
 
         if 'color' not in kwargs and 'c' not in kwargs:
@@ -1002,7 +1003,7 @@
                 x, y = line[1]
                 ax.arrow(x, y, -dx, -dy, **kwargs)
         elif roitype == ROI_TYPE.RECT and subtype == ROI_SUBTYPE.TEXT:
-            coords = self.coordinates(True)[0]
+            coords = self.coordinates(multi=True)[0]
             if 'fontsize' not in kwargs and self.text_size > 0:
                 kwargs['fontsize'] = self.text_size
             text = ax.text(
@@ -1045,18 +1046,23 @@
         if invert_yaxis:
             ax.invert_yaxis()
 
-        if fig is not None:
+        if show and fig is not None:
             pyplot.show()
 
     def coordinates(
-        self, multi: bool = False
+        self,
+        *,
+        multi: bool = False,
     ) -> NDArray[Any] | list[NDArray[Any]]:
         """Return x, y coordinates as numpy array for display."""
         coords: Any
         if self.subpixel_coordinates is not None:
             coords = self.subpixel_coordinates.copy()
         elif self.integer_coordinates is not None:
-            coords = self.integer_coordinates + [self.left, self.top]
+            coords = self.integer_coordinates + [  # noqa: RUF005
+                self.left,
+                self.top,
+            ]
         elif self.multi_coordinates is not None:
             coordslist = self.path2coords(self.multi_coordinates)
             if not multi:
@@ -1084,7 +1090,7 @@
         return [coords] if multi else coords
 
     def hexcolor(self, b: bytes, /, default: str | None = None) -> str | None:
-        """Return color (bytes) as hex triplet or None if black."""
+        """Return color (bytes) as hex triplet or default if black."""
         if b == ROI_COLOR_NONE:
             return default
         if self.byteorder == '>':
@@ -1096,9 +1102,11 @@
         multi_coordinates: NDArray[numpy.float32], /
     ) -> list[NDArray[numpy.float32]]:
         """Return list of coordinate arrays from 2D geometric path."""
-        coordinates = []
-        points: list[list[float]] = []
-        path: list[float] = multi_coordinates.tolist()
+        coordinates: list[NDArray[numpy.float32]] = []
+        points: list[tuple[float, float]] = []
+        path: list[float] = []
+
+        path = multi_coordinates.tolist()
         n = 0
         m = 0
         while n < len(path):
@@ -1110,30 +1118,30 @@
                         numpy.array(points, dtype=numpy.float32)
                     )
                     points = []
-                points.append([path[n + 1], path[n + 2]])
+                points.append((path[n + 1], path[n + 2]))
                 m = len(points) - 1
                 n += 3
             elif op == 1:
                 # LINETO
-                points.append([path[n + 1], path[n + 2]])
+                points.append((path[n + 1], path[n + 2]))
                 n += 3
             elif op == 4:
                 # CLOSE
                 points.append(points[m])
                 n += 1
-            elif op == 2 or op == 3:
+            elif op == 2 or op == 3:  # noqa: PLR1714
                 # QUADTO or CUBICTO
-                raise NotImplementedError(
-                    f'PathIterator command {op!r} not supported'
-                )
+                msg = f'PathIterator command {op!r} not supported'
+                raise NotImplementedError(msg)
             else:
-                raise RuntimeError(f'invalid PathIterator command {op!r}')
+                msg = f'invalid PathIterator command {op!r}'
+                raise RuntimeError(msg)
 
         coordinates.append(numpy.array(points, dtype=numpy.float32))
         return coordinates
 
     @staticmethod
-    def min_int_coord(value: int | None = None) -> int:
+    def min_int_coord(value: int | None = None, /) -> int:
         """Return minimum integer coordinate value.
 
         The default, -5000, is used by ImageJ.
@@ -1144,26 +1152,31 @@
             return -5000
         if -32768 <= value <= 0:
             return int(value)
-        raise ValueError(f'{value=} out of range')
+        msg = f'{value=} out of range'
+        raise ValueError(msg)
 
     @property
     def composite(self) -> bool:
+        """ROI is composite shape."""
         return self.shape_roi_size > 0
 
     @property
     def subpixelresolution(self) -> bool:
+        """ROI has subpixel resolution."""
         return self.version >= 222 and bool(
             self.options & ROI_OPTIONS.SUB_PIXEL_RESOLUTION
         )
 
     @property
     def drawoffset(self) -> bool:
+        """ROI has draw offset."""
         return self.subpixelresolution and bool(
             self.options & ROI_OPTIONS.DRAW_OFFSET
         )
 
     @property
     def subpixelrect(self) -> bool:
+        """ROI has subpixel rectangle."""
         return (
             self.version >= 223
             and self.subpixelresolution
@@ -1182,10 +1195,57 @@
         return name
 
     @property
+    def properties(self) -> dict[str, Any]:
+        """Return ImagejRoi.props as dictionary."""
+        val: Any
+        props = {}
+        for line in self.props.splitlines():
+            if ':' in line:
+                key, val = line.split(':', 1)
+                key = key.strip()
+                val = val.strip()
+                if val == 'true':
+                    val = True
+                elif val == 'false':
+                    val = False
+                else:
+                    try:
+                        val = int(val)
+                    except ValueError:
+                        with contextlib.suppress(ValueError):
+                            val = float(val)
+                props[key] = val
+        return props
+
+    @properties.setter
+    def properties(self, value: dict[str, Any], /) -> None:
+        """Set ImagejRoi.props from dictionary."""
+        lines = []
+        for item in sorted(value.items()):
+            key, val = item
+            if isinstance(val, bool):
+                val = 'true' if val else 'false'
+            # TODO: does float need specific format?
+            lines.append(f'{key}: {val}\n')
+        self.props = ''.join(lines)
+
+    @property
     def utf16(self) -> str:
         """UTF-16 codec depending on byteorder."""
         return 'utf-16' + ('be' if self.byteorder == '>' else 'le')
 
+    def __hash__(self) -> int:
+        """Return hash of ImagejRoi."""
+        return hash(
+            (
+                self.tobytes(),
+                self.left,
+                self.top,
+                self.right,
+                self.bottom,
+            )
+        )
+
     def __eq__(self, other: object) -> bool:
         """Return True if two ImagejRoi are the same."""
         return (
@@ -1201,9 +1261,9 @@
         info = [f'{self.__class__.__name__}(']
         for name, value in self.__dict__.items():
             if isinstance(value, numpy.ndarray):
-                value = repr(value).replace('    ', ' ')
-                value = value.replace('([[', '([\n    [')
-                info.append(f'{name}=numpy.{value},')
+                v = repr(value).replace('    ', ' ')
+                v = v.replace('([[', '([\n    [')
+                info.append(f'{name}=numpy.{v},')
             elif value == getattr(ImagejRoi, name):
                 pass
             elif isinstance(value, enum.Enum):
@@ -1242,7 +1302,7 @@
             gc: Any,
             tpath: Any,
             affine: Any,
-            rgbFace: Any = None,
+            rgbFace: Any = None,  # noqa: N803
         ) -> None:
             ax = self._text.axes
             renderer = ax.get_figure().canvas.get_renderer()
@@ -1258,7 +1318,7 @@
 
 def oval(rect: ArrayLike, /, points: int = 33) -> NDArray[numpy.float32]:
     """Return coordinates of oval from rectangle corners."""
-    arr = numpy.array(rect, dtype=numpy.float32)
+    arr = numpy.asarray(rect, dtype=numpy.float32)
     c = numpy.linspace(0.0, 2.0 * numpy.pi, num=points, dtype=numpy.float32)
     c = numpy.array([numpy.cos(c), numpy.sin(c)]).T
     r = arr[1] - arr[0]
@@ -1302,129 +1362,8 @@
 
 
 def logger() -> logging.Logger:
-    """Return logging.getLogger('roifile')."""
-    return logging.getLogger(__name__.replace('roifile.roifile', 'roifile'))
-
-
-def test(verbose: bool = False) -> None:
-    """Test roifile.ImagejRoi class."""
-    # test ROIs from a ZIP file
-    rois: Any = ImagejRoi.fromfile('tests/ijzip.zip')
-    assert isinstance(rois, list)
-    assert len(rois) == 7
-    for roi in rois:
-        assert roi == ImagejRoi.frombytes(roi.tobytes())
-        roi.coordinates()
-        if verbose:
-            print(roi)
-        str(roi)
-
-    # re-write ROIs to a ZIP file
-    try:
-        os.remove('_test.zip')
-    except OSError:
-        pass
-
-    def roi_iter() -> Iterator[ImagejRoi]:
-        # issue #9
-        yield from rois
-
-    roiwrite('_test.zip', roi_iter())
-    assert roiread('_test.zip') == rois
-
-    # verify box_combined
-    rois = roiread('tests/box_combined.roi')
-    assert isinstance(rois, ImagejRoi)
-    roi = rois
-    if verbose:
-        print(roi)
-    assert roi == ImagejRoi.frombytes(roi.tobytes())
-    assert roi.name == '0464-0752'
-    assert roi.roitype == ROI_TYPE.RECT
-    assert roi.version == 227
-    assert (roi.top, roi.left, roi.bottom, roi.right) == (316, 692, 612, 812)
-    coords = roi.coordinates(multi=True)
-    assert len(coords) == 31
-    assert coords[0][0][0] == 767.0
-    assert coords[-1][-1][-1] == 587.0
-    assert roi.multi_coordinates is not None
-    assert roi.multi_coordinates[0] == 0.0
-    with open('tests/box_combined.roi', 'rb') as fh:
-        expected = fh.read()
-    assert roi.tobytes() == expected
-    str(roi)
-
-    roi = ImagejRoi.frompoints([[1, 2], [3, 4], [5, 6]])
-    assert roi == ImagejRoi.frombytes(roi.tobytes())
-    assert roi.left == 1
-    assert roi.top == 2
-    assert roi.right == 6
-    assert roi.bottom == 7
-
-    roi = ImagejRoi.frompoints([[1.1, 2.2], [3.3, 4.4], [5.5, 6.6]])
-    assert roi == ImagejRoi.frombytes(roi.tobytes())
-    assert roi.left == 1
-    assert roi.top == 2
-    assert roi.right == 7
-    assert roi.bottom == 8
-
-    roi = ImagejRoi.frompoints([[-5000, 60535], [60534, 65534]])
-    assert roi == ImagejRoi.frombytes(roi.tobytes())
-    assert roi.left == -5000, roi.left
-    assert roi.top == 60535, roi.top
-    assert roi.right == 60535, roi.right
-    assert roi.bottom == 65535, roi.bottom
-
-    # issue #7
-    roi = ImagejRoi.frompoints(
-        numpy.load('tests/issue7.npy').astype(numpy.float32)
-    )
-    assert roi == ImagejRoi.frombytes(roi.tobytes())
-    assert roi.left == 28357, roi.left
-    assert roi.top == 42200, roi.top  # not -23336
-    assert roi.right == 28453, roi.right
-    assert roi.bottom == 42284, roi.bottom  # not -23252
-    coords = roi.coordinates()
-    assert roi.integer_coordinates is not None
-    assert roi.subpixel_coordinates is not None
-    assert roi.integer_coordinates[0, 0] == 0
-    assert roi.integer_coordinates[0, 1] == 15
-    assert roi.subpixel_coordinates[0, 0] == 28357.0
-    assert roi.subpixel_coordinates[0, 1] == 42215.0
-
-    # rotated text
-    rois = roiread('tests/text_rotated.roi')
-    assert isinstance(rois, ImagejRoi)
-    roi = rois
-    if verbose:
-        print(roi)
-    assert roi == ImagejRoi.frombytes(roi.tobytes())
-    assert roi.name == 'Rotated'
-    assert roi.roitype == ROI_TYPE.RECT
-    assert roi.subtype == ROI_SUBTYPE.TEXT
-    assert roi.version == 228
-    assert (roi.top, roi.left, roi.bottom, roi.right) == (252, 333, 280, 438)
-    assert roi.stroke_color == b'\xff\x00\x00\xff'
-    assert roi.text_size == 20
-    assert roi.text_justification == 1
-    assert roi.text_name == 'SansSerif'
-    assert roi.text == 'Enter text...\n'
-    with open('tests/text_rotated.roi', 'rb') as fh:
-        expected = fh.read()
-    assert roi.tobytes() == expected
-    str(roi)
-
-    # read a ROI from a TIFF file
-    rois = roiread('tests/IJMetadata.tif')
-    assert isinstance(rois, list)
-    for roi in rois:
-        assert roi == ImagejRoi.frombytes(roi.tobytes())
-        roi.coordinates()
-        if verbose:
-            print(roi)
-        str(roi)
-
-    assert ImagejRoi() == ImagejRoi()
+    """Return logger for roifile module."""
+    return logging.getLogger('roifile')
 
 
 def main(argv: list[str] | None = None) -> int:
@@ -1440,25 +1379,6 @@
     if argv is None:
         argv = sys.argv
 
-    if len(argv) > 1 and '--test' in argv:
-        if os.path.exists('../tests'):
-            os.chdir('../')
-        import doctest
-
-        m: Any
-        try:
-            import roifile.roifile
-
-            m = roifile.roifile
-        except ImportError:
-            m = None
-        if os.path.exists('tests'):
-            print('running tests')
-            test()
-        print('running doctests')
-        doctest.testmod(m)
-        return 0
-
     if len(argv) == 1:
         files = glob('*.roi')
         files += glob('*.zip')
@@ -1473,28 +1393,26 @@
         files = argv[1:]
 
     for fname in files:
-        print(fname)
+        print(fname)  # noqa: T201
         try:
             rois = ImagejRoi.fromfile(fname)
             title = os.path.split(fname)[-1]
             if isinstance(rois, list):
                 for roi in rois:
-                    print(roi)
-                    print()
+                    print(roi, '\n')  # noqa: T201
                     if sys.flags.dev_mode:
                         assert roi == ImagejRoi.frombytes(roi.tobytes())
                 if rois:
                     rois[0].plot(rois=rois, title=title)
             else:
-                print(rois)
-                print()
+                print(rois, '\n')  # noqa: T201
                 if sys.flags.dev_mode:
                     assert rois == ImagejRoi.frombytes(rois.tobytes())
                 rois.plot(title=title)
         except ValueError as exc:
             if sys.flags.dev_mode:
                 raise
-            print(fname, exc)
+            print(fname, exc)  # noqa: T201
             continue
     return 0
 
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/roifile-2024.9.15/roifile_demo.py 
new/roifile-2026.1.22/roifile_demo.py
--- old/roifile-2024.9.15/roifile_demo.py       2024-09-15 18:53:51.000000000 
+0200
+++ new/roifile-2026.1.22/roifile_demo.py       2026-01-22 21:42:33.000000000 
+0100
@@ -9,14 +9,15 @@
 
 import numpy
 from matplotlib import pyplot
-from roifile import ImagejRoi
 from skimage.measure import find_contours, label, regionprops
 from tifffile import TiffFile, imwrite
 
+from roifile import ImagejRoi
+
 
 def plot_image_overlays(image, overlays, **kwargs):
     """Plot image and overlays (bytes) using matplotlib."""
-    fig, ax = pyplot.subplots()
+    _fig, ax = pyplot.subplots()
     ax.imshow(image, cmap='gray')
     if not isinstance(overlays, list):
         overlays = [overlays]
@@ -29,7 +30,7 @@
 # open an ImageJ TIFF file and read the image and overlay data
 # https://github.com/csachs/imagej-tiff-meta/
 # blob/b6a74daa8c2adf7023d20a447d9a2799614c857a/box.tif
-with TiffFile('tests/box.tif') as tif:
+with TiffFile('tests/data/box.tif') as tif:
     image = tif.pages[0].asarray()
     assert tif.imagej_metadata is not None
     overlays = tif.imagej_metadata['Overlays']
@@ -39,9 +40,7 @@
 # segment the image with scikit-image
 labeled = label(image > 0.5 * image.max())
 for region in regionprops(labeled):
-    if region.area > 10000:
-        labeled[labeled == region.label] = 0
-    elif region.area < 100:
+    if not 100 < region.area < 10000:
         labeled[labeled == region.label] = 0
 segmentation = 1.0 * (labeled > 0)
 
@@ -51,7 +50,9 @@
     for contour in find_contours(segmentation, level=0.9999)
 ]
 
-plot_image_overlays(image, overlays, lw=5)
+plot_image_overlays(image, overlays, linewidth=5)
 
 # write the image and overlays to a new ImageJ TIFF file
 imwrite('roi_test.tif', image, imagej=True, metadata={'Overlays': overlays})
+
+# mypy: allow-untyped-defs, allow-untyped-calls
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/roifile-2024.9.15/setup.py 
new/roifile-2026.1.22/setup.py
--- old/roifile-2024.9.15/setup.py      2024-09-15 18:53:51.000000000 +0200
+++ new/roifile-2026.1.22/setup.py      2026-01-22 21:42:33.000000000 +0100
@@ -8,15 +8,16 @@
 from setuptools import setup
 
 
-def search(pattern, string, flags=0):
+def search(pattern: str, string: str, flags: int = 0) -> str:
     """Return first match of pattern in string."""
     match = re.search(pattern, string, flags)
     if match is None:
-        raise ValueError(f'{pattern!r} not found')
+        msg = f'{pattern=!r} not found'
+        raise ValueError(msg)
     return match.groups()[0]
 
 
-def fix_docstring_examples(docstring):
+def fix_docstring_examples(docstring: str) -> str:
     """Return docstring with examples fixed for GitHub."""
     start = True
     indent = False
@@ -47,7 +48,7 @@
     re.MULTILINE | re.DOTALL,
 )
 readme = '\n'.join(
-    [description, '=' * len(description)] + readme.splitlines()[1:]
+    [description, '=' * len(description), *readme.splitlines()[1:]]
 )
 
 if 'sdist' in sys.argv:
@@ -64,7 +65,7 @@
     license = license.replace('# ', '').replace('#', '')
 
     with open('LICENSE', 'w', encoding='utf-8') as fh:
-        fh.write('BSD 3-Clause License\n\n')
+        fh.write('BSD-3-Clause license\n\n')
         fh.write(license)
 
     revisions = search(
@@ -84,7 +85,7 @@
 setup(
     name='roifile',
     version=version,
-    license='BSD',
+    license='BSD-3-Clause',
     description=description,
     long_description=readme,
     long_description_content_type='text/x-rst',
@@ -99,20 +100,19 @@
     packages=['roifile'],
     package_data={'roifile': ['py.typed']},
     entry_points={'console_scripts': ['roifile = roifile.roifile:main']},
-    python_requires='>=3.9',
+    python_requires='>=3.11',
     install_requires=['numpy'],
     extras_require={'all': ['matplotlib', 'tifffile']},
     platforms=['any'],
     classifiers=[
         'Development Status :: 4 - Beta',
-        'License :: OSI Approved :: BSD License',
         'Intended Audience :: Science/Research',
         'Intended Audience :: Developers',
         'Operating System :: OS Independent',
         'Programming Language :: Python :: 3 :: Only',
-        'Programming Language :: Python :: 3.10',
         'Programming Language :: Python :: 3.11',
         'Programming Language :: Python :: 3.12',
         'Programming Language :: Python :: 3.13',
+        'Programming Language :: Python :: 3.14',
     ],
 )
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/roifile-2024.9.15/tests/conftest.py 
new/roifile-2026.1.22/tests/conftest.py
--- old/roifile-2024.9.15/tests/conftest.py     1970-01-01 01:00:00.000000000 
+0100
+++ new/roifile-2026.1.22/tests/conftest.py     2026-01-22 21:42:33.000000000 
+0100
@@ -0,0 +1,26 @@
+# roifile/tests/conftest.py
+
+"""Pytest configuration."""
+
+import os
+import sys
+
+if os.environ.get('VSCODE_CWD'):
+    # work around pytest not using PYTHONPATH in VSCode
+    sys.path.insert(
+        0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))
+    )
+
+
+def pytest_report_header(config: object) -> str:
+    """Return pytest report header."""
+    try:
+        import roifile
+
+        return (
+              f'Python {sys.version.splitlines()[0]}\n'
+              f'packagedir: {roifile.__path__[0]}\n'
+              f'version: roifile {roifile.__version__}'
+        )
+    except Exception as exc:
+        return f'pytest_report_header failed: {exc!s}'
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/roifile-2024.9.15/tests/test_roifile.py 
new/roifile-2026.1.22/tests/test_roifile.py
--- old/roifile-2024.9.15/tests/test_roifile.py 1970-01-01 01:00:00.000000000 
+0100
+++ new/roifile-2026.1.22/tests/test_roifile.py 2026-01-22 21:42:33.000000000 
+0100
@@ -0,0 +1,355 @@
+# test_roifile.py
+
+# Copyright (c) 2020-2026, Christoph Gohlke
+# All rights reserved.
+#
+# Redistribution and use in source and binary forms, with or without
+# modification, are permitted provided that the following conditions are met:
+#
+# * Redistributions of source code must retain the above copyright
+#   notice, this list of conditions and the following disclaimer.
+# * Redistributions in binary form must reproduce the above copyright
+#   notice, this list of conditions and the following disclaimer in the
+#   documentation and/or other materials provided with the distribution.
+# * Neither the name of the copyright holders nor the names of any
+#   contributors may be used to endorse or promote products derived
+#   from this software without specific prior written permission.
+#
+# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+# ARE DISCLAIMED.  IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE
+# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+# POSSIBILITY OF SUCH DAMAGE.
+
+"""Unittests for the roifile package.
+
+:Version: 2026.1.22
+
+"""
+
+import contextlib
+import glob
+import io
+import os
+import pathlib
+import pickle
+import sys
+from collections.abc import Iterator
+from typing import Any
+
+import numpy
+import pytest
+from matplotlib import pyplot
+
+import roifile
+from roifile import (
+    ROI_COLOR_NONE,
+    ROI_OPTIONS,
+    ROI_POINT_SIZE,
+    ROI_POINT_TYPE,
+    ROI_SUBTYPE,
+    ROI_TYPE,
+    ImagejRoi,
+    __version__,
+    roiread,
+    roiwrite,
+)
+
+HERE = pathlib.Path(os.path.dirname(__file__))
+DATA = HERE / 'data'
+
+
[email protected](__doc__ is None, reason='__doc__ is None')
+def test_version():
+    """Assert roifile versions match docstrings."""
+    ver = ':Version: ' + __version__
+    assert ver in __doc__
+    assert ver in roifile.__doc__
+
+
+def test_read_zip_file():
+    """Test reading ROIs from a ZIP file."""
+    rois: Any = ImagejRoi.fromfile(DATA / 'ijzip.zip')
+    assert isinstance(rois, list)
+    assert len(rois) == 7
+    for roi in rois:
+        assert roi == ImagejRoi.frombytes(roi.tobytes())
+        roi.coordinates()
+        _ = roi.properties
+        str(roi)
+
+
+def test_write_zip_file():
+    """Test writing ROIs to a ZIP file."""
+    rois: Any = ImagejRoi.fromfile(DATA / 'ijzip.zip')
+
+    with contextlib.suppress(OSError):
+        os.remove('_test.zip')
+
+    def roi_iter() -> Iterator[ImagejRoi]:
+        yield from rois
+
+    roiwrite('_test.zip', roi_iter())
+    assert roiread('_test.zip') == rois
+
+
+def test_read_tiff_file():
+    """Test reading ROIs from a TIFF file."""
+    rois = roiread(DATA / 'IJMetadata.tif')
+    assert isinstance(rois, list)
+    for roi in rois:
+        assert roi == ImagejRoi.frombytes(roi.tobytes())
+        roi.coordinates()
+        str(roi)
+
+
+def test_empty_roi():
+    """Test empty ROI equality."""
+    assert ImagejRoi() == ImagejRoi()
+
+
+def test_box_combined():
+    """Test box_combined composite ROI."""
+    rois = roiread(DATA / 'box_combined.roi')
+    assert isinstance(rois, ImagejRoi)
+    roi = rois
+    str(roi)
+    assert roi == ImagejRoi.frombytes(roi.tobytes())
+    assert roi.name == '0464-0752'
+    assert roi.roitype == ROI_TYPE.RECT
+    assert roi.version == 227
+    assert (roi.top, roi.left, roi.bottom, roi.right) == (316, 692, 612, 812)
+    coords = roi.coordinates(multi=True)
+    assert len(coords) == 31
+    assert coords[0][0][0] == 767.0
+    assert coords[-1][-1][-1] == 587.0
+    assert roi.multi_coordinates is not None
+    assert roi.multi_coordinates[0] == 0.0
+    with open(DATA / 'box_combined.roi', 'rb') as fh:
+        expected = fh.read()
+    assert roi.properties == {}
+    assert roi.tobytes() == expected
+    str(roi)
+
+
+def test_frompoints_integer():
+    """Test creating ROI from integer coordinates."""
+    roi = ImagejRoi.frompoints([[1, 2], [3, 4], [5, 6]])
+    assert roi == ImagejRoi.frombytes(roi.tobytes())
+    assert roi.left == 1
+    assert roi.top == 2
+    assert roi.right == 6
+    assert roi.bottom == 7
+
+
+def test_frompoints_float():
+    """Test creating ROI from float coordinates."""
+    roi = ImagejRoi.frompoints([[1.1, 2.2], [3.3, 4.4], [5.5, 6.6]])
+    assert roi == ImagejRoi.frombytes(roi.tobytes())
+    assert roi.left == 1
+    assert roi.top == 2
+    assert roi.right == 7
+    assert roi.bottom == 8
+
+
+def test_frompoints_large_coordinates():
+    """Test creating ROI from large coordinate values."""
+    roi = ImagejRoi.frompoints([[-5000, 60535], [60534, 65534]])
+    assert roi == ImagejRoi.frombytes(roi.tobytes())
+    assert roi.left == -5000, roi.left
+    assert roi.top == 60535, roi.top
+    assert roi.right == 60535, roi.right
+    assert roi.bottom == 65535, roi.bottom
+
+
+def test_rotated_text():
+    """Test reading rotated text ROI."""
+    rois = roiread(DATA / 'text_rotated.roi')
+    assert isinstance(rois, ImagejRoi)
+    roi = rois
+    str(roi)
+    assert roi == ImagejRoi.frombytes(roi.tobytes())
+    assert roi.name == 'Rotated'
+    assert roi.roitype == ROI_TYPE.RECT
+    assert roi.subtype == ROI_SUBTYPE.TEXT
+    assert roi.version == 228
+    assert (roi.top, roi.left, roi.bottom, roi.right) == (252, 333, 280, 438)
+    assert roi.stroke_color == b'\xff\x00\x00\xff'
+    assert roi.text_size == 20
+    assert roi.text_justification == 1
+    assert roi.text_name == 'SansSerif'
+    assert roi.text == 'Enter text...\n'
+    with open(DATA / 'text_rotated.roi', 'rb') as fh:
+        expected = fh.read()
+    assert roi.tobytes() == expected
+    str(roi)
+
+
+def test_properties():
+    """Test ROI properties."""
+    rois = ImagejRoi.fromfile(DATA / '27197299958_88cf5966d3_b.tif')
+    assert isinstance(rois, list)
+    assert len(rois) == 8
+
+    for roi in rois:
+        roi.props.endswith('\n')
+        assert roi == ImagejRoi.frombytes(roi.tobytes())
+        roi.coordinates()
+        str(roi)
+
+    roi = rois[0]
+    assert roi.roitype == ROI_TYPE.LINE
+    assert roi.subtype == ROI_SUBTYPE.UNDEFINED
+    assert roi.options == (
+        ROI_OPTIONS.OVERLAY_LABELS | ROI_OPTIONS.OVERLAY_BACKGROUNDS
+    )
+    assert roi.version == 228
+    assert roi.name == 'Plat'
+    assert roi.stroke_color == b'\xff\xff\x00\x00'
+    assert roi.fill_color == ROI_COLOR_NONE
+    assert len(roi.props) == 418
+    assert roi.props.startswith('%Area: 0\nAbutment_implant_misfit: No\n')
+    assert roi.props.endswith('X: 48\nY: 98.500\n')
+
+    props = roi.properties
+    assert isinstance(props, dict)
+    assert len(props) == 26
+    assert props['%Area'] == 0
+    assert props['Y'] == 98.5
+    assert props['Angle'] == -1.193
+    assert props['Roi'] == 'Plat'
+    assert props['Poor_Quality'] == 'No'
+
+    roi.properties = props  # encode roi.props
+    assert roi.properties == props  # decode roi.props
+    assert len(roi.props) == 415
+    assert roi.props.startswith('%Area: 0\nAbutment_implant_misfit: No\n')
+    assert roi.props.endswith('X: 48\nY: 98.5\n')  # float formatting different
+    assert roi == ImagejRoi.frombytes(roi.tobytes())
+
+    props['Bool'] = False
+    roi.properties = props
+    assert roi.properties['Bool'] is False
+    assert 'BY: 98\nBool: false\n' in roi.props  # sorted by key
+
+
+def test_zipfile():
+    """Test ROIs from ZIP file."""
+    rois = ImagejRoi.fromfile(DATA / 'ijzip.zip')
+    assert isinstance(rois, list)
+    assert len(rois) == 7
+    for roi in rois:
+        assert roi == ImagejRoi.frombytes(roi.tobytes())
+        roi.coordinates()
+        str(roi)
+
+
+def test_pickle():
+    """Test pickling ROI."""
+    rois = ImagejRoi.fromfile(DATA / 'ijzip.zip')
+    fh = io.BytesIO()
+    pickle.dump(rois, fh)
+    for roi0, roi1 in zip(
+        rois, pickle.loads(fh.getvalue()), strict=True  # noqa: S301
+    ):
+        assert roi0 == roi1
+
+
+def test_issue_7():
+    """Test issue #7: large coordinate values."""
+    roi = ImagejRoi.frompoints(
+        numpy.load(DATA / 'issue7.npy').astype(numpy.float32)
+    )
+    assert roi == ImagejRoi.frombytes(roi.tobytes())
+    assert roi.left == 28357, roi.left
+    assert roi.top == 42200, roi.top  # not -23336
+    assert roi.right == 28453, roi.right
+    assert roi.bottom == 42284, roi.bottom  # not -23252
+    _ = roi.coordinates()
+    assert roi.integer_coordinates is not None
+    assert roi.subpixel_coordinates is not None
+    assert roi.integer_coordinates[0, 0] == 0
+    assert roi.integer_coordinates[0, 1] == 15
+    assert roi.subpixel_coordinates[0, 0] == 28357.0
+    assert roi.subpixel_coordinates[0, 1] == 42215.0
+
+
+def test_issue_9():
+    """Test issue #9: roiwrite with iterable input."""
+    # re-write ROIs to a ZIP file
+    rois: Any = ImagejRoi.fromfile(DATA / 'ijzip.zip')
+    assert len(rois) == 7
+
+    # write list
+    with contextlib.suppress(OSError):
+        os.remove('_test.zip')
+    roiwrite('_test.zip', rois)
+    assert roiread('_test.zip') == rois
+
+    # provide names
+    with contextlib.suppress(OSError):
+        os.remove('_test.zip')
+    roiwrite('_test.zip', rois, name=[str(i) for i in range(len(rois))])
+    assert roiread('_test.zip') == rois
+
+    # write generator, issue #9
+    def roi_iter() -> Iterator[ImagejRoi]:
+        yield from rois
+
+    with contextlib.suppress(OSError):
+        os.remove('_test.zip')
+    roiwrite('_test.zip', roi_iter())
+    assert roiread('_test.zip') == rois
+
+
[email protected](
+    'fname', glob.glob('*.roi', root_dir=DATA, recursive=False)
+)
+def test_glob_roi(fname):
+    """Test read all ROI files."""
+    if 'defective' in fname:
+        pytest.xfail(reason='file is marked defective')
+    fname = DATA / fname
+    roi = ImagejRoi.fromfile(fname)
+    assert isinstance(roi, ImagejRoi)
+    str(roi)
+    roi.plot(show=False)
+    pyplot.close()
+
+
[email protected](
+    'fname', glob.glob('*.zip', root_dir=DATA, recursive=False)
+)
+def test_glob_zip(fname):
+    """Test read all ZIP files."""
+    if 'defective' in fname:
+        pytest.xfail(reason='file is marked defective')
+    fname = DATA / fname
+    rois = roiread(fname)
+    assert isinstance(rois, list)
+    for roi in rois:
+        str(roi)
+        roi.plot(show=False)
+    pyplot.close()
+
+
+if __name__ == '__main__':
+    import warnings
+
+    # warnings.simplefilter('always')
+    warnings.filterwarnings('ignore', category=ImportWarning)
+    argv = sys.argv
+    argv.append('--cov-report=html')
+    argv.append('--cov=roifile')
+    argv.append('--verbose')
+    sys.exit(pytest.main(argv))
+
+
+# mypy: allow-untyped-defs, allow-untyped-calls
+# mypy: disable-error-code="arg-type"

Reply via email to