From 1439b81928f1b52c5a0ac7fd81fdd66afd5f72da Mon Sep 17 00:00:00 2001
From: Petr Viktorin <encukou@gmail.com>
Date: Thu, 9 Jan 2025 11:10:28 +0100
Subject: gh-128629: Add Py_PACK_VERSION and Py_PACK_FULL_VERSION (GH-128630)

---
 Doc/c-api/apiabiversion.rst                        | 102 +++++++++++++++------
 Doc/data/stable_abi.dat                            |   2 +
 Doc/whatsnew/3.14.rst                              |   4 +
 Include/patchlevel.h                               |  26 ++++--
 Include/pymacro.h                                  |   9 ++
 Lib/test/test_capi/test_misc.py                    |  43 +++++++++
 Lib/test/test_stable_abi_ctypes.py                 |   2 +
 .../2025-01-08-13-13-18.gh-issue-128629.gSmzyl.rst |   2 +
 Misc/stable_abi.toml                               |   4 +
 Modules/Setup.stdlib.in                            |   2 +-
 Modules/_testlimitedcapi.c                         |   3 +
 Modules/_testlimitedcapi/clinic/version.c.h        |  93 +++++++++++++++++++
 Modules/_testlimitedcapi/parts.h                   |   1 +
 Modules/_testlimitedcapi/version.c                 |  77 ++++++++++++++++
 PC/python3dll.c                                    |   2 +
 PCbuild/_testlimitedcapi.vcxproj                   |   1 +
 PCbuild/_testlimitedcapi.vcxproj.filters           |   1 +
 Python/modsupport.c                                |  17 ++++
 18 files changed, 358 insertions(+), 33 deletions(-)
 create mode 100644 Misc/NEWS.d/next/C_API/2025-01-08-13-13-18.gh-issue-128629.gSmzyl.rst
 create mode 100644 Modules/_testlimitedcapi/clinic/version.c.h
 create mode 100644 Modules/_testlimitedcapi/version.c

diff --git a/Doc/c-api/apiabiversion.rst b/Doc/c-api/apiabiversion.rst
index f6c8284..96050f5 100644
--- a/Doc/c-api/apiabiversion.rst
+++ b/Doc/c-api/apiabiversion.rst
@@ -6,9 +6,13 @@
 API and ABI Versioning
 ***********************
 
+
+Build-time version constants
+----------------------------
+
 CPython exposes its version number in the following macros.
-Note that these correspond to the version code is **built** with,
-not necessarily the version used at **run time**.
+Note that these correspond to the version code is **built** with.
+See :c:var:`Py_Version` for the version used at **run time**.
 
 See :ref:`stable` for a discussion of API and ABI stability across versions.
 
@@ -37,37 +41,83 @@ See :ref:`stable` for a discussion of API and ABI stability across versions.
 .. c:macro:: PY_VERSION_HEX
 
    The Python version number encoded in a single integer.
+   See :c:func:`Py_PACK_FULL_VERSION` for the encoding details.
 
-   The underlying version information can be found by treating it as a 32 bit
-   number in the following manner:
-
-   +-------+-------------------------+-------------------------+--------------------------+
-   | Bytes | Bits (big endian order) | Meaning                 | Value for ``3.4.1a2``    |
-   +=======+=========================+=========================+==========================+
-   |   1   |         1-8             |  ``PY_MAJOR_VERSION``   | ``0x03``                 |
-   +-------+-------------------------+-------------------------+--------------------------+
-   |   2   |         9-16            |  ``PY_MINOR_VERSION``   | ``0x04``                 |
-   +-------+-------------------------+-------------------------+--------------------------+
-   |   3   |         17-24           |  ``PY_MICRO_VERSION``   | ``0x01``                 |
-   +-------+-------------------------+-------------------------+--------------------------+
-   |   4   |         25-28           |  ``PY_RELEASE_LEVEL``   | ``0xA``                  |
-   +       +-------------------------+-------------------------+--------------------------+
-   |       |         29-32           |  ``PY_RELEASE_SERIAL``  | ``0x2``                  |
-   +-------+-------------------------+-------------------------+--------------------------+
+   Use this for numeric comparisons, for example,
+   ``#if PY_VERSION_HEX >= ...``.
 
