[neon/forks/sip6/Neon/release] /: New upstream version 6.11.0

Dmitry Shachnev null at kde.org
Sun Aug 17 07:48:50 BST 2025


Git commit f9e7c168b2e497480208b72d2182b6052070660d by Dmitry Shachnev.
Committed on 21/05/2025 at 21:00.
Pushed by carlosdem into branch 'Neon/release'.

New upstream version 6.11.0

M  +3    -3    .git_archival.txt
M  +4    -0    .gitignore
M  +1    -1    LICENSE
M  +4    -6    docs/abi_12.rst
M  +4    -6    docs/abi_13.rst
M  +18   -0    docs/annotations.rst
M  +1    -1    docs/conf.py
M  +7    -2    docs/directives.rst
M  +97   -0    docs/releases.md
M  +4    -1    docs/specification_files.rst
M  +4    -4    pyproject.toml
M  +1    -1    sipbuild/bindings.py
M  +1    -2    sipbuild/buildable.py
M  +16   -9    sipbuild/builder.py
M  +19   -1    sipbuild/distinfo/distinfo.py
M  +126  -24   sipbuild/generator/instantiations.py
M  +17   -5    sipbuild/generator/outputs/code/code.py
M  +5    -1    sipbuild/generator/outputs/formatters/template.py
M  +2    -1    sipbuild/generator/parser/annotations.py
M  +20   -13   sipbuild/generator/parser/parser_manager.py
M  +55   -16   sipbuild/generator/parser/rules.py
M  +1    -1    sipbuild/generator/python_slots.py
M  +6    -0    sipbuild/generator/resolver/resolver.py
M  +8    -4    sipbuild/generator/specification.py
M  +1    -1    sipbuild/generator/utils.py
M  +1    -1    sipbuild/module/source/12/16/sip.h.in
M  +12   -8    sipbuild/module/source/12/16/siplib.c
M  +1    -1    sipbuild/module/source/12/17/sip.h.in
M  +13   -9    sipbuild/module/source/12/17/siplib.c
M  +1    -1    sipbuild/module/source/13/10/sip.h.in
M  +13   -9    sipbuild/module/source/13/10/sip_core.c
M  +1    -1    sipbuild/module/source/13/9/sip.h.in
M  +12   -8    sipbuild/module/source/13/9/sip_core.c
M  +45   -22   sipbuild/project.py
M  +4    -4    sipbuild/py_versions.py
M  +47   -8    sipbuild/pyproject.py
A  +31   -0    test/README.md
A  +3    -0    test/imported_exceptions/__init__.py
A  +19   -0    test/imported_exceptions/handler_module.sip
A  +21   -0    test/imported_exceptions/test_imported_exceptions.py
A  +19   -0    test/imported_exceptions/thrower_module.sip
R  +4    -1    test/int_convertors/int_convertors_module.sip [from: test/int_convertors/int_convertors.sip - 098% similarity]
M  +2    -2    test/int_convertors/test_int_convertors.py
C  +0    -0    test/movable/__init__.py [from: test/enums/__init__.py - 100% similarity]
A  +127  -0    test/movable/movable_module.sip
A  +47   -0    test/movable/test_movable.py
R  +0    -0    test/py_enums/__init__.py [from: test/enums/__init__.py - 100% similarity]
R  +3    -2    test/py_enums/py_enums_module.sip [from: test/enums/enums.sip - 096% similarity]
R  +25   -22   test/py_enums/test_py_enums.py [from: test/enums/test_enums.py - 085% similarity]
A  +3    -0    test/template_superclasses/__init__.py
A  +71   -0    test/template_superclasses/template_superclasses_module.sip
A  +26   -0    test/template_superclasses/test_template_superclasses.py
A  +3    -0    test/timelines/__init__.py
A  +28   -0    test/timelines/test_timelines.py
A  +50   -0    test/timelines/timelines_module.sip
M  +170  -79   test/utils/sip_test_case.py

https://invent.kde.org/neon/forks/sip6/-/commit/f9e7c168b2e497480208b72d2182b6052070660d

diff --git a/.git_archival.txt b/.git_archival.txt
index 9438b99..3f3a819 100644
--- a/.git_archival.txt
+++ b/.git_archival.txt
@@ -1,3 +1,3 @@
-node: 350f56b80fe8a4d027ad6b301be607cc8c921a19
-node-date: 2025-02-02T13:19:13Z
-describe-name: 6.10.0
+node: b066f3f87f6e969b972f049cc752e09b3f609811
+node-date: 2025-05-16T12:57:02+01:00
+describe-name: 6.11.0
diff --git a/.gitignore b/.gitignore
index 5f52aa0..ba1be36 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,5 +1,9 @@
 # Local additions to Python.gitignore from GitHub.
 sipbuild/_version.py
