diff -pruN 0.9.2-2/.flake8 1.0.2-1/.flake8
--- 0.9.2-2/.flake8	2022-11-24 19:33:57.000000000 +0000
+++ 1.0.2-1/.flake8	1970-01-01 00:00:00.000000000 +0000
@@ -1,14 +0,0 @@
-[flake8]
-max-line-length = 88
-extend-ignore = E203
-exclude =
-    .git,
-    __pycache__,
-    build,
-    dist,
-    docs/_build,
-    release_new_version.py
-per-file-ignores =
-    # This file uses names from Kajiki's IR language, and confuses
-    # flake8:
-    tests/test_runtime.py: F821
diff -pruN 0.9.2-2/.github/workflows/ci.yaml 1.0.2-1/.github/workflows/ci.yaml
--- 0.9.2-2/.github/workflows/ci.yaml	1970-01-01 00:00:00.000000000 +0000
+++ 1.0.2-1/.github/workflows/ci.yaml	2025-05-05 05:23:26.000000000 +0000
@@ -0,0 +1,30 @@
+name: QA Checks
+on: [push, pull_request]
+jobs:
+  unit-tests:
+    name: Unit Tests
+    runs-on: "ubuntu-24.04"
+    steps:
+      - uses: actions/checkout@v4
+      - name: Install Hatch
+        uses: pypa/hatch@257e27e51a6a5616ed08a39a408a21c35c9931bc
+      - name: Run Tests
+        run: hatch test -a
+  speedtest:
+    name: Speedtest
+    runs-on: "ubuntu-24.04"
+    steps:
+      - uses: actions/checkout@v4
+      - name: Install Hatch
+        uses: pypa/hatch@257e27e51a6a5616ed08a39a408a21c35c9931bc
+      - name: Run Speedtest
+        run: hatch run speedtest:run
+  lint:
+    name: Lint
+    runs-on: "ubuntu-24.04"
+    steps:
+      - uses: actions/checkout@v4
+      - name: Install Hatch
+        uses: pypa/hatch@257e27e51a6a5616ed08a39a408a21c35c9931bc
+      - name: Check Linter
+        run: hatch fmt --check
diff -pruN 0.9.2-2/.github/workflows/docs.yaml 1.0.2-1/.github/workflows/docs.yaml
--- 0.9.2-2/.github/workflows/docs.yaml	2022-11-24 19:33:57.000000000 +0000
+++ 1.0.2-1/.github/workflows/docs.yaml	2025-05-05 05:23:26.000000000 +0000
@@ -5,16 +5,13 @@ jobs:
     name: Build
     runs-on: ubuntu-latest
     steps:
-      - uses: actions/checkout@v3
-      - uses: actions/setup-python@v3
-        with:
-          python-version: "3.10"
-          architecture: x64
-      - run: pip install -e ".[docs]"
-      - run: sphinx-build -a docs/ docs/_build/html
+      - uses: actions/checkout@v4
+      - name: Install Hatch
+        uses: pypa/hatch@257e27e51a6a5616ed08a39a408a21c35c9931bc
+      - run: hatch run docs:build
       - run: touch docs/_build/html/.nojekyll
       - name: Upload Artifacts
-        uses: actions/upload-artifact@v3
+        uses: actions/upload-artifact@v4
         with:
           name: htmldocs
           path: docs/_build/html
@@ -24,9 +21,9 @@ jobs:
     if: "github.event_name == 'push' && github.ref == 'refs/heads/master'"
     runs-on: ubuntu-latest
     steps:
-      - uses: actions/checkout@v3
+      - uses: actions/checkout@v4
       - name: Download Artifacts
-        uses: actions/download-artifact@v3
+        uses: actions/download-artifact@v4
         with:
           name: htmldocs
           path: html
diff -pruN 0.9.2-2/.github/workflows/lint.yaml 1.0.2-1/.github/workflows/lint.yaml
--- 0.9.2-2/.github/workflows/lint.yaml	2022-11-24 19:33:57.000000000 +0000
+++ 1.0.2-1/.github/workflows/lint.yaml	1970-01-01 00:00:00.000000000 +0000
@@ -1,23 +0,0 @@
-name: Lint
-on: [push, pull_request]
-jobs:
-  lint:
-    name: "${{ matrix.tool.name }}"
-    runs-on: ubuntu-latest
-    strategy:
-      matrix:
-        tool:
-          - name: black
-            invocation: black --check --diff .
-          - name: flake8
-            invocation: flake8 .
-          - name: isort
-            invocation: isort --check --diff .
-    steps:
-      - uses: actions/checkout@v3
-      - uses: actions/setup-python@v3
-        with:
-          python-version: "3.10"
-          architecture: x64
-      - run: "pip install ${{ matrix.tool.name }}"
-      - run: "${{ matrix.tool.invocation }}"
diff -pruN 0.9.2-2/.github/workflows/unit-tests.yaml 1.0.2-1/.github/workflows/unit-tests.yaml
--- 0.9.2-2/.github/workflows/unit-tests.yaml	2022-11-24 19:33:57.000000000 +0000
+++ 1.0.2-1/.github/workflows/unit-tests.yaml	1970-01-01 00:00:00.000000000 +0000
@@ -1,41 +0,0 @@
-name: Unit Tests
-on: [push, pull_request]
-jobs:
-  unit-tests:
-    name: "Python ${{ matrix.versions.python }}"
-    runs-on: "${{ matrix.versions.os }}"
-    strategy:
-      matrix:
-        versions:
-          - python: 3.4.10
-            os: ubuntu-18.04
-          - python: 3.5.10
-            os: ubuntu-20.04
-          - python: 3.6.15
-            os: ubuntu-20.04
-          - python: 3.7.12
-            os: ubuntu-20.04
-          - python: 3.8.12
-            os: ubuntu-20.04
-          - python: 3.9.12
-            os: ubuntu-20.04
-          - python: 3.10.4
-            os: ubuntu-20.04
-          - python: 3.11.0-alpha - 3.11.0
-            os: ubuntu-20.04
-          - python: pypy-3.6
-            os: ubuntu-20.04
-          - python: pypy-3.7
-            os: ubuntu-20.04
-          - python: pypy-3.8
-            os: ubuntu-20.04
-          - python: pypy-3.9
-            os: ubuntu-20.04
-    steps:
-      - uses: actions/checkout@v3
-      - uses: actions/setup-python@v3
-        with:
-          python-version: "${{ matrix.versions.python }}"
-          architecture: x64
-      - run: pip install -e ".[testing]"
-      - run: pytest -v
diff -pruN 0.9.2-2/.gitignore 1.0.2-1/.gitignore
--- 0.9.2-2/.gitignore	2022-11-24 19:33:57.000000000 +0000
+++ 1.0.2-1/.gitignore	2025-05-05 05:23:26.000000000 +0000
@@ -46,3 +46,9 @@ pypyenv3
 .mr.developer.cfg
 .project
 .pydevproject
+
+
+# emacs
+\#*\#
+.\#*
+flycheck_*
diff -pruN 0.9.2-2/.isort.cfg 1.0.2-1/.isort.cfg
--- 0.9.2-2/.isort.cfg	2022-11-24 19:33:57.000000000 +0000
+++ 1.0.2-1/.isort.cfg	1970-01-01 00:00:00.000000000 +0000
@@ -1,2 +0,0 @@
-[settings]
-profile=black
diff -pruN 0.9.2-2/.vscode/settings.json 1.0.2-1/.vscode/settings.json
--- 0.9.2-2/.vscode/settings.json	1970-01-01 00:00:00.000000000 +0000
+++ 1.0.2-1/.vscode/settings.json	2025-05-05 05:23:26.000000000 +0000
@@ -0,0 +1,7 @@
+{
+    "python.testing.pytestArgs": [
+        "tests"
+    ],
+    "python.testing.unittestEnabled": false,
+    "python.testing.pytestEnabled": true
+}
diff -pruN 0.9.2-2/.vscode/tasks.json 1.0.2-1/.vscode/tasks.json
--- 0.9.2-2/.vscode/tasks.json	1970-01-01 00:00:00.000000000 +0000
+++ 1.0.2-1/.vscode/tasks.json	2025-05-05 05:23:26.000000000 +0000
@@ -0,0 +1,26 @@
+{
+    // See https://go.microsoft.com/fwlink/?LinkId=733558
+    // for the documentation about the tasks.json format
+    "version": "2.0.0",
+    "tasks": [
+        {
+            "label": "Run tests",
+            "type": "shell",
+            "group": {
+                "kind": "test",
+                "isDefault": true
+            },
+            "command": "hatch test -a"
+        },
+        {
+            "label": "Lint/Format",
+            "type": "shell",
+            "command": "hatch fmt"
+        },
+        {
+            "label": "REPL",
+            "type": "shell",
+            "command": "hatch run repl:ptpython"
+        }
+    ]
+}
diff -pruN 0.9.2-2/CHANGES.rst 1.0.2-1/CHANGES.rst
--- 0.9.2-2/CHANGES.rst	2022-11-24 19:33:57.000000000 +0000
+++ 1.0.2-1/CHANGES.rst	2025-05-05 05:23:26.000000000 +0000
@@ -1,6 +1,28 @@
 CHANGES
 =======
 
+1.0.2 (2025-05-04)
+------------------
+
+* Added new `--json` and `--toml` CLI options to load input variables from JSON
+  and TOML files, respectively.  `--toml` requires Python 3.11 or newer.
+
+1.0.1 (2025-05-04)
+------------------
+
+* The `kajiki` CLI entry point was accidentally removed during the conversion to
+  hatchling.  It was added back.
+
+1.0.0 (2025-05-04)
+------------------
+
+* `py:match` and structural pattern matching added.  Python 3.10+ is required
+  for this feature.  Thanks @CastixGitHub for the contribution.
+* PEP-517 backend switched from setuptools to hatchling.
+* Removed API usages of `pkg_resources`.
+* Added integration tests with TurboGears2 to ensure we don't break users.
+* Python 3.8+ is now required.
+
 0.9.2 (2022-11-24)
 ------------------
 