-   Thus ``3.4.1a2`` is hexversion ``0x030401a2`` and ``3.10.0`` is
-   hexversion ``0x030a00f0``.
 
-   Use this for numeric comparisons, e.g. ``#if PY_VERSION_HEX >= ...``.
-
-   This version is also available via the symbol :c:var:`Py_Version`.
+Run-time version
+----------------
 
 .. c:var:: const unsigned long Py_Version
 
-   The Python runtime version number encoded in a single constant integer, with
-   the same format as the :c:macro:`PY_VERSION_HEX` macro.
+   The Python runtime version number encoded in a single constant integer.
+   See :c:func:`Py_PACK_FULL_VERSION` for the encoding details.
    This contains the Python version used at run time.
 
+   Use this for numeric comparisons, for example, ``if (Py_Version >= ...)``.
+
    .. versionadded:: 3.11
 
-All the given macros are defined in :source:`Include/patchlevel.h`.
+
+Bit-packing macros
+------------------
+
+.. c:function:: uint32_t Py_PACK_FULL_VERSION(int major, int minor, int micro, int release_level, int release_serial)
+
+   Return the given version, encoded as a single 32-bit integer with
+   the following structure:
+
+   +------------------+-------+----------------+-----------+--------------------------+
+   |                  | No.   |                |           | Example values           |
+   |                  | of    |                |           +-------------+------------+
+   | Argument         | bits  | Bit mask       | Bit shift | ``3.4.1a2`` | ``3.10.0`` |
+   +==================+=======+================+===========+=============+============+
+   | *major*          |   8   | ``0xFF000000`` | 24        | ``0x03``    | ``0x03``   |
+   +------------------+-------+----------------+-----------+-------------+------------+
+   | *minor*          |   8   | ``0x00FF0000`` | 16        | ``0x04``    | ``0x0A``   |
+   +------------------+-------+----------------+-----------+-------------+------------+
+   | *micro*          |   8   | ``0x0000FF00`` | 8         | ``0x01``    | ``0x00``   |
+   +------------------+-------+----------------+-----------+-------------+------------+
+   | *release_level*  |   4   | ``0x000000F0`` | 4         | ``0xA``     | ``0xF``    |
+   +------------------+-------+----------------+-----------+-------------+------------+
+   | *release_serial* |   4   | ``0x0000000F`` | 0         | ``0x2``     | ``0x0``    |
+   +------------------+-------+----------------+-----------+-------------+------------+
+
+   For example:
+
+   +-------------+------------------------------------+-----------------+
+   | Version     | ``Py_PACK_FULL_VERSION`` arguments | Encoded version |
+   +=============+====================================+=================+
+   | ``3.4.1a2`` | ``(3, 4, 1, 0xA, 2)``              | ``0x030401a2``  |
+   +-------------+------------------------------------+-----------------+
+   | ``3.10.0``  | ``(3, 10, 0, 0xF, 0)``             | ``0x030a00f0``  |
+   +-------------+------------------------------------+-----------------+
+
+   Out-of range bits in the arguments are ignored.
+   That is, the macro can be defined as:
+
+   .. code-block:: c
+
+      #ifndef Py_PACK_FULL_VERSION
+      #define Py_PACK_FULL_VERSION(X, Y, Z, LEVEL, SERIAL) ( \
+         (((X) & 0xff) << 24) |                              \
+         (((Y) & 0xff) << 16) |                              \
+         (((Z) & 0xff) << 8) |                               \
+         (((LEVEL) & 0xf) << 4) |                            \
+         (((SERIAL) & 0xf) << 0))
+      #endif
+
+   ``Py_PACK_FULL_VERSION`` is primarily a macro, intended for use in
+   ``#if`` directives, but it is also available as an exported function.
+
+   .. versionadded:: 3.14
+
+.. c:function:: uint32_t Py_PACK_VERSION(int major, int minor)
+
+   Equivalent to ``Py_PACK_FULL_VERSION(major, minor, 0, 0, 0)``.
+   The result does not correspond to any Python release, but is useful
+   in numeric comparisons.
+
+   .. versionadded:: 3.14
diff --git a/Doc/data/stable_abi.dat b/Doc/data/stable_abi.dat
index 6f9d272..c15f826 100644
--- a/Doc/data/stable_abi.dat
+++ b/Doc/data/stable_abi.dat
@@ -883,6 +883,8 @@ func,Py_Main,3.2,,
 func,Py_MakePendingCalls,3.2,,
 func,Py_NewInterpreter,3.2,,
 func,Py_NewRef,3.10,,
