[Git][debian-gis-team/python-affine][upstream] New upstream version 3.0.0

Antonio Valentino (@antonio.valentino) gitlab at salsa.debian.org
Sat Aug 8 17:01:06 BST 2026



Antonio Valentino pushed to branch upstream at Debian GIS Project / python-affine


Commits:
8fa9e1be by Antonio Valentino at 2026-08-08T15:44:56+00:00
New upstream version 3.0.0
- - - - -


26 changed files:

- + .github/dependabot.yml
- .github/workflows/ci.yml
- .gitignore
- + .pre-commit-config.yaml
- + .readthedocs.yaml
- AUTHORS.txt
- CHANGES.txt
- README.rst
- − affine/tests/test_transform.py
- + docs/Makefile
- + docs/make.bat
- + docs/requirements.txt
- + docs/src/_static/.gitkeep
- + docs/src/conf.py
- + docs/src/index.rst
- pyproject.toml
- − setup.cfg
- affine/__init__.py → src/affine/__init__.py
- + src/affine/py.typed
- affine/tests/__init__.py → tests/__init__.py
- + tests/test_numpy.py
- affine/tests/test_pickle.py → tests/test_pickle.py
- affine/tests/test_rotation.py → tests/test_rotation.py
- affine/tests/test_serialize.py → tests/test_serialize.py
- + tests/test_transform.py
- tox.ini


Changes:

=====================================
.github/dependabot.yml
=====================================
@@ -0,0 +1,11 @@
+# https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates
+version: 2
+updates:
+  - package-ecosystem: "github-actions"
+    directory: "/"
+    schedule:
+      interval: "quarterly"
+    groups:
+      github-actions:
+        patterns:
+          - "*"


=====================================
.github/workflows/ci.yml
=====================================
@@ -9,19 +9,33 @@ on:
     - '*'
   pull_request:
 env:
-  LATEST_PY_VERSION: '3.11'
+  LATEST_PY_VERSION: '3.14'
 
 jobs:
-  tests:
+  pre-commit:
     runs-on: ubuntu-latest
+    steps:
+      - uses: actions/checkout at v6
+      - uses: actions/setup-python at v6
+        with:
+            python-version: 3.*
+      - name: pre-commit checks
+        shell: bash
+        run: |
+          python -m pip install --upgrade pip
+          python -m pip install pre-commit
+          pre-commit run --show-diff-on-failure --all-files
+
+  tests:
+    runs-on: ubuntu-24.04
     strategy:
       matrix:
-        python-version: ['3.7', '3.8', '3.9', '3.10', '3.11']
+        python-version: ['3.9', '3.10', '3.11', '3.12', '3.13', '3.14']
 
     steps:
-      - uses: actions/checkout at v3
+      - uses: actions/checkout at v6
       - name: Set up Python ${{ matrix.python-version }}
-        uses: actions/setup-python at v4
+        uses: actions/setup-python at v6
         with:
           python-version: ${{ matrix.python-version }}
 
@@ -41,42 +55,3 @@ jobs:
           files: ./coverage.xml
           flags: unittests
           fail_ci_if_error: false
-
-  lint:
-    needs: tests
-    runs-on: ubuntu-latest
-    steps:
-      - uses: actions/checkout at v3
-      - name: Set up Python
-        uses: actions/setup-python at v4
-        with:
-          python-version: ${{ env.LATEST_PY_VERSION }}
-
-      - name: Install dependencies
-        run: |
-          python -m pip install --upgrade pip
-          python -m pip install flake8 pydocstyle
-
-      - name: Run lint
-        run: |
-          flake8 --ignore=E501,W503
-          python -m pydocstyle affine
-
-  typing:
-    needs: tests
-    runs-on: ubuntu-latest
-    steps:
-      - uses: actions/checkout at v3
-      - name: Set up Python
-        uses: actions/setup-python at v4
-        with:
-          python-version: ${{ env.LATEST_PY_VERSION }}
-
-      - name: Install dependencies
-        run: |
-          python -m pip install --upgrade pip
-          python -m pip install mypy pytest
-
-      - name: Run typing test
-        run: |
-          mypy affine


=====================================
.gitignore
=====================================
@@ -59,3 +59,4 @@ __pycache__
 *.swp
 
 .coverage.*
+.DS_Store


=====================================
.pre-commit-config.yaml
=====================================
@@ -0,0 +1,22 @@
+ci:
+  autoupdate_schedule: quarterly
+repos:
+  - repo: https://github.com/pre-commit/pre-commit-hooks
+    rev: v6.0.0
+    hooks:
+    - id: check-yaml
+    - id: end-of-file-fixer
+    - id: trailing-whitespace
+  - repo: https://github.com/astral-sh/ruff-pre-commit
+    rev: v0.14.10
+    hooks:
+      # Run the linter.
+      - id: ruff-check
+        args: [ --fix ]
+      # Run the formatter.
+      - id: ruff-format
+  - repo: https://github.com/pre-commit/mirrors-mypy
+    rev: v1.19.1
+    hooks:
+      - id: mypy
+        additional_dependencies: [attrs]


=====================================
.readthedocs.yaml
=====================================
@@ -0,0 +1,15 @@
+version: 2
+
+build:
+  os: ubuntu-24.04
+  tools:
+    python: "3.14"
+
+sphinx:
+   configuration: docs/src/conf.py
+
+python:
+   install:
+   - requirements: docs/requirements.txt
+   - method: pip
+     path: .


=====================================
AUTHORS.txt
=====================================
@@ -1,10 +1,18 @@
 Authors
 =======
 
-- Sean Gillies <sean at mapbox.com>
-- Steven Ring <smr at southsky.com.au>
-- Mike Taves <mwtoews at gmail.com>
-- Kevin Wurster <wursterk at gmail.com>
-- Todd Small <todd_small at icloud.com>
-- Juan Luis Cano Rodríguez <juanlu at satellogic.com>
+- Casey Duncan
+- Sean Gillies
+- Denis Rykov
+- Juan Luis Cano Rodríguez
+- Kevin Wurster
 - Kirill Kouzoubov
+- Loïc Dutrieux
+- Michael Wess
+- Mike Taves
+- Nick Maxwell
+- Rotzbua
+- Ryan Grout
+- Steven Ring
+- Stuart Axon
+- Vincent Sarago


=====================================
CHANGES.txt
=====================================
@@ -1,6 +1,52 @@
 CHANGES
 =======
 