+test/*/*.pyd
+test/*/*.so
+test/*/*.tar.gz
+test/*/*/*
 
 # Byte-compiled / optimized / DLL files
 __pycache__/
diff --git a/LICENSE b/LICENSE
index 0e4caa6..12c2461 100644
--- a/LICENSE
+++ b/LICENSE
@@ -1,4 +1,4 @@
-Copyright 2024 Phil Thompson <phil at riverbankcomputing.com>
+Copyright 2025 Phil Thompson <phil at riverbankcomputing.com>
 
 Redistribution and use in source and binary forms, with or without
 modification, are permitted provided that the following conditions are met:
diff --git a/docs/abi_12.rst b/docs/abi_12.rst
index 6467ca8..17d4482 100644
--- a/docs/abi_12.rst
+++ b/docs/abi_12.rst
@@ -5,18 +5,16 @@ In this section we describe the v12 of the ABI, provided by the :mod:`sip`
 module, that can be used by handwritten code in specification files.
 
 
-.. c:macro:: SIP_API_MAJOR_NR
+.. c:macro:: SIP_ABI_MAJOR_VERSION
 
     This is a C preprocessor symbol that defines the major number of the SIP
-    API.  Its value is a number.  There is no direct relationship between this
-    and the SIP version number.
+    ABI.
 
 
-.. c:macro:: SIP_API_MINOR_NR
+.. c:macro:: SIP_ABI_MINOR_VERSION
 
     This is a C preprocessor symbol that defines the minor number of the SIP
-    API.  Its value is a number.  There is no direct relationship between this
-    and the SIP version number.
+    ABI.
 
 
 .. c:macro:: SIP_BLOCK_THREADS
diff --git a/docs/abi_13.rst b/docs/abi_13.rst
index 46fef4a..32c2378 100644
--- a/docs/abi_13.rst
+++ b/docs/abi_13.rst
@@ -4,18 +4,16 @@ ABI v13 for Handwritten Code
 In this section we describe the v13 of the ABI, provided by the :mod:`sip`
 module, that can be used by handwritten code in specification files.
 
-.. c:macro:: SIP_API_MAJOR_NR
+.. c:macro:: SIP_ABI_MAJOR_VERSION
 
     This is a C preprocessor symbol that defines the major number of the SIP
-    API.  Its value is a number.  There is no direct relationship between this
-    and the SIP version number.
+    ABI.
 
 
-.. c:macro:: SIP_API_MINOR_NR
+.. c:macro:: SIP_ABI_MINOR_VERSION
 
     This is a C preprocessor symbol that defines the minor number of the SIP
-    API.  Its value is a number.  There is no direct relationship between this
-    and the SIP version number.
+    ABI.
 
 
 .. c:macro:: SIP_BLOCK_THREADS
diff --git a/docs/annotations.rst b/docs/annotations.rst
index 88c3b1e..eabbb91 100644
--- a/docs/annotations.rst
+++ b/docs/annotations.rst
@@ -558,6 +558,24 @@ Mapped Type Annotations
     specifies that the handling of ``None`` will be left to the
     :directive:`%ConvertToTypeCode`.
 
+.. mapped-type-annotation:: Movable
+
+    .. versionadded:: 6.11
+
+    If a C++ instance is passed by value as an argument to a function then the
+    class's assignment operator is normally used under the covers.  If the
+    class's assignment operator has been deleted then the generated code will
+    not compile.  This annotation says that the C++ instances of this type
+    should be moved instead, ie. the argument should be wrapped in a called to
+    `std::move()`.  Because the C++ instance is unusable after being passed to
+    `std::move()`, SIP automatically transfers ownership of the instance to C++
+    so that Python doesn't try to call its destructor.
+
+    .. note::
+        SIP does not automatically generate the declaration of `std::move()`
+        so the :directive:`%TypeHeaderCode` for the mapped type should include
+        `#include <utility>`.
+
 
 .. mapped-type-annotation:: NoAssignmentOperator
 
diff --git a/docs/conf.py b/docs/conf.py
index 4c56130..b902c26 100644
--- a/docs/conf.py
+++ b/docs/conf.py
@@ -13,7 +13,7 @@ project = 'sip'
 copyright = '{0} Phil Thompson <phil at riverbankcomputing.com>'.format(
         date.today().year)
 author = 'Phil Thompson'
-version = 'v6.10.0'
+version = 'v6.11.0'
 
 
 # -- General configuration ---------------------------------------------------
diff --git a/docs/directives.rst b/docs/directives.rst
index 6dbe505..10554c5 100644
--- a/docs/directives.rst
+++ b/docs/directives.rst
@@ -653,7 +653,7 @@ For example::
     %End
 
 This directive is used to specify explicit docstrings for modules, classes,
-functions, methods, typedefs and properties.
+namespaces, functions, methods, typedefs and properties.
 
 The docstring of a class is made up of the docstring specified for the class
 itself, with the docstrings specified for each contructor appended.
@@ -1800,7 +1800,12 @@ limited Python API defined in `PEP 384
 <https://www.python.org/dev/peps/pep-0384/>`__.  It also ensures that the C
 preprocessor symbol ``Py_LIMITED_API`` is defined before the :file:`Python.h`
 header file is included.  Python extensions built in this way are independent
-of the version of Python being used.
+of the version of Python being used.  The version of the limited API to use
+(ie. the value assigned to the ``Py_LIMITED_API`` macro) is determined by the
+minimum version of Python supported by the project, ie. the value of the
+``requires-python`` field of the metadata specified in the ``pyproject.toml``
+file.  If this isn't specified then the oldest supported version of Python is
+used.
 
 The optional :directive:`%AutoPyName` sub-directive is used to specify a rule
 for automatically providing Python names.
diff --git a/docs/releases.md b/docs/releases.md
index 1a4b055..b0fd1e3 100644
--- a/docs/releases.md
+++ b/docs/releases.md
@@ -1,6 +1,103 @@
 # Release Notes
 
 
+## v6.11.0
+
+### Added the `/Movable/` mapped type annotation
+
+When the `/Movable/` annotation is specified for a mapped type, values of that
+type are wrapped in calls to `std::move()` when passing them as arguments to a
+C++ callable.  In addition the `/Transfer/` argument annotation is
+automatically applied.
+
+Resolves [#60](https://github.com/Python-SIP/sip/issues/60)
+
+### Support for template arguments in super-classes
+
+It is now possible to invoke a class template as a super-class in a class
+template.
+
+Resolves [#12](https://github.com/Python-SIP/sip/issues/12)
+
+### Determining the version of the limited API to use
+
+The version of the limited API to use is now taken from the `requires-python`
+field of the metadata in a project's `pyproject.toml` file.  If this is not
+specified then (as with previous versions of SIP) the version of the oldest
+supported version of Python is used.
+
+Resolves [#58](https://github.com/Python-SIP/sip/issues/58)
+
+### `%Docstring` support for namespaces
+
+The `%Docstring` directive can now be specified for C++ namespaces.
+
+Resolves [#11](https://github.com/Python-SIP/sip/issues/11)
+
+### Support for `operator~()` in the global scope
+
+`operator~()` can now be specified in the global scope.
+
+Resolves [#9](https://github.com/Python-SIP/sip/issues/9)
+
+### Use consistent timestamps when creating wheel files
+
+The value of the environment variable `SOURCE_DATE_EPOCH`, if defined, will be
+used as the timestamp for all files included in a wheel.  This ensures wheel
+building is repeatable.
+
+Pull request [#70](https://github.com/Python-SIP/sip/pull/70)
+
+### Bindings support for PEP 639
+
+- The `project` section of `pyproject.toml` files may now use `license` to
+  specify a valid SPDX license expression.  `license-files`, if specified, is a
+  list of glob patterns describing the files containing licensing information.
+  The old style of `license` is deprecated.
+- The metadata format of the generated `PKG-INFO` file of an sdist will
+  normally be v2.4.  If the deprecated form of `license` is used in
+  `pyproject.toml` then it will be v2.2.
+- License files will be installed in the `licenses` sub-directory of the
+  generated `.dist-info` directory of a wheel.
+- `packaging` v24.2 is now required.
+
+Resolves [#69](https://github.com/Python-SIP/sip/issues/69)
+
+### Normalised wheel names
+
+The names of wheel files (both those generated by `sip-wheel` and indirectly
+from the sdists created by `sip-module`) now conform to current PyPA standards.
+
+Resolves [#68](https://github.com/Python-SIP/sip/issues/68)
+
+### `pyproject.toml` now conforms to PEP 639
+
+The licensing information in SIP's `pyproject.toml` now conforms to PEP 639.
+This means that the minimum `setuptools` version is v77.
+
+### Bug fixes
+
+- The handling of unknown `%Timeline` tags in `%If` directives has been fixed.
+  An unknown tag is assumed to refer to a later version than all the known
+  tags.  Therefore `(unknown -)` will always be false, and `(- unknown)` will
+  always be true.
+- Generated code will not contain digraphs.  This usually affects C++
+  extensions being built with the default `setuptools` builder.
+- Long deprecation messages are now handled correctly.  Pull request
+  [#67](https://github.com/Python-SIP/sip/pull/67)
+
+### Documentation fixes
+
+- Fixed the documentation for `SIP_ABI_MAJOR_VERSION` and
+  `SIP_ABI_MINOR_VERSION` for both v12 and v13 of the ABI.
+
+### Improved unit tests
+
+The support for writing unit tests for SIP has been improved and documented.
+In particular, test cases that require multiple test modules to be built are
+now supported.
+
+
 ## v6.10.0
 
 ### Introspection of the `sip` module ABI version
diff --git a/docs/specification_files.rst b/docs/specification_files.rst
index dce839a..5ccb542 100644
--- a/docs/specification_files.rst
+++ b/docs/specification_files.rst
@@ -215,7 +215,10 @@ file.
 
     *namespace* ::= **namespace** *name* [**{** {*namespace-line*} **}**] **;**
 
-    *namespace-line* ::= [:directive:`%TypeHeaderCode` | *statement*]
+    *namespace-line* ::= [
+            :directive:`%Docstring` |
+            :directive:`%TypeHeaderCode` |
+            *statement*]
 
     *opaque-class* ::= **class** *scoped-name* **;**
 
diff --git a/pyproject.toml b/pyproject.toml
index ad22b4a..caeaf29 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,7 +1,7 @@
 # The project configuration for sip.
 
 [build-system]
-requires = ["setuptools>=64", "setuptools_scm>=8"]
+requires = ["setuptools>=77", "setuptools_scm>=8"]
 build-backend = "setuptools.build_meta"
 
 [tool.setuptools_scm]
@@ -12,10 +12,10 @@ name = "sip"
 description = "A Python bindings generator for C/C++ libraries"
 readme = "README.md"
 urls.homepage = "https://github.com/Python-SIP/sip"
-dependencies = ["packaging", "setuptools>=69.5", "tomli; python_version<'3.11'"]
+dependencies = ["packaging>=24.2", "setuptools>=75.8.1", "tomli; python_version<'3.11'"]
 requires-python = ">=3.9"
-license = {file = "LICENSE"}
-classifiers = ["License :: OSI Approved :: BSD License"]
+license = "BSD-2-Clause"
+license-files = ["LICENSE"]
 dynamic = ["version"]
 
 [[project.authors]]
diff --git a/sipbuild/bindings.py b/sipbuild/bindings.py
index fe5ba68..e2c836a 100644
--- a/sipbuild/bindings.py
+++ b/sipbuild/bindings.py
@@ -151,7 +151,7 @@ class Bindings(Configurable):
                 project.sip_module)
 
         # Update the target ABI for the project.
-        if project.target_abi is None or project.target_abi < spec.target_abi:
+        if project.target_abi is None or project.target_abi[1] is None or project.target_abi < spec.target_abi:
             project.target_abi = spec.target_abi
 
         # Resolve the types.
diff --git a/sipbuild/buildable.py b/sipbuild/buildable.py
index 43b494a..dd8e324 100644
--- a/sipbuild/buildable.py
+++ b/sipbuild/buildable.py
@@ -61,8 +61,7 @@ class BuildableFromSources(Buildable):
 
         if self.uses_limited_api:
             self.define_macros.append(
-                    'Py_LIMITED_API=0x03{0:02x}0000'.format(
-                            OLDEST_SUPPORTED_MINOR))
+                    'Py_LIMITED_API=' + project.limited_abi_version_str)
 
     def make_names_relative(self):
         """ Make all file and directory names relative to the build directory.
diff --git a/sipbuild/builder.py b/sipbuild/builder.py
index 612bcfc..171d057 100644
--- a/sipbuild/builder.py
+++ b/sipbuild/builder.py
@@ -48,8 +48,7 @@ class Builder(AbstractBuilder):
         project = self.project
 
         # The sdist name.
-        sdist_name = '{}-{}'.format(project.name.replace('-', '_').lower(),
-                project.version_str)
+        sdist_name = project.normalized_name
 
         # Create the sdist root directory.
         sdist_root = os.path.join(project.build_dir, sdist_name)
@@ -164,20 +163,25 @@ class Builder(AbstractBuilder):
         # Copy the wheel contents.
         self.install_project(wheel_build_dir, wheel_tag=wheel_tag)
 
-        wheel_file = '{}-{}'.format(project.name.replace('-', '_'),
-                project.version_str)
+        wheel_file = [project.normalized_name]
 
         if project.build_tag:
-            wheel_file += '-{}'.format(project.build_tag)
+            wheel_file.append(project.build_tag)
 
-        wheel_file += '-{}.whl'.format(wheel_tag)
-        wheel_path = os.path.abspath(os.path.join(wheel_directory, wheel_file))
+        wheel_file.append(wheel_tag)
+        wheel_path = os.path.abspath(
+                os.path.join(wheel_directory, '-'.join(wheel_file) + '.whl'))
 
         # Create the .whl file.
         saved_cwd = os.getcwd()
         os.chdir(wheel_build_dir)
 
-        from zipfile import ZipFile, ZIP_DEFLATED
+        import time 
+        from zipfile import ZipFile, ZipInfo, ZIP_DEFLATED
+
+        # Ensure reproducible wheel file timestamps
+        epoch = int(os.environ.get('SOURCE_DATE_EPOCH', time.time()))
+        zip_timestamp = time.gmtime(epoch)[:6]
 
         with ZipFile(wheel_path, 'w', compression=ZIP_DEFLATED) as zf:
             for dirpath, _, filenames in os.walk('.'):
@@ -185,7 +189,10 @@ class Builder(AbstractBuilder):
                     # This will result in a name with no leading '.'.
                     name = os.path.relpath(os.path.join(dirpath, filename))
 
-                    zf.write(name)
+                    zi = ZipInfo(name, zip_timestamp)
+
+                    with open(name, 'rb') as f:
+                        zf.writestr(zi, f.read())
 
         os.chdir(saved_cwd)
 
diff --git a/sipbuild/distinfo/distinfo.py b/sipbuild/distinfo/distinfo.py
index f6ad7e8..84ff026 100644
--- a/sipbuild/distinfo/distinfo.py
+++ b/sipbuild/distinfo/distinfo.py
@@ -1,9 +1,10 @@
 # SPDX-License-Identifier: BSD-2-Clause
 
-# Copyright (c) 2024 Phil Thompson <phil at riverbankcomputing.com>
+# Copyright (c) 2025 Phil Thompson <phil at riverbankcomputing.com>
 
 
 import base64
+import glob
 import hashlib
 import os
 import shutil
@@ -89,6 +90,23 @@ def create_distinfo(distinfo_dir, wheel_tag, installed, metadata,
     # Reproducable builds.
     installed.sort()
 
+    # Copy any license files.
+    saved = os.getcwd()
+    os.chdir(project_root)
+
+    license_root = os.path.join(distinfo_dir, 'licenses')
+
+    for license_patt in metadata.get('license-file', []):
+        for license_file in glob.glob(license_patt):
+            license_fn = os.path.join(license_root, license_file)
+            installed.append(license_fn)
+
+            real_license_dir = prefix_dir + os.path.dirname(license_fn)
+            os.makedirs(real_license_dir, exist_ok=True)
+            shutil.copy(license_file, real_license_dir)
+
+    os.chdir(saved)
+
     if wheel_tag is None:
         # Create the INSTALLER file.
         installer_fn = os.path.join(distinfo_dir, 'INSTALLER')
diff --git a/sipbuild/generator/instantiations.py b/sipbuild/generator/instantiations.py
index f780053..b351a29 100644
--- a/sipbuild/generator/instantiations.py
+++ b/sipbuild/generator/instantiations.py
@@ -1,13 +1,14 @@
 # SPDX-License-Identifier: BSD-2-Clause
 
-# Copyright (c) 2024 Phil Thompson <phil at riverbankcomputing.com>
+# Copyright (c) 2025 Phil Thompson <phil at riverbankcomputing.com>
 
 
 from copy import copy
 
 from .scoped_name import ScopedName
 from .specification import (Argument, ArgumentType, FunctionCall,
-        IfaceFileType, KwArgs, Signature, TypeHints, Value, ValueType)
+        IfaceFileType, KwArgs, Signature, Template, TypeHints, Value,
+        ValueType, WrappedClass)
 from .templates import (template_code, template_code_blocks,
         template_expansions, template_string)
 from .utils import append_iface_file, cached_name, normalised_scoped_name
@@ -15,7 +16,7 @@ from .utils import append_iface_file, cached_name, normalised_scoped_name
 
 def instantiate_class(p, symbol, fq_cpp_name, tmpl_names, proto_class,
             template, py_name, no_type_name, docstring, pm):
-    """ Instantiate a class template. """
+    """ Instantiate a class template and return the instantiated class. """
 
     # Create the expansions.
     expansions = template_expansions(tmpl_names, template.types)
@@ -75,28 +76,14 @@ def instantiate_class(p, symbol, fq_cpp_name, tmpl_names, proto_class,
     i_class.superclasses = []
 
     for superclass in proto_class.superclasses:
-        superclass_name = superclass.iface_file.fq_cpp_name
-
-        # Don't try and expand defined or scoped classes.
-        if superclass.iface_file.module is not None or not superclass_name.is_simple:
-            i_class.superclasses.append(superclass)
-            continue
-
-        for a, arg in enumerate(tmpl_names.args):
-            if superclass_name.base_name == arg.definition.base_name:
-                tmpl_arg = template.types.args[a]
-
-                if tmpl_arg.type is ArgumentType.DEFINED:
-                    i_superclass = pm.find_class(p, symbol,
-                            IfaceFileType.CLASS, tmpl_arg.definition)
-                elif tmpl_arg.type is ArgumentType.CLASS:
-                    i_superclass = tmpl_arg.definition
-                else:
-                    pm.parser_error(p, symbol,
-                            "template argument '{0}' must expand to a class".format(superclass_name))
-                    i_superclass = superclass
+        if isinstance(superclass, WrappedClass):
+            klass = _superclass_from_class(superclass, p, symbol, tmpl_names,
+                    template, pm)
+        else:
+            klass = _superclass_from_template(superclass, p, symbol,
+                    tmpl_names, template, pm)
 
-                i_class.superclasses.append(i_superclass)
+        i_class.superclasses.append(klass)
 
     # Handle the enums.
     _instantiate_enums(tmpl_names, proto_class, template, i_class, expansions,
@@ -153,6 +140,8 @@ def instantiate_class(p, symbol, fq_cpp_name, tmpl_names, proto_class,
 
     pm.spec.classes.insert(0, i_class)
 
+    return i_class
+
 
 def _instantiate_argument(proto_arg, proto_class, tmpl_names, template,
         i_class, expansions, pm):
@@ -460,3 +449,116 @@ def _instantiate_vars(tmpl_names, proto_class, template, i_class, expansions,
                 expansions)
 
         pm.spec.variables.append(i_var)
+
+
+def _superclass_from_class(klass, p, symbol, tmpl_names, template, pm):
+    """ Return the super-class based on a class or a template argument (that
+    resembles an undefined class.
+    """
+
+    superclass_name = klass.iface_file.fq_cpp_name
+
+    # Only deal with undefined classes with unscoped names which is how
+    # template argument names are passed.
+    if klass.iface_file.module is None and superclass_name.is_simple:
+        superclass = _find_argument_value(superclass_name, p, symbol,
+                tmpl_names, template, pm)
+
+        if superclass is None:
+            pm.parser_error(p, symbol,
+                    "template argument '{0}' must expand to a class".format(
+                            superclass_name))
+        else:
+            klass = superclass
+
+    return klass
+
+
+def _superclass_from_template(superclass, p, symbol, tmpl_names, template, pm):
+    """ Return the super-class based on a class template. """
+
+    # There are two approaches that could be taken with this.  The first is to
+    # instantiate the super-class template to create a Python class (with an
+    # automatically generated name) to use as the super-class.  The second is
+    # to add the contents of the class template to the original class being
+    # instantiated and so avoiding the creation of the 'internal' super-class
+    # class.  We choose the first approach as it is closer to the existing
+    # implementation.  The 'internal' super-class class is an undocumented (but
+    # visible) implementation detail.
+
+    # In order to instantiate the super-class template we need to create a new
+    # Template instance based on the super-class template invocation in the
+    # original class template but with the argument values replaced with those
+    # passed to the original class template.
+    proto_superclass_template = superclass.definition
+
+    superclass_template_args = []
+    superclass_name_parts = []
+
+    for proto_arg in proto_superclass_template.types.args:
+        # Assume we will use the argument as is.
+        superclass_arg = proto_arg
+
+        if proto_arg.type is ArgumentType.DEFINED and proto_arg.definition.is_simple:
+            klass = _find_argument_value(proto_arg.definition.base_name, p,
+                    symbol, tmpl_names, template, pm)
+
+            if klass is not None:
+                superclass_arg = Argument(ArgumentType.CLASS, definition=klass)
+                superclass_name_parts.append(
+                        klass.iface_file.fq_cpp_name.as_word)
+
+        superclass_template_args.append(superclass_arg)
+
+    superclass_template = Template(proto_superclass_template.cpp_name,
+            Signature(args=superclass_template_args))
+
+    # The generated C++ and Python names of the internal super-class is the
+    # name of the super-class template and the names of the values of each
+    # template argument.
+    fq_cpp_name = ScopedName(superclass_template.cpp_name)
+    fq_cpp_name[-1] = _make_generated_name(fq_cpp_name[-1],
+            superclass_name_parts)
+    py_name = _make_generated_name(superclass_template.cpp_name.as_py,
+            superclass_name_parts)
+
+    # Note that any error will have a misleading location as source_location
+    # should be used.
+    klass = pm.instantiate_class_template(p, symbol, fq_cpp_name,
+            superclass_template, py_name, no_type_name=True, docstring=None)
+
+    if klass is None:
+        pm.parser_error_at_location(superclass.source_location,
+                "unknown class template")
+
+    return klass
+
+
+def _find_argument_value(name, p, symbol, tmpl_names, template, pm):
+    """ Return the WrappedClass instance that is the value of a named argument.
+    None is returned is an appropriate value couldn't be found.
+    """
+
+    for a, arg in enumerate(tmpl_names.args):
+        if arg.definition.base_name == name:
+            tmpl_arg = template.types.args[a]
+
+            if tmpl_arg.type is ArgumentType.DEFINED:
+                return pm.find_class(p, symbol, IfaceFileType.CLASS,
+                        tmpl_arg.definition)
+
+            if tmpl_arg.type is ArgumentType.CLASS:
+                return tmpl_arg.definition
+
+    return None
+
+
+def _make_generated_name(base_name, name_parts):
+    """ Return the full generated name from a base name and parts representing
+    each template argument value.
+    """
+
+    all_parts = [base_name]
+    all_parts.extend(name_parts)
+
+    return '__'.join(all_parts)
diff --git a/sipbuild/generator/outputs/code/code.py b/sipbuild/generator/outputs/code/code.py
index 997e3c3..bc84e07 100644
--- a/sipbuild/generator/outputs/code/code.py
+++ b/sipbuild/generator/outputs/code/code.py
@@ -1163,17 +1163,18 @@ f'''    /* Export the module and publish it's API. */
         # Import the helpers.
         sf.write(
 f'''
+
     sip_{module_name}_qt_metaobject = (sip_qt_metaobject_func)sipImportSymbol("qtcore_qt_metaobject");
     sip_{module_name}_qt_metacall = (sip_qt_metacall_func)sipImportSymbol("qtcore_qt_metacall");
     sip_{module_name}_qt_metacast = (sip_qt_metacast_func)sipImportSymbol("qtcore_qt_metacast");
 
     if (!sip_{module_name}_qt_metacast)
         Py_FatalError("Unable to import qtcore_qt_metacast");
-
 ''')
 
     sf.write(
-f'''    /* Initialise the module now all its dependencies have been set up. */
+f'''
+    /* Initialise the module now all its dependencies have been set up. */
     if (sipInitModule(&sipModuleAPI_{module_name}, sipModuleDict) < 0)
     {{
         Py_DECREF(sipModule);
@@ -5353,6 +5354,9 @@ def _call_args(sf, spec, cpp_signature, py_signature):
         indirection = ''
         nr_derefs = len(arg.derefs)
 
+        # The argument may be surrounded by something type-specific.
+        prefix = suffix = ''
+
         if arg.type in (ArgumentType.ASCII_STRING, ArgumentType.LATIN1_STRING, ArgumentType.UTF8_STRING, ArgumentType.SSTRING, ArgumentType.USTRING, ArgumentType.STRING, ArgumentType.WSTRING):
             if nr_derefs > (0 if arg.is_out else 1) and not arg.is_reference:
                 indirection = '&'
@@ -5363,6 +5367,10 @@ def _call_args(sf, spec, cpp_signature, py_signature):
             elif nr_derefs == 0:
                 indirection = '*'
 
+                if arg.type is ArgumentType.MAPPED and arg.definition.movable:
+                    prefix = 'std::move('
+                    suffix = ')'
+
         elif arg.type in (ArgumentType.STRUCT, ArgumentType.UNION, ArgumentType.VOID):
             if nr_derefs == 2:
                 indirection = '&'
@@ -5394,12 +5402,12 @@ def _call_args(sf, spec, cpp_signature, py_signature):
             else:
                 sf.write(f'reinterpret_cast<{arg_cpp_type_name} *>({arg_name})')
         else:
-            sf.write(indirection)
+            sf.write(prefix + indirection)
 
             if arg.array is ArrayArgument.ARRAY_SIZE:
                 sf.write(f'({arg_cpp_type_name})')
 
-            sf.write(arg_name)
+            sf.write(arg_name + suffix)
 
 
 def _get_named_value_decl(spec, scope, type, name):
@@ -9126,7 +9134,11 @@ class SourceFile:
     def write(self, s):
         """ Write a string while tracking the current line number. """
 
-        self._f.write(s)
+        # Older C++ standards (pre-C++17) get confused with digraphs (usually
+        # when the default setuptools is being used to build C++ extensions).
+        # The easiest solution is to hack the string for the most common case
+        # and hope it doesn't have unintended consequences.
+        self._f.write(s.replace('_cast<::', '_cast< ::'))
         self._line_nr += s.count('\n')
 
     def write_code(self, code):
diff --git a/sipbuild/generator/outputs/formatters/template.py b/sipbuild/generator/outputs/formatters/template.py
index 5a72f18..258b414 100644
--- a/sipbuild/generator/outputs/formatters/template.py
+++ b/sipbuild/generator/outputs/formatters/template.py
@@ -1,6 +1,6 @@
 # SPDX-License-Identifier: BSD-2-Clause
 
-# Copyright (c) 2024 Phil Thompson <phil at riverbankcomputing.com>
+# Copyright (c) 2025 Phil Thompson <phil at riverbankcomputing.com>
 
 
 from ...scoped_name import STRIP_NONE
@@ -14,4 +14,8 @@ def fmt_template_as_cpp_type(spec, template, strip=STRIP_NONE, as_xml=False):
     sig_s = fmt_signature_as_cpp_declaration(spec, template.types, strip=strip,
             as_xml=as_xml)
 
+    # Handle digraphs.
+    if sig_s.startswith('::'):
+        sig_s = ' ' + sig_s
+
     return template.cpp_name.cpp_stripped(strip) + '<' + sig_s + '>'
diff --git a/sipbuild/generator/parser/annotations.py b/sipbuild/generator/parser/annotations.py
index d04a0af..2681577 100644
--- a/sipbuild/generator/parser/annotations.py
+++ b/sipbuild/generator/parser/annotations.py
@@ -1,6 +1,6 @@
 # SPDX-License-Identifier: BSD-2-Clause
 
-# Copyright (c) 2024 Phil Thompson <phil at riverbankcomputing.com>
+# Copyright (c) 2025 Phil Thompson <phil at riverbankcomputing.com>
 
 
 class DottedName(str):
@@ -229,6 +229,7 @@ _ANNOTATION_TYPES = {
     'KeywordArgs':              string(),
     'Metatype':                 name(allow_dots=True),
     'Mixin':                    boolean(),
+    'Movable':                  boolean(),
     'NewThread':                boolean(),
     'NoArgParser':              boolean(),
     'NoAssignmentOperator':     boolean(),
diff --git a/sipbuild/generator/parser/parser_manager.py b/sipbuild/generator/parser/parser_manager.py
index 0f1d771..9757369 100644
--- a/sipbuild/generator/parser/parser_manager.py
+++ b/sipbuild/generator/parser/parser_manager.py
@@ -52,7 +52,7 @@ class ParserManager:
         rules.parser = self._parser
 
         # The list of class templates.  Each element is a 2-tuple of the
-        # template arguments and the class itself.
+        # template arguments (as a Signature instance) and the class itself.
         self.class_templates = []
 
         # Public state.
@@ -256,7 +256,7 @@ class ParserManager:
                 type_hints=self.get_type_hints(p, symbol, annotations))
 
         klass.class_key = class_key
-        klass.superclasses = superclasses
+        klass.superclasses = superclasses if superclasses is not None else []
 
         self.push_scope(klass,
                 AccessSpecifier.PRIVATE if class_key is ClassKey.CLASS else AccessSpecifier.PUBLIC)
@@ -579,7 +579,9 @@ class ParserManager:
         base_type = EnumBaseType.ENUM
 
         if base_type_s is not None:
-            if self.spec.target_abi is not None and self.spec.target_abi < (13, 0):
+            # The minor version of the target ABI may not be known yet (and we
+            # don't need it) so just test the major version.
+            if self.spec.target_abi is not None and self.spec.target_abi[0] < 13:
                 self.parser_error(p, symbol,
                         "/BaseType/ is only supported for ABI v13.0 and later")
 
@@ -917,6 +919,7 @@ class ParserManager:
         """ Apply annotations to a mapped type. """
 
         mapped_type.handles_none = annotations.get('AllowNone', False)
+        mapped_type.movable = annotations.get('Movable', False)
         mapped_type.no_assignment_operator = annotations.get(
                 'NoAssignmentOperator', False)
         mapped_type.no_copy_ctor = annotations.get('NoCopyCtor', False)
@@ -1244,7 +1247,7 @@ class ParserManager:
         if upper_name:
             upper_qual = self._find_timeline_qualifier(p, symbol_upper)
             if upper_qual is None:
-                return False
+                return True
         else:
             upper_qual = None
 
@@ -1446,8 +1449,8 @@ class ParserManager:
 
     def instantiate_class_template(self, p, symbol, fq_cpp_name, template,
             py_name, no_type_name, docstring):
-        """ Try and instantiate a class template and return True if one was
-        found.
+        """ Try and instantiate a class template and return the instantiated
+        class or None if no template was found.
         """
 
         # Look for an appropriate class template.
@@ -1456,12 +1459,10 @@ class ParserManager:
                 break
         else:
             # There was no class template to instantiate.
-            return False
-
-        instantiate_class(p, symbol, fq_cpp_name, tmpl_names, proto_class,
-                template, py_name, no_type_name, docstring, self)
+            return None
 
-        return True
+        return instantiate_class(p, symbol, fq_cpp_name, tmpl_names,
+                proto_class, template, py_name, no_type_name, docstring, self)
 
     def lexer_error(self, t, text):
         """ Record an error caused by a token. """
@@ -1531,7 +1532,13 @@ class ParserManager:
     def parser_error(self, p, symbol, text):
         """ Record an error caused by a symbol in a production. """
 
-        self._error_log.log(text, self.get_source_location(p, symbol))
+        self.parser_error_at_location(self.get_source_location(p, symbol),
+                text)
+
+    def parser_error_at_location(self, source_location, text):
+        """ Record an error caused by a symbol in a production. """
+
+        self._error_log.log(text, source_location)
 
     def pop_file(self):
         """ Restore the current .sip file from the stack and make it current.
@@ -1997,7 +2004,7 @@ class ParserManager:
 
         name = p[symbol]
 
-        qual = self.find_qualifier(p, symbol, name)
+        qual = self.find_qualifier(p, symbol, name, required=False)
         if qual is None:
             return None
 
diff --git a/sipbuild/generator/parser/rules.py b/sipbuild/generator/parser/rules.py
index 85090e1..6fd99b7 100644
--- a/sipbuild/generator/parser/rules.py
+++ b/sipbuild/generator/parser/rules.py
@@ -93,6 +93,7 @@ def p_statement(p):
 def p_namespace_statement(p):
     """namespace_statement : if_start
         | if_end
+        | namespace_docstring
         | class_decl
         | class_template
         | enum_decl
@@ -869,6 +870,7 @@ def p_license_arg(p):
 # The mapped type annotations.
 _MAPPED_TYPE_ANNOTATIONS = (
     'AllowNone',
+    'Movable',
     'NoAssignmentOperator',
     'NoCopyCtor',
     'NoDefaultCtor',
@@ -1872,7 +1874,7 @@ def p_superclass_list(p):
 
 
 def p_superclass(p):
-    "superclass : class_access scoped_name"
+    "superclass : class_access simple_superclass"
 
     pm = p.parser.pm
 
@@ -1883,21 +1885,7 @@ def p_superclass(p):
         p[0] = None
         return
 
-    # This is a hack to allow typedef'ed classes to be used before we have
-    # resolved the typedef definitions.  Unlike elsewhere, we require that the
-    # typedef is defined before being used.
-    ad = Argument(ArgumentType.DEFINED, definition=p[2])
-
-    while ad.type is ArgumentType.DEFINED:
-        ad.type = ArgumentType.NONE
-        search_typedefs(pm.spec, ad.definition, ad)
-
-    if ad.type is not ArgumentType.NONE or len(ad.derefs) != 0 or ad.is_const or ad.is_reference:
-        pm.parser_error(p, 2, "super-class list contains an invalid type")
-
-    # Find the actual class.
-    p[0] = pm.find_class(p, 2, IfaceFileType.CLASS, ad.definition,
-            tmpl_arg=pm.parsing_template)
+    p[0] = p[2]
 
 
 def p_class_access(p):
@@ -1912,6 +1900,43 @@ def p_class_access(p):
     p[0] = p[1]
 
 
+def p_simple_superclass(p):
+    """simple_superclass : scoped_name
+        | scoped_name '<' cpp_types '>'"""
+
+    pm = p.parser.pm
+
+    # Handle templates.
+    if len(p) == 5:
+        if pm.parsing_template:
+            # For the moment we just remember enough to identify it later when
+            # we have the values to instantiate it.
+            p[0] = Argument(ArgumentType.TEMPLATE,
+                    definition=Template(p[1], Signature(args=p[3])),
+                    source_location=pm.get_source_location(p, 1))
+        else:
+            pm.parser_error(p, 1,
+                    "super-class templates can only be used in a class template")
+
+        return
+
+    # This is a hack to allow typedef'ed classes to be used before we have
+    # resolved the typedef definitions.  Unlike elsewhere, we require that the
+    # typedef is defined before being used.
+    ad = Argument(ArgumentType.DEFINED, definition=p[1])
+
+    while ad.type is ArgumentType.DEFINED:
+        ad.type = ArgumentType.NONE
+        search_typedefs(pm.spec, ad.definition, ad)
+
+    if ad.type is not ArgumentType.NONE or len(ad.derefs) != 0 or ad.is_const or ad.is_reference:
+        pm.parser_error(p, 1, "super-class list contains an invalid type")
+
+    # Find the actual class.
+    p[0] = pm.find_class(p, 1, IfaceFileType.CLASS, ad.definition,
+            tmpl_arg=pm.parsing_template)
+
+
 def p_opt_class_definition(p):
     """opt_class_definition : '{' opt_class_body '}'
         | empty"""
@@ -3014,6 +3039,20 @@ def p_namespace_body(p):
     """namespace_body : namespace_statement
         | namespace_body namespace_statement"""
 
+def p_namespace_docstring(p):
+    "namespace_docstring : docstring"
+
+    pm = p.parser.pm
+
+    if pm.skipping:
+        return
+
+    if pm.scope.docstring is None:
+        pm.scope.docstring = p[1]
+    else:
+        pm.parser_error(p, 1,
+                "%Docstring has already been defined for this namespace")
+
 
 # C/C++ typedefs. #############################################################
 
diff --git a/sipbuild/generator/python_slots.py b/sipbuild/generator/python_slots.py
index 893fe7d..097544c 100644
--- a/sipbuild/generator/python_slots.py
+++ b/sipbuild/generator/python_slots.py
@@ -80,7 +80,7 @@ def invalid_global_slot(slot):
     level) slot.
     """
 
-    if slot in (PySlot.NEG, PySlot.POS):
+    if slot in (PySlot.INVERT, PySlot.NEG, PySlot.POS):
         return False
 
     if is_number_slot(slot):
diff --git a/sipbuild/generator/resolver/resolver.py b/sipbuild/generator/resolver/resolver.py
index e4211f0..7921a5e 100644
--- a/sipbuild/generator/resolver/resolver.py
+++ b/sipbuild/generator/resolver/resolver.py
@@ -1345,6 +1345,11 @@ def _resolve_py_signature_types(spec, mod, scope, overload, error_log,
                     lambda: _check_array_support(overload, local_arg_nr, scope,
                             error_log))
 
+        # If the type of the argument is a /Movable/ mapped type then add
+        # /Transfer/.
+        if arg.type is ArgumentType.MAPPED and arg.definition.movable:
+            arg.transfer = Transfer.TRANSFER
+
         if scope is not None:
             _scope_default_value(spec, scope, arg)
 
@@ -1833,6 +1838,7 @@ def _instantiate_mapped_type_template(spec, mod, mapped_type_template, type,
     proto_mapped_type = mapped_type_template.mapped_type
 
     mapped_type.handles_none = proto_mapped_type.handles_none
+    mapped_type.movable = proto_mapped_type.movable
     mapped_type.needs_user_state = proto_mapped_type.needs_user_state
     mapped_type.no_assignment_operator = proto_mapped_type.no_assignment_operator
     mapped_type.no_copy_ctor = proto_mapped_type.no_copy_ctor
diff --git a/sipbuild/generator/specification.py b/sipbuild/generator/specification.py
index 57376ba..79c9e3d 100644
--- a/sipbuild/generator/specification.py
+++ b/sipbuild/generator/specification.py
@@ -25,8 +25,8 @@ class AccessSpecifier(Enum):
 
 class ArgumentType(Enum):
     """ The types of either C/C++ or Python arguments.  The numerical values of
-    these can occur in generated code so so types must always be appended and
-    old (unused) types must never be removed.
+    these can occur in generated code so types must always be appended and old
+    (unused) types must never be removed.
     """
 
     # The type hasn't been specified.
@@ -847,6 +847,9 @@ class MappedType:
     # The member functions.
     members: list['Member'] = field(default_factory=list)
 
+    # Set if /Movable/ was specified.
+    movable: bool = False
+
     # Set if the handwritten code requires user state information.
     needs_user_state: bool = False
 
@@ -1620,8 +1623,9 @@ class WrappedClass:
     # The sub-class base class. (resolver)
     subclass_base: Optional['WrappedClass'] = None
 
-    # The super-classes.
-    superclasses: list['WrappedClass'] = field(default_factory=list)
+    # The super-classes.  A super-class can only be a template argument in a
+    # class template.
+    superclasses: list[Union[Argument, 'WrappedClass']] = field(default_factory=list)
 
     # The value of /Supertype/ if specified.
     supertype: Optional[CachedName] = None
diff --git a/sipbuild/generator/utils.py b/sipbuild/generator/utils.py
index a75308e..1b746c4 100644
--- a/sipbuild/generator/utils.py
+++ b/sipbuild/generator/utils.py
@@ -44,7 +44,7 @@ def argument_as_str(arg):
 
             s += '>'
 
-        elif arg.type in (ArgumentType.STRUCT, ArgumentType.DEFINED):
+        elif arg.type in (ArgumentType.CLASS, ArgumentType.STRUCT, ArgumentType.UNION, ArgumentType.DEFINED):
             s = str(arg.definition)
 
         elif arg.type in (ArgumentType.UBYTE, ArgumentType.USTRING):
diff --git a/sipbuild/module/source/12/16/sip.h.in b/sipbuild/module/source/12/16/sip.h.in
index 8f33579..9fffb50 100644
--- a/sipbuild/module/source/12/16/sip.h.in
+++ b/sipbuild/module/source/12/16/sip.h.in
@@ -36,7 +36,7 @@ extern "C" {
 /* The version of the ABI. */
 #define SIP_ABI_MAJOR_VERSION       12
 #define SIP_ABI_MINOR_VERSION       16
-#define SIP_MODULE_PATCH_VERSION    1
+#define SIP_MODULE_PATCH_VERSION    2
 
 
 /*
diff --git a/sipbuild/module/source/12/16/siplib.c b/sipbuild/module/source/12/16/siplib.c
index 409bba8..9b7dc6e 100644
--- a/sipbuild/module/source/12/16/siplib.c
+++ b/sipbuild/module/source/12/16/siplib.c
@@ -7792,21 +7792,25 @@ int sip_api_deprecated(const char *classname, const char *method)
 int sip_api_deprecated_12_16(const char *classname, const char *method,
         const char *message)
 {
-    char buf[100];
+    const unsigned int bufsize = 100 + ( message ? strlen(message) : 0 );
+    char *buf = (char*)sip_api_malloc(bufsize * sizeof(char));
+    unsigned int written = 0;
 
     if (classname == NULL)
-        PyOS_snprintf(buf, sizeof (buf), "%s() is deprecated", method);
+        written = PyOS_snprintf(buf, bufsize, "%s() is deprecated", method);
     else if (method == NULL)
-        PyOS_snprintf(buf, sizeof (buf), "%s constructor is deprecated",
-                classname);
+        written = PyOS_snprintf(buf, bufsize, "%s constructor is deprecated",
+				classname);
     else
-        PyOS_snprintf(buf, sizeof (buf), "%s.%s() is deprecated", classname,
-                method);
+        written = PyOS_snprintf(buf, bufsize, "%s.%s() is deprecated", classname,
+				method );
 
     if (message != NULL)
-        PyOS_snprintf(&buf[strlen(buf)], sizeof (buf), ": %s", message);
+        PyOS_snprintf(buf+written, bufsize-written, ": %s", message);
 
-    return PyErr_WarnEx(PyExc_DeprecationWarning, buf, 1);
+    const int res = PyErr_WarnEx(PyExc_DeprecationWarning, buf, 1);
+    sip_api_free(buf); 
+    return res;
 }
 
 
diff --git a/sipbuild/module/source/12/17/sip.h.in b/sipbuild/module/source/12/17/sip.h.in
index 9c13aae..6289f7e 100644
--- a/sipbuild/module/source/12/17/sip.h.in
+++ b/sipbuild/module/source/12/17/sip.h.in
@@ -36,7 +36,7 @@ extern "C" {
 /* The version of the ABI. */
 #define SIP_ABI_MAJOR_VERSION       12
 #define SIP_ABI_MINOR_VERSION       17
-#define SIP_MODULE_PATCH_VERSION    0
+#define SIP_MODULE_PATCH_VERSION    1
 
 
 /*
diff --git a/sipbuild/module/source/12/17/siplib.c b/sipbuild/module/source/12/17/siplib.c
index a9fb64b..a65e399 100644
--- a/sipbuild/module/source/12/17/siplib.c
+++ b/sipbuild/module/source/12/17/siplib.c
@@ -7800,21 +7800,25 @@ int sip_api_deprecated(const char *classname, const char *method)
 int sip_api_deprecated_12_16(const char *classname, const char *method,
         const char *message)
 {
-    char buf[100];
+    const unsigned int bufsize = 100 + ( message ? strlen(message) : 0 );
+    char *buf = (char*)sip_api_malloc(bufsize * sizeof(char));
+    unsigned int written = 0;
 
     if (classname == NULL)
-        PyOS_snprintf(buf, sizeof (buf), "%s() is deprecated", method);
+        written = PyOS_snprintf(buf, bufsize, "%s() is deprecated", method);
     else if (method == NULL)
-        PyOS_snprintf(buf, sizeof (buf), "%s constructor is deprecated",
-                classname);
+        written = PyOS_snprintf(buf, bufsize, "%s constructor is deprecated",
+				classname);
     else
-        PyOS_snprintf(buf, sizeof (buf), "%s.%s() is deprecated", classname,
-                method);
+        written = PyOS_snprintf(buf, bufsize, "%s.%s() is deprecated", classname,
+				method );
 
     if (message != NULL)
-        PyOS_snprintf(&buf[strlen(buf)], sizeof (buf), ": %s", message);
+        PyOS_snprintf(buf+written, bufsize-written, ": %s", message);
 
-    return PyErr_WarnEx(PyExc_DeprecationWarning, buf, 1);
+    const int res = PyErr_WarnEx(PyExc_DeprecationWarning, buf, 1);
+    sip_api_free(buf); 
+    return res;
 }
 
 
@@ -9825,7 +9829,7 @@ static PyObject *sipWrapperType_alloc(PyTypeObject *self, Py_ssize_t nitems)
 
         ((sipWrapperType *)o)->wt_td = currentType;
 
-        if (sipTypeIsClass(currentType))
+        if (sipTypeIsClass(currentType) || sipTypeIsNamespace(currentType))
         {
             const sipClassTypeDef *ctd = (const sipClassTypeDef *)currentType;
             const char *docstring = ctd->ctd_docstring;
diff --git a/sipbuild/module/source/13/10/sip.h.in b/sipbuild/module/source/13/10/sip.h.in
index ce0acc1..75f8f52 100644
--- a/sipbuild/module/source/13/10/sip.h.in
+++ b/sipbuild/module/source/13/10/sip.h.in
@@ -36,7 +36,7 @@ extern "C" {
 /* The version of the ABI. */
 #define SIP_ABI_MAJOR_VERSION       13
 #define SIP_ABI_MINOR_VERSION       10
-#define SIP_MODULE_PATCH_VERSION    0
+#define SIP_MODULE_PATCH_VERSION    1
 
 
 /*
diff --git a/sipbuild/module/source/13/10/sip_core.c b/sipbuild/module/source/13/10/sip_core.c
index 54e89b1..ba72f7c 100644
--- a/sipbuild/module/source/13/10/sip_core.c
+++ b/sipbuild/module/source/13/10/sip_core.c
@@ -6685,21 +6685,25 @@ int sip_api_deprecated(const char *classname, const char *method)
 int sip_api_deprecated_13_9(const char *classname, const char *method,
         const char *message)
 {
-    char buf[100];
+    const unsigned int bufsize = 100 + ( message ? strlen(message) : 0 );
+    char *buf = (char*)sip_api_malloc(bufsize * sizeof(char));
+    unsigned int written = 0;
 
     if (classname == NULL)
-        PyOS_snprintf(buf, sizeof (buf), "%s() is deprecated", method);
+        written = PyOS_snprintf(buf, bufsize, "%s() is deprecated", method);
     else if (method == NULL)
-        PyOS_snprintf(buf, sizeof (buf), "%s constructor is deprecated",
-                classname);
+        written = PyOS_snprintf(buf, bufsize, "%s constructor is deprecated",
+				classname);
     else
-        PyOS_snprintf(buf, sizeof (buf), "%s.%s() is deprecated", classname,
-                method );
+        written = PyOS_snprintf(buf, bufsize, "%s.%s() is deprecated", classname,
+				method );
 
     if (message != NULL)
-        PyOS_snprintf(&buf[strlen(buf)], sizeof (buf), ": %s", message);
+        PyOS_snprintf(buf+written, bufsize-written, ": %s", message);
 
-    return PyErr_WarnEx(PyExc_DeprecationWarning, buf, 1);
+    const int res = PyErr_WarnEx(PyExc_DeprecationWarning, buf, 1);
+    sip_api_free(buf); 
+    return res;
 }
 
 
@@ -8610,7 +8614,7 @@ static PyObject *sipWrapperType_alloc(PyTypeObject *self, Py_ssize_t nitems)
 
         ((sipWrapperType *)o)->wt_td = currentType;
 
-        if (sipTypeIsClass(currentType))
+        if (sipTypeIsClass(currentType) || sipTypeIsNamespace(currentType))
         {
             const sipClassTypeDef *ctd = (const sipClassTypeDef *)currentType;
             const char *docstring = ctd->ctd_docstring;
diff --git a/sipbuild/module/source/13/9/sip.h.in b/sipbuild/module/source/13/9/sip.h.in
index dd452d9..fd0835a 100644
--- a/sipbuild/module/source/13/9/sip.h.in
+++ b/sipbuild/module/source/13/9/sip.h.in
@@ -36,7 +36,7 @@ extern "C" {
 /* The version of the ABI. */
 #define SIP_ABI_MAJOR_VERSION       13
 #define SIP_ABI_MINOR_VERSION       9
-#define SIP_MODULE_PATCH_VERSION    1
+#define SIP_MODULE_PATCH_VERSION    2
 
 
 /*
diff --git a/sipbuild/module/source/13/9/sip_core.c b/sipbuild/module/source/13/9/sip_core.c
index f29485c..881c422 100644
--- a/sipbuild/module/source/13/9/sip_core.c
+++ b/sipbuild/module/source/13/9/sip_core.c
@@ -6677,21 +6677,25 @@ int sip_api_deprecated(const char *classname, const char *method)
 int sip_api_deprecated_13_9(const char *classname, const char *method,
         const char *message)
 {
-    char buf[100];
+    const unsigned int bufsize = 100 + ( message ? strlen(message) : 0 );
+    char *buf = (char*)sip_api_malloc(bufsize * sizeof(char));
+    unsigned int written = 0;
 
     if (classname == NULL)
-        PyOS_snprintf(buf, sizeof (buf), "%s() is deprecated", method);
+        written = PyOS_snprintf(buf, bufsize, "%s() is deprecated", method);
     else if (method == NULL)
-        PyOS_snprintf(buf, sizeof (buf), "%s constructor is deprecated",
-                classname);
+        written = PyOS_snprintf(buf, bufsize, "%s constructor is deprecated",
+				classname);
     else
-        PyOS_snprintf(buf, sizeof (buf), "%s.%s() is deprecated", classname,
-                method );
+        written = PyOS_snprintf(buf, bufsize, "%s.%s() is deprecated", classname,
+				method );
 
     if (message != NULL)
-        PyOS_snprintf(&buf[strlen(buf)], sizeof (buf), ": %s", message);
+        PyOS_snprintf(buf+written, bufsize-written, ": %s", message);
 
-    return PyErr_WarnEx(PyExc_DeprecationWarning, buf, 1);
+    const int res = PyErr_WarnEx(PyExc_DeprecationWarning, buf, 1);
+    sip_api_free(buf); 
+    return res;
 }
 
 
diff --git a/sipbuild/project.py b/sipbuild/project.py
index 14b9bdb..faf6744 100644
--- a/sipbuild/project.py
+++ b/sipbuild/project.py
@@ -295,9 +295,7 @@ class Project(AbstractProject, Configurable):
         """ Return the name of the .dist-info directory for a target directory.
         """
 
-        return os.path.join(target_dir,
-                '{}-{}.dist-info'.format(self.name.replace('-', '_'),
-                self.version_str))
+        return os.path.join(target_dir, self.normalized_name + '.dist-info')
 
     def get_dunder_init(self):
         """ Return the contents of the __init__.py to install. """
@@ -334,7 +332,7 @@ class Project(AbstractProject, Configurable):
             name_parts = self.sip_module.split('.')
             del name_parts[-1]
 
-            return os.path.join(*name_parts)
+            return os.path.join(*name_parts) if name_parts else '.'
 
         return ''
 
@@ -541,6 +539,12 @@ class Project(AbstractProject, Configurable):
 
         self._minimum_macos_version = value
 
+    @property
+    def normalized_name(self):
+        """ The normalized project name including the version number. """
+
+        return self.name.replace('-', '_').lower() + '-' + self.version_str
+
     @staticmethod
     def open_for_writing(fname):
         """ Open a file for writing while handling any errors. """
@@ -844,28 +848,22 @@ class Project(AbstractProject, Configurable):
         self.metadata = pyproject.get_metadata()
         self._metadata_overrides = self.get_metadata_overrides()
         self.metadata.update(self._metadata_overrides)
-        self.version_str = self.metadata['version']
-
-        # Convert the version as a string to number.
-        base_version = packaging.version.parse(self.version_str).base_version
-        base_version = base_version.split('.')
 
-        while len(base_version) < 3:
-            base_version.append('0')
+        # Get the version string and convert it to a number.
+        self.version_str = self.metadata['version']
 
-        version = 0
-        for part in base_version:
-            version <<= 8
+        try:
+            version = packaging.version.parse(self.version_str)
+        except:
+            raise PyProjectOptionException('version',
+                    "'{0}' is an invalid version number".format(
+                            self.version_str),
+                    section_name='tool.sip.metadata')
 
-            try:
-                version += int(part)
-            except ValueError:
-                raise PyProjectOptionException('version',
-                        "'{0}' is an invalid version number".format(
-                                self.version_str),
-                        section_name='tool.sip.metadata')
+        self.version = version.major << 16 | version.minor << 8 | version.micro
 
-        self.version = version
+        # Get the limited ABI version string.
+        self.limited_abi_version_str = self._get_limited_abi_version_str()
 
         # Configure the project.
         self.configure(pyproject, 'tool.sip.project', tool)
@@ -892,3 +890,28 @@ class Project(AbstractProject, Configurable):
         for bindings in self.bindings.values():
             bindings.configure(pyproject, 'tool.sip.bindings.' + bindings.name,
                     tool)
+
+    def _get_limited_abi_version_str(self):
+        """ Get the version of the limited ABI to be used. """
+
+        try:
+            # The version of the ABI to use is taken from the project metadata.
+            spec_set = packaging.specifiers.SpecifierSet(
+                    self.metadata['requires-python'])
+
+            # Find the oldest Python version that satisfies the requirement.
+            # The 100 is an arbitrary upper bound.
+            min_req_version = next(
+                    spec_set.filter((f'3.{v}' for v in range(100))))
+
+            min_req_version = packaging.version.parse(min_req_version)
+            limited_abi_version_str = '0x{0:02x}{1:02x}{2:02x}00'.format(
+                    min_req_version.major,
+                    min_req_version.minor,
+                    min_req_version.micro)
+        except Exception as e:
+            # Default to the oldest version of Python we support.
+            limited_abi_version_str = '0x03{0:02x}0000'.format(
+                    OLDEST_SUPPORTED_MINOR)
+
+        return limited_abi_version_str
diff --git a/sipbuild/py_versions.py b/sipbuild/py_versions.py
index 5e7f325..c52a101 100644
--- a/sipbuild/py_versions.py
+++ b/sipbuild/py_versions.py
@@ -1,12 +1,12 @@
 # SPDX-License-Identifier: BSD-2-Clause
 
-# Copyright (c) 2024 Phil Thompson <phil at riverbankcomputing.com>
+# Copyright (c) 2025 Phil Thompson <phil at riverbankcomputing.com>
 
 
 # The minimum required version of setuptools.  This is the earliest version
-# that supports PEP 625.  Remember to update pyproject.toml in the root
-# directory.
-MINIMUM_SETUPTOOLS = '69.5'
+# that generates correct wheel names for PyPI.  Remember to update
+# pyproject.toml in the root directory.
+MINIMUM_SETUPTOOLS = '75.8.1'
 
 # The oldest supported minor version of Python v3.  Remember to update
 # pyproject.toml in the root directory.
diff --git a/sipbuild/pyproject.py b/sipbuild/pyproject.py
index c49051f..791ba72 100644
--- a/sipbuild/pyproject.py
+++ b/sipbuild/pyproject.py
@@ -3,6 +3,9 @@
 # Copyright (c) 2024 Phil Thompson <phil at riverbankcomputing.com>
 
 
+from packaging.licenses import (canonicalize_license_expression,
+        InvalidLicenseExpression)
+
 from .exceptions import deprecated, UserFileException
 from .py_versions import OLDEST_SUPPORTED_MINOR
 from .toml import toml_loads
@@ -99,7 +102,7 @@ class PyProject:
         else:
             # The main validation we do is to check that the keys are defined
             # by PEP 621.
-            core_metadata = {'metadata-version': '2.1'}
+            core_metadata = {'metadata-version': '2.4'}
 
             for md_name, md_value in metadata.items():
                 md_name = md_name.lower()
@@ -109,6 +112,8 @@ class PyProject:
                     self._handle_readme(md_value, core_metadata)
                 elif md_name == 'license':
                     self._handle_license(md_value, core_metadata)
+                elif md_name == 'license-files':
+                    self._handle_license_files(md_value, core_metadata)
                 elif md_name == 'authors':
                     self._handle_people(md_value, 'author', core_metadata)
                 elif md_name == 'maintainers':
@@ -134,6 +139,21 @@ class PyProject:
 
                     core_metadata[core_name] = md_value
 
+            # Check the legacy (ie. pre PEP 639) use of 'license'.
+            if 'license' in core_metadata:
+                if 'license-file' in core_metadata:
+                    raise PyProjectOptionException('license',
+                            "cannot be used in it's legacy form if 'license-files' is also specified",
+                            section_name='project')
+
+                deprecated(
+                        "the legacy use of 'license'",
+                        instead="an SPDX license expression and 'license-files'",
+                        filename='pyproject.toml',
+                        line_nr=self._get_line_nr('license ='))
+
+                core_metadata['metadata-version'] = '2.2'
+
             # Check that the name has been specified.
             if 'name' not in core_metadata:
                 raise PyProjectUndefinedOptionException('name',
@@ -229,8 +249,8 @@ class PyProject:
                     section_name='tool.sip')
 
         if core_metadata_version is None:
-            # Default to PEP 566.
-            core_metadata['metadata-version'] = '2.1'
+            # Default to PEP 643.
+            core_metadata['metadata-version'] = '2.2'
 
         return core_metadata
 
@@ -272,13 +292,22 @@ class PyProject:
 
         core_metadata['keywords'] = ', '.join(value)
 
-    @staticmethod
-    def _handle_license(value, core_metadata):
+    @classmethod
+    def _handle_license(cls, value, core_metadata):
         """ Handle the 'license' key. """
 
-        if not isinstance(value, dict):
-            raise PyProjectTypeOptionException('license', "a table",
-                    section_name='project')
+        if isinstance(value, dict):
+            return cls._handle_legacy_license(value, core_metadata)
+
+        try:
+            core_metadata['license-expression'] = canonicalize_license_expression(value)
+        except InvalidLicenseExpression as e:
+            raise PyProjectTypeOptionException('license',
+                    "a valid SPDX license expression", section_name='project')
+
+    @staticmethod
+    def _handle_legacy_license(value, core_metadata):
+        """ Handle the legacy 'license' key. """
 
         text = value.get('text')
         file = value.get('file')
@@ -303,6 +332,16 @@ class PyProject:
 
         core_metadata['license'] = text
 
+    @staticmethod
+    def _handle_license_files(value, core_metadata):
+        """ Handle the 'license-files' key. """
+
+        if not isinstance(value, list):
+            raise PyProjectTypeOptionException('license-files',
+                    "an array of strings", section_name='project')
+
+        core_metadata['license-file'] = value
+
     @classmethod
     def _handle_optional_dependencies(cls, value, core_metadata):
         """ Handle the 'optional-dependencies' key. """
diff --git a/test/README.md b/test/README.md
new file mode 100644
index 0000000..09ccf41
--- /dev/null
+++ b/test/README.md
@@ -0,0 +1,31 @@
+# Unit Tests
+
+Unit tests are written using Python's `unittest` module.  Each sub-directory
+implements a single test case, each of which may contain any number of tests.
+
+
+## Running Unit Tests
+
+To run the complete suite of tests, run:
+
+    python -m unittest
+
+To run the tests of a particular test case, run:
+
+    python -m unittest TEST_SUBDIR/test_TEST.py
+
+
+## Writing Unit Tests
+
+Each test sub-directory should contain an empty `__init__.py` file, a Python
+test script with the prefix `test_` and one or more `.sip` files.
+
+The test script should contain a sub-class of `SIPTestCase`, which is imported
+from the `utils` module.  This will automatically build any modules (defined by
+the `.sip` files) needed to run the tests.  The modules will be available as
+top-level imports.  A module's build is configurable to a limited extent - see
+the class attributes of `SIPTestCase` for the relevant information.
+
+Each `.sip` file should define a single module to build.  The C/C++
+implementation being wrapped would normally be embedded in the `.sip` file
+using appropriate code directives.
diff --git a/test/imported_exceptions/__init__.py b/test/imported_exceptions/__init__.py
new file mode 100644
index 0000000..6f186ca
--- /dev/null
+++ b/test/imported_exceptions/__init__.py
@@ -0,0 +1,3 @@
+# SPDX-License-Identifier: BSD-2-Clause
+
+# Copyright (c) 2025 Phil Thompson <phil at riverbankcomputing.com>
diff --git a/test/imported_exceptions/handler_module.sip b/test/imported_exceptions/handler_module.sip
new file mode 100644
index 0000000..04c1f30
--- /dev/null
+++ b/test/imported_exceptions/handler_module.sip
@@ -0,0 +1,19 @@
+// The SIP implementation of the handler_module test module.
+
+
+%Module(name=handler_module)
+
+
+%Exception std::exception(SIP_Exception) /PyName=StdException, Default/
+{
+%TypeHeaderCode
+#include <exception>
+%End
+
+%RaiseCode
+    const char *detail = sipExceptionRef.what();
+    SIP_BLOCK_THREADS
+    PyErr_SetString(sipException_std_exception, detail);
+    SIP_UNBLOCK_THREADS
+%End
+};
diff --git a/test/imported_exceptions/test_imported_exceptions.py b/test/imported_exceptions/test_imported_exceptions.py
new file mode 100644
index 0000000..b029553
--- /dev/null
+++ b/test/imported_exceptions/test_imported_exceptions.py
@@ -0,0 +1,21 @@
+# SPDX-License-Identifier: BSD-2-Clause
+
+# Copyright (c) 2025 Phil Thompson <phil at riverbankcomputing.com>
+
+
+from utils import SIPTestCase
+
+
+class ImportedExceptionsTestCase(SIPTestCase):
+    """ Test the support for imported exceptions. """
+
+    # Enable exception support.
+    exceptions = True
+
+    def test_Exceptions(self):
+        """ Test the throwing of an imported exception. """
+
+        from handler_module import StdException
+        from thrower_module import throwException
+
+        self.assertRaises(StdException, throwException)
diff --git a/test/imported_exceptions/thrower_module.sip b/test/imported_exceptions/thrower_module.sip
new file mode 100644
index 0000000..6982195
--- /dev/null
+++ b/test/imported_exceptions/thrower_module.sip
@@ -0,0 +1,19 @@
+// The SIP implementation of the thrower_module test module.
+
+
+%Module(name=thrower_module)
+
+%Import handler_module.sip
+
+
+%ModuleHeaderCode
+
+void throwException()
+{
+    throw std::exception();
+};
+
+%End
+
+
+void throwException();
diff --git a/test/int_convertors/int_convertors.sip b/test/int_convertors/int_convertors_module.sip
similarity index 98%
rename from test/int_convertors/int_convertors.sip
rename to test/int_convertors/int_convertors_module.sip
index d2f5496..1b36eb8 100644
--- a/test/int_convertors/int_convertors.sip
+++ b/test/int_convertors/int_convertors_module.sip
@@ -1,4 +1,7 @@
-%Module(name=int_convertors)
+// The SIP implementation of the int_convertors_module tets module.
+
+
+%Module(name=int_convertors_module)
 
 
 %PostInitialisationCode
diff --git a/test/int_convertors/test_int_convertors.py b/test/int_convertors/test_int_convertors.py
index 176a978..fe14bd8 100644
--- a/test/int_convertors/test_int_convertors.py
+++ b/test/int_convertors/test_int_convertors.py
@@ -1,6 +1,6 @@
 # SPDX-License-Identifier: BSD-2-Clause
 
-# Copyright (c) 2024 Phil Thompson <phil at riverbankcomputing.com>
+# Copyright (c) 2025 Phil Thompson <phil at riverbankcomputing.com>
 
 
 from utils import SIPTestCase
@@ -15,7 +15,7 @@ class IntConvertorsTestCase(SIPTestCase):
 
         super().setUpClass()
 
-        from .int_convertors import IntConvertors
+        from int_convertors_module import IntConvertors
 
         # Compute the various test values based on the native sizes.
         cls.CHAR_LOWER = IntConvertors.char_lower()
diff --git a/test/enums/__init__.py b/test/movable/__init__.py
similarity index 100%
copy from test/enums/__init__.py
copy to test/movable/__init__.py
diff --git a/test/movable/movable_module.sip b/test/movable/movable_module.sip
new file mode 100644
index 0000000..14ea49f
--- /dev/null
+++ b/test/movable/movable_module.sip
@@ -0,0 +1,127 @@
+// The SIP implementation of the movable_module test module.
+
+
+%Module(name=movable_module)
+
+
+template <TYPE>
+%MappedType std::unique_ptr<TYPE> /Movable, NoRelease, AllowNone/
+{
+%TypeHeaderCode
+#include <memory>
+#include <utility>      // For std::move().
+%End
+
+%ConvertFromTypeCode
+    const sipTypeDef *td = sipFindType("TYPE*");
+
+    return sipConvertFromNewType(sipCpp->release(), td, NULL);
+%End
+
+%ConvertToTypeCode
+    const sipTypeDef *td = sipFindType("TYPE");
+
+    if (sipIsErr == NULL)
+        return sipCanConvertToType(sipPy, td, 0);
+
+    if (sipPy == Py_None)
+        return 1;
+
+    int state;
+    TYPE *t = reinterpret_cast<TYPE *>(sipConvertToType(sipPy, td, sipTransferObj, 0, &state, sipIsErr));
+
+    sipReleaseType(t, td, state);
+
+    if (*sipIsErr)
+        return 0;
+
+    *sipCppPtr = new std::unique_ptr<TYPE>(t);
+
+    return sipGetState(sipTransferObj);
+%End
+};
+
+
+%ModuleHeaderCode
+class AnObject
+{
+public:
+    AnObject(int i) : mI(i) {}
+    virtual ~AnObject() {}
+
+    int getValue() const
+    {
+        return mI;
+    }
+
+    void increment()
+    {
+        mI++;
+    }
+
+private:
+    int mI;
+};
+%End
+
+
+class AnObject
+{
+public:
+    AnObject(int i);
+    virtual ~AnObject();
+
+    int getValue() const;
+};
+
+
+%ModuleHeaderCode
+#include <memory>
+
+
+class ObjectWrapper
+{
+public:
+    ObjectWrapper() {};
+    ObjectWrapper(const ObjectWrapper& other) : mObject(new AnObject(*other.mObject)) {}
+    ObjectWrapper& operator=(const ObjectWrapper& other)
+    {
+        mObject.reset(new AnObject(*other.mObject));
+        return *this;
+    };
+    virtual ~ObjectWrapper() {}
+
+    int getObjectValue() const
+    {
+        return mObject ? mObject->getValue() : -1000;
+    }
+
+    std::unique_ptr<AnObject> takeObject()
+    {
+        return std::move(mObject);
+    }
+
+    void setObject(std::unique_ptr<AnObject> anObject)
+    {
+        mObject = std::move(anObject);
+        mObject->increment();
+    }
+
+private:
+    std::unique_ptr<AnObject> mObject;
+};
+%End
+
+
+class ObjectWrapper
+{
+public:
+    ObjectWrapper();
+    virtual ~ObjectWrapper();
+
+    int getObjectValue() const;
+
+    std::unique_ptr<AnObject> takeObject();
+
+    void setObject(std::unique_ptr<AnObject> anObject);
+};
diff --git a/test/movable/test_movable.py b/test/movable/test_movable.py
new file mode 100644
index 0000000..04aadb8
--- /dev/null
+++ b/test/movable/test_movable.py
@@ -0,0 +1,47 @@
+# SPDX-License-Identifier: BSD-2-Clause
+
+# Copyright (c) 2025 Phil Thompson <phil at riverbankcomputing.com>
+
+
+from utils import SIPTestCase
+
+
+class MovableTestCase(SIPTestCase):
+    """ Test the support for the /Movable/ mapped type annotation.  (See
+    issue/60.)
+    """
+
+    def test_Movable(self):
+        """ Test the support for /Movable/.  It also verifies the test
+        implementation of the std::unique_ptr mapped type.
+        """
+
+        from sys import getrefcount
+        from movable_module import AnObject, ObjectWrapper
+
+        ao = AnObject(3)
+        ow = ObjectWrapper()
+
+        # Test the value of the object.
+        self.assertEqual(ao.getValue(), 3)
+        self.assertEqual(getrefcount(ao), 2)
+
+        # Test an empty wrapper.
+        self.assertEqual(ow.getObjectValue(), -1000)
+
+        # Test an non-empty wrapper.
+        ow.setObject(ao)
+        self.assertEqual(getrefcount(ao), 3)
+        self.assertEqual(ow.getObjectValue(), 4)
+
+        # Unwrap the object and test the wrapper.
+        ao2 = ow.takeObject()
+        self.assertEqual(getrefcount(ao2), 2)
+        self.assertEqual(ow.getObjectValue(), -1000)
+
+        # Re-test the value of the object.
+        self.assertEqual(ao2.getValue(), 4)
+
+        # Check that the original Python object no longer wraps the C++ object.
+        self.assertEqual(getrefcount(ao), 2)
+        self.assertRaises(RuntimeError, ao.getValue)
diff --git a/test/enums/__init__.py b/test/py_enums/__init__.py
similarity index 100%
rename from test/enums/__init__.py
rename to test/py_enums/__init__.py
diff --git a/test/enums/enums.sip b/test/py_enums/py_enums_module.sip
similarity index 96%
rename from test/enums/enums.sip
rename to test/py_enums/py_enums_module.sip
index 348d18a..99ae9d3 100644
--- a/test/enums/enums.sip
+++ b/test/py_enums/py_enums_module.sip
@@ -1,6 +1,7 @@
-// The bindings for testing support for enums.
+// The SIP implementation of the py_enums_module test module.
 
-%Module(name=enums)
+
+%Module(name=py_enums_module)
 
 
 %ModuleHeaderCode
diff --git a/test/enums/test_enums.py b/test/py_enums/test_py_enums.py
similarity index 85%
rename from test/enums/test_enums.py
rename to test/py_enums/test_py_enums.py
index 95d92d2..49d9d94 100644
--- a/test/enums/test_enums.py
+++ b/test/py_enums/test_py_enums.py
@@ -1,6 +1,6 @@
 # SPDX-License-Identifier: BSD-2-Clause
 
-# Copyright (c) 2024 Phil Thompson <phil at riverbankcomputing.com>
+# Copyright (c) 2025 Phil Thompson <phil at riverbankcomputing.com>
 
 
 from enum import Enum, Flag, IntEnum, IntFlag
@@ -8,8 +8,11 @@ from enum import Enum, Flag, IntEnum, IntFlag
 from utils import SIPTestCase
 
 
-class EnumsTestCase(SIPTestCase):
-    """ Test the support for enums in ABI v13 and later. """
+class PyEnumsTestCase(SIPTestCase):
+    """ Test the support for Python enums in ABI v13 and later. """
+
+    # The ABI version to use.
+    abi_version = '13'
 
     @classmethod
     def setUpClass(cls):
@@ -17,7 +20,7 @@ class EnumsTestCase(SIPTestCase):
 
         super().setUpClass()
 
-        from .enums import EnumClass
+        from py_enums_module import EnumClass
 
         class NamedEnumFixture(EnumClass):
             """ A fixture for testing named enum values. """
@@ -64,7 +67,7 @@ class EnumsTestCase(SIPTestCase):
     def test_ModuleAnon(self):
         """ Test a module level anonymous enum. """
 
-        from .enums import AnonMember
+        from py_enums_module import AnonMember
 
         self.assertIsInstance(AnonMember, int)
         self.assertEqual(AnonMember, 10)
@@ -72,7 +75,7 @@ class EnumsTestCase(SIPTestCase):
     def test_ClassAnon(self):
         """ Test a class level anonymous enum. """
 
-        from .enums import EnumClass
+        from py_enums_module import EnumClass
 
         self.assertIsInstance(EnumClass.ClassAnonMember, int)
         self.assertEqual(EnumClass.ClassAnonMember, 40)
@@ -84,7 +87,7 @@ class EnumsTestCase(SIPTestCase):
     def test_Enum_BaseType(self):
         """ Test /BaseType=Enum/. """
 
-        from .enums import EnumBase
+        from py_enums_module import EnumBase
 
         self.assertTrue(issubclass(EnumBase, Enum))
         self.assertFalse(issubclass(EnumBase, Flag))
@@ -94,7 +97,7 @@ class EnumsTestCase(SIPTestCase):
     def test_Flag_BaseType(self):
         """ Test /BaseType=Flag/. """
 
-        from .enums import FlagBase
+        from py_enums_module import FlagBase
 
         self.assertTrue(issubclass(FlagBase, Flag))
         self.assertFalse(issubclass(FlagBase, IntEnum))
@@ -103,7 +106,7 @@ class EnumsTestCase(SIPTestCase):
     def test_IntEnum_BaseType(self):
         """ Test /BaseType=IntEnum/. """
 
-        from .enums import IntEnumBase
+        from py_enums_module import IntEnumBase
 
         self.assertFalse(issubclass(IntEnumBase, Flag))
         self.assertTrue(issubclass(IntEnumBase, IntEnum))
@@ -112,7 +115,7 @@ class EnumsTestCase(SIPTestCase):
     def test_IntFlag_BaseType(self):
         """ Test /BaseType=IntFlag/. """
 
-        from .enums import IntFlagBase
+        from py_enums_module import IntFlagBase
 
         self.assertFalse(issubclass(IntFlagBase, IntEnum))
         self.assertTrue(issubclass(IntFlagBase, IntFlag))
@@ -124,7 +127,7 @@ class EnumsTestCase(SIPTestCase):
     def test_ModuleNamed(self):
         """ Test a module level named enum. """
 
-        from .enums import NamedEnum
+        from py_enums_module import NamedEnum
 
         self.assertTrue(issubclass(NamedEnum, Enum))
         self.assertEqual(NamedEnum.NamedMember.value, 20)
@@ -132,7 +135,7 @@ class EnumsTestCase(SIPTestCase):
     def test_ClassNamed(self):
         """ Test a class level named enum. """
 
-        from .enums import EnumClass
+        from py_enums_module import EnumClass
 
         self.assertTrue(issubclass(EnumClass.ClassNamedEnum, Enum))
         self.assertEqual(EnumClass.ClassNamedEnum.ClassNamedMember.value, 50)
@@ -140,7 +143,7 @@ class EnumsTestCase(SIPTestCase):
     def test_named_get_member(self):
         """ named enum virtual result with a member value. """
 
-        from .enums import EnumClass
+        from py_enums_module import EnumClass
 
         self.install_hook()
         self.assertEqual(self.member_fixture.named_get(),
@@ -150,7 +153,7 @@ class EnumsTestCase(SIPTestCase):
     def test_named_set_member(self):
         """ named enum function argument with a member value. """
 
-        from .enums import EnumClass
+        from py_enums_module import EnumClass
 
         self.member_fixture.named_set(
                 EnumClass.ClassNamedEnum.ClassNamedMember)
@@ -158,14 +161,14 @@ class EnumsTestCase(SIPTestCase):
     def test_named_var_member(self):
         """ named enum instance variable with a member value. """
 
-        from .enums import EnumClass
+        from py_enums_module import EnumClass
 
         self.member_fixture.named_var = EnumClass.ClassNamedEnum.ClassNamedMember
 
     def test_named_overload_set(self):
         """ overloaded named enum function argument. """
 
-        from .enums import EnumClass
+        from py_enums_module import EnumClass
 
         self.member_fixture.named_overload_set(
                 EnumClass.ClassNamedEnum.ClassNamedMember)
@@ -174,7 +177,7 @@ class EnumsTestCase(SIPTestCase):
     def test_named_get_int(self):
         """ named enum virtual result with an integer value. """
 
-        from .enums import EnumClass
+        from py_enums_module import EnumClass
 
         with self.assertRaises(TypeError):
             self.install_hook()
@@ -200,7 +203,7 @@ class EnumsTestCase(SIPTestCase):
     def test_ModuleScoped(self):
         """ Test a module level C++11 scoped enum. """
 
-        from .enums import ScopedEnum
+        from py_enums_module import ScopedEnum
 
         self.assertTrue(issubclass(ScopedEnum, Enum))
         self.assertEqual(ScopedEnum.ScopedMember.value, 30)
@@ -208,7 +211,7 @@ class EnumsTestCase(SIPTestCase):
     def test_ClassScoped(self):
         """ Test a class level C++11 scoped enum. """
 
-        from .enums import EnumClass
+        from py_enums_module import EnumClass
 
         self.assertTrue(issubclass(EnumClass.ClassScopedEnum, Enum))
         self.assertEqual(EnumClass.ClassScopedEnum.ClassScopedMember.value, 70)
@@ -216,7 +219,7 @@ class EnumsTestCase(SIPTestCase):
     def test_scoped_get_member(self):
         """ scoped enum virtual result with a member value. """
 
-        from .enums import EnumClass
+        from py_enums_module import EnumClass
 
         self.install_hook()
         self.assertIs(self.scoped_enum_fixture.scoped_get(),
@@ -226,7 +229,7 @@ class EnumsTestCase(SIPTestCase):
     def test_scoped_set_member(self):
         """ scoped enum function argument with a member value. """
 
-        from .enums import EnumClass
+        from py_enums_module import EnumClass
 
         self.scoped_enum_fixture.scoped_set(
                 EnumClass.ClassScopedEnum.ClassScopedMember)
@@ -234,6 +237,6 @@ class EnumsTestCase(SIPTestCase):
     def test_scoped_var_member(self):
         """ scoped enum instance variable with a member value. """
 
-        from .enums import EnumClass
+        from py_enums_module import EnumClass
 
         self.scoped_enum_fixture.scoped_var = EnumClass.ClassScopedEnum.ClassScopedMember
diff --git a/test/template_superclasses/__init__.py b/test/template_superclasses/__init__.py
new file mode 100644
index 0000000..6f186ca
--- /dev/null
+++ b/test/template_superclasses/__init__.py
@@ -0,0 +1,3 @@
+# SPDX-License-Identifier: BSD-2-Clause
+
+# Copyright (c) 2025 Phil Thompson <phil at riverbankcomputing.com>
diff --git a/test/template_superclasses/template_superclasses_module.sip b/test/template_superclasses/template_superclasses_module.sip
new file mode 100644
index 0000000..e139e76
--- /dev/null
+++ b/test/template_superclasses/template_superclasses_module.sip
@@ -0,0 +1,71 @@
+// The SIP implementation of the template_superclasses_module test module.
+
+
+%Module(name=template_superclasses_module)
+
+
+%ModuleHeaderCode
+
+class BaseClass
+{
+};
+
+template <class T1>
+class ValueWrapper : public BaseClass
+{
+public:
+    T1 getValue() const {
+        return T1();
+    }
+};
+
+template <class T2>
+class ExtendedValueWrapper : public ValueWrapper<T2>
+{
+};
+
+class AValue
+{
+};
+
+class BValue
+{
+};
+
+typedef ExtendedValueWrapper<AValue> AValueWrapper;
+typedef ExtendedValueWrapper<BValue> BValueWrapper;
+
+%End
+
+
+class BaseClass
+{
+};
+
+
+template <T1>
+class ValueWrapper : public BaseClass
+{
+public:
+    T1 getValue() const;
+};
+
+
+template <T2>
+class ExtendedValueWrapper : public ValueWrapper<T2>
+{
+};
+
+
+class AValue
+{
+};
+
+
+class BValue
+{
+};
+
+
+typedef ExtendedValueWrapper<AValue> AValueWrapper;
+typedef ExtendedValueWrapper<BValue> BValueWrapper;
diff --git a/test/template_superclasses/test_template_superclasses.py b/test/template_superclasses/test_template_superclasses.py
new file mode 100644
index 0000000..32fb877
--- /dev/null
+++ b/test/template_superclasses/test_template_superclasses.py
@@ -0,0 +1,26 @@
+# SPDX-License-Identifier: BSD-2-Clause
+
+# Copyright (c) 2025 Phil Thompson <phil at riverbankcomputing.com>
+
+
+from utils import SIPTestCase
+
+
+class TemplateSuperclassesTestCase(SIPTestCase):
+    """ Test the support for template arguments as super-classes in a template
+    class.  (See issue/12.)
+    """
+
+    def test_TemplateSuperclasses(self):
+        """ Test the use of template arguments as super-classes. """
+
+        from template_superclasses_module import (AValue, AValueWrapper,
+                BaseClass, BValue, BValueWrapper)
+
+        self.assertTrue(issubclass(AValueWrapper, BaseClass))
+        a = AValueWrapper()
+        self.assertIsInstance(a.getValue(), AValue)
+
+        self.assertTrue(issubclass(BValueWrapper, BaseClass))
+        b = BValueWrapper()
+        self.assertIsInstance(b.getValue(), BValue)
diff --git a/test/timelines/__init__.py b/test/timelines/__init__.py
new file mode 100644
index 0000000..6f186ca
--- /dev/null
+++ b/test/timelines/__init__.py
@@ -0,0 +1,3 @@
+# SPDX-License-Identifier: BSD-2-Clause
+
+# Copyright (c) 2025 Phil Thompson <phil at riverbankcomputing.com>
diff --git a/test/timelines/test_timelines.py b/test/timelines/test_timelines.py
new file mode 100644
index 0000000..e0f26fd
--- /dev/null
+++ b/test/timelines/test_timelines.py
@@ -0,0 +1,28 @@
+# SPDX-License-Identifier: BSD-2-Clause
+
+# Copyright (c) 2025 Phil Thompson <phil at riverbankcomputing.com>
+
+
+from utils import SIPTestCase
+
+
+class TimelinesTestCase(SIPTestCase):
+    """ Test the support for timelines. """
+
+    # The list of tags.
+    tags = ['v2']
+
+    def test_Timelines(self):
+        """ Test the support for timelines. """
+
+        import timelines_module
+
+        mod_dict = timelines_module.__dict__
+
+        self.assertIn('between_v1_and_v3_enabled', mod_dict)
+        self.assertIn('up_to_unknown_enabled', mod_dict)
+
+        self.assertNotIn('up_to_v2_disabled', mod_dict)
+        self.assertNotIn('v3_and_after_disabled', mod_dict)
+        self.assertNotIn('unknown_and_after_disabled', mod_dict)
+        self.assertNotIn('between_unknown1_and_unknown2_disabled', mod_dict)
diff --git a/test/timelines/timelines_module.sip b/test/timelines/timelines_module.sip
new file mode 100644
index 0000000..f3ff6ca
--- /dev/null
+++ b/test/timelines/timelines_module.sip
@@ -0,0 +1,50 @@
+// The SIP implementation of the timelines_module test module.
+
+
+%Module(name=timelines_module)
+
+
+%Timeline {v1 v2 v3}
+
+
+%ModuleHeaderCode
+
+// We implement them all.
+void up_to_v2_disabled() {}
+void v3_and_after_disabled() {}
+void between_v1_and_v3_enabled() {}
+void up_to_unknown_enabled() {}
+void unknown_and_after_disabled() {}
+void between_unknown1_and_unknown2_disabled() {}
+
+%End
+
+
+%If (- v2)
+void up_to_v2_disabled();
+%End
+
+
+%If (v3 -)
+void v3_and_after_disabled();
+%End
+
+
+%If (v1 - v3)
+void between_v1_and_v3_enabled();
+%End
+
+
+%If (- unknown)
+void up_to_unknown_enabled();
+%End
+
+
+%If (unknown -)
+void unknown_and_after_disabled();
+%End
+
+
+%If (unknown1 - unknown2)
+void between_unknown1_and_unknown2_disabled();
+%End
diff --git a/test/utils/sip_test_case.py b/test/utils/sip_test_case.py
index b1ff2eb..c3310e9 100644
--- a/test/utils/sip_test_case.py
+++ b/test/utils/sip_test_case.py
@@ -1,6 +1,6 @@
 # SPDX-License-Identifier: BSD-2-Clause
 
-# Copyright (c) 2024 Phil Thompson <phil at riverbankcomputing.com>
+# Copyright (c) 2025 Phil Thompson <phil at riverbankcomputing.com>
 
 
 import glob
@@ -9,105 +9,62 @@ import os
 import shutil
 import subprocess
 import sys
+import tarfile
 import unittest
 
 
 class SIPTestCase(unittest.TestCase):
     """ Encapsulate a test case that tests a set of standalone bindings. """
 
-    # The ABI version to use.  None implies the latest major version.  Note
-    # that the enum tests are specific to ABI v13.
-    #abi_version = None
-    abi_version = '13.8'
-    #abi_version = '12.15'
+    # The ABI version to use.  None implies the latest major version.
+    abi_version = None
 
-    @classmethod
-    def setUpClass(cls):
-        """ Build a test extension module. """
-
-        # Get the name of the test directory from the file implementing the
-        # test case.
-        test_dir = os.path.dirname(inspect.getfile(cls))
-
-        # Look for a .sip file in the test directory.
-        sip_files = glob.glob(os.path.join(test_dir, '*.sip'))
-
-        if len(sip_files) != 1:
-            raise Exception(
-                    "expected to find a single .sip file in '{0}'".format(
-                            test_dir))
+    # Set if exception support should be enabled.
+    exceptions = False
 
-        module_name = os.path.basename(sip_files[0])[:-4]
+    # The list of tags to be used to configure the test modules.
+    tags = None
 
-        # Create a pyproject.toml file.
-        pyproject_toml = os.path.join(test_dir, 'pyproject.toml')
+    # Set if a separate sip module should be generated.  It will be built
+    # automatically if more than one module is being built.
+    use_sip_module = False
 
-        with open(pyproject_toml, 'w') as f:
-            f.write(_PYPROJECT_TOML.format(module_name=module_name))
+    @classmethod
+    def setUpClass(cls):
+        """ Build a test extension module. """
 
-            if cls.abi_version is not None:
-                f.write(_ABI_VERSION.format(abi_version=cls.abi_version))
+        # Get the name of the test's root directory from the file implementing
+        # the test case.
+        root_dir = os.path.dirname(inspect.getfile(cls))
 
-        # Build the extension module.
-        cwd = os.getcwd()
-        subprocess.run([sys.executable, '-m', 'sipbuild.tools.build', '--verbose'], cwd=test_dir).check_returncode()
-        os.chdir(cwd)
+        cls._modules = []
 
-        # Move the extension module to the test directory.
-        build_dir = os.path.join(test_dir, 'build')
-
-        # The distutils and setuptools builders leave the module in different
-        # places.
-        for subdirs in ((module_name, ), (module_name, 'build', 'lib*')):
-            module_pattern = [build_dir]
-            module_pattern.extend(subdirs)
-            module_pattern.append(
-                    module_name + '*.pyd' if sys.platform == 'win32' else '*.so')
-
-            module_path = glob.glob(os.path.join(*module_pattern))
-            if len(module_path) != 0:
-                break
-        else:
-            raise Exception(
-                    "no '{0}' extension module was built".format(module_name))
+        sip_files = glob.glob(os.path.join(root_dir, '*.sip'))
+        use_sip_module = cls.use_sip_module or len(sip_files) > 1
 
-        if len(module_path) != 1:
-            raise Exception(
-                    "unable to determine file name of the '{0}' extension module".format(module_name))
+        # Build a sip module if required.
+        if use_sip_module:
+            cls._modules.append(cls._build_sip_module(root_dir))
 
-        module_path = module_path[0]
-        module_impl = os.path.join(test_dir, os.path.basename(module_path))
+        for sip_file in sip_files:
+            cls._modules.append(
+                    cls._build_test_module(sip_file, use_sip_module, root_dir))
 
-        # On Windows the module may be lying around from a previous run.
-        try:
-            os.remove(module_impl)
-        except:
-            pass
-
-        os.rename(module_path, module_impl)
-
-        # Remove the build artifacts.
-        os.remove(pyproject_toml)
-        shutil.rmtree(build_dir, ignore_errors=True)
-
-        # Provide tearDownClass() with the values it needs to tidy up.
-        cls._module_name = module_name
-        cls._module_impl = module_impl
+        # Fix the path.
+        sys.path.insert(0, root_dir)
 
     @classmethod
     def tearDownClass(cls):
-        """ Unload the module and remove the extension module. """
+        """ Unload the module. """
 
-        try:
-            del sys.modules[cls._module_name]
-        except KeyError:
-            pass
+        for module_name in cls._modules:
+            try:
+                del sys.modules[module_name]
+            except KeyError:
+                pass
 
-        # This will fail on Windows (probably because the module is loaded).
-        try:
-            os.remove(cls._module_impl)
-        except:
-            pass
+        # Restore the path.
+        del sys.path[0]
 
     def install_hook(self):
         """ Install a hook that will catch any exceptions raised by a Python
@@ -137,6 +94,135 @@ class SIPTestCase(unittest.TestCase):
         # Save the exception for later.
         self._exc = xvalue
 
+    @classmethod
+    def _build_test_module(cls, sip_file, use_sip_module, root_dir):
+        """ Build a test extension module and return its name. """
+
+        build_dir = sip_file[:-4]
+        module_name = os.path.basename(build_dir)
+
+        # Remove any previous build directory.
+        shutil.rmtree(build_dir, ignore_errors=True)
+
+        os.mkdir(build_dir)
+
+        # Create a pyproject.toml file.
+        pyproject_toml = os.path.join(build_dir, 'pyproject.toml')
+
+        with open(pyproject_toml, 'w') as f:
+            f.write(_PYPROJECT_TOML.format(module_name=module_name))
+
+            if cls.abi_version is not None:
+                f.write(_ABI_VERSION.format(abi_version=cls.abi_version))
+
+            if use_sip_module:
+                f.write(_SIP_MODULE)
+
+            if cls.tags is not None or cls.exceptions:
+                f.write(f'\n[tool.sip.bindings.{module_name}]\n')
+
+                if cls.tags is not None:
+                    tags_s = ', '.join([f'"{t}"' for t in cls.tags])
+                    f.write(f'tags = [{tags_s}]\n')
+
+                if cls.exceptions:
+                    f.write('exceptions = true\n')
+
+        # Build and move the test module.
+        cls._build_module(module_name,
+                ['-m', 'sipbuild.tools.build', '--verbose'], build_dir,
+                root_dir, impl_subdirs=[module_name, 'build'])
+
+        return module_name
+
+    @classmethod
+    def _build_sip_module(cls, root_dir):
+        """ Build the sip module and return its name. """
+
+        sip_module_name = 'sip'
+
+        # Create the sdist.
+        args = [sys.executable, '-m', 'sipbuild.tools.module', '--sdist',
+            '--target-dir', root_dir
+        ]
+
+        if cls.abi_version is not None:
+            args.append('--abi-version')
+            args.append(cls.abi_version)
+
+        args.append(sip_module_name)
+
+        subprocess.run(args).check_returncode()
+
+        # Find the sdist and unpack it.
+        sdists = glob.glob(
+                os.path.join(root_dir, sip_module_name + '-*.tar.gz'))
+
+        if len(sdists) != 1:
+            raise Exception(
+                "unable to determine the name of the sip module sdist file")
+
+        sdist = sdists[0]
+
+        with tarfile.open(sdist) as tf:
+            tf.extractall(path=root_dir, filter='data')
+
+        # Build and move the module.
+        src_dir = os.path.join(root_dir, os.path.basename(sdist)[:-7])
+
+        cls._build_module(sip_module_name, ['setup.py', 'build'], src_dir,
+                root_dir)
+
+        return sip_module_name
+
+    @classmethod
+    def _build_module(cls, module_name, build_args, src_dir, root_dir,
+            impl_subdirs=None):
+        """ Build a module and move the implementation to the test's root
+        directory.
+        """
+
+        cwd = os.getcwd()
+        os.chdir(src_dir)
+
+        # Do the build.
+        args = [sys.executable] + build_args
+        subprocess.run(args).check_returncode()
+
+        # Find the implementation.  Note that we only support setuptools and
+        # not distutils.
+        impl_pattern = ['build']
+
+        if impl_subdirs is not None:
+            impl_pattern.extend(impl_subdirs)
+
+        impl_pattern.append('lib*')
+        impl_pattern.append(
+                module_name + '*.pyd' if sys.platform == 'win32' else '*.so')
+
+        impl_paths = glob.glob(os.path.join(*impl_pattern))
+        if len(impl_paths) == 0:
+            raise Exception(
+                    "no '{0}' extension module was built".format(module_name))
+
+        if len(impl_paths) != 1:
+            raise Exception(
+                    "unable to determine file name of the '{0}' extension module".format(module_name))
+
+        impl_path = os.path.abspath(impl_paths[0])
+        impl = os.path.basename(impl_path)
+
+        os.chdir(root_dir)
+
+        try:
+            os.remove(impl)
+        except:
+            pass
+
+        os.rename(impl_path, impl)
+
+        os.chdir(cwd)
+
 
 # The prototype pyproject.toml file.
 _PYPROJECT_TOML = """
@@ -149,8 +235,13 @@ name = "{module_name}"
 
 [tool.sip.project]
 minimum-macos-version = "10.9"
+sip-files-dir = ".."
 """
 
 _ABI_VERSION = """
 abi-version = "{abi_version}"
 """
+
+_SIP_MODULE = """
+sip-module = "sip"
+"""



More information about the Neon-commits mailing list