+func,Py_PACK_FULL_VERSION,3.14,,
+func,Py_PACK_VERSION,3.14,,
 func,Py_REFCNT,3.14,,
 func,Py_ReprEnter,3.2,,
 func,Py_ReprLeave,3.2,,
diff --git a/Doc/whatsnew/3.14.rst b/Doc/whatsnew/3.14.rst
index 16851b4..72abfeb 100644
--- a/Doc/whatsnew/3.14.rst
+++ b/Doc/whatsnew/3.14.rst
@@ -1243,6 +1243,10 @@ New features
   file.
   (Contributed by Victor Stinner in :gh:`127350`.)
 
+* Add macros :c:func:`Py_PACK_VERSION` and :c:func:`Py_PACK_FULL_VERSION` for
+  bit-packing Python version numbers.
+  (Contributed by Petr Viktorin in :gh:`128629`.)
+
 
 Porting to Python 3.14
 ----------------------
diff --git a/Include/patchlevel.h b/Include/patchlevel.h
index 6d4f719..eca2ca0 100644
--- a/Include/patchlevel.h
+++ b/Include/patchlevel.h
@@ -1,4 +1,5 @@
-
+#ifndef _Py_PATCHLEVEL_H
+#define _Py_PATCHLEVEL_H
 /* Python version identification scheme.
 
    When the major or minor version changes, the VERSION variable in
@@ -26,10 +27,23 @@
 #define PY_VERSION              "3.14.0a3+"
 /*--end constants--*/
 
+
+#define _Py_PACK_FULL_VERSION(X, Y, Z, LEVEL, SERIAL) ( \
+    (((X) & 0xff) << 24) |                              \
+    (((Y) & 0xff) << 16) |                              \
+    (((Z) & 0xff) << 8) |                               \
+    (((LEVEL) & 0xf) << 4) |                            \
+    (((SERIAL) & 0xf) << 0))
+
 /* Version as a single 4-byte hex number, e.g. 0x010502B2 == 1.5.2b2.
    Use this for numeric comparisons, e.g. #if PY_VERSION_HEX >= ... */
-#define PY_VERSION_HEX ((PY_MAJOR_VERSION << 24) | \
-                        (PY_MINOR_VERSION << 16) | \
-                        (PY_MICRO_VERSION <<  8) | \
-                        (PY_RELEASE_LEVEL <<  4) | \
-                        (PY_RELEASE_SERIAL << 0))
+#define PY_VERSION_HEX _Py_PACK_FULL_VERSION( \
+    PY_MAJOR_VERSION,                         \
+    PY_MINOR_VERSION,                         \
+    PY_MICRO_VERSION,                         \
+    PY_RELEASE_LEVEL,                         \
+    PY_RELEASE_SERIAL)
+
+// Public Py_PACK_VERSION is declared in pymacro.h; it needs <inttypes.h>.
+
+#endif //_Py_PATCHLEVEL_H
diff --git a/Include/pymacro.h b/Include/pymacro.h
index e0378f9..a82f347 100644
--- a/Include/pymacro.h
+++ b/Include/pymacro.h
@@ -190,4 +190,13 @@
 // "comparison of unsigned expression in '< 0' is always false".
 #define _Py_IS_TYPE_SIGNED(type) ((type)(-1) <= 0)
 