+3.0.0 (2026-08-08)
+------------------
+
+- `Affine.__array__()` has been deleted to remove a dependency on Numpy and
+  associated typing annotations (#140). To convert an Affine instance to
+  a Numpy array, do this: `numpy.array(Affine()).reshape(3, 3)`.
+
+3.0rc3 (2026-01-30)
+-------------------
+
+The single file module affine.py has been moved to affine/__init__.py so that
+a py.typed file can be distributed in the same directory (#136).
+
+3.0rc2 (2026-01-27)
+-------------------
+
+3.0rc1 tagged the wrong commit. The distributions on PyPI have the correct
+version, but the GitHub release does not. This is fixed by 3.0rc2, which makes
+no other changes.
+
+3.0rc1 (2026-01-26)
+-------------------
+
+This is the first 3.0 release candidate. There are no significant changes
+since 3.0b1.
+
+3.0b1 (2025-01-29)
+------------------
+
+- Matrix multiplication with tuples of coordinate matrices, like the output of
+  numpy.meshgrid(), has been restored (#126).
+
+3.0a1 (2025-01-27)
+------------------
+
+- Type hint annotations for functions and methods are complete (#121).
+- Affine raises ValueError if initialized with values for g, h, and i that are
+  not 0.0, 0.0, and 1.0, respectively (#117).
+- Python version support was changed to 3.9+ (#110).
+- Switch from namedtuple to attrs for implementation of the Affine class and
+  use functools.cached_property(), which absolutely requires Python 3.8+
+  (#111).
+- Source was moved to a single-module affine.py in the src directory (#112).
+- Add numpy __array__ interface (#108).
+- Add support for ``@`` matrix multiplier methods (#122).
+
 2.4.0 (2023-01-19)
 ------------------
 
@@ -63,7 +109,7 @@ Bug fixes:
 
 2.0b2 (2016-05-16)
 ------------------
-- Bug fix: restore ``Affine __rmul__`` even though it permits dubious 
+- Bug fix: restore ``Affine __rmul__`` even though it permits dubious
   vector * matrix expressions (#27).
 
 2.0b1 (2016-05-16)


=====================================
README.rst
=====================================
@@ -9,8 +9,12 @@ Matrices describing 2D affine transformation of the plane.
 .. image:: https://codecov.io/gh/rasterio/affine/branch/main/graph/badge.svg
     :target: https://codecov.io/gh/rasterio/affine
 
+.. image:: https://readthedocs.org/projects/affine/badge/?version=latest
+       :target: https://affine.readthedocs.io/en/latest/?badge=latest
+       :alt: Documentation Status
+
 The Affine package is derived from Casey Duncan's Planar package. Please see
-the copyright statement in `affine/__init__.py <affine/__init__.py>`__.
+the copyright statement in `src/affine.py <src/affine.py>`__.
 
 Usage
 -----
@@ -18,7 +22,7 @@ Usage
 The 3x3 augmented affine transformation matrix for transformations in two
 dimensions is illustrated below.
 
-::
+.. code-block:: none
 
   | x' |   | a  b  c | | x |
   | y' | = | d  e  f | | y |
@@ -47,8 +51,9 @@ Matrices can be created by passing the values ``a, b, c, d, e, f`` to the
   Affine(0.7071067811865476, -0.7071067811865475, 0.0,
          0.7071067811865475, 0.7071067811865476, 0.0)
 
-These matrices can be applied to ``(x, y)`` tuples to obtain transformed
-coordinates ``(x', y')``.
+These matrices can be applied to ``(x, y)`` tuples using the
+``*`` operator (or the ``@`` matrix multiplier operator for
+future releases) to obtain transformed coordinates ``(x', y')``.
 
 .. code-block:: pycon
 
@@ -87,11 +92,10 @@ origin can be easily computed.
   >>> fwd * (col, row)
   (-237481.5, 195036.4)
 
-The reverse transformation is obtained using the ``~`` operator.
+The reverse transformation is obtained using the ``~`` inverse operator.
 
 .. code-block:: pycon
 
   >>> rev = ~fwd
   >>> rev * fwd * (col, row)
   (0.0, 99.99999999999999)
-


=====================================
affine/tests/test_transform.py deleted
=====================================
@@ -1,543 +0,0 @@
-#############################################################################
-# Planar is Copyright (c) 2010 by Casey Duncan
-# 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(s) of the copyright holders nor the names of its
-#   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 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 HOLDERS 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.
-#############################################################################
-
-"""Transform unit tests"""
-
-import math
-import unittest
-from textwrap import dedent
-
-import pytest
-
-import affine
-from affine import Affine, EPSILON
-
-
-def seq_almost_equal(t1, t2, error=0.00001):
-    assert len(t1) == len(t2), f"{t1!r} != {t2!r}"
-    for m1, m2 in zip(t1, t2):
-        assert abs(m1 - m2) <= error, f"{t1!r} != {t2!r}"
-
-
-class PyAffineTestCase(unittest.TestCase):
-    def test_zero_args(self):
-        with pytest.raises(TypeError):
-            Affine()
-
-    def test_wrong_arg_type(self):
-        with pytest.raises(TypeError):
-            Affine(None)
-
-    def test_args_too_few(self):
-        with pytest.raises(TypeError):
-            Affine(1, 2)
-
-    def test_args_too_many(self):
-        with pytest.raises(TypeError):
-            Affine(*range(10))
-
-    def test_args_members_wrong_type(self):
-        with pytest.raises(TypeError):
-            Affine(0, 2, 3, None, None, "")
-
-    def test_len(self):
-        t = Affine(1, 2, 3, 4, 5, 6)
-        assert len(t) == 9
-
-    def test_slice_last_row(self):
-        t = Affine(1, 2, 3, 4, 5, 6)
-        assert t[-3:] == (0, 0, 1)
-
-    def test_members_are_floats(self):
-        t = Affine(1, 2, 3, 4, 5, 6)
-        for m in t:
-            assert isinstance(m, float), repr(m)
-
-    def test_getitem(self):
-        t = Affine(1, 2, 3, 4, 5, 6)
-        assert t[0] == 1
-        assert t[1] == 2
-        assert t[2] == 3
-        assert t[3] == 4
-        assert t[4] == 5
-        assert t[5] == 6
-        assert t[6] == 0
-        assert t[7] == 0
-        assert t[8] == 1
-        assert t[-1] == 1
-
-    def test_getitem_wrong_type(self):
-        t = Affine(1, 2, 3, 4, 5, 6)
-        with pytest.raises(TypeError):
-            t["foobar"]
-
-    def test_str(self):
-        t = Affine(1.111, 2.222, 3.333, -4.444, -5.555, 6.666)
-        assert str(t) == dedent(
-            """\
-            | 1.11, 2.22, 3.33|
-            |-4.44,-5.55, 6.67|
-            | 0.00, 0.00, 1.00|"""
-        )
-
-    def test_repr(self):
-        t = Affine(1.111, 2.222, 3.456, 4.444, 5.5, 6.25)
-        assert repr(t) == dedent(
-            """\
-            Affine(1.111, 2.222, 3.456,
-                   4.444, 5.5, 6.25)"""
-        )
-
-    def test_identity_constructor(self):
-        ident = Affine.identity()
-        assert isinstance(ident, Affine)
-        assert tuple(ident) == (1, 0, 0, 0, 1, 0, 0, 0, 1)
-        assert ident.is_identity
-
-    def test_permutation_constructor(self):
-        perm = Affine.permutation()
-        assert isinstance(perm, Affine)
-        assert tuple(perm) == (0, 1, 0, 1, 0, 0, 0, 0, 1)
-        assert (perm * perm).is_identity
-
-    def test_translation_constructor(self):
-        trans = Affine.translation(2, -5)
-        assert isinstance(trans, Affine)
-        assert tuple(trans) == (1, 0, 2, 0, 1, -5, 0, 0, 1)
-
-    def test_scale_constructor(self):
-        scale = Affine.scale(5)
-        assert isinstance(scale, Affine)
-        assert tuple(scale) == (5, 0, 0, 0, 5, 0, 0, 0, 1)
-        scale = Affine.scale(-1, 2)
-        assert tuple(scale) == (-1, 0, 0, 0, 2, 0, 0, 0, 1)
-        assert tuple(Affine.scale(1)) == tuple(Affine.identity())
-
-    def test_shear_constructor(self):
-        shear = Affine.shear(30)
-        assert isinstance(shear, Affine)
-        mx = math.tan(math.radians(30))
-        seq_almost_equal(tuple(shear), (1, mx, 0, 0, 1, 0, 0, 0, 1))
-        shear = Affine.shear(-15, 60)
-        mx = math.tan(math.radians(-15))
-        my = math.tan(math.radians(60))
-        seq_almost_equal(tuple(shear), (1, mx, 0, my, 1, 0, 0, 0, 1))
-        shear = Affine.shear(y_angle=45)
-        seq_almost_equal(tuple(shear), (1, 0, 0, 1, 1, 0, 0, 0, 1))
-
-    def test_rotation_constructor(self):
-        rot = Affine.rotation(60)
-        assert isinstance(rot, Affine)
-        r = math.radians(60)
-        s, c = math.sin(r), math.cos(r)
-        assert tuple(rot) == (c, -s, 0, s, c, 0, 0, 0, 1)
-        rot = Affine.rotation(337)
-        r = math.radians(337)
-        s, c = math.sin(r), math.cos(r)
-        seq_almost_equal(tuple(rot), (c, -s, 0, s, c, 0, 0, 0, 1))
-        assert tuple(Affine.rotation(0)) == tuple(Affine.identity())
-
-    def test_rotation_constructor_quadrants(self):
-        assert tuple(Affine.rotation(0)) == (1, 0, 0, 0, 1, 0, 0, 0, 1)
-        assert tuple(Affine.rotation(90)) == (0, -1, 0, 1, 0, 0, 0, 0, 1)
-        assert tuple(Affine.rotation(180)) == (-1, 0, 0, 0, -1, 0, 0, 0, 1)
-        assert tuple(Affine.rotation(-180)) == (-1, 0, 0, 0, -1, 0, 0, 0, 1)
-        assert tuple(Affine.rotation(270)) == (0, 1, 0, -1, 0, 0, 0, 0, 1)
-        assert tuple(Affine.rotation(-90)) == (0, 1, 0, -1, 0, 0, 0, 0, 1)
-        assert tuple(Affine.rotation(360)) == (1, 0, 0, 0, 1, 0, 0, 0, 1)
-        assert tuple(Affine.rotation(450)) == (0, -1, 0, 1, 0, 0, 0, 0, 1)
-        assert tuple(Affine.rotation(-450)) == (0, 1, 0, -1, 0, 0, 0, 0, 1)
-
-    def test_rotation_constructor_with_pivot(self):
-        assert tuple(Affine.rotation(60)) == tuple(Affine.rotation(60, pivot=(0, 0)))
-        rot = Affine.rotation(27, pivot=(2, -4))
-        r = math.radians(27)
-        s, c = math.sin(r), math.cos(r)
-        assert tuple(rot) == (
-            c,
-            -s,
-            2 - 2 * c - 4 * s,
-            s,
-            c,
-            -4 - 2 * s + 4 * c,
-            0,
-            0,
-            1,
-        )
-        assert tuple(Affine.rotation(0, (-3, 2))) == tuple(Affine.identity())
-
-    def test_rotation_contructor_wrong_arg_types(self):
-        with pytest.raises(TypeError):
-            Affine.rotation(1, 1)
-
-    def test_determinant(self):
-        assert Affine.identity().determinant == 1
-        assert Affine.scale(2).determinant == 4
-        assert Affine.scale(0).determinant == 0
-        assert Affine.scale(5, 1).determinant == 5
-        assert Affine.scale(-1, 1).determinant == -1
-        assert Affine.scale(-1, 0).determinant == 0
-        assert Affine.rotation(77).determinant == pytest.approx(1)
-        assert Affine.translation(32, -47).determinant == pytest.approx(1)
-
-    def test_is_rectilinear(self):
-        assert Affine.identity().is_rectilinear
-        assert Affine.scale(2.5, 6.1).is_rectilinear
-        assert Affine.translation(4, -1).is_rectilinear
-        assert Affine.rotation(90).is_rectilinear
-        assert not Affine.shear(4, -1).is_rectilinear
-        assert not Affine.rotation(-26).is_rectilinear
-
-    def test_is_conformal(self):
-        assert Affine.identity().is_conformal
-        assert Affine.scale(2.5, 6.1).is_conformal
-        assert Affine.translation(4, -1).is_conformal
-        assert Affine.rotation(90).is_conformal
-        assert Affine.rotation(-26).is_conformal
-        assert not Affine.shear(4, -1).is_conformal
-
-    def test_is_orthonormal(self):
-        assert Affine.identity().is_orthonormal
-        assert Affine.translation(4, -1).is_orthonormal
-        assert Affine.rotation(90).is_orthonormal
-        assert Affine.rotation(-26).is_orthonormal
-        assert not Affine.scale(2.5, 6.1).is_orthonormal
-        assert not Affine.scale(0.5, 2).is_orthonormal
-        assert not Affine.shear(4, -1).is_orthonormal
-
-    def test_is_degenerate(self):
-        assert not Affine.identity().is_degenerate
-        assert not Affine.translation(2, -1).is_degenerate
-        assert not Affine.shear(0, -22.5).is_degenerate
-        assert not Affine.rotation(88.7).is_degenerate
-        assert not Affine.scale(0.5).is_degenerate
-        assert Affine.scale(0).is_degenerate
-        assert Affine.scale(-10, 0).is_degenerate
-        assert Affine.scale(0, 300).is_degenerate
-        assert Affine.scale(0).is_degenerate
-        assert Affine.scale(0).is_degenerate
-
-    def test_column_vectors(self):
-        a, b, c = Affine(2, 3, 4, 5, 6, 7).column_vectors
-        assert isinstance(a, tuple)
-        assert isinstance(b, tuple)
-        assert isinstance(c, tuple)
-        assert a == (2, 5)
-        assert b == (3, 6)
-        assert c == (4, 7)
-
-    def test_almost_equals(self):
-        EPSILON = 1e-5
-        E = EPSILON * 0.5
-        t = Affine(1.0, E, 0, -E, 1.0 + E, E)
-        assert t.almost_equals(Affine.identity())
-        assert Affine.identity().almost_equals(t)
-        assert t.almost_equals(t)
-        t = Affine(1.0, 0, 0, -EPSILON, 1.0, 0)
-        assert not t.almost_equals(Affine.identity())
-        assert not Affine.identity().almost_equals(t)
-        assert t.almost_equals(t)
-
-    def test_almost_equals_2(self):
-        EPSILON = 1e-10
-        E = EPSILON * 0.5
-        t = Affine(1.0, E, 0, -E, 1.0 + E, E)
-        assert t.almost_equals(Affine.identity(), precision=EPSILON)
-        assert Affine.identity().almost_equals(t, precision=EPSILON)
-        assert t.almost_equals(t, precision=EPSILON)
-        t = Affine(1.0, 0, 0, -EPSILON, 1.0, 0)
-        assert not t.almost_equals(Affine.identity(), precision=EPSILON)
-        assert not Affine.identity().almost_equals(t, precision=EPSILON)
-        assert t.almost_equals(t, precision=EPSILON)
-
-    def test_equality(self):
-        t1 = Affine(1, 2, 3, 4, 5, 6)
-        t2 = Affine(6, 5, 4, 3, 2, 1)
-        t3 = Affine(1, 2, 3, 4, 5, 6)
-        assert t1 == t3
-        assert not t1 == t2
-        assert t2 == t2
-        assert not t1 != t3
-        assert not t2 != t2
-        assert t1 != t2
-        assert not t1 == 1
-        assert t1 != 1
-
-    def test_gt(self):
-        with pytest.raises(TypeError):
-            Affine(1, 2, 3, 4, 5, 6) > Affine(6, 5, 4, 3, 2, 1)
-
-    def test_lt(self):
-        with pytest.raises(TypeError):
-            Affine(1, 2, 3, 4, 5, 6) < Affine(6, 5, 4, 3, 2, 1)
-
-    def test_add(self):
-        with pytest.raises(TypeError):
-            Affine(1, 2, 3, 4, 5, 6) + Affine(6, 5, 4, 3, 2, 1)
-
-    def test_sub(self):
-        with pytest.raises(TypeError):
-            Affine(1, 2, 3, 4, 5, 6) - Affine(6, 5, 4, 3, 2, 1)
-
-    def test_mul_by_identity(self):
-        t = Affine(1, 2, 3, 4, 5, 6)
-        assert tuple(t * Affine.identity()) == tuple(t)
-
-    def test_mul_transform(self):
-        t = Affine.rotation(5) * Affine.rotation(29)
-        assert isinstance(t, Affine)
-        seq_almost_equal(t, Affine.rotation(34))
-        t = Affine.scale(3, 5) * Affine.scale(2)
-        seq_almost_equal(t, Affine.scale(6, 10))
-
-    def test_itransform(self):
-        pts = [(4, 1), (-1, 0), (3, 2)]
-        r = Affine.scale(-2).itransform(pts)
-        assert r is None, r
-        assert pts == [(-8, -2), (2, 0), (-6, -4)]
-
-        A = Affine.rotation(33)
-        pts = [(4, 1), (-1, 0), (3, 2)]
-        pts_expect = [A * pt for pt in pts]
-        r = A.itransform(pts)
-        assert r is None
-        assert pts == pts_expect
-
-    def test_mul_wrong_type(self):
-        with pytest.raises(TypeError):
-            Affine(1, 2, 3, 4, 5, 6) * None
-
-    def test_mul_sequence_wrong_member_types(self):
-        class NotPtSeq:
-            @classmethod
-            def from_points(cls, points):
-                list(points)
-
-            def __iter__(self):
-                yield 0
-
-        with pytest.raises(TypeError):
-            Affine(1, 2, 3, 4, 5, 6) * NotPtSeq()
-
-    def test_imul_transform(self):
-        t = Affine.translation(3, 5)
-        t *= Affine.translation(-2, 3.5)
-        assert isinstance(t, Affine)
-        seq_almost_equal(t, Affine.translation(1, 8.5))
-
-    def test_inverse(self):
-        seq_almost_equal(~Affine.identity(), Affine.identity())
-        seq_almost_equal(~Affine.translation(2, -3), Affine.translation(-2, 3))
-        seq_almost_equal(~Affine.rotation(-33.3), Affine.rotation(33.3))
-        t = Affine(1, 2, 3, 4, 5, 6)
-        seq_almost_equal(~t * t, Affine.identity())
-
-    def test_cant_invert_degenerate(self):
-        t = Affine.scale(0)
-        with pytest.raises(affine.TransformNotInvertibleError):
-            ~t
-
-    def test_bad_type_world(self):
-        """wrong type, i.e don't use readlines()"""
-        with pytest.raises(TypeError):
-            affine.loadsw(["1.0", "0.0", "0.0", "1.0", "0.0", "0.0"])
-
-    def test_bad_value_world(self):
-        """Wrong number of parameters."""
-        with pytest.raises(ValueError):
-            affine.loadsw("1.0\n0.0\n0.0\n1.0\n0.0\n0.0\n0.0")
-
-    def test_simple_world(self):
-        s = "1.0\n0.0\n0.0\n-1.0\n100.5\n199.5\n"
-        a = affine.loadsw(s)
-        assert a == Affine(1.0, 0.0, 100.0, 0.0, -1.0, 200.0)
-        assert affine.dumpsw(a) == s
-
-    def test_real_world(self):
-        s = dedent(
-            """\
-                 39.9317755024
-                 30.0907511581
-                 30.0907511576
-                -39.9317755019
-            2658137.2266720217
-            5990821.7039887439"""
-        )  # no EOL
-        a1 = affine.loadsw(s)
-        assert a1.almost_equals(
-            Affine(
-                39.931775502364644,
-                30.090751157602412,
-                2658102.2154086917,
-                30.090751157602412,
-                -39.931775502364644,
-                5990826.624500916,
-            )
-        )
-        a1out = affine.dumpsw(a1)
-        assert isinstance(a1out, str)
-        a2 = affine.loadsw(a1out)
-        assert a1.almost_equals(a2)
-
-
-# We're using pytest for tests added after 1.0 and don't need unittest
-# test case classes.
-
-
-def test_gdal():
-    t = Affine.from_gdal(-237481.5, 425.0, 0.0, 237536.4, 0.0, -425.0)
-    assert t.c == t.xoff == -237481.5
-    assert t.a == 425.0
-    assert t.b == 0.0
-    assert t.f == t.yoff == 237536.4
-    assert t.d == 0.0
-    assert t.e == -425.0
-    assert tuple(t) == (425.0, 0.0, -237481.5, 0.0, -425.0, 237536.4, 0, 0, 1)
-    assert t.to_gdal() == (-237481.5, 425.0, 0.0, 237536.4, 0.0, -425.0)
-
-
-def test_shapely():
-    t = Affine(425.0, 0.0, -237481.5, 0.0, -425.0, 237536.4)
-    assert t.to_shapely() == (425.0, 0.0, 0.0, -425, -237481.5, 237536.4)
-
-
-def test_imul_number():
-    t = Affine(1, 2, 3, 4, 5, 6)
-    try:
-        t *= 2.0
-    except TypeError:
-        assert True
-
-
-def test_mul_tuple():
-    t = Affine(1, 2, 3, 4, 5, 6)
-    t * (2.0, 2.0)
-
-
-def test_rmul_tuple():
-    with pytest.warns(DeprecationWarning):
-        t = Affine(1, 2, 3, 4, 5, 6)
-        (2.0, 2.0) * t
-
-
-def test_transform_precision():
-    t = Affine.rotation(45.0)
-    assert t.precision == EPSILON
-    t.precision = 1e-10
-    assert t.precision == 1e-10
-    assert Affine.rotation(0.0).precision == EPSILON
-
-
-def test_associative():
-    point = (12, 5)
-    trans = Affine.translation(-10.0, -5.0)
-    rot90 = Affine.rotation(90.0)
-    result1 = rot90 * (trans * point)
-    result2 = (rot90 * trans) * point
-    seq_almost_equal(result1, (0.0, 2.0))
-    seq_almost_equal(result1, result2)
-
-
-def test_roundtrip():
-    point = (12, 5)
-    trans = Affine.translation(3, 4)
-    rot37 = Affine.rotation(37.0)
-    point_prime = (trans * rot37) * point
-    roundtrip_point = ~(trans * rot37) * point_prime
-    seq_almost_equal(point, roundtrip_point)
-
-
-def test_eccentricity():
-    assert Affine.identity().eccentricity == 0.0
-    assert Affine.scale(2).eccentricity == 0.0
-    # assert_equal(Affine.scale(0).eccentricity, ?)
-    assert Affine.scale(2, 1).eccentricity == pytest.approx(math.sqrt(3) / 2)
-    assert Affine.scale(2, 3).eccentricity == pytest.approx(math.sqrt(5) / 3)
-    assert Affine.scale(1, 0).eccentricity == 1.0
-    assert Affine.rotation(77).eccentricity == pytest.approx(0.0)
-    assert Affine.translation(32, -47).eccentricity == pytest.approx(0.0)
-    assert Affine.scale(-1, 1).eccentricity == pytest.approx(0.0)
-
-
-def test_eccentricity_complex():
-    assert (Affine.scale(2, 3) * Affine.rotation(77)).eccentricity == pytest.approx(
-        math.sqrt(5) / 3
-    )
-    assert (Affine.rotation(77) * Affine.scale(2, 3)).eccentricity == pytest.approx(
-        math.sqrt(5) / 3
-    )
-    assert (
-        Affine.translation(32, -47) * Affine.rotation(77) * Affine.scale(2, 3)
-    ).eccentricity == pytest.approx(math.sqrt(5) / 3)
-
-
-def test_rotation_angle():
-    assert Affine.identity().rotation_angle == 0.0
-    assert Affine.scale(2).rotation_angle == 0.0
-    assert Affine.scale(2, 1).rotation_angle == 0.0
-    assert Affine.translation(32, -47).rotation_angle == pytest.approx(0.0)
-    assert Affine.rotation(30).rotation_angle == pytest.approx(30)
-    assert Affine.rotation(-150).rotation_angle == pytest.approx(-150)
-
-
-def test_rotation_improper():
-    with pytest.raises(affine.UndefinedRotationError):
-        Affine.scale(-1, 1).rotation_angle
-
-
-# See gh-71 for bug report motivating this test.
-def test_mul_fallback_unpack():
-    """Support fallback in case that other is a single object."""
-
-    class TextPoint:
-        """Not iterable, will trigger ValueError in Affine.__mul__."""
-
-        def __rmul__(self, other):
-            return other * (1, 2)
-
-    assert Affine.identity() * TextPoint() == (1, 2)
-
-
-# See gh-71 for bug report motivating this test.
-def test_mul_fallback_type_error():
-    """Support fallback in case that other is an unexpected type."""
-
-    class TextPoint:
-        """Iterable, but values trigger TypeError in Affine.__mul__."""
-
-        def __iter__(self):
-            return ("1", "2")
-
-        def __rmul__(self, other):
-            return other * (1, 2)
-
-    assert Affine.identity() * TextPoint() == (1, 2)
-
-
-if __name__ == "__main__":
-    unittest.main()


=====================================
docs/Makefile
=====================================
@@ -0,0 +1,20 @@
+# Minimal makefile for Sphinx documentation
+#
+
+# You can set these variables from the command line, and also
+# from the environment for the first two.
+SPHINXOPTS    ?=
+SPHINXBUILD   ?= sphinx-build
+SOURCEDIR     = src
+BUILDDIR      = build
+
+# Put it first so that "make" without argument is like "make help".
+help:
+	@$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
+
+.PHONY: help Makefile
+
+# Catch-all target: route all unknown targets to Sphinx using the new
+# "make mode" option.  $(O) is meant as a shortcut for $(SPHINXOPTS).
+%: Makefile
+	@$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)


=====================================
docs/make.bat
=====================================
@@ -0,0 +1,35 @@
+ at ECHO OFF
+
+pushd %~dp0
+
+REM Command file for Sphinx documentation
+
+if "%SPHINXBUILD%" == "" (
+	set SPHINXBUILD=sphinx-build
+)
+set SOURCEDIR=src
+set BUILDDIR=build
+
+%SPHINXBUILD% >NUL 2>NUL
+if errorlevel 9009 (
+	echo.
+	echo.The 'sphinx-build' command was not found. Make sure you have Sphinx
+	echo.installed, then set the SPHINXBUILD environment variable to point
+	echo.to the full path of the 'sphinx-build' executable. Alternatively you
+	echo.may add the Sphinx directory to PATH.
+	echo.
+	echo.If you don't have Sphinx installed, grab it from
+	echo.https://www.sphinx-doc.org/
+	exit /b 1
+)
+
+if "%1" == "" goto help
+
+%SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O%
+goto end
+
+:help
+%SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O%
+
+:end
+popd


=====================================
docs/requirements.txt
=====================================
@@ -0,0 +1,2 @@
+attrs
+sphinx==9.1.0


=====================================
docs/src/_static/.gitkeep
=====================================


=====================================
docs/src/conf.py
=====================================
@@ -0,0 +1,40 @@
+# Configuration file for the Sphinx documentation builder.
+#
+# For the full list of built-in configuration values, see the documentation:
+# https://www.sphinx-doc.org/en/master/usage/configuration.html
+import os
+import sys
+
+sys.path.append(os.path.abspath("../../src"))
+
+# -- Project information -----------------------------------------------------
+# https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information
+
+project = "rasterio/affine"
+project_copyright = "%Y, Sean Gillies"
+author = "Sean Gillies"
+release = "development"
+
+# -- General configuration ---------------------------------------------------
+# https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration
+
+extensions = [
+    "sphinx.ext.autodoc",
+    "sphinx.ext.coverage",
+    "sphinx.ext.ifconfig",
+    "sphinx.ext.napoleon",  # NumPy doc style.
+    "sphinx.ext.todo",
+    "sphinx.ext.viewcode",
+]
+
+templates_path = ["_templates"]
+exclude_patterns = ["build", "Thumbs.db", ".DS_Store"]
+
+# -- Options for HTML output -------------------------------------------------
+# https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output
+
+html_theme = "nature"
+html_static_path = ["_static"]
+
+# If this is not None, a 'Last updated on:' timestamp is inserted at every page bottom.
+html_last_updated_fmt = ""


=====================================
docs/src/index.rst
=====================================
@@ -0,0 +1,28 @@
+Welcome to rasterio/affine's documentation!
+===========================================
+
+This is the documentation of the latest development version at https://github.com/rasterio/affine .
+
+.. include:: ../../README.rst
+
+Module documentation
+====================
+
+.. automodule:: affine
+   :members:
+
+.. include:: ../../CHANGES.txt
+.. include:: ../../AUTHORS.txt
+
+License
+=======
+
+.. include:: ../../LICENSE.txt
+   :literal:
+
+Indices and tables
+==================
+
+* :ref:`genindex`
+* :ref:`modindex`
+* :ref:`search`


=====================================
pyproject.toml
=====================================
@@ -18,19 +18,22 @@ classifiers = [
     "Programming Language :: Python :: 3 :: Only",
     "Topic :: Multimedia :: Graphics :: Graphics Conversion",
     "Topic :: Scientific/Engineering :: GIS",
+    "Typing :: Typed",
 ]
 license = {text = "BSD-3-Clause"}
-requires-python = ">=3.7"
+requires-python = ">=3.9"
+dependencies = [
+    "attrs",
+]
 
 [project.optional-dependencies]
 test = [
-    "pytest >=4.6",
+    "numpy>=1.20",
+    "pytest>=6.0",
     "pytest-cov",
 ]
 dev = [
-    "pydocstyle",
-    "flake8",
-    "coveralls",
+    "coveralls>=3.0",
 ]
 
 [project.urls]
@@ -41,4 +44,39 @@ include = [
     "AUTHORS.txt",
     "CHANGES.txt",
     "LICENSE.txt",
+    "docs/",
+    "tests/",
+]
+exclude = ["docs/build/**"]
+
+[tool.pytest.ini_options]
+pythonpath = "src"
+testpaths = ["tests"]
+
+[tool.ruff.lint]
+select = [
+    "B",  # flake8-bugbear
+    "D",  # pydocstyle
+    "E", "W",  # pycodestyle
+    "F",  # Pyflakes
+    "I",  # isort
+    "NPY", # NumPy-specific rules
+    "PT", # flake8-pytest-style
+    "RET", # flake8-return
+    "RUF", # Ruff-specific rules
+    "SIM", # flake8-simplify
+    "UP", # pyupgrade
 ]
+ignore = [
+    "D105",  # Missing docstring in magic method
+]
+
+[tool.ruff.lint.per-file-ignores]
+"tests/**.py" = ["B", "D"]
+"docs/**.py" = ["D"]
+
+[tool.ruff.lint.isort]
+force-sort-within-sections = true
+
+[tool.ruff.lint.pydocstyle]
+convention = "numpy"


=====================================
setup.cfg deleted
=====================================
@@ -1,6 +0,0 @@
-[tool:pytest]
-testpaths: affine/tests
-
-[pydocstyle]
-select: D1
-add-ignore: D105


=====================================
affine/__init__.py → src/affine/__init__.py
=====================================
@@ -1,4 +1,4 @@
-"""Affine transformation matrices
+"""Affine transformation matrices.
 
 The Affine package is derived from Casey Duncan's Planar package. See the
 copyright statement below.
@@ -32,16 +32,22 @@ copyright statement below.
 # EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
 #############################################################################
 
-from collections import namedtuple
+from __future__ import annotations
+
+from collections.abc import MutableSequence, Sequence
+from functools import cached_property
 import math
+from typing import overload
 import warnings
 
+from attrs import astuple, define, field
 
 __all__ = ["Affine"]
 __author__ = "Sean Gillies"
-__version__ = "2.4.0"
+__version__ = "3.0.0"
 
 EPSILON: float = 1e-5
+EPSILON2: float = 1e-10
 
 
 class AffineError(Exception):
@@ -49,33 +55,14 @@ class AffineError(Exception):
 
 
 class TransformNotInvertibleError(AffineError):
-    """The transform could not be inverted"""
+    """The transform could not be inverted."""
 
 
 class UndefinedRotationError(AffineError):
-    """The rotation angle could not be computed for this transform"""
-
-
-def cached_property(func):
-    """Special property decorator that caches the computed
-    property value in the object's instance dict the first
-    time it is accessed.
-    """
-    name = func.__name__
-    doc = func.__doc__
-
-    def getter(self, name=name):
-        try:
-            return self.__dict__[name]
-        except KeyError:
-            self.__dict__[name] = value = func(self)
-            return value
-
-    getter.func_name = name
-    return property(getter, doc=doc)
+    """The rotation angle could not be computed for this transform."""
 
 
-def cos_sin_deg(deg: float):
+def cos_sin_deg(deg: float) -> tuple[float, float]:
     """Return the cosine and sin for the given angle in degrees.
 
     With special-case handling of multiples of 90 for perfect right
@@ -84,272 +71,297 @@ def cos_sin_deg(deg: float):
     deg = deg % 360.0
     if deg == 90.0:
         return 0.0, 1.0
-    elif deg == 180.0:
+    if deg == 180.0:
         return -1.0, 0
-    elif deg == 270.0:
+    if deg == 270.0:
         return 0, -1.0
     rad = math.radians(deg)
     return math.cos(rad), math.sin(rad)
 
 
-class Affine(namedtuple("Affine", ("a", "b", "c", "d", "e", "f", "g", "h", "i"))):
+ at define(frozen=True)
+class Affine:
     """Two dimensional affine transform for 2D linear mapping.
 
+    Parallel lines are preserved by these transforms. Affine transforms
+    can perform any combination of translations, scales/flips, shears,
+    and rotations. Class methods are provided to conveniently compose
+    transforms from these operations.
+
     Parameters
     ----------
     a, b, c, d, e, f : float
-        Coefficients of an augmented affine transformation matrix
-
-        | x' |   | a  b  c | | x |
-        | y' | = | d  e  f | | y |
-        | 1  |   | 0  0  1 | | 1 |
+        Coefficients of the 3 x 3 augmented affine transformation matrix.
 
-        `a`, `b`, and `c` are the elements of the first row of the
-        matrix. `d`, `e`, and `f` are the elements of the second row.
+    g, h, i : float, optional
+        Coefficients of the 3 x 3 augmented affine transformation matrix.
 
     Attributes
     ----------
     a, b, c, d, e, f, g, h, i : float
-        The coefficients of the 3x3 augmented affine transformation
-        matrix
+        Coefficients of the 3 x 3 augmented affine transformation matrix.
 
-        | x' |   | a  b  c | | x |
-        | y' | = | d  e  f | | y |
-        | 1  |   | g  h  i | | 1 |
+        .. code-block:: none
+
+            | x' |   | a  b  c | | x |
+            | y' | = | d  e  f | | y |
+            | 1  |   | g  h  i | | 1 |
 
         `g`, `h`, and `i` are always 0, 0, and 1.
 
-    The Affine package is derived from Casey Duncan's Planar package.
-    See the copyright statement below.  Parallel lines are preserved by
-    these transforms. Affine transforms can perform any combination of
-    translations, scales/flips, shears, and rotations.  Class methods
-    are provided to conveniently compose transforms from these
-    operations.
-
-    Internally the transform is stored as a 3x3 transformation matrix.
-    The transform may be constructed directly by specifying the first
-    two rows of matrix values as 6 floats. Since the matrix is an affine
-    transform, the last row is always ``(0, 0, 1)``.
-
-    N.B.: multiplication of a transform and an (x, y) vector *always*
-    returns the column vector that is the matrix multiplication product
-    of the transform and (x, y) as a column vector, no matter which is
-    on the left or right side. This is obviously not the case for
-    matrices and vectors in general, but provides a convenience for
-    users of this class.
+    Notes
+    -----
+    Multiplication of a transform and an (x, y) vector *always* returns
+    the column vector that is the matrix multiplication product of the
+    transform and (x, y) as a column vector, no matter which is on the
+    left or right side. This is obviously not the case for matrices and
+    vectors in general, but provides a convenience for users of this
+    class.
 
+    The Affine package is derived from Casey Duncan's Planar package.
+    See the copyright statement.
     """
 
-    precision = EPSILON
-
-    def __new__(
-        cls,
-        a: float,
-        b: float,
-        c: float,
-        d: float,
-        e: float,
-        f: float,
-        g: float = 0.0,
-        h: float = 0.0,
-        i: float = 1.0,
-    ):
-        """Create a new object
+    a: float = field(converter=float)
+    b: float = field(converter=float)
+    c: float = field(converter=float)
+    d: float = field(converter=float)
+    e: float = field(converter=float)
+    f: float = field(converter=float)
 
-        Parameters
-        ----------
-        a, b, c, d, e, f : float
-            Elements of an augmented affine transformation matrix.
-        """
-        return tuple.__new__(
-            cls,
-            (
-                a * 1.0,
-                b * 1.0,
-                c * 1.0,
-                d * 1.0,
-                e * 1.0,
-                f * 1.0,
-                g * 1.0,
-                h * 1.0,
-                i * 1.0,
-            ),
-        )
+    # The class has 3 attributes that don't have to be specified: g, h,
+    # and i. If they are, the given value has to be the same as the
+    # default value. This allows a new instances to be created from the
+    # tuple form of another, like Affine(*Affine.identity()).
+
+    g: float = field(default=0.0, converter=float)
+
+    @g.validator
+    def _check_g(self, attribute, value):
+        if value != 0.0:
+            raise ValueError("g must be equal to 0.0")
+
+    h: float = field(default=0.0, converter=float)
+
+    @h.validator
+    def _check_h(self, attribute, value):
+        if value != 0.0:
+            raise ValueError("h must be equal to 0.0")
+
+    i: float = field(default=1.0, converter=float)
+
+    @i.validator
+    def _check_i(self, attribute, value):
+        if value != 1.0:
+            raise ValueError("i must be equal to 1.0")
 
     @classmethod
-    def from_gdal(cls, c: float, a: float, b: float, f: float, d: float, e: float):
+    def from_gdal(
+        cls, c: float, a: float, b: float, f: float, d: float, e: float
+    ) -> Affine:
         """Use same coefficient order as GDAL's GetGeoTransform().
 
-        :param c, a, b, f, d, e: 6 floats ordered by GDAL.
-        :rtype: Affine
+        Parameters
+        ----------
+        c, a, b, f, d, e : float
+            Parameters ordered by GDAL's GeoTransform.
+
+        Returns
+        -------
+        Affine
         """
-        return cls.__new__(cls, a, b, c, d, e, f)
+        return cls(a, b, c, d, e, f)
 
     @classmethod
-    def identity(cls):
+    def identity(cls) -> Affine:
         """Return the identity transform.
 
-        :rtype: Affine
+        Returns
+        -------
+        Affine
         """
         return identity
 
     @classmethod
-    def translation(cls, xoff: float, yoff: float):
+    def translation(cls, xoff: float, yoff: float) -> Affine:
         """Create a translation transform from an offset vector.
 
-        :param xoff: Translation x offset.
-        :type xoff: float
-        :param yoff: Translation y offset.
-        :type yoff: float
-        :rtype: Affine
+        Parameters
+        ----------
+        xoff, yoff : float
+            Translation offsets in x and y directions.
+
+        Returns
+        -------
+        Affine
         """
-        return tuple.__new__(cls, (1.0, 0.0, xoff, 0.0, 1.0, yoff, 0.0, 0.0, 1.0))
+        return cls(1.0, 0.0, xoff, 0.0, 1.0, yoff)
 
     @classmethod
-    def scale(cls, *scaling):
+    def scale(cls, *scaling: float) -> Affine:
         """Create a scaling transform from a scalar or vector.
 
-        :param scaling: The scaling factor. A scalar value will
-            scale in both dimensions equally. A vector scaling
-            value scales the dimensions independently.
-        :type scaling: float or sequence
-        :rtype: Affine
+        Parameters
+        ----------
+        *scaling : float or sequence of two floats
+            One or two scaling factors. A scalar value will scale in
+            both dimensions equally. A vector scaling value scales the
+            dimensions independently.
+
+        Returns
+        -------
+        Affine
         """
         if len(scaling) == 1:
-            sx = sy = float(scaling[0])
+            sx = scaling[0]
+            sy = sx
         else:
             sx, sy = scaling
-        return tuple.__new__(cls, (sx, 0.0, 0.0, 0.0, sy, 0.0, 0.0, 0.0, 1.0))
+        return cls(sx, 0.0, 0.0, 0.0, sy, 0.0)
 
     @classmethod
-    def shear(cls, x_angle: float = 0, y_angle: float = 0):
+    def shear(cls, x_angle: float = 0.0, y_angle: float = 0.0) -> Affine:
         """Create a shear transform along one or both axes.
 
-        :param x_angle: Shear angle in degrees parallel to the x-axis.
-        :type x_angle: float
-        :param y_angle: Shear angle in degrees parallel to the y-axis.
-        :type y_angle: float
-        :rtype: Affine
+        Parameters
+        ----------
+        x_angle, y_angle : float
+            Shear angles in degrees parallel to the x- and y-axis.
+
+        Returns
+        -------
+        Affine
         """
         mx = math.tan(math.radians(x_angle))
         my = math.tan(math.radians(y_angle))
-        return tuple.__new__(cls, (1.0, mx, 0.0, my, 1.0, 0.0, 0.0, 0.0, 1.0))
+        return cls(1.0, mx, 0.0, my, 1.0, 0.0)
 
     @classmethod
-    def rotation(cls, angle: float, pivot=None):
+    def rotation(cls, angle: float, pivot: Sequence[float] | None = None) -> Affine:
         """Create a rotation transform at the specified angle.
 
-        A pivot point other than the coordinate system origin may be
-        optionally specified.
-
-        :param angle: Rotation angle in degrees, counter-clockwise
-            about the pivot point.
-        :type angle: float
-        :param pivot: Point to rotate about, if omitted the rotation is
-            about the origin.
-        :type pivot: sequence
-        :rtype: Affine
+        Parameters
+        ----------
+        angle : float
+            Rotation angle in degrees, counter-clockwise about the pivot
+            point.
+        pivot : sequence of float (px, py), optional
+            Pivot point coordinates to rotate around. If None (default),
+            the pivot point is the coordinate system origin (0.0, 0.0).
+
+        Returns
+        -------
+        Affine
         """
         ca, sa = cos_sin_deg(angle)
         if pivot is None:
-            return tuple.__new__(cls, (ca, -sa, 0.0, sa, ca, 0.0, 0.0, 0.0, 1.0))
-        else:
-            px, py = pivot
-            return tuple.__new__(
-                cls,
-                (
-                    ca,
-                    -sa,
-                    px - px * ca + py * sa,
-                    sa,
-                    ca,
-                    py - px * sa - py * ca,
-                    0.0,
-                    0.0,
-                    1.0,
-                ),
-            )
+            return cls(ca, -sa, 0.0, sa, ca, 0.0)
+        px, py = pivot
+        # fmt: off
+        return cls(
+            ca, -sa, px - px * ca + py * sa,
+            sa, ca, py - px * sa - py * ca,
+        )
+        # fmt: on
 
     @classmethod
-    def permutation(cls, *scaling):
-        """Create the permutation transform
+    def permutation(cls, *scaling: float) -> Affine:
+        """Create the permutation transform.
 
         For 2x2 matrices, there is only one permutation matrix that is
         not the identity.
 
-        :rtype: Affine
-        """
+        Parameters
+        ----------
+        *scaling : any
+            Ignored.
 
-        return tuple.__new__(cls, (0.0, 1.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0))
+        Returns
+        -------
+        Affine
+        """
+        return cls(0.0, 1.0, 0.0, 1.0, 0.0, 0.0)
 
     def __str__(self) -> str:
         """Concise string representation."""
         return (
-            "|% .2f,% .2f,% .2f|\n" "|% .2f,% .2f,% .2f|\n" "|% .2f,% .2f,% .2f|"
-        ) % self
+            f"|{self.a: .2f},{self.b: .2f},{self.c: .2f}|\n"
+            f"|{self.d: .2f},{self.e: .2f},{self.f: .2f}|\n"
+            f"|{self.g: .2f},{self.h: .2f},{self.i: .2f}|"
+        )
 
     def __repr__(self) -> str:
         """Precise string representation."""
-        return ("Affine(%r, %r, %r,\n" "       %r, %r, %r)") % self[:6]
+        return (
+            f"Affine({self.a!r}, {self.b!r}, {self.c!r},\n"
+            f"       {self.d!r}, {self.e!r}, {self.f!r})"
+        )
 
-    def to_gdal(self):
-        """Return same coefficient order as GDAL's SetGeoTransform().
+    def to_gdal(self) -> tuple[float, float, float, float, float, float]:
+        """Return same coefficient order expected by GDAL's SetGeoTransform().
 
-        :rtype: tuple
+        Returns
+        -------
+        tuple
+            Ordered: c, a, b, f, d, e.
         """
         return (self.c, self.a, self.b, self.f, self.d, self.e)
 
-    def to_shapely(self):
-        """Return an affine transformation matrix compatible with shapely
-
-        Shapely's affinity module expects an affine transformation matrix
-        in (a,b,d,e,xoff,yoff) order.
+    def to_shapely(self) -> tuple[float, float, float, float, float, float]:
+        """Return affine transformation parameters for shapely's affinity module.
 
-        :rtype: tuple
+        Returns
+        -------
+        tuple
+            Ordered: a, b, d, e, c, f.
         """
-        return (self.a, self.b, self.d, self.e, self.xoff, self.yoff)
+        return (self.a, self.b, self.d, self.e, self.c, self.f)
 
     @property
     def xoff(self) -> float:
-        """Alias for 'c'"""
+        """Alias for 'c'."""
         return self.c
 
     @property
     def yoff(self) -> float:
-        """Alias for 'f'"""
+        """Alias for 'f'."""
         return self.f
 
     @cached_property
     def determinant(self) -> float:
-        """The determinant of the transform matrix.
+        """Evaluate the determinant of the transform matrix.
 
         This value is equal to the area scaling factor when the
         transform is applied to a shape.
+
+        Returns
+        -------
+        float
         """
-        a, b, c, d, e, f, g, h, i = self
-        return a * e - b * d
+        return self.a * self.e - self.b * self.d
 
     @property
-    def _scaling(self):
+    def _scaling(self) -> tuple[float, float]:
         """The absolute scaling factors of the transformation.
 
-        This tuple represents the absolute value of the scaling factors of the
-        transformation, sorted from bigger to smaller.
+        This tuple represents the absolute value of the scaling factors
+        of the transformation, sorted from bigger to smaller.
         """
-        a, b, _, d, e, _, _, _, _ = self
+        a, b, d, e = self.a, self.b, self.d, self.e
 
         # The singular values are the square root of the eigenvalues
         # of the matrix times its transpose, M M*
         # Computing trace and determinant of M M*
-        trace = a ** 2 + b ** 2 + d ** 2 + e ** 2
-        det = (a * e - b * d) ** 2
+        trace = a**2 + b**2 + d**2 + e**2
+        det2 = (a * e - b * d) ** 2
 
-        delta = trace ** 2 / 4 - det
-        if delta < 1e-12:
-            delta = 0
+        delta = trace**2 / 4.0 - det2
+        if delta < EPSILON2:
+            delta = 0.0
 
-        l1 = math.sqrt(trace / 2 + math.sqrt(delta))
-        l2 = math.sqrt(trace / 2 - math.sqrt(delta))
+        sqrt_delta = math.sqrt(delta)
+        l1 = math.sqrt(trace / 2.0 + sqrt_delta)
+        l2 = math.sqrt(trace / 2.0 - sqrt_delta)
         return l1, l2
 
     @property
@@ -359,36 +371,37 @@ class Affine(namedtuple("Affine", ("a", "b", "c", "d", "e", "f", "g", "h", "i"))
         This value represents the eccentricity of an ellipse under
         this affine transformation.
 
-        Raises NotImplementedError for improper transformations.
+        Raises
+        ------
+        NotImplementedError
+            For improper transformations.
         """
         l1, l2 = self._scaling
-        return math.sqrt(l1 ** 2 - l2 ** 2) / l1
+        return math.sqrt(l1**2 - l2**2) / l1
 
     @property
     def rotation_angle(self) -> float:
         """The rotation angle in degrees of the affine transformation.
 
-        This is the rotation angle in degrees of the affine transformation,
-        assuming it is in the form M = R S, where R is a rotation and S is a
-        scaling.
+        This is the rotation angle in degrees of the affine
+        transformation, assuming it is in the form M = R S, where R is
+        a rotation and S is a scaling.
 
-        Raises UndefinedRotationError for improper and degenerate
-        transformations.
+        Raises
+        ------
+        UndefinedRotationError
+            For improper and degenerate transformations.
         """
-        a, b, _, c, d, _, _, _, _ = self
         if self.is_proper or self.is_degenerate:
             l1, _ = self._scaling
-            y, x = c / l1, a / l1
-            return math.atan2(y, x) * 180 / math.pi
-        else:
-            raise UndefinedRotationError
+            y, x = self.d / l1, self.a / l1
+            return math.degrees(math.atan2(y, x))
+        raise UndefinedRotationError
 
     @property
     def is_identity(self) -> bool:
-        """True if this transform equals the identity matrix,
-        within rounding limits.
-        """
-        return self is identity or self.almost_equals(identity, self.precision)
+        """True if this transform equals the identity matrix, within rounding limits."""
+        return self is identity or self.almost_equals(identity, EPSILON)
 
     @property
     def is_rectilinear(self) -> bool:
@@ -397,9 +410,8 @@ class Affine(namedtuple("Affine", ("a", "b", "c", "d", "e", "f", "g", "h", "i"))
         i.e., whether a shape would remain axis-aligned, within rounding
         limits, after applying the transform.
         """
-        a, b, c, d, e, f, g, h, i = self
-        return (abs(a) < self.precision and abs(e) < self.precision) or (
-            abs(d) < self.precision and abs(b) < self.precision
+        return (abs(self.a) < EPSILON and abs(self.e) < EPSILON) or (
+            abs(self.d) < EPSILON and abs(self.b) < EPSILON
         )
 
     @property
@@ -410,8 +422,7 @@ class Affine(namedtuple("Affine", ("a", "b", "c", "d", "e", "f", "g", "h", "i"))
         transform, within rounding limits.  This implies that the
         transform has no effective shear.
         """
-        a, b, c, d, e, f, g, h, i = self
-        return abs(a * b + d * e) < self.precision
+        return abs(self.a * self.b + self.d * self.e) < EPSILON
 
     @property
     def is_orthonormal(self) -> bool:
@@ -423,48 +434,83 @@ class Affine(namedtuple("Affine", ("a", "b", "c", "d", "e", "f", "g", "h", "i"))
         and unit-length.  Applying an orthonormal transform to a shape
         always results in a congruent shape.
         """
-        a, b, c, d, e, f, g, h, i = self
+        a, b, d, e = self.a, self.b, self.d, self.e
         return (
             self.is_conformal
-            and abs(1.0 - (a * a + d * d)) < self.precision
-            and abs(1.0 - (b * b + e * e)) < self.precision
+            and abs(1.0 - (a * a + d * d)) < EPSILON
+            and abs(1.0 - (b * b + e * e)) < EPSILON
         )
 
     @cached_property
     def is_degenerate(self) -> bool:
-        """True if this transform is degenerate.
+        """Return True if this transform is degenerate.
+
+        A degenerate transform will collapse a shape to an effective area
+        of zero, and cannot be inverted.
 
-        Which means that it will collapse a shape to an effective area
-        of zero. Degenerate transforms cannot be inverted.
+        Returns
+        -------
+        bool
         """
         return self.determinant == 0.0
 
     @cached_property
     def is_proper(self) -> bool:
-        """True if this transform is proper.
+        """Return True if this transform is proper.
 
-        Which means that it does not include reflection.
+        A proper transform (with a positive determinant) does not include
+        reflection.
+
+        Returns
+        -------
+        bool
         """
         return self.determinant > 0.0
 
     @property
-    def column_vectors(self):
-        """The values of the transform as three 2D column vectors"""
-        a, b, c, d, e, f, _, _, _ = self
-        return (a, d), (b, e), (c, f)
+    def column_vectors(
+        self,
+    ) -> tuple[tuple[float, float], tuple[float, float], tuple[float, float]]:
+        """The values of the transform as three 2D column vectors.
+
+        Returns
+        -------
+        tuple of three tuple pairs
+            Ordered (a, d), (b, e), (c, f).
+        """
+        return (self.a, self.d), (self.b, self.e), (self.c, self.f)
 
-    def almost_equals(self, other, precision: float = EPSILON) -> bool:
+    def almost_equals(self, other: Affine, precision: float | None = None) -> bool:
         """Compare transforms for approximate equality.
 
-        :param other: Transform being compared.
-        :type other: Affine
-        :return: True if absolute difference between each element
-            of each respective transform matrix < ``self.precision``.
+        Parameters
+        ----------
+        other : Affine
+            Transform being compared.
+        precision : float, default EPSILON
+            Precision to use to evaluate equality.
+
+        Returns
+        -------
+        bool
+            True if absolute difference between each element
+            of each respective transform matrix < ``precision``.
         """
-        for i in (0, 1, 2, 3, 4, 5):
-            if abs(self[i] - other[i]) >= precision:
-                return False
-        return True
+        precision = precision or EPSILON
+        return all(abs(sv - ov) < precision for sv, ov in zip(self, other))
+
+    @cached_property
+    def _astuple(self) -> tuple[float]:
+        return astuple(self)
+
+    def __getitem__(self, index):
+        return self._astuple[index]
+
+    def __iter__(self):
+        return iter(self._astuple)
+
+    def __len__(self):
+        return 9
 
     def __gt__(self, other) -> bool:
         return NotImplemented
@@ -480,104 +526,164 @@ class Affine(namedtuple("Affine", ("a", "b", "c", "d", "e", "f", "g", "h", "i"))
 
     __iadd__ = __add__
 
-    def __mul__(self, other):
-        """Multiplication
+    @overload
+    def __matmul__(self, other: Affine) -> Affine: ...
+    @overload
+    def __matmul__(self, other: tuple[float, float]) -> tuple[float, float]: ...
+    @overload
+    def __matmul__(
+        self, other: tuple[float, float, float]
+    ) -> tuple[float, float, float]: ...
+    # For other float sequences, we don't know the returned tuple length here
+    @overload
+    def __matmul__(self, other: Sequence[float]) -> tuple[float, ...]: ...
+    def __matmul__(self, other):
+        """Matrix multiplication.
 
         Apply the transform using matrix multiplication, creating
         a resulting object of the same type.  A transform may be applied
         to another transform, a vector, vector array, or shape.
 
-        :param other: The object to transform.
-        :type other: Affine, :class:`~planar.Vec2`,
-            :class:`~planar.Vec2Array`, :class:`~planar.Shape`
-        :rtype: Same as ``other``
+        Parameters
+        ----------
+        other : Affine or iterable of (vx, vy, [vw])
+
+        Returns
+        -------
+        Affine or a tuple of two or three items
         """
-        sa, sb, sc, sd, se, sf, _, _, _ = self
+        sa, sb, sc, sd, se, sf = self[:6]
         if isinstance(other, Affine):
-            oa, ob, oc, od, oe, of, _, _, _ = other
-            return tuple.__new__(
-                self.__class__,
-                (
-                    sa * oa + sb * od,
-                    sa * ob + sb * oe,
-                    sa * oc + sb * of + sc,
-                    sd * oa + se * od,
-                    sd * ob + se * oe,
-                    sd * oc + se * of + sf,
-                    0.0,
-                    0.0,
-                    1.0,
-                ),
+            oa, ob, oc, od, oe, of = other[:6]
+            return self.__class__(
+                sa * oa + sb * od,
+                sa * ob + sb * oe,
+                sa * oc + sb * of + sc,
+                sd * oa + se * od,
+                sd * ob + se * oe,
+                sd * oc + se * of + sf,
             )
-        else:
+        # vector of 2 or 3 items
+        try:
+            num_items = len(other)
+        except (TypeError, ValueError):
+            return NotImplemented
+        if num_items == 2:
+            vx, vy = other
+        elif num_items == 3:
+            vx, vy, vw = other
+            vw_eq_one = vw == 1.0
             try:
-                vx, vy = other
-                return (vx * sa + vy * sb + sc, vx * sd + vy * se + sf)
-            except (ValueError, TypeError):
-                return NotImplemented
+                is_eq_one = bool(vw_eq_one)
+                msg = "third value must be 1.0"
+            except ValueError:
+                is_eq_one = (vw_eq_one).all()
+                msg = "third values must all be 1.0"
+            if not is_eq_one:
+                raise ValueError(msg)
+        else:
+            raise TypeError("expected vector of 2 or 3 items")
+        px = vx * sa + vy * sb + sc
+        py = vx * sd + vy * se + sf
+        if num_items == 2:
+            return (px, py)
+        return (px, py, vw)
+
+    def __rmatmul__(self, other):
+        return NotImplemented
 
-    def __rmul__(self, other):
-        """Right hand multiplication
+    def __imatmul__(self, other):  # type: ignore
+        if not isinstance(other, Affine):
+            raise TypeError("Operation not supported")
+        return NotImplemented
 
-        .. deprecated:: 2.3.0
-            Right multiplication will be prohibited in version 3.0. This method
-            will raise AffineError.
+    @overload
+    def __mul__(self, other: Affine) -> Affine: ...
+    @overload
+    def __mul__(self, other: tuple[float, float]) -> tuple[float, float]: ...
+    def __mul__(self, other):
+        """Multiplication.
 
-        Notes
-        -----
-        We should not be called if other is an affine instance This is
-        just a guarantee, since we would potentially return the wrong
-        answer in that case.
-        """
-        warnings.warn(
-            "Right multiplication will be prohibited in version 3.0",
-            DeprecationWarning,
-            stacklevel=2,
-        )
-        assert not isinstance(other, Affine)
-        return self.__mul__(other)
+        Apply the transform using matrix multiplication, creating
+        a resulting object of the same type.  A transform may be applied
+        to another transform, a vector, vector array, or shape.
 
-    def __imul__(self, other):
-        if isinstance(other, Affine) or isinstance(other, tuple):
-            return self.__mul__(other)
-        else:
+        Parameters
+        ----------
+        other : Affine or iterable of (vx, vy)
+
+        Returns
+        -------
+        Affine or a tuple of two items
+        """
+        # TODO: consider enabling this for 3.1
+        # warnings.warn(
+        #     "Use `@` matmul instead of `*` mul operator for matrix multiplication",
+        #     PendingDeprecationWarning,
+        #     stacklevel=2,
+        # )
+        if isinstance(other, Affine):
+            return self.__matmul__(other)
+        try:
+            _, _ = other
+            return self.__matmul__(other)
+        except (ValueError, TypeError):
             return NotImplemented
 
-    def itransform(self, seq) -> None:
-        """Transform a sequence of points or vectors in place.
+    def __rmul__(self, other):
+        return NotImplemented
+
+    def __imul__(self, other):  # type: ignore
+        if isinstance(other, tuple):
+            warnings.warn(
+                "in-place multiplication with tuple is deprecated",
+                DeprecationWarning,
+                stacklevel=2,
+            )
+        return NotImplemented
+
+    def itransform(self, seq: MutableSequence[Sequence[float]]) -> None:
+        """Transform a sequence of points or vectors in-place.
+
+        Parameters
+        ----------
+        seq : mutable sequence
 
-        :param seq: Mutable sequence of :class:`~planar.Vec2` to be
-            transformed.
-        :returns: None, the input sequence is mutated in place.
+        Returns
+        -------
+        None
+            The input sequence is mutated in-place.
         """
         if self is not identity and self != identity:
-            sa, sb, sc, sd, se, sf, _, _, _ = self
+            sa, sb, sc, sd, se, sf = self[:6]
             for i, (x, y) in enumerate(seq):
                 seq[i] = (x * sa + y * sb + sc, x * sd + y * se + sf)
 
     def __invert__(self):
         """Return the inverse transform.
 
-        :raises: :except:`TransformNotInvertible` if the transform
-            is degenerate.
+        Raises
+        ------
+        TransformNotInvertible
+            If the transform is degenerate.
         """
         if self.is_degenerate:
             raise TransformNotInvertibleError("Cannot invert degenerate transform")
         idet = 1.0 / self.determinant
-        sa, sb, sc, sd, se, sf, _, _, _ = self
+        sa, sb, sc, sd, se, sf = self[:6]
         ra = se * idet
         rb = -sb * idet
         rd = -sd * idet
         re = sa * idet
-        return tuple.__new__(
-            self.__class__,
-            (ra, rb, -sc * ra - sf * rb, rd, re, -sc * rd - sf * re, 0.0, 0.0, 1.0),
+        # fmt: off
+        return self.__class__(
+            ra, rb, -sc * ra - sf * rb,
+            rd, re, -sc * rd - sf * re,
         )
-
-    __hash__ = tuple.__hash__  # hash is not inherited in Py 3
+        # fmt: on
 
     def __getnewargs__(self):
-        """Pickle protocol support
+        """Pickle protocol support.
 
         Notes
         -----
@@ -585,7 +691,7 @@ class Affine(namedtuple("Affine", ("a", "b", "c", "d", "e", "f", "g", "h", "i"))
         9 elements rather than the 6 that are required for the
         constructor.  This method ensures that only the 6 are provided.
         """
-        return self.a, self.b, self.c, self.d, self.e, self.f
+        return self[:6]
 
 
 identity = Affine(1, 0, 0, 0, 1, 0)
@@ -594,32 +700,71 @@ identity = Affine(1, 0, 0, 0, 1, 0)
 # Miscellaneous utilities
 
 
-def loadsw(s: str):
-    """Returns Affine from the contents of a world file string.
+def loadsw(s: str) -> Affine:
+    """Return Affine from the contents of a world file string.
 
     This method also translates the coefficients from center- to
     corner-based coordinates.
 
-    :param s: str with 6 floats ordered in a world file.
-    :rtype: Affine
+    Parameters
+    ----------
+    s : str
+        String with 6 floats ordered in a world file.
+
+    Returns
+    -------
+    Affine
     """
     if not hasattr(s, "split"):
         raise TypeError("Cannot split input string")
     coeffs = s.split()
     if len(coeffs) != 6:
-        raise ValueError("Expected 6 coefficients, found %d" % len(coeffs))
+        raise ValueError(f"Expected 6 coefficients, found {len(coeffs)}")
     a, d, b, e, c, f = (float(x) for x in coeffs)
-    center = tuple.__new__(Affine, [a, b, c, d, e, f, 0.0, 0.0, 1.0])
-    return center * Affine.translation(-0.5, -0.5)
+    center = Affine(a, b, c, d, e, f)
+    return center @ Affine.translation(-0.5, -0.5)
 
 
-def dumpsw(obj) -> str:
+def dumpsw(obj: Affine) -> str:
     """Return string for a world file.
 
     This method also translates the coefficients from corner- to
     center-based coordinates.
 
-    :rtype: str
+    Returns
+    -------
+    str
     """
-    center = obj * Affine.translation(0.5, 0.5)
+    center = obj @ Affine.translation(0.5, 0.5)
     return "\n".join(repr(getattr(center, x)) for x in list("adbecf")) + "\n"
+
+
+def set_epsilon(epsilon: float) -> None:
+    """Set the global absolute error value and rounding limit.
+
+    This value is accessible via the affine.EPSILON global variable.
+
+    Parameters
+    ----------
+    epsilon : float
+        The global absolute error value and rounding limit for
+        approximate floating point comparison operations.
+
+    Returns
+    -------
+    None
+
+    Notes
+    -----
+    The default value of ``0.00001`` is suitable for values that are in
+    the "countable range". You may need a larger epsilon when using
+    large absolute values, and a smaller value for very small values
+    close to zero. Otherwise approximate comparison operations will not
+    behave as expected.
+    """
+    global EPSILON, EPSILON2
+    EPSILON = float(epsilon)
+    EPSILON2 = EPSILON**2
+
+
+set_epsilon(1e-5)


=====================================
src/affine/py.typed
=====================================


=====================================
affine/tests/__init__.py → tests/__init__.py
=====================================


=====================================
tests/test_numpy.py
=====================================
@@ -0,0 +1,132 @@
+"""Test interoperability with NumPy."""
+
+import pytest
+
+from affine import Affine
+
+try:
+    import numpy as np
+    from numpy import testing
+except ImportError:
+    pytest.skip("requires numpy", allow_module_level=True)
+
+
+def test_array():
+    """Affine instance has 9 elements, becomes 1-D Numpy array."""
+    assert np.array(Affine.identity()).shape == (9,)
+
+
+def test_linalg():
+    # cross-check properties with numpy's linear algebra module
+    ar = np.array(
+        [
+            [0, -2, 2],
+            [3, 0, 5],
+            [0, 0, 1],
+        ]
+    )
+    tfm = Affine(*ar.flatten())
+    assert tfm.determinant == pytest.approx(6.0)
+    assert np.linalg.det(ar) == pytest.approx(6.0)
+
+    expected_inv = np.array(
+        [
+            [0, 1 / 3, -5 / 3],
+            [-1 / 2, 0, 1],
+            [0, 0, 1],
+        ]
+    )
+    testing.assert_allclose(np.array(~tfm).reshape(3, 3), expected_inv)
+    testing.assert_allclose(np.linalg.inv(ar), expected_inv)
+
+
+def test_matmul_3x3_array():
+    A = Affine(2, 0, 3, 0, 3, 2)
+    Ar = np.array(A).reshape(3, 3)
+
+    # matrix @ matrix = matrix
+    res = A @ Affine.identity()
+    assert isinstance(res, Affine)
+    testing.assert_equal(np.array(res).reshape(3, 3), Ar)
+    res = Ar @ np.eye(3)
+    assert isinstance(res, np.ndarray)
+    testing.assert_equal(res, Ar)
+
+
+def test_matmul_vector():
+    A = Affine(2, 0, 3, 0, 3, 2)
+    Ar = np.array(A).reshape(3, 3)
+
+    # matrix @ vector = vector
+    v = (2, 3, 1)
+    vr = np.array(v)
+    expected_p = (7, 11, 1)
+    res = A @ v
+    assert isinstance(res, tuple)
+    testing.assert_equal(res, expected_p)
+    res = A @ vr
+    assert isinstance(res, tuple)
+    testing.assert_equal(res, expected_p)
+    res = Ar @ vr
+    assert isinstance(res, np.ndarray)
+    testing.assert_equal(res, expected_p)
+
+
+def test_matmul_items():
+    A = Affine(2, 0, 3, 0, 3, 2) @ Affine.rotation(45)
+    shape = (4, 5)
+    vx, vy = np.meshgrid(np.arange(shape[1]), np.arange(shape[0]))
+    expected_px = np.array(
+        [
+            [3.0, 4.4142137, 5.8284273, 7.2426405, 8.656855],
+            [1.5857865, 3.0, 4.4142137, 5.8284273, 7.2426405],
+            [0.17157288, 1.5857865, 3.0, 4.4142137, 5.8284273],
+            [-1.2426407, 0.17157288, 1.5857865, 3.0, 4.4142137],
+        ]
+    )
+    expected_py = np.array(
+        [
+            [2.0, 4.1213202, 6.2426405, 8.363961, 10.485281],
+            [4.1213202, 6.2426405, 8.363961, 10.485281, 12.606602],
+            [6.2426405, 8.363961, 10.485281, 12.606602, 14.727922],
+            [8.363961, 10.485281, 12.606602, 14.727922, 16.849243],
+        ]
+    )
+    px, py = A @ (vx, vy)
+    testing.assert_allclose(px, expected_px)
+    testing.assert_allclose(py, expected_py)
+
+    px, py, pw = A @ (vx, vy, 1)
+    testing.assert_allclose(px, expected_px)
+    testing.assert_allclose(py, expected_py)
+    assert pw == 1
+
+    px, py, pw = A @ (vx, vy, np.ones(shape))
+    testing.assert_allclose(px, expected_px)
+    testing.assert_allclose(py, expected_py)
+    testing.assert_array_equal(pw, np.ones(shape))
+
+    # see GH-125 with mul `*` op
+    px, py = A * (vx, vy)
+    testing.assert_allclose(px, expected_px)
+    testing.assert_allclose(py, expected_py)
+
+
+def test_matmul_item_errors():
+    shape = (4, 5)
+    vx, vy = np.meshgrid(np.arange(shape[1]), np.arange(shape[0]))
+    with pytest.raises(ValueError, match=r"third value must be 1.0"):
+        Affine.identity() @ (vx, vy, 2)
+    with pytest.raises(ValueError, match=r"third values must all be 1.0"):
+        Affine.identity() @ (vx, vy, np.zeros(shape))
+    with pytest.raises(TypeError, match="2 or 3 items"):
+        Affine.identity() @ (vx, vy, np.ones(shape), np.ones(shape))
+
+
+def test_mul_item_errors():
+    shape = (4, 5)
+    vx, vy = np.meshgrid(np.arange(shape[1]), np.arange(shape[0]))
+    with pytest.raises(TypeError):
+        Affine.identity() * (vx, vy, 1)
+    with pytest.raises(TypeError):
+        Affine.identity() * (vx, vy, np.zeros(shape))


=====================================
affine/tests/test_pickle.py → tests/test_pickle.py
=====================================
@@ -2,9 +2,8 @@
 Validate that instances of `affine.Affine()` can be pickled and unpickled.
 """
 
-
-import pickle
 from multiprocessing import Pool
+import pickle
 
 import affine
 


=====================================
affine/tests/test_rotation.py → tests/test_rotation.py
=====================================
@@ -1,5 +1,7 @@
 import math
 
+import pytest
+
 from affine import Affine
 
 
@@ -14,7 +16,7 @@ def test_rotation_angle():
         |
         0---------*
 
-    Affine.rotation(45.0) * (1.0, 0.0) == (0.707..., 0.707...)
+    Affine.rotation(45.0) @ (1.0, 0.0) == (0.707..., 0.707...)
 
         |
         |      *
@@ -22,9 +24,10 @@ def test_rotation_angle():
         |
         0----------
     """
-    x, y = Affine.rotation(45.0) * (1.0, 0.0)
-    assert round(x, 14) == round(math.sqrt(2.0) / 2.0, 14)
-    assert round(y, 14) == round(math.sqrt(2.0) / 2.0, 14)
+    x, y = Affine.rotation(45.0) @ (1.0, 0.0)
+    sqrt2div2 = math.sqrt(2.0) / 2.0
+    assert x == pytest.approx(sqrt2div2)
+    assert y == pytest.approx(sqrt2div2)
 
 
 def test_rotation_matrix():
@@ -34,12 +37,16 @@ def test_rotation_matrix():
     | sin(a)  cos(a) |
 
     """
-    rot = Affine.rotation(90.0)
-    assert round(rot.a, 15) == round(math.cos(math.pi / 2.0), 15)
-    assert round(rot.b, 15) == round(-math.sin(math.pi / 2.0), 15)
+    deg = 90.0
+    rot = Affine.rotation(deg)
+    rad = math.radians(deg)
+    cosrad = math.cos(rad)
+    sinrad = math.sin(rad)
+    assert rot.a == pytest.approx(cosrad)
+    assert rot.b == pytest.approx(-sinrad)
     assert rot.c == 0.0
-    assert round(rot.d, 15) == round(math.sin(math.pi / 2.0), 15)
-    assert round(rot.e, 15) == round(math.cos(math.pi / 2.0), 15)
+    assert rot.d == pytest.approx(sinrad)
+    assert rot.e == pytest.approx(cosrad)
     assert rot.f == 0.0
 
 
@@ -48,8 +55,8 @@ def test_rotation_matrix_pivot():
     rot = Affine.rotation(90.0, pivot=(1.0, 1.0))
     exp = (
         Affine.translation(1.0, 1.0)
-        * Affine.rotation(90.0)
-        * Affine.translation(-1.0, -1.0)
+        @ Affine.rotation(90.0)
+        @ Affine.translation(-1.0, -1.0)
     )
     for r, e in zip(rot, exp):
-        assert round(r, 15) == round(e, 15)
+        assert r == pytest.approx(e)


=====================================
affine/tests/test_serialize.py → tests/test_serialize.py
=====================================


=====================================
tests/test_transform.py
=====================================
@@ -0,0 +1,658 @@
+#############################################################################
+# Planar is Copyright (c) 2010 by Casey Duncan
+# 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(s) of the copyright holders nor the names of its
+#   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 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 HOLDERS 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.
+#############################################################################
+
+"""Transform unit tests"""
+
+import math
+from textwrap import dedent
+
+import pytest
+
+import affine
+from affine import Affine
+
+
+def seq_almost_equal(t1, t2, error=0.00001):
+    assert len(t1) == len(t2), f"{t1!r} != {t2!r}"
+    for m1, m2 in zip(t1, t2):
+        assert abs(m1 - m2) <= error, f"{t1!r} != {t2!r}"
+
+
+def test_zero_args():
+    with pytest.raises(TypeError):
+        Affine()
+
+
+def test_wrong_arg_type():
+    with pytest.raises(TypeError):
+        Affine(None)
+
+
+def test_args_too_few():
+    with pytest.raises(TypeError):
+        Affine(1, 2)
+
+
+def test_args_too_many():
+    with pytest.raises(TypeError):
+        Affine(*range(10))
+
+
+def test_args_members_wrong_type():
+    with pytest.raises(TypeError):
+        Affine(0, 2, 3, None, None, "")
+
+
+def test_len():
+    t = Affine(1, 2, 3, 4, 5, 6)
+    assert len(t) == 9
+
+
+def test_slice_last_row():
+    t = Affine(1, 2, 3, 4, 5, 6)
+    assert t[-3:] == (0, 0, 1)
+
+
+def test_members():
+    t = Affine(1, 2, 3, 4, 5, 6)
+    assert t.a == 1
+    assert t.b == 2
+    assert t.c == 3
+    assert t.d == 4
+    assert t.e == 5
+    assert t.f == 6
+    assert t.g == 0
+    assert t.h == 0
+    assert t.i == 1
+    # these are aliases
+    assert t.c is t.xoff
+    assert t.f is t.yoff
+
+
+def test_members_are_floats():
+    t = Affine(1, 2, 3, 4, 5, 6)
+    for m in t:
+        assert isinstance(m, float), repr(m)
+
+
+def test_getitem():
+    t = Affine(1, 2, 3, 4, 5, 6)
+    assert t[0] == 1
+    assert t[1] == 2
+    assert t[2] == 3
+    assert t[3] == 4
+    assert t[4] == 5
+    assert t[5] == 6
+    assert t[6] == 0
+    assert t[7] == 0
+    assert t[8] == 1
+    assert t[-1] == 1
+
+
+def test_getitem_wrong_type():
+    t = Affine(1, 2, 3, 4, 5, 6)
+    with pytest.raises(TypeError):
+        t["foobar"]
+
+
+def test_str():
+    t = Affine(1.111, 2.222, 3.333, -4.444, -5.555, 6.666)
+    assert str(t) == dedent(
+        """\
+        | 1.11, 2.22, 3.33|
+        |-4.44,-5.55, 6.67|
+        | 0.00, 0.00, 1.00|"""
+    )
+
+
+def test_repr():
+    t = Affine(1.111, 2.222, 3.456, 4.444, 5.5, 6.25)
+    assert repr(t) == dedent(
+        """\
+        Affine(1.111, 2.222, 3.456,
+               4.444, 5.5, 6.25)"""
+    )
+
+
+def test_identity_constructor():
+    ident = Affine.identity()
+    assert isinstance(ident, Affine)
+    assert tuple(ident) == (1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0)
+    assert ident.is_identity
+
+
+def test_permutation_constructor():
+    perm = Affine.permutation()
+    assert isinstance(perm, Affine)
+    assert tuple(perm) == (0.0, 1.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0)
+    assert (perm @ perm).is_identity
+
+
+def test_translation_constructor():
+    trans = Affine.translation(2, -5)
+    assert isinstance(trans, Affine)
+    assert tuple(trans) == (1.0, 0.0, 2.0, 0.0, 1.0, -5.0, 0.0, 0.0, 1.0)
+
+
+def test_scale_constructor():
+    scale = Affine.scale(5)
+    assert isinstance(scale, Affine)
+    assert tuple(scale) == (5, 0, 0, 0, 5, 0, 0, 0, 1)
+    scale = Affine.scale(-1, 2)
+    assert tuple(scale) == (-1, 0, 0, 0, 2, 0, 0, 0, 1)
+    assert tuple(Affine.scale(1)) == tuple(Affine.identity())
+
+
+def test_shear_constructor():
+    shear = Affine.shear(30)
+    assert isinstance(shear, Affine)
+    mx = math.tan(math.radians(30))
+    seq_almost_equal(tuple(shear), (1, mx, 0, 0, 1, 0, 0, 0, 1))
+    shear = Affine.shear(-15, 60)
+    mx = math.tan(math.radians(-15))
+    my = math.tan(math.radians(60))
+    seq_almost_equal(tuple(shear), (1, mx, 0, my, 1, 0, 0, 0, 1))
+    shear = Affine.shear(y_angle=45)
+    seq_almost_equal(tuple(shear), (1, 0, 0, 1, 1, 0, 0, 0, 1))
+
+
+def test_rotation_constructor():
+    rot = Affine.rotation(60)
+    assert isinstance(rot, Affine)
+    r = math.radians(60)
+    s, c = math.sin(r), math.cos(r)
+    assert tuple(rot) == (c, -s, 0, s, c, 0, 0, 0, 1)
+    rot = Affine.rotation(337)
+    r = math.radians(337)
+    s, c = math.sin(r), math.cos(r)
+    seq_almost_equal(tuple(rot), (c, -s, 0, s, c, 0, 0, 0, 1))
+    assert tuple(Affine.rotation(0)) == tuple(Affine.identity())
+
+
+def test_rotation_constructor_quadrants():
+    assert tuple(Affine.rotation(0)) == (1, 0, 0, 0, 1, 0, 0, 0, 1)
+    assert tuple(Affine.rotation(90)) == (0, -1, 0, 1, 0, 0, 0, 0, 1)
+    assert tuple(Affine.rotation(180)) == (-1, 0, 0, 0, -1, 0, 0, 0, 1)
+    assert tuple(Affine.rotation(-180)) == (-1, 0, 0, 0, -1, 0, 0, 0, 1)
+    assert tuple(Affine.rotation(270)) == (0, 1, 0, -1, 0, 0, 0, 0, 1)
+    assert tuple(Affine.rotation(-90)) == (0, 1, 0, -1, 0, 0, 0, 0, 1)
+    assert tuple(Affine.rotation(360)) == (1, 0, 0, 0, 1, 0, 0, 0, 1)
+    assert tuple(Affine.rotation(450)) == (0, -1, 0, 1, 0, 0, 0, 0, 1)
+    assert tuple(Affine.rotation(-450)) == (0, 1, 0, -1, 0, 0, 0, 0, 1)
+
+
+def test_rotation_constructor_with_pivot():
+    assert tuple(Affine.rotation(60)) == tuple(Affine.rotation(60, pivot=(0, 0)))
+    rot = Affine.rotation(27, pivot=(2, -4))
+    r = math.radians(27)
+    s, c = math.sin(r), math.cos(r)
+    # fmt: off
+    assert tuple(rot) == (
+        c, -s, 2 - 2 * c - 4 * s,
+        s, c, -4 - 2 * s + 4 * c,
+        0, 0, 1,
+    )
+    # fmt: on
+    assert tuple(Affine.rotation(0, (-3, 2))) == tuple(Affine.identity())
+
+
+def test_rotation_contructor_wrong_arg_types():
+    with pytest.raises(TypeError):
+        Affine.rotation(1, 1)
+
+
+def test_determinant():
+    assert Affine.identity().determinant == 1
+    assert Affine.scale(2).determinant == 4
+    assert Affine.scale(0).determinant == 0
+    assert Affine.scale(5, 1).determinant == 5
+    assert Affine.scale(-1, 1).determinant == -1
+    assert Affine.scale(-1, 0).determinant == 0
+    assert Affine.rotation(77).determinant == pytest.approx(1)
+    assert Affine.translation(32, -47).determinant == pytest.approx(1)
+
+
+def test_is_rectilinear():
+    assert Affine.identity().is_rectilinear
+    assert Affine.scale(2.5, 6.1).is_rectilinear
+    assert Affine.translation(4, -1).is_rectilinear
+    assert Affine.rotation(90).is_rectilinear
+    assert not Affine.shear(4, -1).is_rectilinear
+    assert not Affine.rotation(-26).is_rectilinear
+
+
+def test_is_conformal():
+    assert Affine.identity().is_conformal
+    assert Affine.scale(2.5, 6.1).is_conformal
+    assert Affine.translation(4, -1).is_conformal
+    assert Affine.rotation(90).is_conformal
+    assert Affine.rotation(-26).is_conformal
+    assert not Affine.shear(4, -1).is_conformal
+
+
+def test_is_orthonormal():
+    assert Affine.identity().is_orthonormal
+    assert Affine.translation(4, -1).is_orthonormal
+    assert Affine.rotation(90).is_orthonormal
+    assert Affine.rotation(-26).is_orthonormal
+    assert not Affine.scale(2.5, 6.1).is_orthonormal
+    assert not Affine.scale(0.5, 2).is_orthonormal
+    assert not Affine.shear(4, -1).is_orthonormal
+
+
+def test_is_degenerate():
+    assert not Affine.identity().is_degenerate
+    assert not Affine.translation(2, -1).is_degenerate
+    assert not Affine.shear(0, -22.5).is_degenerate
+    assert not Affine.rotation(88.7).is_degenerate
+    assert not Affine.scale(0.5).is_degenerate
+    assert Affine.scale(0).is_degenerate
+    assert Affine.scale(-10, 0).is_degenerate
+    assert Affine.scale(0, 300).is_degenerate
+    assert Affine.scale(0).is_degenerate
+    assert Affine.scale(0).is_degenerate
+
+
+def test_column_vectors():
+    a, b, c = Affine(2, 3, 4, 5, 6, 7).column_vectors
+    assert isinstance(a, tuple)
+    assert isinstance(b, tuple)
+    assert isinstance(c, tuple)
+    assert a == (2, 5)
+    assert b == (3, 6)
+    assert c == (4, 7)
+
+
+def test_almost_equals():
+    EPSILON = 1e-5
+    E = EPSILON * 0.5
+    t = Affine(1.0, E, 0, -E, 1.0 + E, E)
+    assert t.almost_equals(Affine.identity())
+    assert Affine.identity().almost_equals(t)
+    assert t.almost_equals(t)
+    t = Affine(1.0, 0, 0, -EPSILON, 1.0, 0)
+    assert not t.almost_equals(Affine.identity())
+    assert not Affine.identity().almost_equals(t)
+    assert t.almost_equals(t)
+
+
+def test_almost_equals_2():
+    EPSILON = 1e-10
+    E = EPSILON * 0.5
+    t = Affine(1.0, E, 0, -E, 1.0 + E, E)
+    assert t.almost_equals(Affine.identity(), precision=EPSILON)
+    assert Affine.identity().almost_equals(t, precision=EPSILON)
+    assert t.almost_equals(t, precision=EPSILON)
+    t = Affine(1.0, 0, 0, -EPSILON, 1.0, 0)
+    assert not t.almost_equals(Affine.identity(), precision=EPSILON)
+    assert not Affine.identity().almost_equals(t, precision=EPSILON)
+    assert t.almost_equals(t, precision=EPSILON)
+
+
+def test_equality():
+    t1 = Affine(1, 2, 3, 4, 5, 6)
+    t2 = Affine(6, 5, 4, 3, 2, 1)
+    t3 = Affine(1, 2, 3, 4, 5, 6)
+    assert t1 == t3
+    assert t1 != t2
+    assert t2 == t2
+    assert t1 == t3
+    assert t2 == t2
+    assert t1 != t2
+    assert t1 != 1
+    assert t1 != 1
+
+
+def test_gt():
+    with pytest.raises(TypeError):
+        Affine(1, 2, 3, 4, 5, 6) > Affine(6, 5, 4, 3, 2, 1)
+
+
+def test_lt():
+    with pytest.raises(TypeError):
+        Affine(1, 2, 3, 4, 5, 6) < Affine(6, 5, 4, 3, 2, 1)
+
+
+def test_add():
+    with pytest.raises(TypeError):
+        Affine(1, 2, 3, 4, 5, 6) + Affine(6, 5, 4, 3, 2, 1)
+
+
+def test_sub():
+    with pytest.raises(TypeError):
+        Affine(1, 2, 3, 4, 5, 6) - Affine(6, 5, 4, 3, 2, 1)
+
+
+def test_matmul_by_identity():
+    t = Affine(1, 2, 3, 4, 5, 6)
+    assert tuple(t @ Affine.identity()) == tuple(t)
+
+
+def test_matmul_transform():
+    t = Affine.rotation(5) @ Affine.rotation(29)
+    assert isinstance(t, Affine)
+    seq_almost_equal(t, Affine.rotation(34))
+    t = Affine.scale(3, 5) @ Affine.scale(2)
+    seq_almost_equal(t, Affine.scale(6, 10))
+
+
+def test_itransform():
+    pts = [(4, 1), (-1, 0), (3, 2)]
+    r = Affine.scale(-2).itransform(pts)
+    assert r is None, r
+    assert pts == [(-8, -2), (2, 0), (-6, -4)]
+
+    A = Affine.rotation(33)
+    pts = [(4, 1), (-1, 0), (3, 2)]
+    pts_expect = [A @ pt for pt in pts]
+    r = A.itransform(pts)
+    assert r is None
+    assert pts == pts_expect
+
+
+def test_mul_wrong_type():
+    with pytest.raises(TypeError):
+        Affine(1, 2, 3, 4, 5, 6) * None
+
+
+def test_matmul_wrong_type():
+    with pytest.raises(TypeError):
+        Affine(1, 2, 3, 4, 5, 6) @ None
+
+
+def test_matmul_sequence_wrong_member_types():
+    class NotPtSeq:
+        @classmethod
+        def from_points(cls, points):
+            list(points)
+
+        def __iter__():
+            yield 0
+
+    with pytest.raises(TypeError):
+        Affine(1, 2, 3, 4, 5, 6) * NotPtSeq()
+
+    with pytest.raises(TypeError):
+        Affine(1, 2, 3, 4, 5, 6) @ NotPtSeq()
+
+
+def test_imul_errors():
+    t = Affine.identity()
+    with pytest.raises(TypeError):
+        t *= 2.0
+
+
+def test_imatmul_errors():
+    t = Affine.identity()
+    with pytest.raises(TypeError):
+        t @= 2.0
+    with pytest.raises(TypeError):
+        t @= (1, 2)
+    with pytest.raises(TypeError):
+        t @= (1, 2, 1)
+
+
+def test_imul_tuple():
+    t = Affine.translation(3, 5)
+    with pytest.deprecated_call():
+        t *= (4, 5)
+    assert isinstance(t, tuple)  # changes type!
+    assert t == (7.0, 10.0)
+
+
+def test_imul_transform():
+    t = Affine.translation(3, 5)
+    t *= Affine.translation(-2, 3.5)
+    assert isinstance(t, Affine)
+    seq_almost_equal(t, Affine.translation(1, 8.5))
+
+
+def test_imatmul_transform():
+    t = Affine.translation(3, 5)
+    t @= Affine.translation(-2, 3.5)
+    assert isinstance(t, Affine)
+    seq_almost_equal(t, Affine.translation(1, 8.5))
+
+
+def test_inverse():
+    seq_almost_equal(~Affine.identity(), Affine.identity())
+    seq_almost_equal(~Affine.translation(2, -3), Affine.translation(-2, 3))
+    seq_almost_equal(~Affine.rotation(-33.3), Affine.rotation(33.3))
+    t = Affine(1, 2, 3, 4, 5, 6)
+    seq_almost_equal(~t @ t, Affine.identity())
+
+
+def test_cant_invert_degenerate():
+    t = Affine.scale(0)
+    with pytest.raises(affine.TransformNotInvertibleError):
+        ~t
+
+
+def test_bad_type_world():
+    """wrong type, i.e don't use readlines()"""
+    with pytest.raises(TypeError):
+        affine.loadsw(["1.0", "0.0", "0.0", "1.0", "0.0", "0.0"])
+
+
+def test_bad_value_world():
+    """Wrong number of parameters."""
+    with pytest.raises(ValueError, match="Expected 6 coefficients"):
+        affine.loadsw("1.0\n0.0\n0.0\n1.0\n0.0\n0.0\n0.0")
+
+
+def test_simple_world():
+    s = "1.0\n0.0\n0.0\n-1.0\n100.5\n199.5\n"
+    a = affine.loadsw(s)
+    assert a == Affine(1.0, 0.0, 100.0, 0.0, -1.0, 200.0)
+    assert affine.dumpsw(a) == s
+
+
+def test_real_world():
+    s = dedent(
+        """\
+                39.9317755024
+                30.0907511581
+                30.0907511576
+            -39.9317755019
+        2658137.2266720217
+        5990821.7039887439"""
+    )  # no EOL
+    a1 = affine.loadsw(s)
+    assert a1.almost_equals(
+        Affine(
+            39.931775502364644,
+            30.090751157602412,
+            2658102.2154086917,
+            30.090751157602412,
+            -39.931775502364644,
+            5990826.624500916,
+        )
+    )
+    a1out = affine.dumpsw(a1)
+    assert isinstance(a1out, str)
+    a2 = affine.loadsw(a1out)
+    assert a1.almost_equals(a2)
+
+
+def test_gdal():
+    t = Affine.from_gdal(-237481.5, 425.0, 0.0, 237536.4, 0.0, -425.0)
+    assert t.c == t.xoff == -237481.5
+    assert t.a == 425.0
+    assert t.b == 0.0
+    assert t.f == t.yoff == 237536.4
+    assert t.d == 0.0
+    assert t.e == -425.0
+    assert tuple(t) == (425.0, 0.0, -237481.5, 0.0, -425.0, 237536.4, 0, 0, 1)
+    assert t.to_gdal() == (-237481.5, 425.0, 0.0, 237536.4, 0.0, -425.0)
+
+
+def test_shapely():
+    t = Affine(425.0, 0.0, -237481.5, 0.0, -425.0, 237536.4)
+    assert t.to_shapely() == (425.0, 0.0, 0.0, -425, -237481.5, 237536.4)
+
+
+def test_rmul_errors():
+    t = Affine.identity()
+    with pytest.raises(TypeError):
+        (1.0, 1.0) * t
+
+
+def test_rmatmul_errors():
+    t = Affine.identity()
+    with pytest.raises(TypeError):
+        (1.0, 1.0) @ t
+    with pytest.raises(TypeError):
+        (1.0, 1.0, 1.0) @ t
+
+
+def test_mul_tuple():
+    t = Affine(1, 2, 3, 4, 5, 6)
+    assert t * (2, 2) == (9, 24)
+    with pytest.raises(TypeError):
+        t * (2, 2, 1)
+
+
+def test_associative():
+    point = (12, 5)
+    trans = Affine.translation(-10.0, -5.0)
+    rot90 = Affine.rotation(90.0)
+    result1 = rot90 @ (trans @ point)
+    result2 = (rot90 @ trans) @ point
+    seq_almost_equal(result1, (0.0, 2.0))
+    seq_almost_equal(result1, result2)
+
+
+def test_roundtrip():
+    point = (12, 5)
+    trans = Affine.translation(3, 4)
+    rot37 = Affine.rotation(37.0)
+    point_prime = (trans @ rot37) @ point
+    roundtrip_point = ~(trans @ rot37) @ point_prime
+    seq_almost_equal(point, roundtrip_point)
+
+
+def test_eccentricity():
+    assert Affine.identity().eccentricity == 0.0
+    assert Affine.scale(2).eccentricity == 0.0
+    # assert_equal(Affine.scale(0).eccentricity, ?)
+    assert Affine.scale(2, 1).eccentricity == pytest.approx(math.sqrt(3) / 2)
+    assert Affine.scale(2, 3).eccentricity == pytest.approx(math.sqrt(5) / 3)
+    assert Affine.scale(1, 0).eccentricity == 1.0
+    assert Affine.rotation(77).eccentricity == pytest.approx(0.0)
+    assert Affine.translation(32, -47).eccentricity == pytest.approx(0.0)
+    assert Affine.scale(-1, 1).eccentricity == pytest.approx(0.0)
+
+
+def test_eccentricity_complex():
+    assert (Affine.scale(2, 3) @ Affine.rotation(77)).eccentricity == pytest.approx(
+        math.sqrt(5) / 3
+    )
+    assert (Affine.rotation(77) @ Affine.scale(2, 3)).eccentricity == pytest.approx(
+        math.sqrt(5) / 3
+    )
+    assert (
+        Affine.translation(32, -47) @ Affine.rotation(77) @ Affine.scale(2, 3)
+    ).eccentricity == pytest.approx(math.sqrt(5) / 3)
+
+
+def test_rotation_angle():
+    assert Affine.identity().rotation_angle == 0.0
+    assert Affine.scale(2).rotation_angle == 0.0
+    assert Affine.scale(2, 1).rotation_angle == 0.0
+    assert Affine.translation(32, -47).rotation_angle == pytest.approx(0.0)
+    assert Affine.rotation(30).rotation_angle == pytest.approx(30)
+    assert Affine.rotation(-150).rotation_angle == pytest.approx(-150)
+
+
+def test_rotation_improper():
+    with pytest.raises(affine.UndefinedRotationError):
+        Affine.scale(-1, 1).rotation_angle
+
+
+# See gh-71 for bug report motivating this test.
+def test_mul_fallback_unpack():
+    """Support fallback in case that other is a single object."""
+
+    class TextPoint:
+        """Not iterable, will trigger ValueError in Affine.__mul__."""
+
+        def __rmul__(self, other):
+            return other * (1, 2)
+
+        def __rmatmul__(self, other):
+            return other @ (1, 2)
+
+    assert Affine.identity() * TextPoint() == (1, 2)
+
+    assert Affine.identity() @ TextPoint() == (1, 2)
+
+
+# See gh-71 for bug report motivating this test.
+def test_mul_fallback_type_error():
+    """Support fallback in case that other is an unexpected type."""
+
+    class TextPoint:
+        """Iterable, but values trigger TypeError in Affine.__mul__."""
+
+        def __iter__(self):
+            return ("1", "2")
+
+        def __rmul__(self, other):
+            return other * (1, 2)
+
+        def __rmatmul__(self, other):
+            return other @ (1, 2)
+
+    assert Affine.identity() * TextPoint() == (1, 2)
+    assert Affine.identity() @ TextPoint() == (1, 2)
+
+
+def test_init_invalid_g():
+    with pytest.raises(ValueError, match="g must"):
+        Affine(0, 0, 0, 0, 0, 0, 1)
+
+
+def test_init_invalid_h():
+    with pytest.raises(ValueError, match="h must"):
+        Affine(0, 0, 0, 0, 0, 0, 0, 1)
+
+
+def test_init_invalid_i():
+    with pytest.raises(ValueError, match="i must"):
+        Affine(0, 0, 0, 0, 0, 0, 0, 0, 0)
+
+
+def test_matmul_invalid_vector():
+    with pytest.raises(ValueError, match=r"must be 1.0"):
+        Affine.identity() @ (2.0, 3.0, 0.0)
+
+
+ at pytest.mark.parametrize("vec", [(2.0,), (2.0, 2.0, 1.0, 1.0)])
+def test_matmul_invalid_vector_2(vec):
+    with pytest.raises(TypeError, match="2 or 3 items"):
+        Affine.identity() @ vec


=====================================
tox.ini
=====================================
@@ -1,11 +1,11 @@
 [tox]
 envlist =
-    py37,py38,py39,py310,py311
+    py39,py310,py311,py312,py313,py314
 
 [testenv]
 usedevelop = true
 deps =
+    numpy
     pytest-cov
-    responses
 commands =
-    python -m pytest affine/tests --cov affine --cov-report term-missing
+    python -m pytest  --cov affine --cov-report term-missing



View it on GitLab: https://salsa.debian.org/debian-gis-team/python-affine/-/commit/8fa9e1be21f3c9a0b0256d02c59b00f19f85cb54

-- 
View it on GitLab: https://salsa.debian.org/debian-gis-team/python-affine/-/commit/8fa9e1be21f3c9a0b0256d02c59b00f19f85cb54
You're receiving this email because of your account on salsa.debian.org. Manage all notifications: https://salsa.debian.org/-/profile/notifications | Help: https://salsa.debian.org/help


-------------- next part --------------
An HTML attachment was scrubbed...
URL: <http://alioth-lists.debian.net/pipermail/pkg-grass-devel/attachments/20260808/45568909/attachment-0001.htm>


More information about the Pkg-grass-devel mailing list