diff -pruN 0.9.2-2/MANIFEST.in 1.0.2-1/MANIFEST.in
--- 0.9.2-2/MANIFEST.in	2022-11-24 19:33:57.000000000 +0000
+++ 1.0.2-1/MANIFEST.in	1970-01-01 00:00:00.000000000 +0000
@@ -1,8 +0,0 @@
-# http://docs.python.org/3/distutils/sourcedist.html#specifying-the-files-to-distribute
-
-include *.rst
-recursive-include kajiki *
-prune kajiki/perf
-recursive-include docs *
-prune docs/_build
-global-exclude *.pyc
diff -pruN 0.9.2-2/debian/changelog 1.0.2-1/debian/changelog
--- 0.9.2-2/debian/changelog	2025-05-04 18:26:08.000000000 +0000
+++ 1.0.2-1/debian/changelog	2025-08-31 08:55:08.000000000 +0000
@@ -1,3 +1,11 @@
+python-kajiki (1.0.2-1) unstable; urgency=medium
+
+  * Team upload.
+  * New upstream version 1.0.2
+  * Convert from setuptools to hatchling (Closes: #1083657)
+
+ -- Alexandre Detiste <tchet@debian.org>  Sun, 31 Aug 2025 10:55:08 +0200
+
 python-kajiki (0.9.2-2) unstable; urgency=medium
 
   * Team upload.
diff -pruN 0.9.2-2/debian/control 1.0.2-1/debian/control
--- 0.9.2-2/debian/control	2025-05-04 18:26:08.000000000 +0000
+++ 1.0.2-1/debian/control	2025-08-31 08:55:08.000000000 +0000
@@ -6,16 +6,18 @@ Uploaders:
  TANIGUCHI Takaki <takaki@debian.org>,
 Build-Depends:
  debhelper-compat (= 13),
- dh-python,
  dh-sequence-python3,
  dh-sequence-sphinxdoc <!nodoc>,
+ furo <!nodoc>,
+ pybuild-plugin-pyproject,
  python3-all,
  python3-babel,
+ python3-hatchling,
+ python3-hatch-sphinx <!nodoc>,
  python3-linetable,
  python3-pytest <!nocheck>,
- python3-setuptools,
  python3-sphinx <!nodoc>,
-Standards-Version: 4.6.2
+Standards-Version: 4.7.2
 Testsuite: autopkgtest-pkg-pybuild
 Homepage: https://github.com/jackrosenthal/kajiki
 Vcs-Git: https://salsa.debian.org/python-team/packages/python-kajiki.git
@@ -45,7 +47,6 @@ Description: ${source:Synopsis} - doc
 Package: python3-kajiki
 Architecture: all
 Depends:
- python3-pkg-resources,
  ${misc:Depends},
  ${python3:Depends},
 Provides:
diff -pruN 0.9.2-2/debian/patches/ftbfs.patch 1.0.2-1/debian/patches/ftbfs.patch
--- 0.9.2-2/debian/patches/ftbfs.patch	1970-01-01 00:00:00.000000000 +0000
+++ 1.0.2-1/debian/patches/ftbfs.patch	2025-08-31 08:55:08.000000000 +0000
@@ -0,0 +1,11 @@
+--- a/docs/conf.py
++++ b/docs/conf.py
+@@ -53,7 +53,7 @@
+ 
+ # The full version, including alpha/beta/rc tags.
+ release = subprocess.run(
+-    ["hatch", "version"],  # noqa: S607
++    ["hatchling", "version"],  # noqa: S607
+     check=True,
+     stdout=subprocess.PIPE,
+     encoding="utf-8",
diff -pruN 0.9.2-2/debian/patches/series 1.0.2-1/debian/patches/series
--- 0.9.2-2/debian/patches/series	1970-01-01 00:00:00.000000000 +0000
+++ 1.0.2-1/debian/patches/series	2025-08-31 08:55:08.000000000 +0000
@@ -0,0 +1 @@
+ftbfs.patch
diff -pruN 0.9.2-2/docs/conf.py 1.0.2-1/docs/conf.py
--- 0.9.2-2/docs/conf.py	2022-11-24 19:33:57.000000000 +0000
+++ 1.0.2-1/docs/conf.py	2025-05-05 05:23:26.000000000 +0000
@@ -2,6 +2,7 @@
 # sphinx-quickstart on Wed Jul  7 22:31:03 2010.
 #
 # This file is execfile()d with the current directory set to its containing dir.
+# ruff: noqa: INP001
 #
 # Note that not all possible configuration values are present in this
 # autogenerated file.
@@ -9,12 +10,10 @@
 # All configuration values have a default; values that are commented out
 # serve to show the default.
 
-from kajiki import version as _version
+import site
+import subprocess
 
-# If extensions (or modules to document with autodoc) are in another directory,
-# add these directories to sys.path here. If the directory is relative to the
-# documentation root, use os.path.abspath to make it absolute, like shown here.
-# sys.path.append(os.path.abspath('..'))
+site.addsitedir("..")
 
 # -- General configuration -----------------------------------------------------
 
@@ -44,18 +43,24 @@ master_doc = "index"
 
 # General information about the project.
 project = "Kajiki"
-copyright = (
+copyright = (  # noqa: A001
     "2010-2021, Rick Copeland, Nando Florestan, Alessandro Molina, and Jack Rosenthal"
 )
 
 # The version info for the project you're documenting, acts as replacement for
 # |version| and |release|, also used in various other places throughout the
 # built documents.
-#
-# The short X.Y version.
-version = _version.__version__
+
 # The full version, including alpha/beta/rc tags.
-release = _version.__release__
+release = subprocess.run(
+    ["hatch", "version"],  # noqa: S607
+    check=True,
+    stdout=subprocess.PIPE,
+    encoding="utf-8",
+).stdout.strip()
+
+# The short X.Y version.
+version = ".".join(release.split(".", 2)[:-1])
 
 # The language for content autogenerated by Sphinx. Refer to documentation
 # for a list of supported languages.
@@ -96,20 +101,16 @@ pygments_style = "sphinx"
 
 # The theme to use for HTML and HTML Help pages.  See the documentation for
 # a list of builtin themes.
-html_theme = "alabaster"
+html_theme = "furo"
 
 # Theme options are theme-specific and customize the look and feel of a theme
 # further.  For a list of options available for each theme, see the
 # documentation.
-html_theme_options = dict(
-    description="Fast XML and text templates for Python",
-    logo="marlin.png",
-    logo_name=True,
-    github_banner=True,
-    github_button=True,
-    github_user="jackrosenthal",
-    github_repo="kajiki",
-)
+html_theme_options = {
+    "source_repository": "https://github.com/jackrosenthal/kajiki",
+    "source_branch": "master",
+    "source_directory": "docs/",
+}
 
 # Add any paths that contain custom themes here, relative to this directory.
 # html_theme_path = []
@@ -123,12 +124,12 @@ html_theme_options = dict(
 
 # The name of an image file (relative to this directory) to place at the top
 # of the sidebar.
-# html_logo = None
+html_logo = "_static/marlin.png"
 
 # The name of an image file (within the static path) to use as favicon of the
 # docs.  This file should be a Windows icon file (.ico) being 16x16 or 32x32
 # pixels large.
-html_favicon = "favicon.ico"
+html_favicon = "_static/favicon.ico"
 
 # Add any paths that contain custom static files (such as style sheets) here,
 # relative to this directory. They are copied after the builtin static files,
diff -pruN 0.9.2-2/docs/migrating_from_genshi.rst 1.0.2-1/docs/migrating_from_genshi.rst
--- 0.9.2-2/docs/migrating_from_genshi.rst	2022-11-24 19:33:57.000000000 +0000
+++ 1.0.2-1/docs/migrating_from_genshi.rst	2025-05-05 05:23:26.000000000 +0000
@@ -18,9 +18,10 @@ identical to those of Genshi_.
  * ``py:strip``
  * ``xi:include`` -- renamed ``py:include``
 
-Note that, in particular, ``py:match`` is not supported.  But Kajiki
-supports the following additional directives:
+Note that, in particular, py:match in Kajiki differs from Genshi, implementing `PEP634 <https://peps.python.org/pep-0636/>`_.
 
+Kajiki also supports the following additional directives not in Genshi:
+   
  * ``py:extends`` - indicates that this is an extension template.  The parent
    template will be read in and used for layout, with any ``py:block`` directives in
    the child template overriding the ``py:block`` directives defined in the parent.
diff -pruN 0.9.2-2/docs/xml-templates.rst 1.0.2-1/docs/xml-templates.rst
--- 0.9.2-2/docs/xml-templates.rst	2022-11-24 19:33:57.000000000 +0000
+++ 1.0.2-1/docs/xml-templates.rst	2025-05-05 05:23:26.000000000 +0000
@@ -171,6 +171,32 @@ Perform multiple tests to render one of
 <div>
 3 is odd</div>
 
+py:match, py:case
+^^^^^^^^^^^^^^^^^
+
+Similar to ``py:switch`` this makes use of `PEP622 <https://peps.python.org/pep-0622/>`_
+Structural Pattern Matching:
+
+>>> import sys, pytest
+>>> if sys.version_info < (3, 10): pytest.skip('pep622 unsupported')
+>>> Template = kajiki.XMLTemplate('''<div>
+... $i is <py:match on="i % 2">
+... <py:case match="0">even</py:case>
+... <py:case match="_">odd</py:case>
+... </py:match></div>''')
+>>> print(Template(dict(i=4)).render())
+<div>
+4 is even</div>
+>>> print(Template(dict(i=3)).render())
+<div>
+3 is odd</div>
+
+.. note::
+
+   ``py:match`` compiles directly to Python's ``match`` syntax, and will
+   therefore not work on versions less than 3.10.  Only use this syntax if your
+   project targets Python 3.10 or newer.
+
 py:for
 ^^^^^^^^^^^^^
 
@@ -465,7 +491,8 @@ Directive  Usable as an attribute  Usabl
 py:if      ✅                       ✅                            test
 py:else    ✅                       ✅
 py:switch  ❌                       ✅                            test
-py:case    ✅                       ✅                            value
+py:match   ❌                       ✅                            on
+py:case    ✅                       ✅                            value or match (for usage with py:switch or py:match)
 py:for     ✅                       ✅                            each
 py:def     ✅                       ✅                            function
 py:call    ❌                       ✅                            args, function
diff -pruN 0.9.2-2/kajiki/__init__.py 1.0.2-1/kajiki/__init__.py
--- 0.9.2-2/kajiki/__init__.py	2022-11-24 19:33:57.000000000 +0000
+++ 1.0.2-1/kajiki/__init__.py	2025-05-05 05:23:26.000000000 +0000
@@ -1,11 +1,10 @@
 """Kajiki public API."""
 
-from .loader import FileLoader, MockLoader, PackageLoader
-from .template import Template
-from .text import TextTemplate
-from .util import expose, flattener
-from .version import __release__, __version__
-from .xml_template import XMLTemplate
+from kajiki.loader import FileLoader, MockLoader, PackageLoader
+from kajiki.template import Template
+from kajiki.text import TextTemplate
+from kajiki.util import expose, flattener
+from kajiki.xml_template import XMLTemplate
 
 __all__ = [
     "expose",
diff -pruN 0.9.2-2/kajiki/__main__.py 1.0.2-1/kajiki/__main__.py
--- 0.9.2-2/kajiki/__main__.py	2022-11-24 19:33:57.000000000 +0000
+++ 1.0.2-1/kajiki/__main__.py	2025-05-05 05:23:26.000000000 +0000
@@ -1,12 +1,21 @@
 """Command-line interface to Kajiki to render a single template."""
 
 import argparse
+import json
 import os
+import pathlib
 import site
 import sys
 
 import kajiki.loader
 
+try:
+    import tomllib
+except ImportError:
+    _TOML_AVAILABLE = False
+else:
+    _TOML_AVAILABLE = True
+
 
 def _kv_pair(pair):
     """Convert a KEY=VALUE string to a 2-tuple of (KEY, VALUE).
@@ -15,9 +24,8 @@ def _kv_pair(pair):
     """
     key, sep, value = pair.partition("=")
     if not sep:
-        raise argparse.ArgumentTypeError(
-            "Expected a KEY=VALUE pair, got {}".format(pair)
-        )
+        msg = f"Expected a KEY=VALUE pair, got {pair}"
+        raise argparse.ArgumentTypeError(msg)
     return key, value
 
 
@@ -28,10 +36,7 @@ def main(argv=None):
         "--mode",
         dest="force_mode",
         choices=["text", "xml", "html", "html5"],
-        help=(
-            "Force a specific templating mode instead of auto-detecting "
-            "based on extension."
-        ),
+        help="Force a specific templating mode instead of auto-detecting based on extension.",
     )
     parser.add_argument(
         "-i",
@@ -56,6 +61,21 @@ def main(argv=None):
         help="Template variables, passed as KEY=VALUE pairs.",
     )
     parser.add_argument(
+        "--json",
+        action="append",
+        default=[],
+        type=pathlib.Path,
+        help="Load template variables from a JSON file.",
+    )
+    if _TOML_AVAILABLE:
+        parser.add_argument(
+            "--toml",
+            action="append",
+            default=[],
+            type=pathlib.Path,
+            help="Load template variables from a TOML file.",
+        )
+    parser.add_argument(
         "-p",
         "--package",
         dest="loader_type",
@@ -86,9 +106,21 @@ def main(argv=None):
         opts.paths.append(os.path.dirname(opts.file_or_package) or ".")
         loader_kwargs["path"] = opts.paths
 
+    template_variables = {}
+    for json_file in opts.json:
+        with json_file.open("r", encoding="utf-8") as f:
+            template_variables.update(json.load(f))
+
+    if _TOML_AVAILABLE:
+        for toml_file in opts.toml:
+            with toml_file.open("rb") as f:
+                template_variables.update(tomllib.load(f))
+
+    template_variables.update(dict(opts.template_variables))
+
     loader = opts.loader_type(force_mode=opts.force_mode, **loader_kwargs)
     template = loader.import_(opts.file_or_package)
-    result = template(dict(opts.template_variables)).render()
+    result = template(template_variables).render()
     opts.output_file.write(result)
 
     # Close the output file to avoid a ResourceWarning during unit
diff -pruN 0.9.2-2/kajiki/doctype.py 1.0.2-1/kajiki/doctype.py
--- 0.9.2-2/kajiki/doctype.py	2022-11-24 19:33:57.000000000 +0000
+++ 1.0.2-1/kajiki/doctype.py	2025-05-05 05:23:26.000000000 +0000
@@ -1,7 +1,7 @@
 import re
 
 
-class DocumentTypeDeclaration(object):
+class DocumentTypeDeclaration:
     """Represents a http://en.wikipedia.org/wiki/Document_Type_Declaration
 
     This is used to lookup DTDs details by its string, DTDs can
@@ -9,14 +9,15 @@ class DocumentTypeDeclaration(object):
     using :meth:`.matching` method:
 
     >>> from kajiki.doctype import DocumentTypeDeclaration
-    >>> dtd = DocumentTypeDeclaration("html4transitional",
-    ...                               "-//W3C//DTD HTML 4.01 Transitional//EN",
-    ...                               "http://www.w3.org/TR/html4/loose.dtd",
-    ...                               rendering_mode='html')
+    >>> dtd = DocumentTypeDeclaration(
+    ...     "html4transitional",
+    ...     "-//W3C//DTD HTML 4.01 Transitional//EN",
+    ...     "http://www.w3.org/TR/html4/loose.dtd",
+    ...     rendering_mode="html",
+    ... )
     >>> dtd.uri
     'http://www.w3.org/TR/html4/loose.dtd'
-    >>> DocumentTypeDeclaration.by_uri[
-    ...     "http://www.w3.org/TR/html4/loose.dtd"] = dtd
+    >>> DocumentTypeDeclaration.by_uri["http://www.w3.org/TR/html4/loose.dtd"] = dtd
     >>> match = DocumentTypeDeclaration.matching(
     ...     '<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN" '
     ...     '"http://www.w3.org/TR/html4/loose.dtd">'
@@ -44,7 +45,7 @@ class DocumentTypeDeclaration(object):
         self.uri = uri
         self.rendering_mode = rendering_mode
         self.root_element = root_element
-        assert kind in (
+        assert kind in (  # noqa: S101
             "PUBLIC",
             "SYSTEM",
             "",
@@ -53,11 +54,7 @@ class DocumentTypeDeclaration(object):
         self._cached_str = None
 
         self.regex = re.compile(
-            str(self)
-            .replace(" ", r"\s+")
-            .replace(".", r"\.")
-            .replace("[", r"\[")
-            .replace("]", r"\]"),
+            str(self).replace(" ", r"\s+").replace(".", r"\.").replace("[", r"\[").replace("]", r"\]"),
             flags=re.IGNORECASE,
         )
 
@@ -74,7 +71,7 @@ class DocumentTypeDeclaration(object):
             self._cached_str = " ".join(alist) + ">"
         return self._cached_str
 
-    by_uri = dict()  # We store the public DTDs here.
+    by_uri = {}  # We store the public DTDs here.  # noqa: RUF012
 
     @classmethod
     def matching(cls, dtd_string):
@@ -84,8 +81,7 @@ class DocumentTypeDeclaration(object):
         for dtd in cls.by_uri.values():
             if dtd.regex.match(dtd_string):
                 return dtd
-        else:
-            return None
+        return None
 
     REGEX = re.compile(r"<!DOCTYPE[^>]+>")  # This matches any DTD.
 
@@ -163,7 +159,7 @@ def extract_dtd(markup):
     these values might be an empty string:
 
     >>> markup = (
-    ...     '<!DOCTYPE HTML PUBLIC '
+    ...     "<!DOCTYPE HTML PUBLIC "
     ...     '"-//W3C//DTD HTML 4.01 Transitional//EN" '
     ...     '"http://www.w3.org/TR/html4/loose.dtd">'
     ...     '''<html>
@@ -173,7 +169,8 @@ def extract_dtd(markup):
     ...     <body>
     ...     ...
     ...     </body>
-    ...     </html>''')
+    ...     </html>'''
+    ... )
     >>> import kajiki.doctype
     >>> dtd, dtd_pos, markup_without_dtd = kajiki.doctype.extract_dtd(markup)
     >>> print(dtd)  # doctest: +NORMALIZE_WHITESPACE
@@ -225,7 +222,6 @@ def extract_dtd(markup):
             # The position for a prospective DTD is *after* the <?xml ...?> declaration,
             # because it's not allowed for there to be anything before it.
             return "", decl_match.end(), markup
-        else:
-            return "", 0, markup
+        return "", 0, markup
     found = match.group()
     return found, match.start(), markup.replace(found, "", 1)
diff -pruN 0.9.2-2/kajiki/html_utils.py 1.0.2-1/kajiki/html_utils.py
--- 0.9.2-2/kajiki/html_utils.py	2022-11-24 19:33:57.000000000 +0000
+++ 1.0.2-1/kajiki/html_utils.py	2025-05-05 05:23:26.000000000 +0000
@@ -1,18 +1,14 @@
-HTML_EMPTY_ATTRS = set(
-    [
-        "checked",
-        "disabled",
-        "readonly",
-        "multiple",
-        "selected",
-        "nohref",
-        "ismap",
-        "declare",
-        "defer",
-    ]
-)
-HTML_OPTIONAL_END_TAGS = set(
-    ["area", "base", "br", "col", "hr", "img", "input", "link", "meta", "param"]
-)
-HTML_REQUIRED_END_TAGS = set(["script"])
-HTML_CDATA_TAGS = set(("script", "style"))
+HTML_EMPTY_ATTRS = {
+    "checked",
+    "disabled",
+    "readonly",
+    "multiple",
+    "selected",
+    "nohref",
+    "ismap",
+    "declare",
+    "defer",
+}
+HTML_OPTIONAL_END_TAGS = {"area", "base", "br", "col", "hr", "img", "input", "link", "meta", "param"}
+HTML_REQUIRED_END_TAGS = {"script"}
+HTML_CDATA_TAGS = {"script", "style"}
diff -pruN 0.9.2-2/kajiki/i18n.py 1.0.2-1/kajiki/i18n.py
--- 0.9.2-2/kajiki/i18n.py	2022-11-24 19:33:57.000000000 +0000
+++ 1.0.2-1/kajiki/i18n.py	2025-05-05 05:23:26.000000000 +0000
@@ -1,7 +1,7 @@
 from io import BytesIO
 from tokenize import TokenError
 
-from .ir import ExprNode, TranslatableTextNode
+from kajiki.ir import ExprNode, TranslatableTextNode
 
 
 def gettext(s):
@@ -10,8 +10,8 @@ def gettext(s):
 
 def extract(fileobj, keywords, comment_tags, options):
     """Babel entry point that extracts translation strings from XML templates."""
-    from .template import KajikiSyntaxError
-    from .xml_template import _Compiler, _DomTransformer, _Parser
+    from kajiki.template import KajikiSyntaxError
+    from kajiki.xml_template import _Compiler, _DomTransformer, _Parser
 
     try:
         from babel.messages.extract import extract_python
@@ -39,9 +39,7 @@ def extract(fileobj, keywords, comment_t
                 yield (node.lineno, "_", node.text, [])
         elif extract_expr and isinstance(node, ExprNode):
             try:
-                for e in extract_python(
-                    BytesIO(node.text.encode("utf-8")), keywords, comment_tags, options
-                ):
+                for e in extract_python(BytesIO(node.text.encode("utf-8")), keywords, comment_tags, options):
                     yield (node.lineno, e[1], e[2], e[3])
             except (TokenError, SyntaxError) as e:
-                raise KajikiSyntaxError(e, source, "<string>", node.lineno, 0)
+                raise KajikiSyntaxError(e, source, "<string>", node.lineno, 0) from e
diff -pruN 0.9.2-2/kajiki/integration/pyramid.py 1.0.2-1/kajiki/integration/pyramid.py
--- 0.9.2-2/kajiki/integration/pyramid.py	2022-11-24 19:33:57.000000000 +0000
+++ 1.0.2-1/kajiki/integration/pyramid.py	2025-05-05 05:23:26.000000000 +0000
@@ -3,7 +3,7 @@ Pyramid_ web framework.
 
 To enable it, add this to your Pyramid web app configuration::
 
-    config.include('kajiki.integration.pyramid')
+    config.include("kajiki.integration.pyramid")
 
 Also add something like the following to the
 application section of your Pyramid application's .ini file::
@@ -34,8 +34,8 @@ from pyramid.interfaces import IRenderer
 from pyramid.resource import abspath_from_resource_spec
 from zope.interface import implementer
 
-from .. import XMLTemplate
-from ..loader import Loader
+from kajiki import XMLTemplate
+from kajiki.loader import Loader
 
 
 def includeme(config):
@@ -62,17 +62,17 @@ class PyramidKajikiLoader(Loader):
     def implementation(self):  # ITemplateRenderer implementation
         return self
 
-    def __init__(self, auto_reload=False, mode="html5"):
+    def __init__(self, auto_reload=False, mode="html5"):  # noqa: FBT002
         self.auto_reload = auto_reload
         self.mode = mode
         self._timestamps = {}
         super().__init__()
 
-    def _load(self, name, *a, **kw):
+    def _load(self, name, **kw):
         """Called when the template actually needs to be (re)compiled."""
-        return XMLTemplate(source=None, filename=name, mode=self.mode, *a, **kw)
+        return XMLTemplate(source=None, filename=name, mode=self.mode, **kw)
 
-    def import_(self, name, *a, **kw):
+    def import_(self, name, **kw):
         """Overrides Loader.import_().
 
         * Resolves the resource spec into an absolute path for the template.
@@ -83,9 +83,9 @@ class PyramidKajikiLoader(Loader):
             mtime = stat(name).st_mtime
             if mtime > self._timestamps.get(name, 0):
                 del self.modules[name]
-        return super().import_(name, *a, **kw)
+        return super().import_(name, **kw)
 
-    def __call__(self, value, system, is_fragment=False):
+    def __call__(self, value, system, is_fragment=False):  # noqa: FBT002
         """IRenderer implementation.
 
         ``value`` is the result of the view.
@@ -103,26 +103,11 @@ class PyramidKajikiLoader(Loader):
         template = self.import_(name, is_fragment=is_fragment)
         try:
             system.update(value)
-        except (TypeError, ValueError):
-            raise ValueError(
-                "The Kajiki template renderer was passed a " "non-dictionary as value."
-            )
-        # self._save_template_as_python(template, system, name)  # to debug
+        except (TypeError, ValueError) as e:
+            msg = "The Kajiki template renderer was passed a non-dictionary as value."
+            raise ValueError(msg) from e
         return template(system).render()
 
-    def _save_template_as_python(
-        self, template, context, name, dir="kajiki_debug", encoding="utf-8"
-    ):
-        "Just a debugging device used in the development of Kajiki itself."
-        from codecs import open
-        from os import makedirs, path
-
-        makedirs(dir, exist_ok=True)
-        path = path.join(dir, name.replace(":", "-").replace("/", "_") + ".py")
-        with open(path, "w", encoding=encoding) as f:
-            f.write(template(context).py_text)
-        print("Compiled Kajiki template written to " + path)
-
     def fragment(self, renderer_name, dic, view=None, request=None):
         """Example usage from a class-based view:
 
@@ -134,12 +119,12 @@ class PyramidKajikiLoader(Loader):
         return self(
             is_fragment=True,
             value=dic,
-            system=dict(
-                renderer_name=renderer_name,
-                request=request or view.request,
-                view=view,
-                context=dic,
-            ),
+            system={
+                "renderer_name": renderer_name,
+                "request": request or view.request,
+                "view": view,
+                "context": dic,
+            },
         )
 
 
diff -pruN 0.9.2-2/kajiki/ir.py 1.0.2-1/kajiki/ir.py
--- 0.9.2-2/kajiki/ir.py	2022-11-24 19:33:57.000000000 +0000
+++ 1.0.2-1/kajiki/ir.py	2025-05-05 05:23:26.000000000 +0000
@@ -1,7 +1,7 @@
 import re
 from itertools import chain
 
-from .util import flattener, gen_name, window
+from kajiki.util import default_alias_for, flattener, gen_name, window
 
 
 def generate_python(ir):
@@ -12,10 +12,15 @@ def generate_python(ir):
         elif isinstance(node, DedentNode):
             cur_indent -= 4
         for line in node.py():
-            yield line.indent(cur_indent)
+            if isinstance(line, IndentNode):
+                cur_indent += 4
+            elif isinstance(line, DedentNode):
+                cur_indent -= 4
+            else:
+                yield line.indent(cur_indent)
 
 
-class Node(object):
+class Node:
     def __init__(self):
         self.filename = "<string>"
         self.lineno = 0
@@ -44,14 +49,12 @@ class HierNode(Node):
         self.body = tuple(x for x in body if x is not None)
 
     def body_iter(self):
-        for x in optimize(flattener(map(flattener, self.body))):
-            yield x
+        yield from optimize(flattener(map(flattener, self.body)))
 
     def __iter__(self):
         yield self
         yield IndentNode()
-        for x in self.body_iter():
-            yield x
+        yield from self.body_iter()
         yield DedentNode()
 
 
@@ -102,13 +105,12 @@ class ImportNode(Node):
     def __init__(self, tpl_name, alias=None):
         super().__init__()
         self.tpl_name = tpl_name
+        if alias is None:
+            alias = default_alias_for(tpl_name)
         self.alias = alias
 
     def py(self):
-        yield self.line(
-            "local.__kj__.import_(%r, %r, self.__globals__)"
-            % (self.tpl_name, self.alias)
-        )
+        yield self.line(f"{self.alias} = local.__kj__.import_({self.tpl_name!r}, {self.alias!r}, self.__globals__)")
 
 
 class IncludeNode(Node):
@@ -117,10 +119,7 @@ class IncludeNode(Node):
         self.tpl_name = tpl_name
 
     def py(self):
-        yield self.line(
-            "yield local.__kj__.import_(%r, None, self.__globals__).__main__()"
-            % (self.tpl_name)
-        )
+        yield self.line(f"yield local.__kj__.import_({self.tpl_name!r}, None, self.__globals__).__main__()")
 
 
 class ExtendNode(Node):
@@ -129,7 +128,7 @@ class ExtendNode(Node):
         self.tpl_name = tpl_name
 
     def py(self):
-        yield self.line("yield local.__kj__.extend(%r).__main__()" % (self.tpl_name))
+        yield self.line(f"yield local.__kj__.extend({self.tpl_name!r}).__main__()")
 
 
 class DefNode(HierNode):
@@ -141,7 +140,7 @@ class DefNode(HierNode):
 
     def py(self):
         yield self.line(self.prefix)
-        yield self.line("def %s:" % (self.decl))
+        yield self.line(f"def {self.decl}:")
 
     def __iter__(self):
         yield self
@@ -176,13 +175,12 @@ class CallNode(HierNode):
 
     def py(self):
         yield self.line("@__kj__.flattener.decorate")
-        yield self.line("def %s:" % (self.decl))
+        yield self.line(f"def {self.decl}:")
 
     def __iter__(self):
         yield self
         yield IndentNode()
-        for x in self.body_iter():
-            yield x
+        yield from self.body_iter()
         yield DedentNode()
         yield self.CallTail(self.call)
 
@@ -193,11 +191,10 @@ class ForNode(HierNode):
         self.decl = decl
 
     def py(self):
-        yield self.line("for %s:" % (self.decl))
+        yield self.line(f"for {self.decl}:")
 
 
 class WithNode(HierNode):
-
     assignment_pattern = re.compile(r"(?:^|;)\s*([^;=]+)=(?!=)", re.M)
 
     class WithTail(Node):
@@ -206,12 +203,10 @@ class WithNode(HierNode):
             self.var_names = var_names
 
         def py(self):
-            yield self.line(
-                "(%s,) = local.__kj__.pop_with()" % (",".join(self.var_names),)
-            )
+            yield self.line("({},) = local.__kj__.pop_with()".format(",".join(self.var_names)))
             # yield self.line('if %s == (): del %s' % (v, v))
 
-    def __init__(self, vars, *body):
+    def __init__(self, vars, *body):  # noqa: A002
         super().__init__(body)
         assignments = []
         matches = self.assignment_pattern.finditer(vars)
@@ -224,17 +219,13 @@ class WithNode(HierNode):
         self.var_names = [lhs for lhs, _ in assignments]
 
     def py(self):
-        yield self.line(
-            "local.__kj__.push_with(locals(), [%s])"
-            % (",".join('"%s"' % k for k in self.var_names),)
-        )
+        yield self.line("local.__kj__.push_with(locals(), [{}])".format(",".join(f'"{k}"' for k in self.var_names)))
         for k, v in self.vars:
-            yield self.line("%s = %s" % (k, v))
+            yield self.line(f"{k} = {v}")
 
     def __iter__(self):
         yield self
-        for x in self.body_iter():
-            yield x
+        yield from self.body_iter()
         yield self.WithTail(self.var_names)
 
 
@@ -248,13 +239,12 @@ class SwitchNode(HierNode):
         self.decl = decl
 
     def py(self):
-        yield self.line("local.__kj__.push_switch(%s)" % self.decl)
+        yield self.line(f"local.__kj__.push_switch({self.decl})")
         yield self.line("if False: pass")
 
     def __iter__(self):
         yield self
-        for x in self.body_iter():
-            yield x
+        yield from self.body_iter()
         yield self.SwitchTail()
 
 
@@ -264,7 +254,41 @@ class CaseNode(HierNode):
         self.decl = decl
 
     def py(self):
-        yield self.line("elif local.__kj__.case(%s):" % self.decl)
+        yield self.line(f"elif local.__kj__.case({self.decl}):")
+
+
+class MatchNode(HierNode):
+    """Structural Pattern Matching Node"""
+
+    def __init__(self, decl, *body):
+        super().__init__(body)
+        self.decl = decl
+
+    def py(self):
+        yield self.line(f"match ({self.decl}):")
+        yield IndentNode()
+
+    def __iter__(self):
+        yield self
+        yield from self.body_iter()
+        yield DedentNode()
+
+
+class MatchCaseNode(HierNode):
+    """Structural Pattern Matching Case Node"""
+
+    def __init__(self, decl, *body):
+        super().__init__(body)
+        self.decl = decl
+
+    def py(self):
+        yield self.line(f"case {self.decl}:")
+        yield IndentNode()
+
+    def __iter__(self):
+        yield self
+        yield from self.body_iter()
+        yield DedentNode()
 
 
 class IfNode(HierNode):
@@ -273,7 +297,7 @@ class IfNode(HierNode):
         self.decl = decl
 
     def py(self):
-        yield self.line("if %s:" % self.decl)
+        yield self.line(f"if {self.decl}:")
 
 
 class ElseNode(HierNode):
@@ -293,9 +317,9 @@ class TextNode(Node):
         self.guard = guard
 
     def py(self):
-        s = "yield %r" % self.text
+        s = f"yield {self.text!r}"
         if self.guard:
-            yield self.line("if %s: %s" % (self.guard, s))
+            yield self.line(f"if {self.guard}: {s}")
         else:
             yield self.line(s)
 
@@ -303,12 +327,9 @@ class TextNode(Node):
 class TranslatableTextNode(TextNode):
     def py(self):
         text = self.text.strip()
-        if text:
-            s = "yield local.__kj__.gettext(%r)" % self.text
-        else:
-            s = "yield %r" % self.text
+        s = f"yield local.__kj__.gettext({self.text!r})" if text else f"yield {self.text!r}"
         if self.guard:
-            yield self.line("if %s: %s" % (self.guard, s))
+            yield self.line(f"if {self.guard}: {s}")
         else:
             yield self.line(s)
 
@@ -318,16 +339,16 @@ class ExprNode(Node):
     is executed.
     """
 
-    def __init__(self, text, safe=False):
+    def __init__(self, text, safe=False):  # noqa: FBT002
         super().__init__()
         self.text = text
         self.safe = safe
 
     def py(self):
         if self.safe:
-            yield self.line("yield %s" % self.text)
+            yield self.line(f"yield {self.text}")
         else:
-            yield self.line("yield self.__kj__.escape(%s)" % self.text)
+            yield self.line(f"yield self.__kj__.escape({self.text})")
 
 
 class AttrNode(HierNode):
@@ -341,12 +362,9 @@ class AttrNode(HierNode):
         def py(self):
             gen = self.p.genname
             x = gen_name()
-            yield self.line("%s = self.__kj__.collect(%s())" % (gen, gen))
-            yield self.line(
-                "for %s in self.__kj__.render_attrs({%r:%s}, %r):"
-                % (x, self.p.attr, gen, self.p.mode)
-            )
-            yield self.line("    yield %s" % x)
+            yield self.line(f"{gen} = self.__kj__.collect({gen}())")
+            yield self.line(f"for {x} in self.__kj__.render_attrs({{{self.p.attr!r}:{gen}}}, {self.p.mode!r}):")
+            yield self.line(f"    yield {x}")
 
     def __init__(self, attr, value, guard=None, mode="xml"):
         super().__init__(value)
@@ -356,21 +374,17 @@ class AttrNode(HierNode):
         self.genname = gen_name()
 
     def py(self):
-        yield self.line("def %s():" % self.genname)
+        yield self.line(f"def {self.genname}():")
 
     def __iter__(self):
         if self.guard:
-            new_body = IfNode(
-                self.guard, AttrNode(self.attr, value=self.body, mode=self.mode)
-            )
-            for x in new_body:
-                yield x
+            new_body = IfNode(self.guard, AttrNode(self.attr, value=self.body, mode=self.mode))
+            yield from new_body
         else:
             yield self
             yield IndentNode()
             if self.body:
-                for part in self.body_iter():
-                    yield part
+                yield from self.body_iter()
             else:
                 yield TextNode("")
             yield DedentNode()
@@ -388,14 +402,11 @@ class AttrsNode(Node):
         x = gen_name()
 
         def _body():
-            yield self.line(
-                "for %s in self.__kj__.render_attrs(%s, %r):"
-                % (x, self.attrs, self.mode)
-            )
-            yield self.line("    yield %s" % x)
+            yield self.line(f"for {x} in self.__kj__.render_attrs({self.attrs}, {self.mode!r}):")
+            yield self.line(f"    yield {x}")
 
         if self.guard:
-            yield self.line("if %s:" % self.guard)
+            yield self.line(f"if {self.guard}:")
             for line in _body():
                 yield line.indent()
         else:
@@ -409,7 +420,7 @@ class PythonNode(Node):
         self.module_level = False
         blocks = []
         for b in body:
-            assert isinstance(b, TextNode)
+            assert isinstance(b, TextNode)  # noqa: S101
             blocks.append(b.text)
         text = "".join(blocks)
         if text[0] == "%":
@@ -429,18 +440,14 @@ class PythonNode(Node):
             if prefix is None:
                 rest = line.lstrip()
                 prefix = line[: len(line) - len(rest)]
-            assert line.startswith(prefix)
+            assert line.startswith(prefix)  # noqa: S101
             yield line[len(prefix) :]
 
 
 def optimize(iter_node):
     last_node = None
     for node in iter_node:
-        if (
-            type(node) == TextNode
-            and type(last_node) == TextNode
-            and last_node.guard == node.guard
-        ):
+        if type(node) == TextNode and type(last_node) == TextNode and last_node.guard == node.guard:
             last_node.text += node.text
             # Erase this node by not yielding it.
             continue
@@ -451,7 +458,7 @@ def optimize(iter_node):
         yield last_node
 
 
-class PyLine(object):
+class PyLine:
     def __init__(self, filename, lineno, text, indent=0):
         self._filename = filename
         self._lineno = lineno
@@ -464,13 +471,8 @@ class PyLine(object):
     def __str__(self):
         return (" " * self._indent) + self._text
         if self._lineno:
-            return (
-                (" " * self._indent)
-                + self._text
-                + "\t# %s:%d" % (self._filename, self._lineno)
-            )
-        else:
-            return (" " * self._indent) + self._text
+            return (" " * self._indent) + self._text + "\t# %s:%d" % (self._filename, self._lineno)
+        return (" " * self._indent) + self._text
 
     def __repr__(self):
-        return "%s:%s %s" % (self._filename, self._lineno, self)
+        return f"{self._filename}:{self._lineno} {self}"
diff -pruN 0.9.2-2/kajiki/lnotab.py 1.0.2-1/kajiki/lnotab.py
--- 0.9.2-2/kajiki/lnotab.py	2022-11-24 19:33:57.000000000 +0000
+++ 1.0.2-1/kajiki/lnotab.py	2025-05-05 05:23:26.000000000 +0000
@@ -29,17 +29,7 @@ if at least one column jumps by more tha
 than one pair is written to the table. In case #b, there's no way to know
 from looking at the table later how many were written.	That's the delicate
 part.  A user of c_lnotab desiring to find the source line number
-corresponding to a bytecode address A should do something like this
-
-    lineno = addr = 0
-    for addr_incr, line_incr in c_lnotab:
-        addr += addr_incr
-        if addr > A:
-            return lineno
-        lineno += line_incr
-
-Note: this is no longer valid as of CPython 3.6.  After CPython 3.6,
-the line offset is signed, and this code should be used:
+corresponding to a bytecode address A should do something like this:
 
     lineno = addr = 0
     for addr_incr, line_incr in co_lnotab:
@@ -61,32 +51,28 @@ expand 300, 300 to 255, 255, 45, 45,
 
 def lnotab(pairs, first_lineno=0):
     """Yields byte integers representing the pairs of integers passed in."""
-    assert first_lineno <= pairs[0][1]
+    assert first_lineno <= pairs[0][1]  # noqa: S101
     cur_byte, cur_line = 0, first_lineno
     for byte_off, line_off in pairs:
         byte_delta = byte_off - cur_byte
         line_delta = line_off - cur_line
-        assert byte_delta >= 0
-        while byte_delta > 255:
+        assert byte_delta >= 0  # noqa: S101
+        while byte_delta > 255:  # noqa: PLR2004
             yield 255  # byte
             yield 0  # line
             byte_delta -= 255
         yield byte_delta
-        # The threshold of 0x80 is smaller than necessary on Python
-        # 3.4 and 3.5 (the value is treated as unsigned), but won't
-        # produce an incorrect lnotab.  On Python 3.6+, 0x80 is the
-        # correct value.
-        while line_delta >= 0x80:
+        while line_delta >= 0x80:  # noqa: PLR2004
             yield 0x7F  # line
             yield 0  # byte
             line_delta -= 0x7F
-        while line_delta < -0x80:
+        while line_delta < -0x80:  # noqa: PLR2004
             yield 0x80  # line
             yield 0  # byte
             line_delta += 0x80
         if line_delta < 0:
             line_delta += 0x100
-            assert 0x80 <= line_delta <= 0xFF
+            assert 0x80 <= line_delta <= 0xFF  # noqa: S101, PLR2004
         yield line_delta
         cur_byte, cur_line = byte_off, line_off
 
diff -pruN 0.9.2-2/kajiki/loader.py 1.0.2-1/kajiki/loader.py
--- 0.9.2-2/kajiki/loader.py	2022-11-24 19:33:57.000000000 +0000
+++ 1.0.2-1/kajiki/loader.py	2025-05-05 05:23:26.000000000 +0000
@@ -1,26 +1,36 @@
+from __future__ import annotations
+
 import os
+import sys
+from pathlib import Path
+
+if sys.version_info < (3, 9):
+    import importlib_resources
+else:
+    import importlib.resources as importlib_resources
 
-import pkg_resources
+from kajiki.util import default_alias_for
 
 
-class Loader(object):
-    def __init__(self):
+class Loader:
+    def __init__(self, reload=False):  # noqa: FBT002
+        self._reload = reload
         self.modules = {}
 
-    def import_(self, name, *args, **kwargs):
+    def import_(self, name, **kwargs):
         """Returns the template if it is already in the cache,
         else loads the template, caches it and returns it.
         """
         mod = self.modules.get(name)
-        if mod:
+        if not self._reload and mod:
             return mod
-        mod = self._load(name, *args, **kwargs)
+        mod = self._load(name, **kwargs)
         mod.loader = self
         self.modules[name] = mod
         return mod
 
     def default_alias_for(self, name):
-        return os.path.splitext(os.path.basename(name))[0]
+        return default_alias_for(name)
 
     @property
     def load(self):
@@ -39,97 +49,109 @@ class FileLoader(Loader):
     def __init__(
         self,
         path,
-        reload=True,
+        reload=False,  # noqa: FBT002
         force_mode=None,
-        autoescape_text=False,
+        autoescape_text=False,  # noqa: FBT002
         xml_autoblocks=None,
-        **template_options
+        **template_options,
     ):
-        super().__init__()
+        super().__init__(reload=reload)
         from kajiki import TextTemplate, XMLTemplate
 
         if isinstance(path, str):
             self.path = path.split(";")
+        elif isinstance(path, Path):
+            self.path = [path]
         else:
             self.path = path
-        self._timestamps = {}
-        self._reload = reload
+
         self._force_mode = force_mode
         self._autoescape_text = autoescape_text
         self._xml_autoblocks = xml_autoblocks
         self._template_options = template_options
-        self.extension_map = dict(
-            txt=lambda *a, **kw: TextTemplate(
-                autoescape=self._autoescape_text, *a, **kw
-            ),
-            xml=XMLTemplate,
-            html=lambda *a, **kw: XMLTemplate(mode="html", *a, **kw),
-            html5=lambda *a, **kw: XMLTemplate(mode="html5", *a, **kw),
-        )
+        self.extension_map = {
+            "txt": lambda **kw: TextTemplate(autoescape=self._autoescape_text, **kw),
+            "xml": XMLTemplate,
+            "html": lambda **kw: XMLTemplate(mode="html", **kw),
+            "html5": lambda **kw: XMLTemplate(mode="html5", **kw),
+        }
 
-    def _filename(self, name):
+    def _filename(self, name: str) -> str | Path | None:
+        """Get the filename of the requested resource."""
         for base in self.path:
-            fn = os.path.join(base, name)
-            if os.path.exists(fn):
-                return fn
-        return None
+            path = Path(base) / name
+            if path.is_file():
+                return path
+
+        msg = f"{name} not found in any of {self.path}"
+        raise FileNotFoundError(msg)
 
-    def import_(self, name, *args, **kwargs):
+    def _find_resource(self, name: str) -> Path:
+        """Locate the loadable resource and return a Path to it."""
         filename = self._filename(name)
-        if self._reload and name in self.modules:
-            mtime = os.stat(filename).st_mtime
-            if mtime > self._timestamps.get(name, 0):
-                del self.modules[name]
-        return super().import_(name, *args, **kwargs)
-
-    def _load(self, name, encoding="utf-8", *args, **kwargs):
-        """Text templates are read in text mode and XML templates are read in
-        binary mode. Thus, the ``encoding`` argument is only used for reading
-        text template files.
-        """
+        if not filename:
+            msg = f"{self!r}._filename returned {filename!r}"
+            raise FileNotFoundError(msg)
+        path = Path(filename)
+        if not path.is_file():
+            msg = f"{filename} doesn't exist or isn't a file."
+            raise FileNotFoundError(msg)
+        return path
+
+    def _load(self, name, encoding="utf-8", **kwargs):
+        """Load a template from file."""
         from kajiki import TextTemplate, XMLTemplate
 
         options = self._template_options.copy()
         options.update(kwargs)
 
-        filename = self._filename(name)
-        if filename is None:
-            raise IOError("Unknown template %r" % name)
-        self._timestamps[name] = os.stat(filename).st_mtime
+        resource = self._find_resource(name)
+        source = resource.read_text(encoding=encoding)
+
         if self._force_mode == "text":
             return TextTemplate(
-                filename=filename, autoescape=self._autoescape_text, *args, **options
+                source=source,
+                filename=str(resource),
+                autoescape=self._autoescape_text,
+                **options,
             )
-        elif self._force_mode:
+
+        if self._force_mode:
             return XMLTemplate(
-                filename=filename,
+                source=source,
+                filename=str(resource),
                 mode=self._force_mode,
                 autoblocks=self._xml_autoblocks,
-                *args,
-                **options
-            )
-        else:
-            ext = os.path.splitext(filename)[1][1:]
-            return self.extension_map[ext](
-                source=None, filename=filename, *args, **options
+                **options,
             )
 
+        ext = Path(resource.name).suffix.lstrip(".")
+        return self.extension_map[ext](source=source, filename=str(resource), **options)
+
 
 class PackageLoader(FileLoader):
-    def __init__(self, reload=True, force_mode=None):
-        super().__init__(None, reload, force_mode)
+    def __init__(self, reload=False, force_mode=None):  # noqa: FBT002
+        super().__init__(None, reload=reload, force_mode=force_mode)
 
-    def _filename(self, name):
+    def _find_resource(self, name):
         package, module = name.rsplit(".", 1)
-        found = dict()
-        for fn in pkg_resources.resource_listdir(package, "."):
-            if fn == name:
-                return pkg_resources.resource_filename(package, fn)
-            root, ext = os.path.splitext(fn)
-            if root == module:
-                found[ext] = fn
-        for ext in (".xml", ".html", ".html5", ".txt"):
-            if ext in found:
-                return pkg_resources.resource_filename(package, found[ext])
-        else:
-            raise IOError("Unknown template %r" % name)
+        package_resource = importlib_resources.files(package)
+
+        if package_resource.is_file():
+            msg = f"{package} refers to a module, not a package."
+            raise OSError(msg)
+
+        for resource in package_resource.iterdir():
+            if not resource.is_file():
+                continue
+
+            root, ext = os.path.splitext(resource.name)
+            if root != module:
+                continue
+
+            for match_ext in (".xml", ".html", ".html5", ".txt"):
+                if match_ext == ext:
+                    return resource
+
+        msg = f"Unknown template {name!r}"
+        raise FileNotFoundError(msg)
diff -pruN 0.9.2-2/kajiki/markup_template.py 1.0.2-1/kajiki/markup_template.py
--- 0.9.2-2/kajiki/markup_template.py	2022-11-24 19:33:57.000000000 +0000
+++ 1.0.2-1/kajiki/markup_template.py	2025-05-05 05:23:26.000000000 +0000
@@ -1,7 +1,7 @@
 DIRECTIVES = [
     ("def", "function"),
     ("call", "function"),
-    ("case", "value"),
+    ("case", ("value", "match")),
     ("else", ""),
     ("for", "each"),
     ("if", "test"),
@@ -11,5 +11,5 @@ DIRECTIVES = [
     ("block", "name"),
     ("extends", "href"),
 ]
-QDIRECTIVES = [("py:%s" % (k,), v) for k, v in DIRECTIVES]
+QDIRECTIVES = [(f"py:{k}", v) for k, v in DIRECTIVES]
 QDIRECTIVES_DICT = dict(QDIRECTIVES)
diff -pruN 0.9.2-2/kajiki/perf/tables.html 1.0.2-1/kajiki/perf/tables.html
--- 0.9.2-2/kajiki/perf/tables.html	2022-11-24 19:33:57.000000000 +0000
+++ 1.0.2-1/kajiki/perf/tables.html	1970-01-01 00:00:00.000000000 +0000
@@ -1,11 +0,0 @@
-<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN"
-          "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
-<table xmlns:py="http://genshi.edgewall.org/">
-  <tbody>
-    <tr py:for="i in range(size)">
-      <td py:for="j in range(size)">
-        $i, $j
-      </td>
-    </tr>
-  </tbody>
-</table>
diff -pruN 0.9.2-2/kajiki/template.py 1.0.2-1/kajiki/template.py
--- 0.9.2-2/kajiki/template.py	2022-11-24 19:33:57.000000000 +0000
+++ 1.0.2-1/kajiki/template.py	2025-05-05 05:23:26.000000000 +0000
@@ -7,21 +7,19 @@ from sys import version_info
 import linetable
 
 import kajiki
-from kajiki import i18n
+from kajiki import i18n, lnotab
+from kajiki.html_utils import HTML_EMPTY_ATTRS
+from kajiki.ir import generate_python
+from kajiki.util import flattener, literal
 
-from . import lnotab
-from .html_utils import HTML_EMPTY_ATTRS
-from .ir import generate_python
-from .util import flattener, literal
 
-
-class _obj(object):
+class _obj:  # noqa: N801
     def __init__(self, **kw):
         for k, v in kw.items():
             setattr(self, k, v)
 
 
-class _Template(object):
+class _Template:
     """Base Class for all compiled Kajiki Templates.
 
     All kajiki templates created from a :class:`kajiki.ir.TemplateNode` will
@@ -54,19 +52,19 @@ class _Template(object):
             context = {}
         self._context = context
         base_globals = self.base_globals or {}
-        self.__globals__ = dict(
-            local=self,
-            self=self,
-            defined=lambda x: x in self.__globals__,
-            literal=literal,
-            Markup=literal,
-            gettext=i18n.gettext,
-            __builtins__=__builtins__,
-            __kj__=kajiki,
-        )
+        self.__globals__ = {
+            "local": self,
+            "self": self,
+            "defined": lambda x: x in self.__globals__,
+            "literal": literal,
+            "Markup": literal,
+            "gettext": i18n.gettext,
+            "__builtins__": __builtins__,
+            "__kj__": kajiki,
+        }
         self.__globals__.update(base_globals)
         for k, v in self.__methods__:
-            v = v.bind_instance(self)
+            v = v.bind_instance(self)  # noqa: PLW2901
             setattr(self, k, v)
             self.__globals__[k] = v
         self.__kj__ = _obj(
@@ -104,7 +102,7 @@ class _Template(object):
         """Used by the code generated by the template to translate static text"""
         return self.__globals__["gettext"](s)
 
-    def _push_with(self, locals_, vars):
+    def _push_with(self, locals_, vars):  # noqa: A002
         """Enter a ``py:with`` block.
 
         When a ``py:with`` block is encountered, previous values
@@ -154,7 +152,7 @@ class _Template(object):
                     """Capture the 'k' variable in a closure"""
 
                     def trampoline(*a, **kw):
-                        global parent
+                        global parent  # noqa: PLW0602
                         return getattr(parent, k)(*a, **kw)
 
                     return trampoline
@@ -215,17 +213,11 @@ class _Template(object):
             # In 3, html.escape() translates the single quote to '&#39;'
             # In 2.6 and 2.7, cgi.escape() does not touch the single quote.
             # Preserve our tests and Kajiki behaviour across Python versions:
-            return (
-                uval.replace("&", "&amp;")
-                .replace("<", "&lt;")
-                .replace(">", "&gt;")
-                .replace('"', "&quot;")
-            )
+            return uval.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;").replace('"', "&quot;")
             # .replace("'", '&#39;'))
             # Above we do NOT escape the single quote; we don't need it because
             # all HTML attributes are double-quoted in our output.
-        else:
-            return uval
+        return uval
 
     _re_escape = re.compile(r'&|<|>|"')
 
@@ -241,13 +233,13 @@ class _Template(object):
         if attrs is not None:
             for k, v in sorted(attrs):
                 if k in HTML_EMPTY_ATTRS and v in (True, False):
-                    v = k if v else None
+                    v = k if v else None  # noqa: PLW2901
                 if v is None:
                     continue
                 if mode.startswith("html") and k in HTML_EMPTY_ATTRS:
                     yield " " + k.lower()
                 else:
-                    yield ' %s="%s"' % (k, self._escape(v))
+                    yield f' {k}="{self._escape(v)}"'
 
     def _collect(self, it):
         result = []
@@ -260,12 +252,11 @@ class _Template(object):
                 result.append(str(part))
         if result:
             return "".join(result)
-        else:
-            return None
+        return None
 
     @classmethod
     def annotate_lnotab(cls, py_to_tpl):
-        for name, meth in cls.__methods__:
+        for _name, meth in cls.__methods__:
             meth.annotate_lnotab(cls.filename, py_to_tpl, dict(py_to_tpl))
 
     def defined(self, name):
@@ -273,7 +264,7 @@ class _Template(object):
         return name in self._context
 
 
-def Template(ns):
+def Template(ns):  # noqa: N802
     """Creates a :class:`._Template` subclass from an entity with ``exposed`` functions.
 
     Kajiki uses classes as containers of the exposed functions for convenience,
@@ -285,13 +276,14 @@ def Template(ns):
         class Example:
             @kajiki.expose
             def __main__():
-                yield 'Hi'
+                yield "Hi"
+
 
         t = kajiki.Template(Example)
         output = t().render()
 
         print(output)
-        'Hi'
+        "Hi"
     """
     dct = {}
     methods = dct["__methods__"] = []
@@ -313,23 +305,23 @@ def from_ir(ir_node, base_globals=None):
     or replace default ones
     """
     if base_globals is None:
-        base_globals = dict()
+        base_globals = {}
     py_lines = list(generate_python(ir_node))
     py_text = "\n".join(map(str, py_lines))
     py_linenos = []
     last_lineno = 0
     py_lineno = 1
     for line in py_lines:
-        lno = max(last_lineno, line._lineno or 0)
+        lno = max(last_lineno, line._lineno or 0)  # noqa: SLF001
         for _ in range(str(line).count("\n") + 1):
             py_linenos.append((py_lineno, lno))
             py_lineno += 1
         last_lineno = lno
-    dct = dict(kajiki=kajiki)
+    dct = {"kajiki": kajiki}
     try:
-        exec(py_text, dct)
+        exec(py_text, dct)  # noqa: S102
     except (SyntaxError, IndentationError) as e:  # pragma no cover
-        raise KajikiSyntaxError(e.msg, py_text, e.filename, e.lineno, e.offset)
+        raise KajikiSyntaxError(e.msg, py_text, e.filename, e.lineno, e.offset) from e
     tpl = dct["template"]
     tpl.base_globals = base_globals.copy()
     tpl.base_globals.update(dct)
@@ -339,7 +331,7 @@ def from_ir(ir_node, base_globals=None):
     return tpl
 
 
-class TplFunc(object):
+class TplFunc:
     """A template function attached to a _Template.
 
     By default template functions (ie: __main__) depends
@@ -360,16 +352,15 @@ class TplFunc(object):
 
     def __repr__(self):  # pragma no cover
         if self._inst:
-            return "<bound tpl_function %r of %r>" % (self._func.__name__, self._inst)
-        else:
-            return "<unbound tpl_function %r>" % (self._func.__name__)
+            return f"<bound tpl_function {self._func.__name__!r} of {self._inst!r}>"
+        return f"<unbound tpl_function {self._func.__name__!r}>"
 
     def __call__(self, *args, **kwargs):
         if self._bound_func is None:
             self._bound_func = self._bind_globals(self._inst.__globals__)
         return self._bound_func(*args, **kwargs)
 
-    def _bind_globals(self, globals):
+    def _bind_globals(self, globals):  # noqa: A002
         """Return a function which has the globals dict set to 'globals'
         and which flattens the result of self._func'.
         """
@@ -380,9 +371,7 @@ class TplFunc(object):
             self._func.__defaults__,
             self._func.__closure__,
         )
-        return functools.update_wrapper(
-            lambda *a, **kw: flattener(func(*a, **kw)), func
-        )
+        return functools.update_wrapper(lambda *a, **kw: flattener(func(*a, **kw)), func)
 
     def annotate_lnotab(self, filename, py_to_tpl, py_to_tpl_dct):
         if not py_to_tpl:
@@ -423,7 +412,7 @@ class TplFunc(object):
 def patch_code_file_lines(code, filename, firstlineno, lnotab):
     code_args = (
         code.co_argcount,
-        code.co_posonlyargcount if version_info >= (3, 8) else "REMOVE",
+        code.co_posonlyargcount,
         code.co_kwonlyargcount,
         code.co_nlocals,
         code.co_stacksize,
@@ -446,10 +435,7 @@ def patch_code_file_lines(code, filename
 
 class KajikiTemplateError(Exception):
     def __init__(self, msg, source, filename, linen, coln):
-        super().__init__(
-            "[%s:%s] %s\n%s"
-            % (filename, linen, msg, self._get_source_snippet(source, linen))
-        )
+        super().__init__(f"[{filename}:{linen}] {msg}\n{self._get_source_snippet(source, linen)}")
         self.filename = filename
         self.linenum = linen
         self.colnum = coln
@@ -463,10 +449,8 @@ class KajikiTemplateError(Exception):
         parts = []
         for i in range(lineno - 2, lineno + 2):
             if 0 <= i < len(lines):
-                parts.append(
-                    "\t {arrow} {src}\n".format(
-                        arrow="-->" if i == lineno else "   ", src=lines[i]
-                    )
+                parts.append(  # noqa: PERF401
+                    "\t {arrow} {src}\n".format(arrow="-->" if i == lineno else "   ", src=lines[i])
                 )
         return "".join(parts)
 
diff -pruN 0.9.2-2/kajiki/text.py 1.0.2-1/kajiki/text.py
--- 0.9.2-2/kajiki/text.py	2022-11-24 19:33:57.000000000 +0000
+++ 1.0.2-1/kajiki/text.py	2025-05-05 05:23:26.000000000 +0000
@@ -19,8 +19,7 @@ import tokenize
 from itertools import chain
 
 import kajiki
-
-from . import ir
+from kajiki import ir
 
 _pattern = r"""
 \$(?:
@@ -42,19 +41,18 @@ _pattern = r"""
 _re_pattern = re.compile(_pattern, re.VERBOSE | re.IGNORECASE | re.MULTILINE)
 
 
-def TextTemplate(source=None, filename=None, autoescape=False, encoding="utf-8"):
-    assert source or filename, (
-        "You must either provide a *source* argument "
-        "or a *filename* argument to TextTemplate()."
+def TextTemplate(source=None, filename=None, autoescape=False, encoding="utf-8"):  # noqa: FBT002, N802
+    assert source or filename, (  # noqa: S101
+        "You must either provide a *source* argument " "or a *filename* argument to TextTemplate()."
     )
     if source is None:
         with codecs.open(filename, encoding=encoding) as f:
             source = f.read()
     if filename is None:
         filename = "<string>"
-    assert isinstance(
+    assert isinstance(  # noqa: S101
         source, str
-    ), "*source* must be a unicode string, not a {}".format(type(source))
+    ), f"*source* must be a unicode string, not a {type(source)}"
     scanner = _Scanner(filename, source)
     tree = _Parser(scanner, autoescape).parse()
     tree.filename = filename
@@ -68,7 +66,7 @@ def _diff_pos(last_pos, new_pos):
     return new_pos[1]
 
 
-class _Scanner(object):
+class _Scanner:
     def __init__(self, filename, source):
         self.filename = filename
         self.source = source
@@ -104,11 +102,10 @@ class _Scanner(object):
             elif groups["tag_bare_invalid"] is not None:
                 continue
             else:
-                msg = "Syntax error %s:%s" % (self.filename, self.lineno)
-                for i, line in enumerate(self.source.splitlines()):
-                    print("%3d %s" % (i + 1, line))
-                print(msg)
-                assert False, groups
+                msg = f"Syntax error {self.filename}:{self.lineno}"
+                for i, line in enumerate(self.source.splitlines(), 1):
+                    msg += f"{i:3} {line}\n"
+                raise SyntaxError(msg)
         if self.pos != len(source):
             yield self.text(source[self.pos :])
 
@@ -116,7 +113,7 @@ class _Scanner(object):
         return self._pos
 
     def _set_pos(self, value):
-        assert value >= getattr(self, "_pos", 0)
+        assert value >= getattr(self, "_pos", 0)  # noqa: S101
         self._pos = value
 
     pos = property(_get_pos, _set_pos)
@@ -145,7 +142,7 @@ class _Scanner(object):
 
     def _get_tag(self, tagname):
         end = self.source.find("%}", self.pos)
-        assert end > 0
+        assert end > 0  # noqa: S101
         body = self.source[self.pos : end]
         self.pos = end + 2
         if body.endswith("-"):
@@ -177,10 +174,11 @@ class _Scanner(object):
                 text = self.source[self.pos : self.pos + text_len - 1]
                 self.pos += text_len
                 return self.expr(text)
+        return None
 
 
-class _Parser(object):
-    def __init__(self, tokenizer, autoescape=False):
+class _Parser:
+    def __init__(self, tokenizer, autoescape=False):  # noqa: FBT002
         self.tokenizer = tokenizer
         self.functions = collections.defaultdict(list)
         self.functions["__main__()"] = []
@@ -224,11 +222,11 @@ class _Parser(object):
                     if token.tagname in stoptags:
                         yield token
                         break
-                    parser = getattr(self, "_parse_%s" % token.tagname)
+                    parser = getattr(self, f"_parse_{token.tagname}")
                     yield parser(token)
                 else:
-                    msg = "Parse error: %r unexpected" % token
-                    assert False, msg
+                    msg = f"Parse error: {token!r} unexpected"
+                    raise SyntaxError(msg)  # noqa: TRY004
             except StopIteration:
                 yield None
                 break
@@ -239,20 +237,17 @@ class _Parser(object):
         self._in_def = old_in_def
         if self._in_def:
             return ir.InnerDefNode(token.body, *body[:-1])
-        else:
-            self.functions[token.body.strip()] = body[:-1]
-            return None
+        self.functions[token.body.strip()] = body[:-1]
+        return None
 
     def _parse_call(self, token):
         b = token.body.find("(")
         e = token.body.find(")", b)
-        assert e > b > -1
+        assert e > b > -1  # noqa: S101
         arglist = token.body[b : e + 1]
         call = token.body[e + 1 :].strip()
         body = list(self._parse_body("end"))
-        return ir.CallNode(
-            "$caller%s" % arglist, call.replace("%caller", "$caller"), *body[:-1]
-        )
+        return ir.CallNode(f"$caller{arglist}", call.replace("%caller", "$caller"), *body[:-1])
 
     def _parse_if(self, token):
         body = list(self._parse_body("end", "else"))
@@ -275,14 +270,14 @@ class _Parser(object):
         self.push_tok(stoptok)
         return ir.CaseNode(token.body, *body[:-1])
 
-    def _parse_else(self, token):
+    def _parse_else(self, token):  # noqa: ARG002
         body = list(self._parse_body("end"))
         return ir.ElseNode(*body[:-1])
 
     def _parse_extends(self, token):
         parts = shlex.split(token.body)
         fn = parts[0]
-        assert len(parts) == 1
+        assert len(parts) == 1  # noqa: S101
         self._is_child = True
         return ir.ExtendNode(fn)
 
@@ -290,29 +285,24 @@ class _Parser(object):
         parts = shlex.split(token.body)
         fn = parts[0]
         if len(parts) > 1:
-            assert parts[1] == "as"
+            assert parts[1] == "as"  # noqa: S101
             return ir.ImportNode(fn, parts[2])
-        else:
-            return ir.ImportNode(fn)
+        return ir.ImportNode(fn)
 
     def _parse_include(self, token):
         parts = shlex.split(token.body)
         fn = parts[0]
-        assert len(parts) == 1
+        assert len(parts) == 1  # noqa: S101
         return ir.IncludeNode(fn)
 
     def _parse_py(self, token):
         body = token.body.strip()
-        if body:
-            body = [ir.TextNode(body), None]
-        else:
-            body = list(self._parse_body("end"))
+        body = [ir.TextNode(body), None] if body else list(self._parse_body("end"))
         node = ir.PythonNode(*body[:-1])
         if node.module_level:
             self.mod_py.append(node)
             return None
-        else:
-            return node
+        return node
 
     def _parse_block(self, token):
         fname = "_kj_block_" + token.body.strip()
@@ -321,20 +311,19 @@ class _Parser(object):
         self.functions[decl] = body
         if self._is_child:
             parent_block = "parent." + fname
-            body.insert(0, ir.PythonNode(ir.TextNode("parent_block=%s" % parent_block)))
+            body.insert(0, ir.PythonNode(ir.TextNode(f"parent_block={parent_block}")))
             return None
-        else:
-            return ir.ExprNode(decl)
+        return ir.ExprNode(decl)
 
 
-class _Token(object):
+class _Token:
     def __init__(self, filename, lineno, text):
         self.filename = filename
         self.lineno = lineno
         self.text = text
 
     def __repr__(self):  # pragma no cover
-        return "<%s %r>" % (self.__class__.__name__, self.text)
+        return f"<{self.__class__.__name__} {self.text!r}>"
 
 
 class _Expr(_Token):
diff -pruN 0.9.2-2/kajiki/util.py 1.0.2-1/kajiki/util.py
--- 0.9.2-2/kajiki/util.py	2022-11-24 19:33:57.000000000 +0000
+++ 1.0.2-1/kajiki/util.py	2025-05-05 05:23:26.000000000 +0000
@@ -1,3 +1,4 @@
+import os.path
 from collections import deque
 from random import randint
 from threading import local
@@ -8,7 +9,7 @@ def expose(func):
     return func
 
 
-class flattener(object):
+class flattener:  # noqa: N801
     def __init__(self, iterator):
         while type(iterator) == flattener:
             iterator = iterator.iterator
@@ -54,7 +55,7 @@ def literal(text):
     return flattener(iter([text]))
 
 
-class NameGen(object):
+class NameGen:
     lcl = local()
 
     def __init__(self):
@@ -64,12 +65,12 @@ class NameGen(object):
     def gen(cls, hint):
         if not hasattr(cls.lcl, "inst"):
             cls.lcl.inst = NameGen()
-        return cls.lcl.inst._gen(hint)
+        return cls.lcl.inst._gen(hint)  # noqa: SLF001
 
     def _gen(self, hint):
         r = hint
         while r in self.names:
-            r = "%s_%d" % (hint, randint(0, len(self.names) * 10))
+            r = "%s_%d" % (hint, randint(0, len(self.names) * 10))  # noqa: S311
         self.names.add(r)
         return r
 
@@ -85,3 +86,7 @@ def window(seq, n=2):
     for item in seq:
         win.append(item)
         yield win
+
+
+def default_alias_for(name):
+    return os.path.splitext(os.path.basename(name))[0]
diff -pruN 0.9.2-2/kajiki/version.py 1.0.2-1/kajiki/version.py
--- 0.9.2-2/kajiki/version.py	2022-11-24 19:33:57.000000000 +0000
+++ 1.0.2-1/kajiki/version.py	1970-01-01 00:00:00.000000000 +0000
@@ -1,2 +0,0 @@
-__version__ = "0.8"
-__release__ = "0.9.2"
diff -pruN 0.9.2-2/kajiki/xml_template.py 1.0.2-1/kajiki/xml_template.py
--- 0.9.2-2/kajiki/xml_template.py	2022-11-24 19:33:57.000000000 +0000
+++ 1.0.2-1/kajiki/xml_template.py	2025-05-05 05:23:26.000000000 +0000
@@ -1,29 +1,31 @@
+import abc
 import collections
 import html
 import io
 import re
 from codecs import open
+from sys import version_info
 from xml import sax
 from xml.dom import minidom as dom
 from xml.sax import SAXParseException
 
-from . import ir, template
-from .doctype import DocumentTypeDeclaration, extract_dtd
-from .html_utils import HTML_CDATA_TAGS, HTML_OPTIONAL_END_TAGS, HTML_REQUIRED_END_TAGS
-from .markup_template import QDIRECTIVES, QDIRECTIVES_DICT
+from kajiki import ir, template
+from kajiki.doctype import DocumentTypeDeclaration, extract_dtd
+from kajiki.html_utils import HTML_CDATA_TAGS, HTML_OPTIONAL_END_TAGS, HTML_REQUIRED_END_TAGS
+from kajiki.markup_template import QDIRECTIVES, QDIRECTIVES_DICT
 
 impl = dom.getDOMImplementation(" ")
 
 
-def XMLTemplate(
+def XMLTemplate(  # noqa: N802
     source=None,
     filename=None,
     mode=None,
-    is_fragment=False,
+    is_fragment=False,  # noqa: FBT002
     encoding="utf-8",
     autoblocks=None,
-    cdata_scripts=True,
-    strip_text=False,
+    cdata_scripts=True,  # noqa: FBT002
+    strip_text=False,  # noqa: FBT002
     base_globals=None,
 ):
     """Given XML source code of a Kajiki Templates parses and returns
@@ -59,8 +61,7 @@ def XMLTemplate(
         autoblocks=autoblocks,
         cdata_scripts=cdata_scripts,
     ).compile()
-    t = template.from_ir(ir_, base_globals=base_globals)
-    return t
+    return template.from_ir(ir_, base_globals=base_globals)
 
 
 def annotate(gen):
@@ -72,7 +73,7 @@ def annotate(gen):
     return inner
 
 
-class _Compiler(object):
+class _Compiler:
     """Compiles a DOM tree into IR :class:`kajiki.ir.TemplateNode`.
 
     Intermediate Representation is a tree of nodes that represent
@@ -84,9 +85,9 @@ class _Compiler(object):
         filename,
         doc,
         mode=None,
-        is_fragment=False,
+        is_fragment=False,  # noqa: FBT002
         autoblocks=None,
-        cdata_scripts=True,
+        cdata_scripts=True,  # noqa: FBT002
     ):
         self.filename = filename
         self.doc = doc
@@ -101,7 +102,7 @@ class _Compiler(object):
         self.is_child = False
         # The rendering mode is either specified in the *mode* argument,
         # or inferred from the DTD:
-        self._dtd = DocumentTypeDeclaration.matching(self.doc._dtd)
+        self._dtd = DocumentTypeDeclaration.matching(self.doc._dtd)  # noqa: SLF001
         if mode:
             self.mode = mode
         elif self._dtd:
@@ -130,19 +131,18 @@ class _Compiler(object):
             registries of the compiler ``compile`` should
             never be called twice or might lead to unexpected results.
         """
-        templateNodes = [
+        templateNodes = [  # noqa: N806
             n for n in self.doc.childNodes if not isinstance(n, dom.Comment)
         ]
         if len(templateNodes) != 1:
-            raise XMLTemplateCompileError(
-                "expected a single root node in document", self.doc, self.filename, 0
-            )
+            msg = "expected a single root node in document"
+            raise XMLTemplateCompileError(msg, self.doc, self.filename, 0)
 
         body = list(self._compile_node(templateNodes[0]))
         # Never emit doctypes on fragments
         if not self.is_fragment and not self.is_child:
-            if self.doc._dtd:
-                dtd = self.doc._dtd
+            if self.doc._dtd:  # noqa: SLF001
+                dtd = self.doc._dtd  # noqa: SLF001
             elif self.mode == "html5":
                 dtd = "<!DOCTYPE html>"
             else:
@@ -176,10 +176,8 @@ class _Compiler(object):
         if node.hasAttribute("py:autoblock"):
             guard = node.getAttribute("py:autoblock").lower()
             if guard not in ("false", "true"):
-                raise ValueError(
-                    "py:autoblock is evaluated at compile time "
-                    "and only accepts True/False constants"
-                )
+                msg = "py:autoblock is evaluated at compile time " "and only accepts True/False constants"
+                raise ValueError(msg)
             if guard == "false":
                 # We throw away the attribute so it doesn't remain in rendered nodes.
                 node.removeAttribute("py:autoblock")
@@ -198,22 +196,19 @@ class _Compiler(object):
         """
         if isinstance(node, dom.Comment):
             return self._compile_comment(node)
-        elif isinstance(node, dom.Text):
+        if isinstance(node, dom.Text):
             return self._compile_text(node)
-        elif isinstance(node, dom.ProcessingInstruction):
+        if isinstance(node, dom.ProcessingInstruction):
             return self._compile_pi(node)
-        elif self._is_autoblock(node):
+        if self._is_autoblock(node):
             # Set the name of the block equal to the tag itself.
             node.setAttribute("name", node.tagName)
             return self._compile_block(node)
-        elif node.tagName.startswith("py:"):
+        if node.tagName.startswith("py:"):
             # Handle directives
-            compiler = getattr(
-                self, "_compile_%s" % node.tagName.split(":")[-1], self._compile_xml
-            )
+            compiler = getattr(self, "_compile_{}".format(node.tagName.split(":")[-1]), self._compile_xml)
             return compiler(node)
-        else:
-            return self._compile_xml(node)
+        return self._compile_xml(node)
 
     @annotate
     def _compile_xml(self, node):
@@ -236,12 +231,12 @@ class _Compiler(object):
         content = attrs = guard = None
         if node.hasAttribute("py:strip"):
             guard = node.getAttribute("py:strip")
-            if guard == "":  # py:strip="" means yes, do strip the tag
+            if guard == "":  # py:strip="" means yes, do strip the tag  # noqa: SIM108
                 guard = "False"
             else:
-                guard = "not (%s)" % guard
+                guard = f"not ({guard})"
             node.removeAttribute("py:strip")
-        yield ir.TextNode("<%s" % node.tagName, guard)
+        yield ir.TextNode(f"<{node.tagName}", guard)
         for k, v in sorted(node.attributes.items()):
             tc = _TextCompiler(
                 self.filename,
@@ -251,7 +246,7 @@ class _Compiler(object):
                 in_html_attr=True,
                 compiler_instance=self,
             )
-            v = list(tc)
+            v = list(tc)  # noqa: PLW2901
             if k == "py:content":
                 content = node.getAttribute("py:content")
                 continue
@@ -264,51 +259,44 @@ class _Compiler(object):
         if content:
             yield ir.TextNode(">", guard)
             yield ir.ExprNode(content)
-            yield ir.TextNode("</%s>" % node.tagName, guard)
-        else:
-            if node.childNodes:
+            yield ir.TextNode(f"</{node.tagName}>", guard)
+        elif node.childNodes:
+            yield ir.TextNode(">", guard)
+            if self.cdata_scripts and node.tagName in HTML_CDATA_TAGS:
+                # Special behaviour for <script>, <style> tags:
+                if self.mode == "xml":  # Start escaping
+                    yield ir.TextNode("/*<![CDATA[*/")
+                # Need to unescape the contents of these tags
+                for child in node.childNodes:
+                    # CDATA for scripts and styles are automatically managed.
+                    if getattr(child, "_cdata", False):
+                        continue
+                    assert isinstance(child, dom.Text)  # noqa: S101
+                    for x in self._compile_text(child):
+                        if child.escaped:  # If user declared CDATA no escaping happened.
+                            x.text = html.unescape(x.text)
+                        yield x
+                if self.mode == "xml":  # Finish escaping
+                    yield ir.TextNode("/*]]>*/")
+            else:
+                for cn in node.childNodes:
+                    # Keep CDATA sections around if declared by user
+                    if getattr(cn, "_cdata", False):
+                        yield ir.TextNode(cn.data)
+                        continue
+                    for x in self._compile_node(cn):
+                        yield x
+            if not (self.mode.startswith("html") and node.tagName in HTML_OPTIONAL_END_TAGS):
+                yield ir.TextNode(f"</{node.tagName}>", guard)
+        elif node.tagName in HTML_REQUIRED_END_TAGS:
+            yield ir.TextNode(f"></{node.tagName}>", guard)
+        elif self.mode.startswith("html"):
+            if node.tagName in HTML_OPTIONAL_END_TAGS:
                 yield ir.TextNode(">", guard)
-                if self.cdata_scripts and node.tagName in HTML_CDATA_TAGS:
-                    # Special behaviour for <script>, <style> tags:
-                    if self.mode == "xml":  # Start escaping
-                        yield ir.TextNode("/*<![CDATA[*/")
-                    # Need to unescape the contents of these tags
-                    for child in node.childNodes:
-                        # CDATA for scripts and styles are automatically managed.
-                        if getattr(child, "_cdata", False):
-                            continue
-                        assert isinstance(child, dom.Text)
-                        for x in self._compile_text(child):
-                            if (
-                                child.escaped
-                            ):  # If user declared CDATA no escaping happened.
-                                x.text = html.unescape(x.text)
-                            yield x
-                    if self.mode == "xml":  # Finish escaping
-                        yield ir.TextNode("/*]]>*/")
-                else:
-                    for cn in node.childNodes:
-                        # Keep CDATA sections around if declared by user
-                        if getattr(cn, "_cdata", False):
-                            yield ir.TextNode(cn.data)
-                            continue
-                        for x in self._compile_node(cn):
-                            yield x
-                if not (
-                    self.mode.startswith("html")
-                    and node.tagName in HTML_OPTIONAL_END_TAGS
-                ):
-                    yield ir.TextNode("</%s>" % node.tagName, guard)
-            elif node.tagName in HTML_REQUIRED_END_TAGS:
-                yield ir.TextNode("></%s>" % node.tagName, guard)
             else:
-                if self.mode.startswith("html"):
-                    if node.tagName in HTML_OPTIONAL_END_TAGS:
-                        yield ir.TextNode(">", guard)
-                    else:
-                        yield ir.TextNode("></%s>" % node.tagName, guard)
-                else:
-                    yield ir.TextNode("/>", guard)
+                yield ir.TextNode(f"></{node.tagName}>", guard)
+        else:
+            yield ir.TextNode("/>", guard)
 
     @annotate
     def _compile_replace(self, node):
@@ -345,8 +333,7 @@ class _Compiler(object):
         self.is_child = True
         href = node.getAttribute("href")
         yield ir.ExtendNode(href)
-        for x in self._compile_nop(node):
-            yield x
+        yield from self._compile_nop(node)
 
     @annotate
     def _compile_include(self, node):
@@ -369,7 +356,7 @@ class _Compiler(object):
         self.functions[decl] = body
         if self.is_child:
             parent_block = "parent." + fname
-            body.insert(0, ir.PythonNode(ir.TextNode("parent_block=%s" % parent_block)))
+            body.insert(0, ir.PythonNode(ir.TextNode(f"parent_block={parent_block}")))
         else:
             yield ir.ExprNode(decl)
 
@@ -395,11 +382,7 @@ class _Compiler(object):
             defn = "$caller(" + node.childNodes[0].getAttribute("args") + ")"
         else:
             defn = "$caller()"
-        yield ir.CallNode(
-            defn,
-            node.getAttribute("function").replace("%caller", "$caller"),
-            *self._compile_nop(node)
-        )
+        yield ir.CallNode(defn, node.getAttribute("function").replace("%caller", "$caller"), *self._compile_nop(node))
 
     @annotate
     def _compile_text(self, node):
@@ -409,17 +392,14 @@ class _Compiler(object):
             # script and style should always be untranslatable.
             kwargs["node_type"] = ir.TextNode
 
-        tc = _TextCompiler(
-            self.filename, node.data, node.lineno, compiler_instance=self, **kwargs
-        )
-        for x in tc:
-            yield x
+        tc = _TextCompiler(self.filename, node.data, node.lineno, compiler_instance=self, **kwargs)
+        yield from tc
 
     @annotate
     def _compile_comment(self, node):
         """Convert comments to their intermediate representation."""
         if not node.data.startswith("!"):
-            yield ir.TextNode("<!-- %s -->" % node.data)
+            yield ir.TextNode(f"<!-- {node.data} -->")
 
     @annotate
     def _compile_for(self, node):
@@ -441,9 +421,9 @@ class _Compiler(object):
             if isinstance(n, ir.TextNode) and not n.text.strip():
                 continue
             elif not isinstance(n, (ir.CaseNode, ir.ElseNode)):
+                msg = "py:switch directive can only contain py:case and py:else nodes " "and cannot be placed on a tag."
                 raise XMLTemplateCompileError(
-                    "py:switch directive can only contain py:case and py:else nodes "
-                    "and cannot be placed on a tag.",
+                    msg,
                     doc=self.doc,
                     filename=self.filename,
                     linen=node.lineno,
@@ -453,9 +433,49 @@ class _Compiler(object):
         yield ir.SwitchNode(node.getAttribute("test"), *body)
 
     @annotate
+    def _compile_match(self, node):
+        """Convert py:match nodes to their IR."""
+        if version_info < (3, 10):
+            msg = "At least Python 3.10 is required to use the py:match directive"
+            raise XMLTemplateCompileError(
+                msg,
+                doc=self.doc,
+                filename=self.filename,
+                linen=node.lineno,
+            )
+        body = []
+
+        # Filter out empty text nodes and report unsupported nodes
+        for n in self._compile_nop(node):
+            if isinstance(n, ir.TextNode) and not n.text.strip():
+                continue
+            elif not isinstance(n, ir.MatchCaseNode):
+                msg = "py:match directive can only contain py:case nodes and cannot be placed on a tag."
+                raise XMLTemplateCompileError(
+                    msg,
+                    doc=self.doc,
+                    filename=self.filename,
+                    linen=node.lineno,
+                )
+            body.append(n)
+
+        yield ir.MatchNode(node.getAttribute("on"), *body)
+
+    @annotate
     def _compile_case(self, node):
         """Convert py:case nodes to their intermediate representation."""
-        yield ir.CaseNode(node.getAttribute("value"), *list(self._compile_nop(node)))
+        if node.getAttribute("value"):
+            yield ir.CaseNode(node.getAttribute("value"), *list(self._compile_nop(node)))
+        elif node.getAttribute("match"):
+            yield ir.MatchCaseNode(node.getAttribute("match"), *list(self._compile_nop(node)))
+        else:
+            msg = "case must have either value or match attribute, the former for py:switch, the latter for py:match"
+            raise XMLTemplateCompileError(
+                msg,
+                doc=self.doc,
+                filename=self.filename,
+                linen=node.lineno,
+            )
 
     @annotate
     def _compile_if(self, node):
@@ -470,9 +490,12 @@ class _Compiler(object):
             and not node.parentNode.hasAttribute("py:switch")
             and getattr(node.previousSibling, "tagName", "") != "py:if"
         ):
-            raise XMLTemplateCompileError(
+            msg = (
                 "py:else directive must be inside a py:switch or directly after py:if "
-                "without text or spaces in between",
+                "without text or spaces in between"
+            )
+            raise XMLTemplateCompileError(
+                msg,
                 doc=self.doc,
                 filename=self.filename,
                 linen=node.lineno,
@@ -483,8 +506,7 @@ class _Compiler(object):
     @annotate
     def _compile_nop(self, node):
         for c in node.childNodes:
-            for x in self._compile_node(c):
-                yield x
+            yield from self._compile_node(c)
 
 
 def make_text_node(text, guard=None):
@@ -498,7 +520,7 @@ def make_text_node(text, guard=None):
     return ir.TextNode(text, guard)
 
 
-class _TextCompiler(object):
+class _TextCompiler:
     """Separates expressions such as ${some_var} from the ordinary text
     around them in the template source and generates :class:`.ir.ExprNode`
     instances and :class:`.ir.TextNode` instances accordingly.
@@ -510,7 +532,7 @@ class _TextCompiler(object):
         source,
         lineno,
         node_type=make_text_node,
-        in_html_attr=False,
+        in_html_attr=False,  # noqa: FBT002
         compiler_instance=None,
     ):
         self.filename = filename
@@ -587,40 +609,38 @@ class _TextCompiler(object):
         except SyntaxError as se:
             end = sum(
                 [self.pos, se.offset]
-                + [
-                    len(line) + 1
-                    for idx, line in enumerate(py_expr().splitlines())
-                    if idx < se.lineno - 1
-                ]
+                + [len(line) + 1 for idx, line in enumerate(py_expr().splitlines()) if idx < se.lineno - 1]
             )
             if py_expr(end)[-1] != "}":
                 # for example unclosed strings
+                msg = f"Kajiki can't compile the python expression `{py_expr()[:-1]}`"
                 raise XMLTemplateCompileError(
-                    "Kajiki can't compile the python expression `%s`" % py_expr()[:-1],
+                    msg,
                     doc=self.doc,
                     filename=self.filename,
                     linen=self.lineno,
-                )
-            else:
-                # if the expression ends in a } then it may be valid
-                try:
-                    compile(py_expr(end - 1), "check_validity", "eval")
-                except SyntaxError:
-                    # for example + operators with a single operand
-                    raise XMLTemplateCompileError(
-                        "Kajiki detected an invalid python expression `%s`"
-                        % py_expr()[:-1],
-                        doc=self.doc,
-                        filename=self.filename,
-                        linen=self.lineno,
-                    )
+                ) from None
+
+            # if the expression ends in a } then it may be valid
+            try:
+                compile(py_expr(end - 1), "check_validity", "eval")
+            except SyntaxError:
+                # for example + operators with a single operand
+                msg = f"Kajiki detected an invalid python expression `{py_expr()[:-1]}`"
+                raise XMLTemplateCompileError(
+                    msg,
+                    doc=self.doc,
+                    filename=self.filename,
+                    linen=self.lineno,
+                ) from None
 
             py_text = py_expr(end - 1)
             self.pos = end
             return self.expr(py_text)
         else:
+            msg = "Braced expression not terminated"
             raise XMLTemplateCompileError(
-                "Braced expression not terminated",
+                msg,
                 doc=self.doc,
                 filename=self.filename,
                 linen=self.lineno,
@@ -652,56 +672,48 @@ class _Parser(sax.ContentHandler):
         """
         sax.ContentHandler.__init__(self)
         if not isinstance(source, str):
-            raise TypeError("The template source must be a unicode string.")
+            msg = "The template source must be a unicode string."
+            raise TypeError(msg)
         self._els = []
         self._doc = dom.Document()
         self._filename = filename
         # Store the original DTD in the document for the compiler to use later
-        self._doc._dtd, position, source = extract_dtd(source)
+        self._doc._dtd, position, source = extract_dtd(source)  # noqa: SLF001
         # Use our own DTD just for XML parsing
         self._source = source[:position] + self.DTD + source[position:]
         self._cdata_stack = []
 
     def parse(self):
         """Parse an XML/HTML document to its DOM representation."""
-        self._parser = parser = sax.make_parser()
+        self._parser = parser = sax.make_parser()  # noqa: S317
         parser.setFeature(sax.handler.feature_external_pes, False)
         parser.setFeature(sax.handler.feature_external_ges, False)
         parser.setFeature(sax.handler.feature_namespaces, False)
         parser.setProperty(sax.handler.property_lexical_handler, self)
         parser.setContentHandler(self)
         source = sax.xmlreader.InputSource()
-        # Sweet XMLReader.parse() documentation says:
-        # "As a limitation, the current implementation only accepts byte
-        # streams; processing of character streams is for further study."
-        # So if source is unicode, we pre-encode it:
-        # TODO Is this dance really necessary? Can't I just call a function?
-        byts = self._source.encode("utf-8")
-        source.setEncoding("utf-8")
-        source.setByteStream(io.BytesIO(byts))
+        source.setCharacterStream(io.StringIO(self._source))
         source.setSystemId(self._filename)
 
         try:
             parser.parse(source)
         except SAXParseException as e:
-            exc = XMLTemplateParseError(
+            raise XMLTemplateParseError(
                 e.getMessage(),
                 self._source,
                 self._filename,
                 e.getLineNumber(),
                 e.getColumnNumber(),
-            )
-            exc.__cause__ = None
-            raise exc
+            ) from None
 
-        self._doc._source = self._source
+        self._doc._source = self._source  # noqa: SLF001
         return self._doc
 
     # ContentHandler implementation
-    def startDocument(self):
+    def startDocument(self):  # noqa: N802
         self._els.append(self._doc)
 
-    def startElement(self, name, attrs):
+    def startElement(self, name, attrs):  # noqa: N802
         el = self._doc.createElement(name)
         el.lineno = self._parser.getLineNumber()
         for k, v in attrs.items():
@@ -709,9 +721,9 @@ class _Parser(sax.ContentHandler):
         self._els[-1].appendChild(el)
         self._els.append(el)
 
-    def endElement(self, name):
+    def endElement(self, name):  # noqa: N802
         popped = self._els.pop()
-        assert name == popped.tagName
+        assert name == popped.tagName  # noqa: S101
 
     def characters(self, content):
         should_escape = not self._cdata_stack
@@ -722,12 +734,12 @@ class _Parser(sax.ContentHandler):
         node.escaped = should_escape
         self._els[-1].appendChild(node)
 
-    def processingInstruction(self, target, data):
+    def processingInstruction(self, target, data):  # noqa: N802
         node = self._doc.createProcessingInstruction(target, data)
         node.lineno = self._parser.getLineNumber()
         self._els[-1].appendChild(node)
 
-    def skippedEntity(self, name):
+    def skippedEntity(self, name):  # noqa: N802
         # Deals with an HTML entity such as &nbsp; (XML itself defines
         # very few entities.)
 
@@ -744,17 +756,21 @@ class _Parser(sax.ContentHandler):
             name += ";"
         return self.characters(html.entities.html5[name])
 
-    def startElementNS(self, name, qname, attrs):  # pragma no cover
-        raise NotImplementedError("startElementNS")
+    @abc.abstractmethod
+    def startElementNS(self, name, qname, attrs):  # noqa: N802
+        pass
 
-    def endElementNS(self, name, qname):  # pragma no cover
-        raise NotImplementedError("startElementNS")
+    @abc.abstractmethod
+    def endElementNS(self, name, qname):  # noqa: N802
+        pass
 
-    def startPrefixMapping(self, prefix, uri):  # pragma no cover
-        raise NotImplementedError("startPrefixMapping")
+    @abc.abstractmethod
+    def startPrefixMapping(self, prefix, uri):  # noqa: N802
+        pass
 
-    def endPrefixMapping(self, prefix):  # pragma no cover
-        raise NotImplementedError("endPrefixMapping")
+    @abc.abstractmethod
+    def endPrefixMapping(self, prefix):  # noqa: N802
+        pass
 
     # LexicalHandler implementation
     def comment(self, text):
@@ -762,28 +778,28 @@ class _Parser(sax.ContentHandler):
         node.lineno = self._parser.getLineNumber()
         self._els[-1].appendChild(node)
 
-    def startCDATA(self):
+    def startCDATA(self):  # noqa: N802
         node = self._doc.createTextNode("<![CDATA[")
-        node._cdata = True
+        node._cdata = True  # noqa: SLF001
         node.lineno = self._parser.getLineNumber()
         self._els[-1].appendChild(node)
         self._cdata_stack.append(self._els[-1])
 
-    def endCDATA(self):
+    def endCDATA(self):  # noqa: N802
         node = self._doc.createTextNode("]]>")
-        node._cdata = True
+        node._cdata = True  # noqa: SLF001
         node.lineno = self._parser.getLineNumber()
         self._els[-1].appendChild(node)
         self._cdata_stack.pop()
 
-    def startDTD(self, name, pubid, sysid):
+    def startDTD(self, name, pubid, sysid):  # noqa: N802
         self._doc.doctype = impl.createDocumentType(name, pubid, sysid)
 
-    def endDTD(self):
+    def endDTD(self):  # noqa: N802
         pass
 
 
-class _DomTransformer(object):
+class _DomTransformer:
     """Applies standard Kajiki transformations to a parsed document.
 
     Given a document generated by :class:`.Parser` it applies some
@@ -796,7 +812,7 @@ class _DomTransformer(object):
     The Transformer mutates the original document.
     """
 
-    def __init__(self, doc, strip_text=True):
+    def __init__(self, doc, strip_text=True):  # noqa: FBT002
         self._transformed = False
         self.doc = doc
         self._strip_text = strip_text
@@ -889,9 +905,7 @@ class _DomTransformer(object):
                         empty_text_len = len(child.data) - len(rstripped_data)
                         empty_text = child.data[-empty_text_len:]
                         end_node = child.ownerDocument.createTextNode(empty_text)
-                        end_node.lineno = child.lineno + child.data[
-                            :-empty_text_len
-                        ].count("\n")
+                        end_node.lineno = child.lineno + child.data[:-empty_text_len].count("\n")
                         end_node.escaped = child.escaped
                         tree.replaceChild(newChild=end_node, oldChild=child)
                         tree.insertBefore(newChild=child, refChild=end_node)
@@ -909,9 +923,7 @@ class _DomTransformer(object):
                     # Move lineno forward the amount of lines we are
                     # going to strip.
                     lstripped_data = child.data.lstrip()
-                    child.lineno += child.data[
-                        : len(child.data) - len(lstripped_data)
-                    ].count("\n")
+                    child.lineno += child.data[: len(child.data) - len(lstripped_data)].count("\n")
                     child.data = child.data.strip()
             else:
                 cls._strip_text_nodes(child)
@@ -934,7 +946,7 @@ class _DomTransformer(object):
             </py:if>
 
         This ensures that whenever a template is processed there is no
-        different between the two formats as the Compiler will always
+        difference between the two formats as the Compiler will always
         receive the latter.
         """
         if isinstance(tree, dom.Document):
@@ -943,9 +955,11 @@ class _DomTransformer(object):
         if not isinstance(getattr(tree, "tagName", None), str):
             return tree
         if tree.tagName in QDIRECTIVES_DICT:
-            tree.setAttribute(
-                tree.tagName, tree.getAttribute(QDIRECTIVES_DICT[tree.tagName])
-            )
+            attrs = QDIRECTIVES_DICT[tree.tagName]
+            if not isinstance(attrs, tuple):
+                attrs = [attrs]
+            for attr in attrs:
+                tree.setAttribute(tree.tagName, tree.getAttribute(attr))
             tree.tagName = "py:nop"
         if tree.tagName != "py:nop" and tree.hasAttribute("py:extends"):
             value = tree.getAttribute("py:extends")
@@ -962,7 +976,17 @@ class _DomTransformer(object):
             # nsmap = (parent is not None) and parent.nsmap or tree.nsmap
             el = tree.ownerDocument.createElement(directive)
             el.lineno = tree.lineno
-            if attr:
+            if isinstance(attr, tuple):
+                # eg: handle bare py:case tags
+                for at in attr:
+                    el.setAttribute(at, dict(tree.attributes.items()).get(at))
+                if directive == "py:case" and tree.nodeName != "py:case":
+                    if tree.parentNode.nodeName == "py:match" or "py:match" in tree.parentNode.attributes:
+                        at = "on"
+                    else:
+                        at = "value"
+                    el.setAttribute(at, value)
+            elif attr:
                 el.setAttribute(attr, value)
             # el.setsourceline = tree.sourceline
             parent.replaceChild(newChild=el, oldChild=tree)
diff -pruN 0.9.2-2/pyproject.toml 1.0.2-1/pyproject.toml
--- 0.9.2-2/pyproject.toml	1970-01-01 00:00:00.000000000 +0000
+++ 1.0.2-1/pyproject.toml	2025-05-05 05:23:26.000000000 +0000
@@ -0,0 +1,110 @@
+[build-system]
+requires = ["hatchling"]
+build-backend = "hatchling.build"
+
+[project]
+name = "kajiki"
+version = "1.0.2"
+description = "Fast XML-based template engine with Genshi syntax and Jinja blocks"
+readme = "README.rst"
+license = "MIT"
+requires-python = ">=3.8"
+authors = [
+    { name = "Rick Copeland", email = "rick446@usa.net" },
+    { name = "Nando Florestan", email = "nandoflorestan@gmail.com" },
+    { name = "Alessandro Molina", email = "alessandro@molina.fyi" },
+    { name = "Jack Rosenthal", email = "jack@rosenth.al" },
+]
+maintainers = [
+    { name = "Jack Rosenthal", email = "jack@rosenth.al" },
+]
+keywords = [
+    "chameleon",
+    "engine",
+    "genshi",
+    "html",
+    "jinja",
+    "jinja2",
+    "mako",
+    "template",
+    "templating",
+    "xhtml",
+    "xml",
+]
+classifiers = [
+    "Development Status :: 5 - Production/Stable",
+    "Environment :: Web Environment",
+    "Intended Audience :: Developers",
+    "License :: OSI Approved :: MIT License",
+    "Operating System :: OS Independent",
+    "Programming Language :: Python :: 3",
+    "Programming Language :: Python :: 3.8",
+    "Programming Language :: Python :: 3.9",
+    "Programming Language :: Python :: 3.10",
+    "Programming Language :: Python :: 3.11",
+    "Programming Language :: Python :: 3.12",
+    "Programming Language :: Python :: 3.13",
+    "Programming Language :: Python :: Implementation :: CPython",
+    "Programming Language :: Python :: Implementation :: PyPy",
+    "Programming Language :: Python",
+    "Topic :: Internet :: WWW/HTTP :: Dynamic Content",
+    "Topic :: Software Development :: Libraries :: Python Modules",
+    "Topic :: Text Processing :: Markup :: HTML",
+    "Topic :: Text Processing :: Markup :: XML",
+]
+dependencies = [
+    "linetable",
+    'importlib_resources; python_version < "3.9"',
+]
+
+[project.urls]
+Homepage = "https://github.com/jackrosenthal/kajiki"
+
+[project.scripts]
+kajiki = "kajiki.__main__:main"
+
+[project.entry-points."babel.extractors"]
+kajiki = "kajiki.i18n:extract"
+
+[tool.hatch.build.targets.sdist]
+include = [
+    "/kajiki",
+    "/tests",
+]
+
+[tool.hatch.envs.hatch-test]
+extra-dependencies = [
+    "TurboGears2==2.5.0",
+]
+
+[[tool.hatch.envs.hatch-test.matrix]]
+python = ["3.8", "3.9", "3.10", "3.11", "3.12", "3.13", "pypy3.10"]
+
+[tool.hatch.envs.default]
+installer = "uv"
+python = "3.12"
+
+[tool.hatch.envs.docs]
+dependencies = [
+    "furo",
+    "sphinx",
+]
+
+[tool.hatch.envs.docs.scripts]
+build = "sphinx-build -M html docs docs/_build"
+
+[tool.hatch.envs.speedtest]
+dependencies = [
+    "genshi",
+]
+
+[tool.hatch.envs.speedtest.scripts]
+run = "./speedtest.py"
+
+[tool.hatch.envs.repl]
+extra-dependencies = [
+    "ptpython"
+]
+
+[tool.ruff.lint.extend-per-file-ignores]
+"tests/*" = ["INP001", "SLF001"]
diff -pruN 0.9.2-2/release_new_version.py 1.0.2-1/release_new_version.py
--- 0.9.2-2/release_new_version.py	2022-11-24 19:33:57.000000000 +0000
+++ 1.0.2-1/release_new_version.py	1970-01-01 00:00:00.000000000 +0000
@@ -1,48 +0,0 @@
-#!/usr/bin/env python
-
-"""Script that releases a new version of the software."""
-
-from releaser import Releaser  # easy_install -UZ releaser
-from releaser.git_steps import *
-from releaser.steps import *
-
-# These settings are used by multiple release steps below.
-config = dict(
-    github_user="jackrosenthal",  # TODO infer from .git/config
-    github_repository="kajiki",
-    branch="master",  # Only release new versions in this git branch
-    changes_file="CHANGES.rst",
-    version_file="kajiki/version.py",  # The version number is in this file
-    version_keyword="release",  # Part of the variable name in that file
-    log_file="release.log.utf-8.tmp",
-    verbosity="info",  # debug | info | warn | error
-)
-
-# You can customize your release process below.
-# Comment out any steps you don't desire and add your own steps.
-Releaser(
-    config,
-    Shell("pytest"),  # First of all ensure tests pass
-    # CheckRstFiles,  # Documentation: recursively verify ALL .rst files, or:
-    # CheckRstFiles('CHANGES.rst', 'LICENSE.rst'),  # just a few.
-    # TODO IMPLEMENT CompileAndVerifyTranslations,
-    # TODO IMPLEMENT BuildSphinxDocumentation,
-    # TODO IMPLEMENT Tell the user to upload the built docs (give URL)
-    EnsureGitClean,  # There are no uncommitted changes in tracked files.
-    EnsureGitBranch,  # I must be in the branch specified in config
-    InteractivelyEnsureChangesDocumented,  # Did you update CHANGES.rst?
-    # =================  All checks pass. RELEASE!  ===========================
-    SetVersionNumberInteractively,  # Ask for version and write to source code
-    # TODO IMPLEMENT CHANGES file: add heading for current version (below dev)
-    GitCommitVersionNumber,
-    GitTag,  # Locally tag the current commit with the new version number
-    InteractivelyApproveDistribution,  # Generate sdist, let user verify it
-    InteractivelyApproveWheel,  # Generate wheel, let user verify it
-    PypiUpload,  # Make and upload a source .tar.gz to https://pypi.org
-    PypiUploadWheel,  # Make and upload source wheel to https://pypi.org
-    # ===========  Post-release: set development version and push  ============
-    SetFutureVersion,  # Writes incremented version, now with 'dev' suffix
-    GitCommitVersionNumber("future_version", msg="Bump version to {0} after release"),
-    GitPush,  # Cannot be undone. If successful, previous steps won't roll back
-    GitPushTags,
-).release()
diff -pruN 0.9.2-2/setup.py 1.0.2-1/setup.py
--- 0.9.2-2/setup.py	2022-11-24 19:33:57.000000000 +0000
+++ 1.0.2-1/setup.py	1970-01-01 00:00:00.000000000 +0000
@@ -1,76 +0,0 @@
-#!/usr/bin/env python
-
-import os
-
-from setuptools import find_packages, setup
-
-# http://peak.telecommunity.com/DevCenter/setuptools#developer-s-guide
-
-# Get version info
-__version__ = None
-__release__ = None
-exec(open("kajiki/version.py").read())
-
-
-def content_of(*files):
-    here = os.path.abspath(os.path.dirname(__file__))
-    content = []
-    for f in files:
-        with open(os.path.join(here, f)) as stream:
-            content.append(stream.read())
-    return "\n".join(content)
-
-
-setup(
-    name="kajiki",
-    version=__release__,
-    description="Fast XML-based template engine with Genshi syntax and " "Jinja blocks",
-    long_description=content_of("README.rst", "CHANGES.rst"),
-    classifiers=[  # http://pypi.python.org/pypi?:action=list_classifiers
-        "Development Status :: 5 - Production/Stable",
-        "Environment :: Web Environment",
-        "Intended Audience :: Developers",
-        "License :: OSI Approved :: MIT License",
-        "Operating System :: OS Independent",
-        "Programming Language :: Python",
-        "Programming Language :: Python :: 3",
-        "Programming Language :: Python :: 3.4",
-        "Programming Language :: Python :: 3.5",
-        "Programming Language :: Python :: 3.6",
-        "Programming Language :: Python :: 3.7",
-        "Programming Language :: Python :: 3.8",
-        "Programming Language :: Python :: 3.9",
-        "Programming Language :: Python :: 3.10",
-        "Programming Language :: Python :: 3.11",
-        "Programming Language :: Python :: Implementation :: CPython",
-        "Programming Language :: Python :: Implementation :: PyPy",
-        "Topic :: Internet :: WWW/HTTP :: Dynamic Content",
-        "Topic :: Software Development :: Libraries :: Python Modules",
-        "Topic :: Text Processing :: Markup :: HTML",
-        "Topic :: Text Processing :: Markup :: XML",
-    ],
-    keywords="templating engine template genshi jinja jinja2 mako "
-    "chameleon xml html xhtml",
-    author="Rick Copeland",
-    author_email="rick446@usa.net",
-    maintainer="Jack Rosenthal",
-    maintainer_email="jack@rosenth.al",
-    url="https://github.com/jackrosenthal/kajiki",
-    license="MIT",
-    packages=find_packages(exclude=["ez_setup", "examples", "tests"]),
-    include_package_data=True,
-    zip_safe=False,
-    python_requires=">=3.4",
-    install_requires=["linetable"],
-    extras_require={
-        "testing": ["babel", "pytest"],
-        "docs": ["sphinx"],
-    },
-    entry_points="""
-          [console_scripts]
-          kajiki = kajiki.__main__:main
-
-          [babel.extractors]
-          kajiki = kajiki.i18n:extract
-      """,
-)
diff -pruN 0.9.2-2/speedtest.py 1.0.2-1/speedtest.py
--- 0.9.2-2/speedtest.py	2022-11-24 19:33:57.000000000 +0000
+++ 1.0.2-1/speedtest.py	2025-05-05 05:23:26.000000000 +0000
@@ -1,4 +1,6 @@
-#!/usr/bin/env python
+#!/usr/bin/env -S hatch env run -e speedtest python
+# ruff: noqa: T201
+
 import time
 from collections import defaultdict
 from contextlib import contextmanager
@@ -7,7 +9,7 @@ from genshi.template import MarkupTempla
 
 from kajiki import XMLTemplate
 
-FN = "kajiki/perf/tables.html"
+FN = "tests/data/tables.html"
 
 timings = defaultdict(float)
 
@@ -17,40 +19,30 @@ def timing(s):
     start = time.time()
     yield
     elapsed = time.time() - start
-    print("%s: %s s" % (s, elapsed))
+    print(f"{s}: {elapsed} s")
     timings[s] += elapsed
 
 
 with timing("compile.kajiki"):
     fpt = XMLTemplate(filename=FN)
-    # fpt.compile()
-with timing("compile.genshi"):
-    gt = MarkupTemplate(open(FN))
+with timing("compile.genshi"), open(FN) as f:
+    gt = MarkupTemplate(f)
 with timing("render.100.kajiki"):
-    fpt(dict(size=100)).render()
+    fpt({"size": 100}).render()
 with timing("render.100.genshi"):
     gt.generate(size=100).render()
 with timing("render.100.kajiki"):
-    fpt(dict(size=100)).render()
+    fpt({"size": 100}).render()
 with timing("render.100.genshi"):
     gt.generate(size=100).render()
 with timing("render.100.kajiki"):
-    fpt(dict(size=100)).render()
+    fpt({"size": 100}).render()
 with timing("render.100.genshi"):
     gt.generate(size=100).render()
 with timing("render.500.kajiki"):
-    fpt(dict(size=500)).render()
+    fpt({"size": 500}).render()
 with timing("render.500.genshi"):
     gt.generate(size=500).render()
-print(
-    "Compile kajiki speedup: %s"
-    % (timings["compile.genshi"] / timings["compile.kajiki"])
-)
-print(
-    "Render 100 kajiki speedup: %s"
-    % (timings["render.100.genshi"] / timings["render.100.kajiki"])
-)
-print(
-    "Render 500 kajiki speedup: %s"
-    % (timings["render.500.genshi"] / timings["render.500.kajiki"])
-)
+print("Compile kajiki speedup:", timings["compile.genshi"] / timings["compile.kajiki"])
+print("Render 100 kajiki speedup:", timings["render.100.genshi"] / timings["render.100.kajiki"])
+print("Render 500 kajiki speedup:", timings["render.500.genshi"] / timings["render.500.kajiki"])
diff -pruN 0.9.2-2/tests/conftest.py 1.0.2-1/tests/conftest.py
--- 0.9.2-2/tests/conftest.py	2022-11-24 19:33:57.000000000 +0000
+++ 1.0.2-1/tests/conftest.py	2025-05-05 05:23:26.000000000 +0000
@@ -10,16 +10,6 @@ def add_test_data_module():
     here = pathlib.Path(__file__).parent
     data_package = here / "data" / "__init__.py"
     spec = importlib.util.spec_from_file_location("kajiki_test_data", str(data_package))
-
-    # TODO(Python3.4): below dance can change to
-    # importlib.util.module_from_spec() when support for Python 3.4 is
-    # dropped.
-    module = type(sys)(spec.name)
-    module.__name__ = spec.name
-    module.__loader__ = spec.loader
-    module.__package__ = spec.parent
-    module.__path__ = spec.submodule_search_locations
-    module.__file__ = spec.origin
-
+    module = importlib.util.module_from_spec(spec)
     sys.modules[spec.name] = module
     spec.loader.exec_module(module)
diff -pruN 0.9.2-2/tests/data/tables.html 1.0.2-1/tests/data/tables.html
--- 0.9.2-2/tests/data/tables.html	1970-01-01 00:00:00.000000000 +0000
+++ 1.0.2-1/tests/data/tables.html	2025-05-05 05:23:26.000000000 +0000
@@ -0,0 +1,11 @@
+<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN"
+          "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
+<table xmlns:py="http://genshi.edgewall.org/">
+  <tbody>
+    <tr py:for="i in range(size)">
+      <td py:for="j in range(size)">
+        $i, $j
+      </td>
+    </tr>
+  </tbody>
+</table>
diff -pruN 0.9.2-2/tests/integration/test_tg2.py 1.0.2-1/tests/integration/test_tg2.py
--- 0.9.2-2/tests/integration/test_tg2.py	1970-01-01 00:00:00.000000000 +0000
+++ 1.0.2-1/tests/integration/test_tg2.py	2025-05-05 05:23:26.000000000 +0000
@@ -0,0 +1,93 @@
+"""Test we don't break TurboGears' usage of our Python API."""
+
+from __future__ import annotations
+
+import dataclasses
+import wsgiref.util
+from pathlib import Path
+from typing import Any
+
+import pytest
+
+from kajiki import i18n
+
+DATA = Path(__file__).resolve().parent.parent / "data"
+GOLDEN = DATA / "golden"
+
+
+@pytest.fixture
+def tg2():
+    """Import tg2 (if available), and cleanup its mess."""
+    orig_gettext = i18n.gettext
+    try:
+        import tg
+    except ImportError:
+        pytest.skip("TurboGears not installed")
+    else:
+        yield tg
+    finally:
+        i18n.gettext = orig_gettext
+
+
+@pytest.fixture
+def tg2_app(tg2):
+    """Create a new tg2 app with defaults for testing.
+
+    Returns:
+        A WSGI application.
+    """
+
+    class RootController(tg2.TGController):
+        @tg2.expose("kajiki_test_data.kitchensink")
+        def index(self):
+            return {}
+
+    config = tg2.MinimalApplicationConfigurator()
+    config.update_blueprint(
+        {
+            "root_controller": RootController(),
+            "renderers": ["kajiki"],
+            "templating.kajiki.template_extension": ".html",
+        }
+    )
+    return config.make_wsgi_app()
+
+
+@dataclasses.dataclass
+class Response:
+    """Encapsulates a response for testing."""
+
+    status: str
+    headers: list[tuple[str, str]]
+    body: str
+
+
+def app_request(app: Any, path: str = "/", method: str = "GET") -> Response:
+    response_status = ""
+    response_headers = []
+
+    def start_response(status, headers):
+        nonlocal response_status
+        nonlocal response_headers
+        response_status = status
+        response_headers = headers
+
+    environ = {}
+    wsgiref.util.setup_testing_defaults(environ)
+    environ["REQUEST_METHOD"] = method
+    environ["PATH_INFO"] = path
+
+    response_body = b"".join(app(environ, start_response))
+
+    return Response(
+        status=response_status,
+        headers=response_headers,
+        body=response_body,
+    )
+
+
+def test_smoke_tg2(tg2_app):
+    """Do a basic smoke test of using kajiki renderer on tg2."""
+    response = app_request(tg2_app, "/")
+    assert response.status == "200 OK"
+    assert response.body == (GOLDEN / "kitchensink1.html").read_bytes()
diff -pruN 0.9.2-2/tests/test_cli.py 1.0.2-1/tests/test_cli.py
--- 0.9.2-2/tests/test_cli.py	2022-11-24 19:33:57.000000000 +0000
+++ 1.0.2-1/tests/test_cli.py	2025-05-05 05:23:26.000000000 +0000
@@ -1,4 +1,5 @@
-import unittest.mock as mock
+import sys
+from unittest import mock
 
 import pytest
 
@@ -11,7 +12,7 @@ class MainMocks:
         mocked_render = mock.Mock(return_value="render result")
         self.render = mocked_render
 
-        class MockedTemplate(object):
+        class MockedTemplate:
             def render(self, *args, **kwargs):
                 return mocked_render(*args, **kwargs)
 
@@ -20,7 +21,7 @@ class MainMocks:
         mocked_import = mock.Mock(return_value=self.template_type)
         self.import_ = mocked_import
 
-        class MockedLoader(object):
+        class MockedLoader:
             def import_(self, *args, **kwargs):
                 return mocked_import(*args, **kwargs)
 
@@ -37,7 +38,7 @@ def main_mocks(monkeypatch):
 
 
 @pytest.mark.parametrize(
-    ["filename", "load_path"],
+    ("filename", "load_path"),
     [
         ("filename.txt", "."),
         ("/path/to/filename.xml", "/path/to"),
@@ -47,9 +48,7 @@ def main_mocks(monkeypatch):
 def test_simple_file_load(filename, load_path, capsys, main_mocks):
     main([filename])
 
-    main_mocks.file_loader_type.assert_called_once_with(
-        path=[load_path], force_mode=None
-    )
+    main_mocks.file_loader_type.assert_called_once_with(path=[load_path], force_mode=None)
     main_mocks.import_.assert_called_once_with(filename)
     main_mocks.template_type.assert_called_once_with({})
     main_mocks.render.assert_called_once_with()
@@ -108,7 +107,7 @@ def test_output_to_file(tmpdir, main_moc
     main_mocks.template_type.assert_called_once_with({})
     main_mocks.render.assert_called_once_with()
 
-    with open(outfile, "r") as f:
+    with open(outfile) as f:
         assert f.read() == "render result"
 
 
@@ -134,6 +133,37 @@ def test_template_variables_bad(capsys):
 
     captured = capsys.readouterr()
     assert captured.out == ""
-    assert captured.err.endswith(
-        "error: argument -v/--var: Expected a KEY=VALUE pair, got BADBADBAD\n"
+    assert captured.err.endswith("error: argument -v/--var: Expected a KEY=VALUE pair, got BADBADBAD\n")
+
+
+def test_json_template_variables(tmp_path, main_mocks):
+    json_file = tmp_path / "json_file.json"
+    json_file.write_text('{"foo": "bar", "baz": "bip"}')
+    main(["--json", str(json_file), "infile.txt"])
+
+    main_mocks.file_loader_type.assert_called_once_with(path=["."], force_mode=None)
+    main_mocks.import_.assert_called_once_with("infile.txt")
+    main_mocks.template_type.assert_called_once_with(
+        {
+            "foo": "bar",
+            "baz": "bip",
+        }
+    )
+    main_mocks.render.assert_called_once_with()
+
+
+@pytest.mark.skipif(sys.version_info < (3, 11), reason="tomllib new in python3.11")
+def test_toml_template_variables(tmp_path, main_mocks):
+    toml_file = tmp_path / "toml_file.toml"
+    toml_file.write_text('foo = "bar"\nbaz = "bip"')
+    main(["--toml", str(toml_file), "infile.txt"])
+
+    main_mocks.file_loader_type.assert_called_once_with(path=["."], force_mode=None)
+    main_mocks.import_.assert_called_once_with("infile.txt")
+    main_mocks.template_type.assert_called_once_with(
+        {
+            "foo": "bar",
+            "baz": "bip",
+        }
     )
+    main_mocks.render.assert_called_once_with()
diff -pruN 0.9.2-2/tests/test_doctype.py 1.0.2-1/tests/test_doctype.py
--- 0.9.2-2/tests/test_doctype.py	2022-11-24 19:33:57.000000000 +0000
+++ 1.0.2-1/tests/test_doctype.py	2025-05-05 05:23:26.000000000 +0000
@@ -9,16 +9,16 @@ XHTML1 = (
 
 
 @pytest.mark.parametrize(
-    ["uri", "name", "rendering_mode", "stringified"],
+    ("uri", "name", "rendering_mode", "stringified"),
     [
-        ["", "html5", "html5", "<!DOCTYPE html>"],
-        [None, "xhtml5", "xml", "<!DOCTYPE html>"],
-        [
+        ("", "html5", "html5", "<!DOCTYPE html>"),
+        (None, "xhtml5", "xml", "<!DOCTYPE html>"),
+        (
             "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd",
             "xhtml1transitional",
             "xml",
             XHTML1,
-        ],
+        ),
     ],
 )
 def test_dtd_by_uri(uri, name, rendering_mode, stringified):
@@ -36,9 +36,4 @@ def test_extract_dtd():
     assert pos == 0
     assert rest == html
     dtd = DocumentTypeDeclaration.matching(extracted)  # Another function
-    assert (
-        dtd
-        is DocumentTypeDeclaration.by_uri[
-            "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd"
-        ]
-    )
+    assert dtd is DocumentTypeDeclaration.by_uri["http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd"]
diff -pruN 0.9.2-2/tests/test_e2e.py 1.0.2-1/tests/test_e2e.py
--- 0.9.2-2/tests/test_e2e.py	2022-11-24 19:33:57.000000000 +0000
+++ 1.0.2-1/tests/test_e2e.py	2025-05-05 05:23:26.000000000 +0000
@@ -11,7 +11,7 @@ GOLDEN = DATA / "golden"
 
 
 @pytest.mark.parametrize(
-    ["args", "golden_file"],
+    ("args", "golden_file"),
     [
         (["-p", "kajiki_test_data.kitchensink"], "kitchensink1.html"),
         ([str(DATA / "kitchensink.html")], "kitchensink1.html"),
@@ -29,7 +29,7 @@ def test_golden_file(args, golden_file,
 
 
 def test_file_not_found():
-    with pytest.raises(IOError):
+    with pytest.raises(FileNotFoundError):
         main(["/does/not/exist.txt"])
 
 
diff -pruN 0.9.2-2/tests/test_ir.py 1.0.2-1/tests/test_ir.py
--- 0.9.2-2/tests/test_ir.py	2022-11-24 19:33:57.000000000 +0000
+++ 1.0.2-1/tests/test_ir.py	2025-05-05 05:23:26.000000000 +0000
@@ -1,6 +1,7 @@
-#!/usr/bin/env python
+import sys
+from unittest import TestCase
 
-from unittest import TestCase, main
+import pytest
 
 import kajiki
 from kajiki import ir
@@ -21,7 +22,7 @@ class TestBasic(TestCase):
 
     def test(self):
         tpl = kajiki.template.from_ir(self.tpl)
-        rsp = tpl(dict(name="Rick")).render()
+        rsp = tpl({"name": "Rick"}).render()
         assert rsp == "Hello, Rick\n", rsp
 
 
@@ -47,7 +48,36 @@ class TestSwitch(TestCase):
 
     def test_basic(self):
         tpl = kajiki.template.from_ir(self.tpl)
-        rsp = tpl(dict()).render()
+        rsp = tpl({}).render()
+        assert rsp == "0 is even\n1 is odd\n", rsp
+
+
+class TestMatch:
+    def setup_class(self):
+        if sys.version_info < (3, 10):
+            pytest.skip("pep622 unavailable before python3.10")
+
+        self.tpl = ir.TemplateNode(
+            defs=[
+                ir.DefNode(
+                    "__main__()",
+                    ir.ForNode(
+                        "i in range(2)",
+                        ir.ExprNode("i"),
+                        ir.TextNode(" is "),
+                        ir.MatchNode(
+                            "i % 2",
+                            ir.MatchCaseNode("0", ir.TextNode("even\n")),
+                            ir.MatchCaseNode("_", ir.TextNode("odd\n")),
+                        ),
+                    ),
+                )
+            ]
+        )
+
+    def test_basic(self):
+        tpl = kajiki.template.from_ir(self.tpl)
+        rsp = tpl({}).render()
         assert rsp == "0 is even\n1 is odd\n", rsp
 
 
@@ -75,7 +105,7 @@ class TestFunction(TestCase):
 
     def test_basic(self):
         tpl = kajiki.template.from_ir(self.tpl)
-        rsp = tpl(dict(name="Rick")).render()
+        rsp = tpl({"name": "Rick"}).render()
         assert rsp == "0 is even\n1 is odd\n", rsp
 
 
@@ -108,11 +138,8 @@ class TestCall(TestCase):
 
     def test_basic(self):
         tpl = kajiki.template.from_ir(self.tpl)
-        rsp = tpl(dict(name="Rick")).render()
-        assert (
-            rsp == 'Quoth the raven, "Nevermore 0."\n'
-            'Quoth the raven, "Nevermore 1."\n'
-        ), rsp
+        rsp = tpl({"name": "Rick"}).render()
+        assert rsp == 'Quoth the raven, "Nevermore 0."\n' 'Quoth the raven, "Nevermore 1."\n', rsp
 
 
 class TestImport(TestCase):
@@ -158,7 +185,7 @@ class TestImport(TestCase):
         self.tpl = loader.import_("tpl.txt")
 
     def test_import(self):
-        rsp = self.tpl(dict(name="Rick")).render()
+        rsp = self.tpl({"name": "Rick"}).render()
         assert (
             rsp == "0 is even half of 0 is even\n"
             "1 is odd half of 1 is odd\n"
@@ -169,9 +196,7 @@ class TestImport(TestCase):
 
 class TestInclude(TestCase):
     def setUp(self):
-        hdr = ir.TemplateNode(
-            defs=[ir.DefNode("__main__()", ir.TextNode("# header\n"))]
-        )
+        hdr = ir.TemplateNode(defs=[ir.DefNode("__main__()", ir.TextNode("# header\n"))])
         tpl = ir.TemplateNode(
             defs=[
                 ir.DefNode(
@@ -191,7 +216,7 @@ class TestInclude(TestCase):
         self.tpl = loader.import_("tpl.txt")
 
     def test_include(self):
-        rsp = self.tpl(dict(name="Rick")).render()
+        rsp = self.tpl({"name": "Rick"}).render()
         assert rsp == "a\n# header\nb\n", rsp
 
 
@@ -256,7 +281,7 @@ class TestExtends(TestCase):
         self.tpl = loader.import_("child.txt")
 
     def test_extends(self):
-        rsp = self.tpl(dict(name="Rick")).render()
+        rsp = self.tpl({"name": "Rick"}).render()
         assert (
             rsp == "# Header name=Rick\n"
             "## Child Body\n"
@@ -292,11 +317,7 @@ class TestDynamicExtends(TestCase):
         self.tpl = loader.import_("child.txt")
 
     def test_extends(self):
-        rsp = self.tpl(dict(p=0)).render()
+        rsp = self.tpl({"p": 0}).render()
         assert rsp == "Parent 0", rsp
-        rsp = self.tpl(dict(p=1)).render()
+        rsp = self.tpl({"p": 1}).render()
         assert rsp == "Parent 1", rsp
-
-
-if __name__ == "__main__":
-    main()
diff -pruN 0.9.2-2/tests/test_runtime.py 1.0.2-1/tests/test_runtime.py
--- 0.9.2-2/tests/test_runtime.py	2022-11-24 19:33:57.000000000 +0000
+++ 1.0.2-1/tests/test_runtime.py	2025-05-05 05:23:26.000000000 +0000
@@ -1,41 +1,39 @@
-#!/usr/bin/env python
-
-from unittest import TestCase, main
+from unittest import TestCase
 
 import kajiki
 
 
 class TestBasic(TestCase):
     def setUp(self):
-        class tpl:
+        class Tpl:
             @kajiki.expose
             def __main__():
                 yield "Hello,"
-                yield name
+                yield name  # noqa: F821
                 yield "\n"
 
-        self.tpl = kajiki.Template(tpl)
+        self.tpl = kajiki.Template(Tpl)
 
     def test_basic(self):
-        rsp = self.tpl(dict(name="Rick")).render()
+        rsp = self.tpl({"name": "Rick"}).render()
         assert rsp == "Hello,Rick\n", rsp
 
 
 class TestSwitch(TestCase):
     def setUp(self):
-        class tpl:
+        class Tpl:
             @kajiki.expose
             def __main__():
                 for i in range(2):
-                    yield local.__kj__.escape(i)
+                    yield local.__kj__.escape(i)  # noqa: F821
                     yield " is "
-                    local.__kj__.push_switch(i % 2)
-                    if local.__kj__.case(0):
+                    local.__kj__.push_switch(i % 2)  # noqa: F821
+                    if local.__kj__.case(0):  # noqa: F821
                         yield "even\n"
                     else:
                         yield "odd\n"
 
-        self.tpl = kajiki.Template(tpl)
+        self.tpl = kajiki.Template(Tpl)
 
     def test_basic(self):
         rsp = self.tpl().render()
@@ -44,10 +42,10 @@ class TestSwitch(TestCase):
 
 class TestFunction(TestCase):
     def setUp(self):
-        class tpl:
+        class Tpl:
             @kajiki.expose
-            def evenness(n):
-                if n % 2 == 0:
+            def evenness(self):
+                if self % 2 == 0:
                     yield "even"
                 else:
                     yield "odd"
@@ -55,84 +53,81 @@ class TestFunction(TestCase):
             @kajiki.expose
             def __main__():
                 for i in range(2):
-                    yield local.__kj__.escape(i)
+                    yield local.__kj__.escape(i)  # noqa: F821
                     yield " is "
-                    yield evenness(i)
+                    yield evenness(i)  # noqa: F821
                     yield "\n"
 
-        self.tpl = kajiki.Template(tpl)
+        self.tpl = kajiki.Template(Tpl)
 
     def test_basic(self):
-        rsp = self.tpl(dict(name="Rick")).render()
+        rsp = self.tpl({"name": "Rick"}).render()
         assert rsp == "0 is even\n1 is odd\n", rsp
 
 
 class TestCall(TestCase):
     def setUp(self):
-        class tpl:
+        class Tpl:
             @kajiki.expose
-            def quote(caller, speaker):
+            def quote(self, speaker):
                 for i in range(2):
                     yield "Quoth "
                     yield speaker
                     yield ', "'
-                    yield caller(i)
+                    yield self(i)
                     yield '."\n'
 
             @kajiki.expose
             def __main__():
-                @__kj__.flattener.decorate
+                @__kj__.flattener.decorate  # noqa: F821
                 def _kj_lambda(n):
                     yield "Nevermore "
-                    yield local.__kj__.escape(n)
+                    yield local.__kj__.escape(n)  # noqa: F821
 
-                yield quote(_kj_lambda, "the raven")
+                yield quote(_kj_lambda, "the raven")  # noqa: F821
                 del _kj_lambda
 
-        self.tpl = kajiki.Template(tpl)
+        self.tpl = kajiki.Template(Tpl)
 
     def test_basic(self):
-        rsp = self.tpl(dict(name="Rick")).render()
-        assert (
-            rsp == 'Quoth the raven, "Nevermore 0."\n'
-            'Quoth the raven, "Nevermore 1."\n'
-        ), rsp
+        rsp = self.tpl({"name": "Rick"}).render()
+        assert rsp == 'Quoth the raven, "Nevermore 0."\n' 'Quoth the raven, "Nevermore 1."\n', rsp
 
 
 class TestImport(TestCase):
     def setUp(self):
-        class lib_undec:
+        class LibUndec:
             @kajiki.expose
-            def evenness(n):
-                if n % 2 == 0:
+            def evenness(self):
+                if self % 2 == 0:
                     yield "even"
                 else:
                     yield "odd"
 
             @kajiki.expose
-            def half_evenness(n):
+            def half_evenness(self):
                 yield " half of "
-                yield local.__kj__.escape(n)
+                yield local.__kj__.escape(self)  # noqa: F821
                 yield " is "
-                yield evenness(n / 2)
+                yield evenness(self / 2)  # noqa: F821
 
-        lib = kajiki.Template(lib_undec)
+        lib = kajiki.Template(LibUndec)
 
-        class tpl:
+        class Tpl:
             @kajiki.expose
             def __main__():
                 simple_function = lib(dict(globals()))
                 for i in range(4):
-                    yield local.__kj__.escape(i)
+                    yield local.__kj__.escape(i)  # noqa: F821
                     yield " is "
                     yield simple_function.evenness(i)
                     yield simple_function.half_evenness(i)
                     yield "\n"
 
-        self.tpl = kajiki.Template(tpl)
+        self.tpl = kajiki.Template(Tpl)
 
     def test_import(self):
-        rsp = self.tpl(dict(name="Rick")).render()
+        rsp = self.tpl({"name": "Rick"}).render()
         assert (
             rsp == "0 is even half of 0 is even\n"
             "1 is odd half of 1 is odd\n"
@@ -143,54 +138,54 @@ class TestImport(TestCase):
 
 class TestInclude(TestCase):
     def setUp(self):
-        class hdr_undec:
+        class HdrUndec:
             @kajiki.expose
             def __main__():
                 yield "# header\n"
 
-        hdr = kajiki.Template(hdr_undec)
+        hdr = kajiki.Template(HdrUndec)
 
-        class tpl_undec:
+        class TplUndec:
             @kajiki.expose
             def __main__():
                 yield "a\n"
                 yield hdr().__main__()
                 yield "b\n"
 
-        tpl = kajiki.Template(tpl_undec)
+        tpl = kajiki.Template(TplUndec)
         self.tpl = tpl
 
     def test_include(self):
-        rsp = self.tpl(dict(name="Rick")).render()
+        rsp = self.tpl({"name": "Rick"}).render()
         assert rsp == "a\n# header\nb\n", rsp
 
 
 class TestExtends(TestCase):
     def setUp(_self):
-        class parent_tpl_undec:
+        class ParentTplUndec:
             @kajiki.expose
             def __main__():
-                yield header()
-                yield body()
-                yield footer()
+                yield header()  # noqa: F821
+                yield body()  # noqa: F821
+                yield footer()  # noqa: F821
 
             @kajiki.expose
             def header():
                 yield "# Header name="
-                yield name
+                yield name  # noqa: F821
                 yield "\n"
 
             @kajiki.expose
             def body():
                 yield "## Parent Body\n"
                 yield "local.id() = "
-                yield local.id()
+                yield local.id()  # noqa: F821
                 yield "\n"
                 yield "self.id() = "
-                yield self.id()
+                yield self.id()  # noqa: F821
                 yield "\n"
                 yield "child.id() = "
-                yield child.id()
+                yield child.id()  # noqa: F821
                 yield "\n"
 
             @kajiki.expose
@@ -202,39 +197,39 @@ class TestExtends(TestCase):
             def id():
                 yield "parent"
 
-        parent_tpl = kajiki.Template(parent_tpl_undec)
+        parent_tpl = kajiki.Template(ParentTplUndec)
 
-        class mid_tpl_undec:
+        class MidTplUndec:
             @kajiki.expose
             def __main__():
-                yield local.__kj__.extend(parent_tpl).__main__()
+                yield local.__kj__.extend(parent_tpl).__main__()  # noqa: F821
 
             @kajiki.expose
             def id():
                 yield "mid"
 
-        mid_tpl = kajiki.Template(mid_tpl_undec)
+        mid_tpl = kajiki.Template(MidTplUndec)
 
-        class child_tpl_undec:
+        class ChildTplUndec:
             @kajiki.expose
             def __main__():
-                yield local.__kj__.extend(mid_tpl).__main__()
+                yield local.__kj__.extend(mid_tpl).__main__()  # noqa: F821
 
             @kajiki.expose
             def body():
                 yield "## Child Body\n"
-                yield parent.body()
+                yield parent.body()  # noqa: F821
 
             @kajiki.expose
             def id():
                 yield "child"
 
-        child_tpl = kajiki.Template(child_tpl_undec)
+        child_tpl = kajiki.Template(ChildTplUndec)
         _self.parent_tpl = parent_tpl
         _self.child_tpl = child_tpl
 
     def test_extends(self):
-        rsp = self.child_tpl(dict(name="Rick")).render()
+        rsp = self.child_tpl({"name": "Rick"}).render()
         assert (
             rsp == "# Header name=Rick\n"
             "## Child Body\n"
@@ -248,36 +243,32 @@ class TestExtends(TestCase):
 
 class TestDynamicExtends(TestCase):
     def setUp(_self):
-        class parent_0_undec:
+        class Parent0Undec:
             @kajiki.expose
             def __main__():
                 yield "Parent 0"
 
-        parent_0 = kajiki.Template(parent_0_undec)
+        parent_0 = kajiki.Template(Parent0Undec)
 
-        class parent_1_undec:
+        class Parent1Undec:
             @kajiki.expose
             def __main__():
                 yield "Parent 1"
 
-        parent_1 = kajiki.Template(parent_1_undec)
+        parent_1 = kajiki.Template(Parent1Undec)
 
-        class child_tpl:
+        class ChildTpl:
             @kajiki.expose
             def __main__():
-                if p == 0:
-                    yield local.__kj__.extend(parent_0).__main__()
+                if p == 0:  # noqa: F821
+                    yield local.__kj__.extend(parent_0).__main__()  # noqa: F821
                 else:
-                    yield local.__kj__.extend(parent_1).__main__()
+                    yield local.__kj__.extend(parent_1).__main__()  # noqa: F821
 
-        _self.child_tpl = kajiki.Template(child_tpl)
+        _self.child_tpl = kajiki.Template(ChildTpl)
 
     def test_extends(self):
-        rsp = self.child_tpl(dict(p=0)).render()
+        rsp = self.child_tpl({"p": 0}).render()
         assert rsp == "Parent 0", rsp
-        rsp = self.child_tpl(dict(p=1)).render()
+        rsp = self.child_tpl({"p": 1}).render()
         assert rsp == "Parent 1", rsp
-
-
-if __name__ == "__main__":
-    main()
diff -pruN 0.9.2-2/tests/test_text.py 1.0.2-1/tests/test_text.py
--- 0.9.2-2/tests/test_text.py	2022-11-24 19:33:57.000000000 +0000
+++ 1.0.2-1/tests/test_text.py	2025-05-05 05:23:26.000000000 +0000
@@ -1,9 +1,7 @@
-#!/usr/bin/env python
-
 import os
-import sys
-import traceback
-from unittest import TestCase, main
+from unittest import TestCase
+
+import pytest
 
 from kajiki import FileLoader, MockLoader, TextTemplate
 
@@ -24,22 +22,22 @@ class TestBasic(TestCase):
 
     def test_expr_brace(self):
         tpl = TextTemplate(source="Hello, ${name}\n")
-        rsp = tpl(dict(name="Rick")).render()
+        rsp = tpl({"name": "Rick"}).render()
         assert rsp == "Hello, Rick\n", rsp
 
-    def test_expr_None(self):
+    def test_expr_none(self):
         tpl = TextTemplate(source="Hello, ${name}\n")
-        rsp = tpl(dict(name=None)).render()
+        rsp = tpl({"name": None}).render()
         assert rsp == "Hello, \n", rsp
 
     def test_expr_brace_complex(self):
         tpl = TextTemplate(source="Hello, ${{'name':name}['name']}\n")
-        rsp = tpl(dict(name="Rick")).render()
+        rsp = tpl({"name": "Rick"}).render()
         assert rsp == "Hello, Rick\n", rsp
 
     def test_expr_name(self):
         tpl = TextTemplate(source="Hello, $name\n")
-        rsp = tpl(dict(name="Rick")).render()
+        rsp = tpl({"name": "Rick"}).render()
         assert rsp == "Hello, Rick\n", rsp
         tpl = TextTemplate(source="Hello, $obj.name\n")
 
@@ -47,7 +45,7 @@ class TestBasic(TestCase):
             pass
 
         Empty.name = "Rick"
-        rsp = tpl(dict(obj=Empty)).render()
+        rsp = tpl({"obj": Empty}).render()
         assert rsp == "Hello, Rick\n", rsp
 
     def test_expr_multiline(self):
@@ -66,7 +64,7 @@ class TestSwitch(TestCase):
 $i is {%switch i % 2 %}{%case 0%}even\n{%else%}odd\n{%end%}\\
 %end"""
         )
-        rsp = tpl(dict(name="Rick")).render()
+        rsp = tpl({"name": "Rick"}).render()
         assert rsp == "0 is even\n1 is odd\n", rsp
 
     def test_ljust(self):
@@ -75,14 +73,14 @@ $i is {%switch i % 2 %}{%case 0%}even\n{
 $i is {%switch i % 2 %}{%case 0%}even\n{%else%}odd\n{%end%}\\
 %end"""
         )
-        rsp = tpl(dict(name="Rick")).render()
+        rsp = tpl({"name": "Rick"}).render()
         assert rsp == "0 is even\n1 is odd\n", rsp
         tpl = TextTemplate(
             """     {%-for i in range(2)%}\\
 $i is {%switch i % 2 %}{%case 0%}even{%else%}odd{%end%}
     {%-end%}"""
         )
-        rsp = tpl(dict(name="Rick")).render()
+        rsp = tpl({"name": "Rick"}).render()
         assert rsp == "0 is even\n1 is odd\n", rsp
 
     def test_rstrip(self):
@@ -91,7 +89,7 @@ $i is {%switch i % 2 %}{%case 0%}even{%e
 $i is {%switch i % 2 %}{%case 0-%}    even\n{%else%}odd\n{%end%}\\
 %end"""
         )
-        rsp = tpl(dict(name="Rick")).render()
+        rsp = tpl({"name": "Rick"}).render()
         assert rsp == "0 is even\n1 is odd\n", rsp
 
 
@@ -106,7 +104,7 @@ $i is ${evenness(i)}
 %end
 """
         )
-        rsp = tpl(dict(name="Rick")).render()
+        rsp = tpl({"name": "Rick"}).render()
         assert rsp == "0 is even\n1 is odd\n", rsp
 
 
@@ -122,11 +120,8 @@ Quoth $speaker, "${caller(i)}."
 Nevermore $n\\
 %end"""
         )
-        rsp = tpl(dict(name="Rick")).render()
-        assert (
-            rsp == 'Quoth the raven, "Nevermore 0."\n'
-            'Quoth the raven, "Nevermore 1."\n'
-        ), rsp
+        rsp = tpl({"name": "Rick"}).render()
+        assert rsp == 'Quoth the raven, "Nevermore 0."\n' 'Quoth the raven, "Nevermore 1."\n', rsp
 
 
 class TestImport(TestCase):
@@ -151,7 +146,7 @@ $i is ${simple_function.evenness(i)}${si
         )
         loader = MockLoader({"lib.txt": lib, "tpl.txt": tpl})
         tpl = loader.import_("tpl.txt")
-        rsp = tpl(dict(name="Rick")).render()
+        rsp = tpl({"name": "Rick"}).render()
         assert (
             rsp == "0 is even half of 0 is even\n"
             "1 is odd half of 1 is odd\n"
@@ -180,7 +175,7 @@ $i is ${lib.evenness(i)}${lib.half_evenn
         )
         loader = MockLoader({"lib.txt": lib, "tpl.txt": tpl})
         tpl = loader.import_("tpl.txt")
-        rsp = tpl(dict(name="Rick")).render()
+        rsp = tpl({"name": "Rick"}).render()
         assert (
             rsp == "0 is even half of 0 is even\n"
             "1 is odd half of 1 is odd\n"
@@ -201,7 +196,7 @@ b
             }
         )
         tpl = loader.import_("tpl.txt")
-        rsp = tpl(dict(name="Rick")).render()
+        rsp = tpl({"name": "Rick"}).render()
         assert rsp == "a\n# header\nb\n", rsp
 
 
@@ -247,7 +242,7 @@ ${parent.body()}\\
         )
         loader = MockLoader({"parent.txt": parent, "mid.txt": mid, "child.txt": child})
         tpl = loader.import_("child.txt")
-        rsp = tpl(dict(name="Rick")).render()
+        rsp = tpl({"name": "Rick"}).render()
         assert (
             rsp == "# Header name=Rick\n"
             "## Child Body\n"
@@ -275,9 +270,9 @@ ${parent.body()}\\
             }
         )
         tpl = loader.import_("child.txt")
-        rsp = tpl(dict(p=0)).render()
+        rsp = tpl({"p": 0}).render()
         assert rsp == "Parent 0", rsp
-        rsp = tpl(dict(p=1)).render()
+        rsp = tpl({"p": 1}).render()
         assert rsp == "Parent 1", rsp
 
     def test_block(self):
@@ -341,7 +336,7 @@ ${inner(x*2)}\\
 ${add(5)}
 """
         )
-        rsp = tpl(dict(name="Rick")).render()
+        rsp = tpl({"name": "Rick"}).render()
         assert rsp == "15\n", rsp
 
 
@@ -353,7 +348,7 @@ import os
 %end
 ${os.path.join('a','b','c')}"""
         )
-        rsp = tpl(dict(name="Rick")).render()
+        rsp = tpl({"name": "Rick"}).render()
         assert rsp == "a/b/c"
 
     def test_indent(self):
@@ -364,7 +359,7 @@ ${os.path.join('a','b','c')}"""
 %end
 ${os.path.join('a','b','c')}"""
         )
-        rsp = tpl(dict(name="Rick")).render()
+        rsp = tpl({"name": "Rick"}).render()
         assert rsp == "a/b/c"
 
     def test_short(self):
@@ -372,7 +367,7 @@ ${os.path.join('a','b','c')}"""
             """%py import os
 ${os.path.join('a','b','c')}"""
         )
-        rsp = tpl(dict(name="Rick")).render()
+        rsp = tpl({"name": "Rick"}).render()
         assert rsp == "a/b/c"
 
     def test_mod(self):
@@ -383,7 +378,7 @@ ${os.path.join('a','b','c')}\\
 %end
 ${test()}"""
         )
-        rsp = tpl(dict(name="Rick")).render()
+        rsp = tpl({"name": "Rick"}).render()
         assert rsp == "a/b/c"
 
 
@@ -391,20 +386,13 @@ class TestDebug(TestCase):
     def test_debug(self):
         loader = FileLoader(path=os.path.join(os.path.dirname(__file__), "data"))
         tpl = loader.import_("debug.txt")
-        try:
+
+        with pytest.raises(ValueError, match="Test error") as exc_info:
             tpl().render()
-        except ValueError:
-            exc_info = sys.exc_info()
-            stack = traceback.extract_tb(exc_info[2])
-        else:
-            assert False, "Should have raised ValueError"
+
         # Verify we have stack trace entries in the template
-        for fn, lno, func, line in stack:
-            if fn.endswith("debug.txt"):
+        for tb_entry in exc_info.traceback:
+            if tb_entry.path.name == "debug.txt":
                 break
         else:
-            assert False, "Stacktrace is all python"
-
-
-if __name__ == "__main__":
-    main()
+            pytest.fail("Stacktrace is all python")
diff -pruN 0.9.2-2/tests/test_xml.py 1.0.2-1/tests/test_xml.py
--- 0.9.2-2/tests/test_xml.py	2022-11-24 19:33:57.000000000 +0000
+++ 1.0.2-1/tests/test_xml.py	2025-05-05 05:23:26.000000000 +0000
@@ -1,11 +1,11 @@
-#!/usr/bin/env python
-
 import os
 import sys
 import traceback
 import xml.dom.minidom
 from io import BytesIO
-from unittest import TestCase, main
+from unittest import TestCase
+
+import pytest
 
 import kajiki
 from kajiki import FileLoader, MockLoader, PackageLoader, XMLTemplate, i18n
@@ -64,28 +64,27 @@ class TestExpand(TestCase):
             if node.tagName == "div":
                 node = node.childNodes[0]
                 continue
-            assert node.tagName == tagname, "%s != %s" % (node.tagName, tagname)
+            assert node.tagName == tagname, f"{node.tagName} != {tagname}"
             if attr:
-                assert len(node.attributes) == 1
-                assert node.hasAttribute(attr)
-                assert node.getAttribute(attr) == tagname.split(":")[-1]
+                if node.tagName != "py:case":
+                    assert len(node.attributes) == 1, node.attributes.items()
+                    assert node.hasAttribute(attr)
+                    assert node.getAttribute(attr) == tagname.split(":")[-1]
+                else:
+                    assert len(node.attributes) == 2
             else:
                 assert len(node.attributes) == 0
             assert len(node.childNodes) == 1
             node = node.childNodes[0]
 
 
-def perform(source, expected_output, context=dict(name="Rick"), **options):
+def perform(source, expected_output, context=None, **options):
+    context = context or {"name": "Rick"}
     tpl = XMLTemplate(source, **options)
-    try:
-        rsp = tpl(context).render()
-        assert isinstance(rsp, str), "render() must return a unicode string."
-        assert rsp == expected_output, (rsp, expected_output)
-    except Exception as e:
-        print("\n" + tpl.py_text)
-        raise e
-    else:
-        return tpl
+    rsp = tpl(context).render()
+    assert isinstance(rsp, str), "render() must return a string."
+    assert rsp == expected_output, (rsp, expected_output)
+    return tpl
 
 
 class TestSimple(TestCase):
@@ -102,9 +101,7 @@ class TestSimple(TestCase):
         perform(src, src, mode="xml")
 
     def test_textarea_whitespace(self):
-        src = (
-            '<textarea name="foo">\nHey there.  \n\n    I am indented.\n' "</textarea>"
-        )
+        src = '<textarea name="foo">\nHey there.  \n\n    I am indented.\n' "</textarea>"
         perform(src, src, mode="html")
         perform(src, src, mode="xml")
 
@@ -120,21 +117,15 @@ class TestSimple(TestCase):
         must be explicitly be made so.
         """
         script = 'if (1 < 2) { doc.write("<p>Offen&nbsp;bach</p>"); }\n'
-        src = "<script><![CDATA[\n{0}]]></script>".format(script)
-        perform(
-            src, mode="html", expected_output="<script>\n{0}</script>".format(script)
-        )
-        perform(
-            src, "<script>/*<![CDATA[*/\n{0}/*]]>*/</script>".format(script), mode="xml"
-        )
+        src = f"<script><![CDATA[\n{script}]]></script>"
+        perform(src, mode="html", expected_output=f"<script>\n{script}</script>")
+        perform(src, f"<script>/*<![CDATA[*/\n{script}/*]]>*/</script>", mode="xml")
 
     def test_style_escaping(self):
         style = "html > body { display: none; }\n"
-        src = "<style><![CDATA[\n{0}]]></style>".format(style)
-        perform(
-            src, "<style>/*<![CDATA[*/\n{0}/*]]>*/</style>".format(style), mode="xml"
-        )
-        perform(src, "<style>\n{0}</style>".format(style), mode="html")
+        src = f"<style><![CDATA[\n{style}]]></style>"
+        perform(src, f"<style>/*<![CDATA[*/\n{style}/*]]>*/</style>", mode="xml")
+        perform(src, f"<style>\n{style}</style>", mode="html")
 
     def test_script_variable(self):
         """Interpolate variables inside <script> tags"""
@@ -142,21 +133,17 @@ class TestSimple(TestCase):
         perform(src, "<script>/*<![CDATA[*/ Rick /*]]>*/</script>", mode="xml")
         perform(src, "<script> Rick </script>", mode="html")
 
-    def test_CDATA_disabled(self):
+    def test_cdata_disabled(self):
         src = "<script> $name </script>"
         perform(src, "<script> Rick </script>", mode="xml", cdata_scripts=False)
         perform(src, "<script> Rick </script>", mode="html", cdata_scripts=False)
 
-    def test_CDATA_escaping(self):
+    def test_cdata_escaping(self):
         src = """<myxml><data><![CDATA[&gt;&#240; $name]]></data></myxml>"""
-        perform(
-            src, "<myxml><data><![CDATA[&gt;&#240; Rick]]></data></myxml>", mode="xml"
-        )
-        perform(
-            src, "<myxml><data><![CDATA[&gt;&#240; Rick]]></data></myxml>", mode="html"
-        )
+        perform(src, "<myxml><data><![CDATA[&gt;&#240; Rick]]></data></myxml>", mode="xml")
+        perform(src, "<myxml><data><![CDATA[&gt;&#240; Rick]]></data></myxml>", mode="html")
 
-    def test_CDATA_escaping_mixed(self):
+    def test_cdata_escaping_mixed(self):
         src = """<myxml><data><![CDATA[&gt;&#240; $name]]> &gt;</data></myxml>"""
         perform(
             src,
@@ -169,17 +156,17 @@ class TestSimple(TestCase):
             mode="html",
         )
 
-    def test_script_commented_CDATA(self):
+    def test_script_commented_cdata(self):
         script = 'if (1 < 2) { doc.write("<p>Offen&nbsp;bach</p>"); }\n'
-        src = "<script>/*<![CDATA[*/\n{0}/*]]>*/</script>".format(script)
+        src = f"<script>/*<![CDATA[*/\n{script}/*]]>*/</script>"
         perform(
             src,
             mode="html",
-            expected_output="<script>/**/\n{0}/**/</script>".format(script),
+            expected_output=f"<script>/**/\n{script}/**/</script>",
         )
         perform(
             src,
-            "<script>/*<![CDATA[*//**/\n{0}/**//*]]>*/</script>".format(script),
+            f"<script>/*<![CDATA[*//**/\n{script}/**//*]]>*/</script>",
             mode="xml",
         )
 
@@ -212,16 +199,15 @@ class TestSimple(TestCase):
             "<div>Hello, Rick</div>",
         )
 
-    def test_expr_multiline_and_IndentationError(self):
-        try:
+    def test_expr_multiline_and_indentation_error(self):
+        with pytest.raises(XMLTemplateCompileError) as e:
             XMLTemplate(
                 """<div>Hello, ${ 'pippo' +
                 'baudo'}</div>"""
             )().render()
-        except XMLTemplateCompileError as e:
-            assert "`'pippo' +\n                'baudo'`" in str(e), str(e)
-            assert "Hello" in str(e)
-            assert "baudo" in str(e)
+        assert "`'pippo' +\n                'baudo'`" in str(e.value)
+        assert "Hello" in str(e.value)
+        assert "baudo" in str(e.value)
 
     def test_expr_multiline_cdata(self):
         perform(
@@ -238,13 +224,7 @@ class TestSimple(TestCase):
         """
         js = "$(function () { alert('.ready()'); });"
         src = "<html><pre>" + js + "</pre><script>" + js + "</script></html>"
-        out = (
-            "<html><pre>"
-            + js
-            + "</pre><script>/*<![CDATA[*/"
-            + js
-            + "/*]]>*/</script></html>"
-        )
+        out = "<html><pre>" + js + "</pre><script>/*<![CDATA[*/" + js + "/*]]>*/</script></html>"
         perform(src, out)
 
     def test_jquery_shortcut_is_not_expr(self):
@@ -252,13 +232,7 @@ class TestSimple(TestCase):
 
         js = "$.extend({}, {foo: 'bar'})"
         src = "<html><pre>" + js + "</pre><script>" + js + "</script></html>"
-        out = (
-            "<html><pre>"
-            + js
-            + "</pre><script>/*<![CDATA[*/"
-            + js
-            + "/*]]>*/</script></html>"
-        )
+        out = "<html><pre>" + js + "</pre><script>/*<![CDATA[*/" + js + "/*]]>*/</script></html>"
         perform(src, out)
 
     def test_xml_entities(self):
@@ -267,7 +241,7 @@ class TestSimple(TestCase):
 
     def test_html_entities(self):
         source = "<div>Spam&nbsp;Spam &lt; Spam &gt; Spam &hellip;</div>"
-        output = "<div>Spam Spam &lt; Spam &gt; Spam \u2026</div>"
+        output = "<div>Spam\xa0Spam &lt; Spam &gt; Spam \u2026</div>"
         assert chr(32) in output  # normal space
         assert chr(160) in output  # non breaking space
         perform(source, output)
@@ -318,7 +292,7 @@ $i is <py:switch test="i % 4">
         )
 
     def test_switch_div(self):
-        try:
+        with pytest.raises(XMLTemplateCompileError) as e:
             perform(
                 """
         <div class="test" py:switch="5 == 3">
@@ -327,18 +301,61 @@ $i is <py:switch test="i % 4">
         </div>""",
                 "<div><div>False</div></div>",
             )
-        except XMLTemplateCompileError as e:
-            self.assertTrue(
-                "py:switch directive can only contain py:case and py:else nodes"
-                in str(e)
+        assert "py:switch directive can only contain py:case and py:else nodes" in str(e)
+
+
+class TestMatch:
+    def setup_class(self):
+        if sys.version_info < (3, 10):
+            pytest.skip("pep622 unavailable before python3.10")
+
+    def test_match(self):
+        perform(
+            """<div py:for="i in range(2)">
+$i is <py:match on="i % 2">
+<py:case match="0">even</py:case>
+<py:case match="_">odd</py:case>
+</py:match></div>""",
+            """<div>
+0 is even</div><div>
+1 is odd</div>""",
+        )
+
+    def test_match_div(self):
+        with pytest.raises(
+            XMLTemplateCompileError,
+            match="case must have either value or match attribute, the former for py:switch, the latter for py:match",
+        ):
+            perform(
+                """
+        <div class="test" py:match="5 == 3">
+            <p py:case="True">True</p>
+            <p py:case="_">False</p>
+        </div>""",
+                "<div><div>False</div></div>",
+            )
+
+    def test_match_aliens(self):
+        with pytest.raises(
+            XMLTemplateCompileError,
+            match="py:match directive can only contain py:case",
+        ):
+            perform(
+                """<div py:for="i in range(2)">
+$i is <py:match on="i % 2">
+alien
+<py:case match="0">even</py:case>
+<py:case match="_">odd</py:case>
+</py:match></div>""",
+                """<div>
+0 is even</div><div>
+1 is odd</div>""",
             )
-        else:
-            self.assertTrue(False, msg="Should have raised XMLTemplateParseError")
 
 
 class TestElse(TestCase):
     def test_pyif_pyelse(self):
-        try:
+        with pytest.raises(XMLTemplateCompileError) as e:
             perform(
                 """
             <div>
@@ -347,13 +364,7 @@ class TestElse(TestCase):
             </div>""",
                 """<div>False</div>""",
             )
-        except XMLTemplateCompileError as e:
-            self.assertTrue(
-                "py:else directive must be inside a py:switch or directly after py:if"
-                in str(e)
-            )
-        else:
-            self.assertTrue(False, msg="Should have raised XMLTemplateParseError")
+        assert "py:else directive must be inside a py:switch or directly after py:if" in str(e)
 
     def test_pyiftag_pyelse_continuation(self):
         perform(
@@ -409,8 +420,7 @@ class TestWith(TestCase):
 
     def test_with_ordered_multiple(self):
         perform(
-            """<div py:with="a='foo';b=a * 2;c=b[::-1];d=c[:3]">"""
-            """$a $b $c $d</div>""",
+            """<div py:with="a='foo';b=a * 2;c=b[::-1];d=c[:3]">""" """$a $b $c $d</div>""",
             "<div>foo foofoo oofoof oof</div>",
         )
 
@@ -498,7 +508,7 @@ class TestImport(TestCase):
             }
         )
         tpl = loader.import_("tpl.html")
-        rsp = tpl(dict(name="Rick")).render()
+        rsp = tpl({"name": "Rick"}).render()
         assert (
             rsp
             == """<div>
@@ -544,7 +554,7 @@ class TestImport(TestCase):
             }
         )
         tpl = loader.import_("tpl.html")
-        rsp = tpl(dict(name="Rick")).render()
+        rsp = tpl({"name": "Rick"}).render()
         assert (
             rsp
             == """<div>
@@ -572,17 +582,16 @@ class TestImport(TestCase):
                     '${value_of("name")}</p>\n'
                 ),
                 "tpl.html": XMLTemplate(
-                    "<html><body><p>This is the body</p>\n"
-                    '<py:include href="included.html"/></body></html>'
+                    "<html><body><p>This is the body</p>\n" '<py:include href="included.html"/></body></html>'
                 ),
             }
         )
         tpl = loader.import_("tpl.html")
-        rsp = tpl(dict(name="Rick")).render()
+        rsp = tpl({"name": "Rick"}).render()
         assert (
-            "<html><body><p>This is the body</p>\n"
+            rsp == "<html><body><p>This is the body</p>\n"
             "<p>The included template must also access Kajiki globals and "
-            "the template context: Rick</p></body></html>" == rsp
+            "the template context: Rick</p></body></html>"
         )
 
     def test_include_html5(self):
@@ -600,26 +609,24 @@ class TestImport(TestCase):
                 self.sources = sources
                 super().__init__({})
 
-            def _load(self, name, encoding="utf-8", *args, **kwargs):
-                return XMLTemplate(
-                    source=self.sources[name], mode="html5", *args, **kwargs
-                )
+            def _load(self, name, encoding="utf-8", **kwargs):
+                del encoding
+                return XMLTemplate(source=self.sources[name], mode="html5", **kwargs)
 
         loader = XMLSourceLoader(
             {
                 "included.html": "<p>The included template must also "
                 "access Kajiki globals and the template context: "
                 '${value_of("name")}</p>\n',
-                "tpl.html": "<html><body><p>This is the body</p>\n"
-                '<py:include href="included.html"/></body></html>',
+                "tpl.html": "<html><body><p>This is the body</p>\n" '<py:include href="included.html"/></body></html>',
             }
         )
         tpl = loader.import_("tpl.html")
-        rsp = tpl(dict(name="Rick")).render()
+        rsp = tpl({"name": "Rick"}).render()
         assert (
-            "<!DOCTYPE html>\n<html><body><p>This is the body</p>\n"
+            rsp == "<!DOCTYPE html>\n<html><body><p>This is the body</p>\n"
             "<p>The included template must also access Kajiki globals and "
-            "the template context: Rick</p></body></html>" == rsp
+            "the template context: Rick</p></body></html>"
         ), rsp
 
 
@@ -658,7 +665,7 @@ ${parent.body()}
             }
         )
         tpl = loader.import_("child.html")
-        rsp = tpl(dict(name="Rick")).render()
+        rsp = tpl({"name": "Rick"}).render()
         assert (
             rsp
             == """<div>
@@ -691,9 +698,9 @@ child.id() = <span>mid</span>
             }
         )
         tpl = loader.import_("child.html")
-        rsp = tpl(dict(p=0)).render()
+        rsp = tpl({"p": 0}).render()
         assert rsp == "<div><span>Parent 0</span></div>", rsp
-        rsp = tpl(dict(p=1)).render()
+        rsp = tpl({"p": 1}).render()
         assert rsp == "<div><span>Parent 1</span></div>", rsp
 
     def test_block(self):
@@ -913,8 +920,7 @@ import os
 class TestComment(TestCase):
     def test_basic(self):
         perform(
-            "<div><!-- This comment is preserved. -->"
-            "<!--! This comment is stripped. --></div>",
+            "<div><!-- This comment is preserved. -->" "<!--! This comment is stripped. --></div>",
             "<div><!--  This comment is preserved.  --></div>",
         )
 
@@ -938,29 +944,29 @@ class TestAttributes(TestCase):
         perform('<div py:attrs="dict(checked=None)"/>', "<div/>")
 
     def test_strip(self):
-        TPL = '<div><h1 py:strip="header">Header</h1></div>'
-        perform(TPL, "<div>Header</div>", context=dict(header=True))
-        perform(TPL, "<div><h1>Header</h1></div>", context=dict(header=False))
-        TPL = """<div><p py:strip="">It's...</p></div>"""
-        perform(TPL, "<div>It's...</div>")
+        tpl = '<div><h1 py:strip="header">Header</h1></div>'
+        perform(tpl, "<div>Header</div>", context={"header": True})
+        perform(tpl, "<div><h1>Header</h1></div>", context={"header": False})
+        tpl = """<div><p py:strip="">It's...</p></div>"""
+        perform(tpl, "<div>It's...</div>")
 
     def test_html_attrs(self):
-        TPL = '<input type="checkbox" checked="$checked"/>'
-        context0 = dict(checked=None)
-        context1 = dict(checked=True)
-        perform(TPL, '<input type="checkbox"/>', context0, mode="xml")
-        perform(TPL, '<input checked="True" type="checkbox"/>', context1, mode="xml")
-        perform(TPL, '<input type="checkbox">', context0, mode="html")
-        perform(TPL, '<input checked type="checkbox">', context1, mode="html")
+        tpl = '<input type="checkbox" checked="$checked"/>'
+        context0 = {"checked": None}
+        context1 = {"checked": True}
+        perform(tpl, '<input type="checkbox"/>', context0, mode="xml")
+        perform(tpl, '<input checked="True" type="checkbox"/>', context1, mode="xml")
+        perform(tpl, '<input type="checkbox">', context0, mode="html")
+        perform(tpl, '<input checked type="checkbox">', context1, mode="html")
         perform(
-            TPL,
+            tpl,
             '<!DOCTYPE html>\n<input checked type="checkbox">',
             context1,
             mode="html5",
             is_fragment=False,
         )
         perform(
-            "<!DOCTYPE html>\n" + TPL,
+            "<!DOCTYPE html>\n" + tpl,
             '<!DOCTYPE html>\n<input checked type="checkbox">',
             context1,
             mode=None,
@@ -969,18 +975,15 @@ class TestAttributes(TestCase):
 
     def test_xml_namespaces(self):
         """Namespaced attributes pass through."""
-        TPL = '<p xml:lang="en">English text</p>'
-        perform(TPL, TPL, mode="xml")
-        perform(TPL, TPL, mode="html")
+        tpl = '<p xml:lang="en">English text</p>'
+        perform(tpl, tpl, mode="xml")
+        perform(tpl, tpl, mode="html")
 
     def test_escape_attr_values(self):
         """Escape static and dynamic attribute values."""
-        context = dict(url="https://domain.com/path?a=1&b=2")
+        context = {"url": "https://domain.com/path?a=1&b=2"}
         source = """<a title='"Ha!"' href="$url">Link</a>"""
-        output = (
-            '<a href="https://domain.com/path?a=1&amp;b=2" '
-            'title="&quot;Ha!&quot;">Link</a>'
-        )
+        output = '<a href="https://domain.com/path?a=1&amp;b=2" ' 'title="&quot;Ha!&quot;">Link</a>'
         perform(source, output, context, mode="html")
         perform(source, output, context, mode="xml")
 
@@ -989,18 +992,15 @@ class TestDebug(TestCase):
     def test_debug(self):
         loader = FileLoader(path=os.path.join(os.path.dirname(__file__), "data"))
         tpl = loader.import_("debug.html")
-        try:
+        with pytest.raises(ValueError, match="Test error") as exc_info:
             tpl().render()
-            assert False, "Should have raised ValueError"
-        except ValueError:
-            exc_info = sys.exc_info()
-            stack = traceback.extract_tb(exc_info[2])
+
         # Verify we have stack trace entries in the template
-        for fn, lno, func, line in stack:
-            if fn.endswith("debug.html"):
+        for tb_entry in exc_info.traceback:
+            if tb_entry.path.name == "debug.html":
                 break
         else:
-            assert False, "Stacktrace is all python"
+            pytest.fail("Stacktrace is all python")
 
 
 class TestPackageLoader(TestCase):
@@ -1016,23 +1016,21 @@ class TestBuiltinFunctions(TestCase):
 <div py:if="defined('albatross')">$albatross</div>\
 <p py:if="defined('parrot')">$parrot</p></div>""",
             expected_output="<div><p>Bereft of life, it rests in peace</p></div>",
-            context=dict(parrot="Bereft of life, it rests in peace"),
+            context={"parrot": "Bereft of life, it rests in peace"},
         )
 
     def test_value_of(self):
-        TPL = "<p>${value_of('albatross', 'Albatross!!!')}</p>"
-        perform(TPL, expected_output="<p>It's</p>", context=dict(albatross="It's"))
-        perform(TPL, expected_output="<p>Albatross!!!</p>")
+        tpl = "<p>${value_of('albatross', 'Albatross!!!')}</p>"
+        perform(tpl, expected_output="<p>It's</p>", context={"albatross": "It's"})
+        perform(tpl, expected_output="<p>Albatross!!!</p>")
 
     def test_literal(self):
         """Escape by default; literal() marks as safe."""
-        context = dict(albatross="<em>Albatross!!!</em>")
+        context = {"albatross": "<em>Albatross!!!</em>"}
         expected_output = "<p><em>Albatross!!!</em></p>"
         perform("<p>${literal(albatross)}</p>", expected_output, context)
         perform("<p>${Markup(albatross)}</p>", expected_output, context)
-        perform(
-            "<p>$albatross</p>", "<p>&lt;em&gt;Albatross!!!&lt;/em&gt;</p>", context
-        )
+        perform("<p>$albatross</p>", "<p>&lt;em&gt;Albatross!!!&lt;/em&gt;</p>", context)
         from kajiki.util import literal
 
         markup = '<b>"&amp;"</b>'
@@ -1041,21 +1039,18 @@ class TestBuiltinFunctions(TestCase):
 
 class TestTranslation(TestCase):
     def test_scripts_non_translatable(self):
-        src = (
-            "<xml><div>Hi</div><script>hello world</script>"
-            "<style>hello style</style></xml>"
-        )
+        src = "<xml><div>Hi</div><script>hello world</script>" "<style>hello style</style></xml>"
         doc = _Parser("<string>", src).parse()
 
         for n in _Compiler("<string>", doc).compile():
             text = getattr(n, "text", "")
             if text in ("hello world", "hello style"):
-                self.assertFalse(isinstance(n, TranslatableTextNode))
+                assert not isinstance(n, TranslatableTextNode)
 
         for n in _Compiler("<string>", doc, cdata_scripts=False).compile():
             text = getattr(n, "text", "")
             if text in ("hello world", "hello style"):
-                self.assertFalse(isinstance(n, TranslatableTextNode))
+                assert not isinstance(n, TranslatableTextNode)
 
     def test_extract_translate(self):
         src = """<xml><div>Hi</div><p>
@@ -1072,14 +1067,12 @@ class TestTranslation(TestCase):
         for strip_text in (False, True):
             # Build translation table
             messages = {}
-            for _, _, msgid, _ in i18n.extract(
-                BytesIO(src.encode("utf-8")), None, None, {"strip_text": strip_text}
-            ):
-                messages[msgid] = "TRANSLATED(%s)" % msgid
+            for _, _, msgid, _ in i18n.extract(BytesIO(src.encode("utf-8")), None, None, {"strip_text": strip_text}):
+                messages[msgid] = f"TRANSLATED({msgid})"
 
             # Provide a fake translation function
             default_gettext = i18n.gettext
-            i18n.gettext = lambda s: messages[s]
+            i18n.gettext = messages.__getitem__
             try:
                 perform(src, expected[strip_text], strip_text=strip_text)
             finally:
@@ -1095,10 +1088,8 @@ class TestTranslation(TestCase):
 
         # Build translation table
         messages = {"hi": "xi"}
-        for _, _, msgid, _ in i18n.extract(
-            BytesIO(src.encode("utf-8")), [], None, {"extract_python": True}
-        ):
-            messages[msgid] = "TRANSLATED(%s)" % msgid
+        for _, _, msgid, _ in i18n.extract(BytesIO(src.encode("utf-8")), [], None, {"extract_python": True}):
+            messages[msgid] = f"TRANSLATED({msgid})"
 
         # Provide a fake translation function
         default_gettext = i18n.gettext
@@ -1110,78 +1101,62 @@ class TestTranslation(TestCase):
 
     def test_extract_python_inside_invalid(self):
         src = """<xml><div>${_('hi' +)}</div></xml>"""
-        try:
-            list(
-                i18n.extract(
-                    BytesIO(src.encode("utf-8")), [], None, {"extract_python": True}
-                )
-            )
-        except XMLTemplateCompileError as e:
-            assert "_('hi' +)" in str(e)
-        else:
-            assert False, "Should have raised"
+        with pytest.raises(XMLTemplateCompileError, match=r"_\('hi' \+\)"):
+            list(i18n.extract(BytesIO(src.encode("utf-8")), [], None, {"extract_python": True}))
 
     def test_substituting_gettext_with_lambda(self):
         src = """<xml>hi</xml>"""
         expected = """<xml>spam</xml>"""
 
-        perform(src, expected, context=dict(gettext=lambda x: "spam"))
+        perform(src, expected, context={"gettext": lambda _: "spam"})
 
     def test_substituting_gettext_with_lambda_extending(self):
         loader = MockLoader(
             {
                 "parent.html": XMLTemplate("""<div>parent</div>"""),
-                "child.html": XMLTemplate(
-                    """<py:extends href="parent.html"><div>child</div></py:extends>"""
-                ),
+                "child.html": XMLTemplate("""<py:extends href="parent.html"><div>child</div></py:extends>"""),
             }
         )
         tpl = loader.import_("child.html")
-        rsp = tpl(dict(gettext=lambda _: "egg")).render()
+        rsp = tpl({"gettext": lambda _: "egg"}).render()
         assert rsp == """<div>egg</div><div>egg</div>""", rsp
 
     def test_substituting_gettext_with_lambda_extending_twice(self):
         loader = MockLoader(
             {
                 "parent.html": XMLTemplate("<div>parent</div>"),
-                "mid.html": XMLTemplate(
-                    '<py:extends href="parent.html"><div>${variable}</div></py:extends>'
-                ),
-                "child.html": XMLTemplate(
-                    '<py:extends href="mid.html"><div>child</div></py:extends>'
-                ),
+                "mid.html": XMLTemplate('<py:extends href="parent.html"><div>${variable}</div></py:extends>'),
+                "child.html": XMLTemplate('<py:extends href="mid.html"><div>child</div></py:extends>'),
             }
         )
         tpl = loader.import_("child.html")
-        rsp = tpl(dict(variable="spam", gettext=lambda _: "egg")).render()
+        rsp = tpl({"variable": "spam", "gettext": lambda _: "egg"}).render()
         # variables must not be translated
         assert rsp == """<div>egg</div><div>spam</div><div>egg</div>""", rsp
 
     def test_substituting_gettext_with_lambda_extending_file(self):
         loader = FileLoader(
             path=os.path.join(os.path.dirname(__file__), "data"),
-            base_globals=dict(gettext=lambda x: "egg"),
+            base_globals={"gettext": lambda _: "egg"},
         )
         tpl = loader.import_("file_child.html")
-        rsp = tpl(dict()).render()
+        rsp = tpl({}).render()
         assert rsp == """<div>egg</div><div>egg</div>""", rsp
 
     def test_without_substituting_gettext_with_lambda_extending_file(self):
         # this should use i18n.gettext
         loader = FileLoader(path=os.path.join(os.path.dirname(__file__), "data"))
         tpl = loader.import_("file_child.html")
-        rsp = tpl(dict()).render()
+        rsp = tpl({}).render()
         assert rsp == """<div>parent</div><div>child</div>""", rsp
 
 
 class TestDOMTransformations(TestCase):
     def test_empty_text_extraction(self):
-        doc = kajiki.xml_template._Parser(
-            "<string>", """<span>  text  </span>"""
-        ).parse()
+        doc = kajiki.xml_template._Parser("<string>", """<span>  text  </span>""").parse()
         doc = kajiki.xml_template._DomTransformer(doc, strip_text=False).transform()
         text_data = [n.data for n in doc.firstChild.childNodes]
-        self.assertEqual(["  ", "text", "  "], text_data)
+        assert ["  ", "text", "  "] == text_data
 
     def test_empty_text_extraction_lineno(self):
         doc = kajiki.xml_template._Parser(
@@ -1194,38 +1169,33 @@ class TestDOMTransformations(TestCase):
         ).parse()
         doc = kajiki.xml_template._DomTransformer(doc, strip_text=False).transform()
         linenos = [n.lineno for n in doc.firstChild.childNodes]
-        self.assertEqual(
-            [1, 3, 3], linenos
-        )  # Last node starts on same line as it starts with \n
+        assert [1, 3, 3] == linenos  # Last node starts on same line as it starts with \n
 
 
 class TestErrorReporting(TestCase):
     def test_syntax_error(self):
         for strip_text in (False, True):
-            try:
+            with pytest.raises(
+                KajikiSyntaxError,
+                match=r"-->         for i i range\(1, 2\):",
+            ):
                 perform(
                     '<div py:for="i i range(1, 2)">${i}</div>',
                     "",
                     strip_text=strip_text,
                 )
-            except KajikiSyntaxError as exc:
-                assert "-->         for i i range(1, 2):" in str(exc), exc
-            else:
-                assert False
 
+    @pytest.mark.skipif(sys.implementation.name == "pypy", reason="lnotab has issues with pypy")
     def test_code_error(self):
         for strip_text in (False, True):
-            try:
-                child = FileLoader(
-                    os.path.join(os.path.dirname(__file__), "data")
-                ).load("error.html", strip_text=strip_text)
+            child = FileLoader(os.path.join(os.path.dirname(__file__), "data")).load(
+                "error.html", strip_text=strip_text
+            )
+            with pytest.raises(ZeroDivisionError) as exc_info:
                 child().render()
-            except ZeroDivisionError:
-                exn_info = traceback.format_exception(*sys.exc_info())
-                last_line = exn_info[-2]
-                assert "${3/0}" in last_line
-            else:
-                assert False
+            formatted = traceback.format_exception(None, exc_info.value, exc_info.tb)
+            last_line = formatted[-2]
+            assert "${3/0}" in last_line
 
 
 class TestBracketsInExpression(TestCase):
@@ -1240,8 +1210,7 @@ class TestBracketsInExpression(TestCase)
 
     def test_complex(self):
         perform(
-            "<xml><div>${'ciao {  } {' + \"a {} b {{{{} w}}rar\"}${'sd{}'}"
-            " ${1+1}</div></xml>",
+            "<xml><div>${'ciao {  } {' + \"a {} b {{{{} w}}rar\"}${'sd{}'}" " ${1+1}</div></xml>",
             "<xml><div>ciao {  } {a {} b {{{{} w}}rarsd{} 2</div></xml>",
         )
 
@@ -1252,34 +1221,30 @@ class TestBracketsInExpression(TestCase)
         )
 
     def test_raise_unclosed_string(self):
-        try:
+        with pytest.raises(XMLTemplateCompileError) as e:
             XMLTemplate('<x>${"ciao}</x>')
-            assert False, "must raise"
-        except XMLTemplateCompileError as e:
-            # assert "can't compile" in str(e), e  # different between pypy and cpython
-            assert '"ciao' in str(e), e
+        # assert "can't compile" in str(e)  # different between pypy and cpython
+        assert '"ciao' in str(e)
 
     def test_raise_plus_with_an_operand(self):
-        try:
+        with pytest.raises(XMLTemplateCompileError) as e:
             XMLTemplate('<x>${"ciao" + }</x>')
-            assert False, "must raise"
-        except XMLTemplateCompileError as e:
-            assert "detected an invalid python expression" in str(e), e
-            assert '"ciao" +' in str(e), e
+        assert "detected an invalid python expression" in str(e)
+        assert '"ciao" +' in str(e)
 
     def test_unclosed_braced(self):
-        try:
+        with pytest.raises(
+            XMLTemplateCompileError,
+            match="Braced expression not terminated",
+        ):
             XMLTemplate('<x>${"ciao"</x>')
-            assert False, "must raise"
-        except XMLTemplateCompileError as e:
-            assert "Braced expression not terminated" in str(e), e
 
     def test_leading_opening_brace(self):
-        try:
+        with pytest.raises(
+            XMLTemplateCompileError,
+            match="Braced expression not terminated",
+        ):
             XMLTemplate('<x>${{"a", "b"}</x>')
-            assert False, "must raise"
-        except XMLTemplateCompileError as e:
-            assert "Braced expression not terminated" in str(e), e
 
 
 class TestMultipleChildrenInDOM(TestCase):
@@ -1291,34 +1256,33 @@ class TestMultipleChildrenInDOM(TestCase
         assert res == "<x>2</x>"
 
     def test_multiple_nodes(self):
-        try:
+        with pytest.raises(XMLTemplateParseError, match="junk after document"):
             XMLTemplate("<!-- a --><x>${1+1}</x><y>${1+1}</y>")
-        except XMLTemplateParseError as e:
-            assert "junk after document element" in str(e), e
-        else:
-            assert False, "should have raised"
 
     def test_only_comment(self):
-        try:
+        with pytest.raises(XMLTemplateParseError, match="no element found"):
             XMLTemplate("<!-- a -->")
-        except XMLTemplateParseError as e:
-            assert "no element found" in str(e), e
-        else:
-            assert False, "should have raised"
 
 
 class TestSyntaxErrorCallingWithTrailingParenthesis(TestCase):
     def test_raise(self):
-        try:
+        with pytest.raises(XMLTemplateCompileError):
             XMLTemplate(
                 """<div py:strip="True"
 ><py:def function="echo(x)">$x</py:def
 >${echo('hello'))}</div>"""
             )
-            assert False, "should raise"
-        except XMLTemplateCompileError:
-            pass
 
 
-if __name__ == "__main__":
-    main()
+class TestExtendsWithImport(TestCase):
+    def test_extends_with_import(self):
+        loader = MockLoader(
+            {
+                "parent.html": XMLTemplate("<div>" '<py:import href="lib.html"/>' "${lib.foo()}" "</div>"),
+                "lib.html": XMLTemplate("<div>" '<py:def function="foo()"><b>foo</b></py:def>' "</div>"),
+                "child.html": XMLTemplate('<py:extends href="parent.html"/>'),
+            }
+        )
+        child = loader.import_("child.html")
+        r = child().render()
+        assert r == "<div><b>foo</b></div>"