+#if !defined(Py_LIMITED_API) || Py_LIMITED_API+0 >= 0x030E0000 // 3.14
+// Version helpers. These are primarily macros, but have exported equivalents.
+PyAPI_FUNC(uint32_t) Py_PACK_FULL_VERSION(int x, int y, int z, int level, int serial);
+PyAPI_FUNC(uint32_t) Py_PACK_VERSION(int x, int y);
+#define Py_PACK_FULL_VERSION _Py_PACK_FULL_VERSION
+#define Py_PACK_VERSION(X, Y) Py_PACK_FULL_VERSION(X, Y, 0, 0, 0)
+#endif // Py_LIMITED_API < 3.14
+
+
 #endif /* Py_PYMACRO_H */
diff --git a/Lib/test/test_capi/test_misc.py b/Lib/test/test_capi/test_misc.py
index ada3018..b62bc4c 100644
--- a/Lib/test/test_capi/test_misc.py
+++ b/Lib/test/test_capi/test_misc.py
@@ -3335,6 +3335,49 @@ class TestPyThreadId(unittest.TestCase):
         self.assertEqual(len(set(py_thread_ids)), len(py_thread_ids),
                          py_thread_ids)
 
+class TestVersions(unittest.TestCase):
+    full_cases = (
+        (3, 4, 1, 0xA, 2, 0x030401a2),
+        (3, 10, 0, 0xF, 0, 0x030a00f0),
+        (0x103, 0x10B, 0xFF00, -1, 0xF0, 0x030b00f0),  # test masking
+    )
+    xy_cases = (
+        (3, 4, 0x03040000),
+        (3, 10, 0x030a0000),
+        (0x103, 0x10B, 0x030b0000),  # test masking
+    )
+
+    def test_pack_full_version(self):
+        for *args, expected in self.full_cases:
+            with self.subTest(hexversion=hex(expected)):
+                result = _testlimitedcapi.pack_full_version(*args)
+                self.assertEqual(result, expected)
+
+    def test_pack_version(self):
+        for *args, expected in self.xy_cases:
+            with self.subTest(hexversion=hex(expected)):
+                result = _testlimitedcapi.pack_version(*args)
+                self.assertEqual(result, expected)
+
+    def test_pack_full_version_ctypes(self):
+        ctypes = import_helper.import_module('ctypes')
+        ctypes_func = ctypes.pythonapi.Py_PACK_FULL_VERSION
+        ctypes_func.restype = ctypes.c_uint32
+        ctypes_func.argtypes = [ctypes.c_int] * 5
+        for *args, expected in self.full_cases:
+            with self.subTest(hexversion=hex(expected)):
+                result = ctypes_func(*args)
+                self.assertEqual(result, expected)
+
+    def test_pack_version_ctypes(self):
+        ctypes = import_helper.import_module('ctypes')
+        ctypes_func = ctypes.pythonapi.Py_PACK_VERSION
+        ctypes_func.restype = ctypes.c_uint32
+        ctypes_func.argtypes = [ctypes.c_int] * 2
+        for *args, expected in self.xy_cases:
+            with self.subTest(hexversion=hex(expected)):
+                result = ctypes_func(*args)
+                self.assertEqual(result, expected)
 
 if __name__ == "__main__":
     unittest.main()
