diff --git a/Lib/test/test_uuid.py b/Lib/test/test_uuid.py index ee6e57b93966a2..2844aca32f069f 100755 --- a/Lib/test/test_uuid.py +++ b/Lib/test/test_uuid.py @@ -43,6 +43,36 @@ class BaseTestUUID: uuid = None is_c_uuid = False + def test_generic_setattr(self): + # Libraries such as Pydantic bypass __init__ and set UUID slots directly. + # Exercise that construction path for the base type and a subclass. + class UUIDSubclass(self.uuid.UUID): + pass + + class IntSubclass(int): + pass + + for cls in (self.uuid.UUID, UUIDSubclass): + for value in (0, 1, 1 << 127, (1 << 128) - 1, + 0x123456789abcdef0123456789abcdef0, IntSubclass(42)): + with self.subTest(cls=cls, value=value): + u = cls.__new__(cls) + object.__setattr__(u, 'int', value) + object.__setattr__(u, 'is_safe', self.uuid.SafeUUID.safe) + self.assertEqual(u.int, value) + self.assertEqual(u.bytes, value.to_bytes(16, 'big')) + self.assertEqual(u, self.uuid.UUID(int=value)) + self.assertIs(u.is_safe, self.uuid.SafeUUID.safe) + # Replacing the integer must invalidate an already cached hash. + hash(u) + object.__setattr__(u, 'int', 2) + self.assertEqual(hash(u), hash(self.uuid.UUID(int=2))) + self.assertEqual(u.int, 2) + with self.assertRaises(TypeError): + u.int = value + with self.assertRaises(TypeError): + u.is_safe = self.uuid.SafeUUID.unknown + def test_nil_uuid(self): nil_uuid = self.uuid.NIL @@ -1763,6 +1793,31 @@ def time_func(self): @unittest.skipUnless(c_uuid, "requires the C _uuid module") class TestCImplementationCompat(unittest.TestCase): + def test_int_setter_errors(self): + # Reject invalid integers without changing the UUID's bytes or cached hash, + # even when an int subclass bypasses the preliminary range comparison. + import uuid + + class LargeInt(int): + def __gt__(self, other): + return False + + u = uuid.UUID(int=42) + original_hash = hash(u) + for value, error in ((None, TypeError), ('42', TypeError), + (1.5, TypeError), (-1, ValueError), + (1 << 128, ValueError), + (LargeInt(1 << 128), OverflowError)): + with self.subTest(value=value): + with self.assertRaises(error): + object.__setattr__(u, 'int', value) + self.assertEqual(u.int, 42) + self.assertEqual(hash(u), original_hash) + for name in ('int', 'is_safe'): + with self.subTest(name=name): + with self.assertRaises(AttributeError): + object.__delattr__(u, name) + def test_is_safe_lookup_error(self): # Force the lazy SafeUUID lookup to fail before it is cached. The getter # must propagate AttributeError and recover when the attribute returns. diff --git a/Misc/NEWS.d/next/Library/2026-10-21-18-00-00.gh-issue-139122.UuidInt.rst b/Misc/NEWS.d/next/Library/2026-10-21-18-00-00.gh-issue-139122.UuidInt.rst new file mode 100644 index 00000000000000..1c42d4abc1a7c8 --- /dev/null +++ b/Misc/NEWS.d/next/Library/2026-10-21-18-00-00.gh-issue-139122.UuidInt.rst @@ -0,0 +1,4 @@ +Allow setting :class:`uuid.UUID` attributes ``int`` and ``is_safe`` through +:meth:`object.__setattr__` and :c:func:`PyObject_GenericSetAttr`, restoring +compatibility with libraries that construct UUIDs without calling their +initializer. Ordinary attribute assignment remains blocked. diff --git a/Modules/_uuidmodule.c b/Modules/_uuidmodule.c index e957071a4ecef9..42996f34e74872 100644 --- a/Modules/_uuidmodule.c +++ b/Modules/_uuidmodule.c @@ -1100,6 +1100,45 @@ Uuid_get_int(PyObject *op, void *Py_UNUSED(closure)) return get_int(uuidobject_CAST(op)); } +static int +Uuid_set_int(PyObject *op, PyObject *value, void *Py_UNUSED(closure)) +{ + if (value == NULL) { + PyErr_SetString(PyExc_AttributeError, "cannot delete UUID.int"); + return -1; + } + uuid_state *state = get_uuid_state_by_cls(Py_TYPE(op)); + if (validate_int(state, value) < 0) { + return -1; + } + // Convert before updating the object: conversion errors must leave it intact. + uint8_t bytes[UUID_SIZE]; + if (_PyLong_AsByteArray((PyLongObject *)value, bytes, sizeof(bytes), + 0, 0, 1) < 0) { + return -1; + } + uuidobject *self = uuidobject_CAST(op); + Py_BEGIN_CRITICAL_SECTION(op); + memcpy(self->bytes, bytes, sizeof(bytes)); + self->cached_hash = -1; + Py_END_CRITICAL_SECTION(); + return 0; +} + +static int +Uuid_set_is_safe(PyObject *op, PyObject *value, void *Py_UNUSED(closure)) +{ + if (value == NULL) { + PyErr_SetString(PyExc_AttributeError, "cannot delete UUID.is_safe"); + return -1; + } + uuidobject *self = uuidobject_CAST(op); + Py_BEGIN_CRITICAL_SECTION(op); + Py_XSETREF(self->is_safe, Py_NewRef(value)); + Py_END_CRITICAL_SECTION(); + return 0; +} + static PyObject * Uuid_get_is_safe(PyObject *op, void *Py_UNUSED(closure)) { @@ -1568,8 +1607,8 @@ _uuid_UUID__from_int_impl(PyTypeObject *type, PyObject *value) } static PyGetSetDef Uuid_getset[] = { - {"int", Uuid_get_int, NULL, "UUID as a 128-bit integer", NULL}, - {"is_safe", Uuid_get_is_safe, NULL, "UUID safety status", NULL}, + {"int", Uuid_get_int, Uuid_set_int, "UUID as a 128-bit integer", NULL}, + {"is_safe", Uuid_get_is_safe, Uuid_set_is_safe, "UUID safety status", NULL}, {"fields", Uuid_get_fields, NULL, "UUID as a 6-tuple", NULL}, {"hex", Uuid_get_hex, NULL, "UUID as a 32-character hex string", NULL}, {"urn", Uuid_get_urn, NULL, "UUID as a URN", NULL},