diff --git a/Lib/test/test_stable_abi_ctypes.py b/Lib/test/test_stable_abi_ctypes.py
index fa08dc6..f3724ce 100644
--- a/Lib/test/test_stable_abi_ctypes.py
+++ b/Lib/test/test_stable_abi_ctypes.py
@@ -901,6 +901,8 @@ SYMBOL_NAMES = (
     "Py_MakePendingCalls",
     "Py_NewInterpreter",
     "Py_NewRef",
+    "Py_PACK_FULL_VERSION",
+    "Py_PACK_VERSION",
     "Py_REFCNT",
     "Py_ReprEnter",
     "Py_ReprLeave",
diff --git a/Misc/NEWS.d/next/C_API/2025-01-08-13-13-18.gh-issue-128629.gSmzyl.rst b/Misc/NEWS.d/next/C_API/2025-01-08-13-13-18.gh-issue-128629.gSmzyl.rst
new file mode 100644
index 0000000..cde5bf3
--- /dev/null
+++ b/Misc/NEWS.d/next/C_API/2025-01-08-13-13-18.gh-issue-128629.gSmzyl.rst
@@ -0,0 +1,2 @@
+Add macros :c:func:`Py_PACK_VERSION` and :c:func:`Py_PACK_FULL_VERSION` for
+bit-packing Python version numbers.
diff --git a/Misc/stable_abi.toml b/Misc/stable_abi.toml
index f9e51f0..276526a 100644
--- a/Misc/stable_abi.toml
+++ b/Misc/stable_abi.toml
@@ -2540,3 +2540,7 @@
     added = '3.14'
 [function.PyType_Freeze]
     added = '3.14'
+[function.Py_PACK_FULL_VERSION]
+    added = '3.14'
+[function.Py_PACK_VERSION]
+    added = '3.14'
diff --git a/Modules/Setup.stdlib.in b/Modules/Setup.stdlib.in
index 52c0f88..b7357f4 100644
--- a/Modules/Setup.stdlib.in
+++ b/Modules/Setup.stdlib.in
@@ -163,7 +163,7 @@
 @MODULE__TESTBUFFER_TRUE@_testbuffer _testbuffer.c
 @MODULE__TESTINTERNALCAPI_TRUE@_testinternalcapi _testinternalcapi.c _testinternalcapi/test_lock.c _testinternalcapi/pytime.c _testinternalcapi/set.c _testinternalcapi/test_critical_sections.c
 @MODULE__TESTCAPI_TRUE@_testcapi _testcapimodule.c _testcapi/vectorcall.c _testcapi/heaptype.c _testcapi/abstract.c _testcapi/unicode.c _testcapi/dict.c _testcapi/set.c _testcapi/list.c _testcapi/tuple.c _testcapi/getargs.c _testcapi/datetime.c _testcapi/docstring.c _testcapi/mem.c _testcapi/watchers.c _testcapi/long.c _testcapi/float.c _testcapi/complex.c _testcapi/numbers.c _testcapi/structmember.c _testcapi/exceptions.c _testcapi/code.c _testcapi/buffer.c _testcapi/pyatomic.c _testcapi/run.c _testcapi/file.c _testcapi/codec.c _testcapi/immortal.c _testcapi/gc.c _testcapi/hash.c _testcapi/time.c _testcapi/bytes.c _testcapi/object.c _testcapi/monitoring.c  _testcapi/config.c
-@MODULE__TESTLIMITEDCAPI_TRUE@_testlimitedcapi _testlimitedcapi.c _testlimitedcapi/abstract.c _testlimitedcapi/bytearray.c _testlimitedcapi/bytes.c _testlimitedcapi/codec.c _testlimitedcapi/complex.c _testlimitedcapi/dict.c _testlimitedcapi/eval.c _testlimitedcapi/float.c _testlimitedcapi/heaptype_relative.c _testlimitedcapi/list.c _testlimitedcapi/long.c _testlimitedcapi/object.c _testlimitedcapi/pyos.c _testlimitedcapi/set.c _testlimitedcapi/sys.c _testlimitedcapi/tuple.c _testlimitedcapi/unicode.c _testlimitedcapi/vectorcall_limited.c
+@MODULE__TESTLIMITEDCAPI_TRUE@_testlimitedcapi _testlimitedcapi.c _testlimitedcapi/abstract.c _testlimitedcapi/bytearray.c _testlimitedcapi/bytes.c _testlimitedcapi/codec.c _testlimitedcapi/complex.c _testlimitedcapi/dict.c _testlimitedcapi/eval.c _testlimitedcapi/float.c _testlimitedcapi/heaptype_relative.c _testlimitedcapi/list.c _testlimitedcapi/long.c _testlimitedcapi/object.c _testlimitedcapi/pyos.c _testlimitedcapi/set.c _testlimitedcapi/sys.c _testlimitedcapi/tuple.c _testlimitedcapi/unicode.c _testlimitedcapi/vectorcall_limited.c _testlimitedcapi/version.c
 @MODULE__TESTCLINIC_TRUE@_testclinic _testclinic.c
 @MODULE__TESTCLINIC_LIMITED_TRUE@_testclinic_limited _testclinic_limited.c
 
diff --git a/Modules/_testlimitedcapi.c b/Modules/_testlimitedcapi.c
index ba83a23..bcc69a3 100644
--- a/Modules/_testlimitedcapi.c
+++ b/Modules/_testlimitedcapi.c
@@ -83,5 +83,8 @@ PyInit__testlimitedcapi(void)
     if (_PyTestLimitedCAPI_Init_VectorcallLimited(mod) < 0) {
         return NULL;
     }
+    if (_PyTestLimitedCAPI_Init_Version(mod) < 0) {
+        return NULL;
+    }
     return mod;
 }
diff --git a/Modules/_testlimitedcapi/clinic/version.c.h b/Modules/_testlimitedcapi/clinic/version.c.h
new file mode 100644
index 0000000..096c7dd
--- /dev/null
+++ b/Modules/_testlimitedcapi/clinic/version.c.h
@@ -0,0 +1,93 @@
+/*[clinic input]
+preserve
+[clinic start generated code]*/
+
+PyDoc_STRVAR(_testlimitedcapi_pack_full_version__doc__,
+"pack_full_version($module, major, minor, micro, level, serial, /)\n"
+"--\n"
+"\n");
+
+#define _TESTLIMITEDCAPI_PACK_FULL_VERSION_METHODDEF    \
+    {"pack_full_version", (PyCFunction)(void(*)(void))_testlimitedcapi_pack_full_version, METH_FASTCALL, _testlimitedcapi_pack_full_version__doc__},
+
+static PyObject *
+_testlimitedcapi_pack_full_version_impl(PyObject *module, int major,
+                                        int minor, int micro, int level,
+                                        int serial);
+
+static PyObject *
+_testlimitedcapi_pack_full_version(PyObject *module, PyObject *const *args, Py_ssize_t nargs)
+{
+    PyObject *return_value = NULL;
+    int major;
+    int minor;
+    int micro;
+    int level;
+    int serial;
+
+    if (nargs != 5) {
+        PyErr_Format(PyExc_TypeError, "pack_full_version expected 5 arguments, got %zd", nargs);
+        goto exit;
+    }
+    major = PyLong_AsInt(args[0]);
+    if (major == -1 && PyErr_Occurred()) {
+        goto exit;
+    }
+    minor = PyLong_AsInt(args[1]);
+    if (minor == -1 && PyErr_Occurred()) {
+        goto exit;
+    }
+    micro = PyLong_AsInt(args[2]);
+    if (micro == -1 && PyErr_Occurred()) {
+        goto exit;
+    }
+    level = PyLong_AsInt(args[3]);
+    if (level == -1 && PyErr_Occurred()) {
+        goto exit;
+    }
+    serial = PyLong_AsInt(args[4]);
+    if (serial == -1 && PyErr_Occurred()) {
+        goto exit;
+    }
+    return_value = _testlimitedcapi_pack_full_version_impl(module, major, minor, micro, level, serial);
+
+exit:
+    return return_value;
+}
+
+PyDoc_STRVAR(_testlimitedcapi_pack_version__doc__,
+"pack_version($module, major, minor, /)\n"
+"--\n"
+"\n");
+
+#define _TESTLIMITEDCAPI_PACK_VERSION_METHODDEF    \
+    {"pack_version", (PyCFunction)(void(*)(void))_testlimitedcapi_pack_version, METH_FASTCALL, _testlimitedcapi_pack_version__doc__},
+
+static PyObject *
+_testlimitedcapi_pack_version_impl(PyObject *module, int major, int minor);
+
+static PyObject *
+_testlimitedcapi_pack_version(PyObject *module, PyObject *const *args, Py_ssize_t nargs)
+{
+    PyObject *return_value = NULL;
+    int major;
+    int minor;
+
+    if (nargs != 2) {
+        PyErr_Format(PyExc_TypeError, "pack_version expected 2 arguments, got %zd", nargs);
+        goto exit;
+    }
+    major = PyLong_AsInt(args[0]);
+    if (major == -1 && PyErr_Occurred()) {
+        goto exit;
+    }
+    minor = PyLong_AsInt(args[1]);
+    if (minor == -1 && PyErr_Occurred()) {
+        goto exit;
+    }
+    return_value = _testlimitedcapi_pack_version_impl(module, major, minor);
+
+exit:
+    return return_value;
+}
+/*[clinic end generated code: output=aed3e226da77f2d2 input=a9049054013a1b77]*/
diff --git a/Modules/_testlimitedcapi/parts.h b/Modules/_testlimitedcapi/parts.h
index 4107b15..56d566b 100644
--- a/Modules/_testlimitedcapi/parts.h
+++ b/Modules/_testlimitedcapi/parts.h
@@ -40,5 +40,6 @@ int _PyTestLimitedCAPI_Init_Sys(PyObject *module);
 int _PyTestLimitedCAPI_Init_Tuple(PyObject *module);
 int _PyTestLimitedCAPI_Init_Unicode(PyObject *module);
 int _PyTestLimitedCAPI_Init_VectorcallLimited(PyObject *module);
+int _PyTestLimitedCAPI_Init_Version(PyObject *module);
 
 #endif // Py_TESTLIMITEDCAPI_PARTS_H
diff --git a/Modules/_testlimitedcapi/version.c b/Modules/_testlimitedcapi/version.c
new file mode 100644
index 0000000..57cd6e4
--- /dev/null
+++ b/Modules/_testlimitedcapi/version.c
@@ -0,0 +1,77 @@
+/* Test version macros in the limited API */
+
+#include "pyconfig.h"  // Py_GIL_DISABLED
+#ifndef Py_GIL_DISABLED
+#  define Py_LIMITED_API 0x030e0000  // Added in 3.14
+#endif
+
+#include "parts.h"
+#include "clinic/version.c.h"
+#include <stdio.h>
+
+/*[clinic input]
+module _testlimitedcapi
+[clinic start generated code]*/
+/*[clinic end generated code: output=da39a3ee5e6b4b0d input=2700057f9c1135ba]*/
+
+/*[clinic input]
+_testlimitedcapi.pack_full_version
+
+    major: int
+    minor: int
+    micro: int
+    level: int
+    serial: int
+    /
+[clinic start generated code]*/
+
+static PyObject *
+_testlimitedcapi_pack_full_version_impl(PyObject *module, int major,
+                                        int minor, int micro, int level,
+                                        int serial)
+/*[clinic end generated code: output=b87a1e9805648861 input=2a304423be61d2ac]*/
+{
+    uint32_t macro_result = Py_PACK_FULL_VERSION(
+        major, minor, micro, level, serial);
+#undef Py_PACK_FULL_VERSION
+    uint32_t func_result = Py_PACK_FULL_VERSION(
+        major, minor, micro, level, serial);
+
+    assert(macro_result == func_result);
+    return PyLong_FromUnsignedLong((unsigned long)func_result);
+}
+
+/*[clinic input]
+_testlimitedcapi.pack_version
+
+    major: int
+    minor: int
+    /
+[clinic start generated code]*/
+
+static PyObject *
+_testlimitedcapi_pack_version_impl(PyObject *module, int major, int minor)
+/*[clinic end generated code: output=771247bbd06e7883 input=3e39e9dcbc09e86a]*/
+{
+    uint32_t macro_result = Py_PACK_VERSION(major, minor);
+#undef Py_PACK_VERSION
+    uint32_t func_result = Py_PACK_VERSION(major, minor);
+
+    assert(macro_result == func_result);
+    return PyLong_FromUnsignedLong((unsigned long)func_result);
+}
+
+static PyMethodDef TestMethods[] = {
+    _TESTLIMITEDCAPI_PACK_FULL_VERSION_METHODDEF
+    _TESTLIMITEDCAPI_PACK_VERSION_METHODDEF
+    {NULL},
+};
+
+int
+_PyTestLimitedCAPI_Init_Version(PyObject *m)
+{
+    if (PyModule_AddFunctions(m, TestMethods) < 0) {
+        return -1;
+    }
+    return 0;
+}
diff --git a/PC/python3dll.c b/PC/python3dll.c
index 8657ddb..84b3c73 100755
--- a/PC/python3dll.c
+++ b/PC/python3dll.c
@@ -81,6 +81,8 @@ EXPORT_FUNC(Py_Main)
 EXPORT_FUNC(Py_MakePendingCalls)
 EXPORT_FUNC(Py_NewInterpreter)
 EXPORT_FUNC(Py_NewRef)
+EXPORT_FUNC(Py_PACK_FULL_VERSION)
+EXPORT_FUNC(Py_PACK_VERSION)
 EXPORT_FUNC(Py_REFCNT)
 EXPORT_FUNC(Py_ReprEnter)
 EXPORT_FUNC(Py_ReprLeave)
diff --git a/PCbuild/_testlimitedcapi.vcxproj b/PCbuild/_testlimitedcapi.vcxproj
index 846e027..0ea5edb 100644
--- a/PCbuild/_testlimitedcapi.vcxproj
+++ b/PCbuild/_testlimitedcapi.vcxproj
@@ -112,6 +112,7 @@
     <ClCompile Include="..\Modules\_testlimitedcapi\tuple.c" />
     <ClCompile Include="..\Modules\_testlimitedcapi\unicode.c" />
     <ClCompile Include="..\Modules\_testlimitedcapi\vectorcall_limited.c" />
+    <ClCompile Include="..\Modules\_testlimitedcapi\version.c" />
   </ItemGroup>
   <ItemGroup>
     <ResourceCompile Include="..\PC\python_nt.rc" />
diff --git a/PCbuild/_testlimitedcapi.vcxproj.filters b/PCbuild/_testlimitedcapi.vcxproj.filters
index 57be2e2..b379090 100644
--- a/PCbuild/_testlimitedcapi.vcxproj.filters
+++ b/PCbuild/_testlimitedcapi.vcxproj.filters
@@ -28,6 +28,7 @@
     <ClCompile Include="..\Modules\_testlimitedcapi\tuple.c" />
     <ClCompile Include="..\Modules\_testlimitedcapi\unicode.c" />
     <ClCompile Include="..\Modules\_testlimitedcapi\vectorcall_limited.c" />
+    <ClCompile Include="..\Modules\_testlimitedcapi\version.c" />
     <ClCompile Include="..\Modules\_testlimitedcapi.c" />
   </ItemGroup>
   <ItemGroup>
diff --git a/Python/modsupport.c b/Python/modsupport.c
index 0fb7783..517dc97 100644
--- a/Python/modsupport.c
+++ b/Python/modsupport.c
@@ -648,3 +648,20 @@ PyModule_AddType(PyObject *module, PyTypeObject *type)
 
     return PyModule_AddObjectRef(module, name, (PyObject *)type);
 }
+
+
+/* Exported functions for version helper macros */
+
+#undef Py_PACK_FULL_VERSION
+uint32_t
+Py_PACK_FULL_VERSION(int x, int y, int z, int level, int serial)
+{
+    return _Py_PACK_FULL_VERSION(x, y, z, level, serial);
+}
+
+#undef Py_PACK_VERSION
+uint32_t
+Py_PACK_VERSION(int x, int y)
+{
+    return Py_PACK_FULL_VERSION(x, y, 0, 0, 0);
+}
-- 
cgit v0.12