diff --git a/Makefile b/Makefile index 1ee8c63..5eac13d 100644 --- a/Makefile +++ b/Makefile @@ -124,9 +124,12 @@ test-str: test-async: $(PYTHON) -m pytest tests/test_async.py $(PYTEST_FLAGS) +test-enums: + $(PYTHON) -m pytest tests/test_newtype_enums.py $(PYTEST_FLAGS) + # Run memory leak tests test-leak: - $(PYTHON) -m pytest --enable-leak-tracking -W error --stacks 10 tests/test_newtype_init.py $(PYTEST_FLAGS) + $(PYTHON) -m pytest --enable-leak-tracking -W error tests/test_newtype_init.py $(PYTEST_FLAGS) # Run a specific test file (usage: make test-file FILE=test_newtype.py) test-file: diff --git a/examples/newtype_enums.py b/examples/newtype_enums.py index 7b5990d..b23dda3 100644 --- a/examples/newtype_enums.py +++ b/examples/newtype_enums.py @@ -14,6 +14,7 @@ class ENV(NewType(str), Enum): # type: ignore[misc] PREPROD = "PREPROD" PROD = "PROD" + class RegularENV(str, Enum): LOCAL = "LOCAL" @@ -58,6 +59,7 @@ class RollYourOwnNewTypeEnum(ENVVariant, Enum): # type: ignore[no-redef] PREPROD = "PREPROD" PROD = "PROD" + # mypy doesn't raise errors here def test_nt_env_replace() -> None: diff --git a/examples/newtype_enums_int.py b/examples/newtype_enums_int.py new file mode 100644 index 0000000..f78f30d --- /dev/null +++ b/examples/newtype_enums_int.py @@ -0,0 +1,85 @@ +from enum import Enum +from weakref import WeakValueDictionary + +import pytest + +from newtype import NewType + + +class GenericWrappedBoundedInt(NewType(int)): + MAX_VALUE: int = 0 + + __CONCRETE_BOUNDED_INTS__ = WeakValueDictionary() + + def __new__(cls, value: int): + inst = super().__new__(cls, value % cls.MAX_VALUE) + return inst + + def __repr__(self) -> str: + return f"" + + def __str__(self) -> str: + return str(int(self)) + + def __class_getitem__(cls, idx=MAX_VALUE): + if not isinstance(idx, int): + raise TypeError(f"cannot make `BoundedInt[{idx}]`") + + if idx not in cls.__CONCRETE_BOUNDED_INTS__: + + class ConcreteBoundedInt(cls): + MAX_VALUE = idx + + cls.__CONCRETE_BOUNDED_INTS__[idx] = ConcreteBoundedInt + + return cls.__CONCRETE_BOUNDED_INTS__[idx] + + +class Severity(GenericWrappedBoundedInt[5], Enum): + DEBUG = 0 + INFO = 1 + WARNING = 2 + ERROR = 3 + CRITICAL = 4 + + +def test_severity(): + assert Severity.DEBUG == 0 + assert Severity.INFO == 1 + assert Severity.WARNING == 2 + assert Severity.ERROR == 3 + assert Severity.CRITICAL == 4 + + with pytest.raises(AttributeError, match=r"[c|C]annot\s+reassign\s+\w+"): + Severity.ERROR += 1 + + severity = Severity.ERROR + assert severity == 3 + + severity += 1 + assert severity == 4 + assert severity != 3 + assert isinstance(severity, int) + assert isinstance(severity, Severity) + assert severity is not Severity.ERROR + assert severity is Severity.CRITICAL + + severity -= 1 + assert severity == 3 + assert severity != 4 + assert isinstance(severity, int) + assert isinstance(severity, Severity) + assert severity is Severity.ERROR + assert severity is not Severity.CRITICAL + + severity = Severity.DEBUG + assert severity == 0 + assert str(severity.value) == "0" + with pytest.raises(ValueError, match=r"\d+ is not a valid Severity"): + severity -= 1 + + severity = Severity.CRITICAL + assert severity == 4 + assert str(severity.value) == "4" + with pytest.raises(ValueError, match=r"\d+ is not a valid Severity"): + severity += 1 diff --git a/newtype/extensions/newtype_init.c b/newtype/extensions/newtype_init.c index fa7c11c..3e2b7ed 100644 --- a/newtype/extensions/newtype_init.c +++ b/newtype/extensions/newtype_init.c @@ -101,6 +101,7 @@ static PyObject* NewTypeInit_call(NewTypeInitObject* self, PyObject* func; if (self->has_get) { + DEBUG_PRINT("`self->has_get`: %d\n", self->has_get); if (self->obj == NULL && self->cls == NULL) { // free standing function PyErr_SetString( @@ -117,6 +118,8 @@ static PyObject* NewTypeInit_call(NewTypeInitObject* self, self->func_get, self->obj, self->cls, NULL); } } else { + DEBUG_PRINT("`self->func_get`: %s\n", + PyUnicode_AsUTF8(PyObject_Repr(self->func_get))); func = self->func_get; } @@ -179,6 +182,7 @@ static PyObject* NewTypeInit_call(NewTypeInitObject* self, result = PyObject_Call(func, args, kwds); } else { PyErr_SetString(PyExc_TypeError, "Invalid type object in descriptor"); + DEBUG_PRINT("`self->cls` is not a valid type object\n"); result = NULL; } diff --git a/newtype/extensions/newtype_meth.c b/newtype/extensions/newtype_meth.c index 6a62537..94f2aeb 100644 --- a/newtype/extensions/newtype_meth.c +++ b/newtype/extensions/newtype_meth.c @@ -94,6 +94,8 @@ static PyObject* NewTypeMethod_call(NewTypeMethodObject* self, self->func_get, Py_None, self->wrapped_cls, NULL); } else { DEBUG_PRINT("`self->obj` is not NULL\n"); + DEBUG_PRINT("`self->wrapped_cls`: %s\n", + PyUnicode_AsUTF8(PyObject_Repr(self->wrapped_cls))); func = PyObject_CallFunctionObjArgs( self->func_get, self->obj, self->wrapped_cls, NULL); } @@ -145,7 +147,9 @@ static PyObject* NewTypeMethod_call(NewTypeMethodObject* self, { // now we try to build an instance of the subtype DEBUG_PRINT("`result` is an instance of `self->wrapped_cls`\n"); PyObject *init_args, *init_kwargs; - PyObject *new_inst, *args_combined; + PyObject* new_inst; + PyObject* args_combined = NULL; + Py_ssize_t args_len = 0; if (self->obj == NULL) { PyObject* first_elem; @@ -158,73 +162,136 @@ static PyObject* NewTypeMethod_call(NewTypeMethodObject* self, first_elem = PyTuple_GetItem(args, 0); Py_XINCREF( first_elem); // Increment reference count of the first element - + DEBUG_PRINT("`first_elem`: %s\n", + PyUnicode_AsUTF8(PyObject_Repr(first_elem))); } else { // `args` is empty here, then we are done actually + DEBUG_PRINT("`args` is empty\n"); goto done; }; if (PyObject_IsInstance(first_elem, (PyObject*)self->cls)) { init_args = PyObject_GetAttrString(first_elem, NEWTYPE_INIT_ARGS_STR); init_kwargs = PyObject_GetAttrString(first_elem, NEWTYPE_INIT_KWARGS_STR); + DEBUG_PRINT("`init_args`: %s\n", + PyUnicode_AsUTF8(PyObject_Repr(init_args))); + DEBUG_PRINT("`init_kwargs`: %s\n", + PyUnicode_AsUTF8(PyObject_Repr(init_kwargs))); } else { // first element is not the subtype, so we are done also + DEBUG_PRINT("`first_elem` is not the subtype\n"); goto done; } Py_XDECREF(first_elem); } else { // `self->obj` is not NULL + DEBUG_PRINT("`self->obj` is not NULL\n"); init_args = PyObject_GetAttrString(self->obj, NEWTYPE_INIT_ARGS_STR); init_kwargs = PyObject_GetAttrString(self->obj, NEWTYPE_INIT_KWARGS_STR); + DEBUG_PRINT("`init_args`: %s\n", + PyUnicode_AsUTF8(PyObject_Repr(init_args))); + DEBUG_PRINT("`init_kwargs`: %s\n", + PyUnicode_AsUTF8(PyObject_Repr(init_kwargs))); } - Py_ssize_t args_len = PyTuple_Size(init_args); - Py_ssize_t combined_args_len = 1 + args_len; - args_combined = PyTuple_New(combined_args_len); - if (args_combined == NULL) { - Py_XDECREF(init_args); - Py_XDECREF(init_kwargs); - Py_DECREF(result); - return NULL; // Use return NULL instead of Py_RETURN_NONE - } - - // Set the first item of the new tuple to `result` - PyTuple_SET_ITEM(args_combined, - 0, - result); // `result` is now owned by `args_combined` - - // Copy items from `init_args` to `args_combined` - for (Py_ssize_t i = 0; i < args_len; i++) { - PyObject* item = PyTuple_GetItem(init_args, i); // Borrowed reference - if (item == NULL) { - Py_DECREF(args_combined); + if (init_args != NULL) { + DEBUG_PRINT("`init_args` is not NULL\n"); + args_len = PyTuple_Size(init_args); + DEBUG_PRINT("`args_len`: %zd\n", args_len); + Py_ssize_t combined_args_len = 1 + args_len; + DEBUG_PRINT("`combined_args_len`: %zd\n", combined_args_len); + args_combined = PyTuple_New(combined_args_len); + DEBUG_PRINT("`args_combined`: %s\n", + PyUnicode_AsUTF8(PyObject_Repr(args_combined))); + if (args_combined == NULL) { Py_XDECREF(init_args); Py_XDECREF(init_kwargs); - return NULL; + Py_DECREF(result); + DEBUG_PRINT("`args_combined` is NULL\n"); + return NULL; // Use return NULL instead of Py_RETURN_NONE } - Py_INCREF(item); // Increase reference count + // Set the first item of the new tuple to `result` PyTuple_SET_ITEM(args_combined, - i + 1, - item); // `item` is now owned by `args_combined` + 0, + result); // `result` is now owned by `args_combined` + + // Copy items from `init_args` to `args_combined` + for (Py_ssize_t i = 0; i < args_len; i++) { + PyObject* item = PyTuple_GetItem(init_args, i); // Borrowed reference + if (item == NULL) { + DEBUG_PRINT("`item` is NULL\n"); + Py_DECREF(args_combined); + Py_XDECREF(init_args); + Py_XDECREF(init_kwargs); + return NULL; + } + DEBUG_PRINT("`item`: %s\n", PyUnicode_AsUTF8(PyObject_Repr(item))); + Py_INCREF(item); // Increase reference count + PyTuple_SET_ITEM(args_combined, + i + 1, + item); // `item` is now owned by `args_combined` + } + DEBUG_PRINT("`args_combined`: %s\n", + PyUnicode_AsUTF8(PyObject_Repr(args_combined))); } - DEBUG_PRINT("`args_combined`: %s\n", - PyUnicode_AsUTF8(PyObject_Repr(args_combined))); + + if (init_args == NULL || init_kwargs == NULL) { + DEBUG_PRINT("`init_args` or `init_kwargs` is NULL\n"); + }; if (init_kwargs != NULL) { DEBUG_PRINT("`init_kwargs`: %s\n", PyUnicode_AsUTF8(PyObject_Repr(init_kwargs))); }; - // Call the function or constructor + // If `args_combined` is NULL, create a new tuple with one item + // and set `result` as the first item of the tuple + if (init_args == NULL) { + DEBUG_PRINT("`init_args` is NULL\n"); + + if (PyObject_SetAttrString( + self->obj, NEWTYPE_INIT_ARGS_STR, PyTuple_New(0)) + < 0) + { + result = NULL; + goto done; + } + if (PyObject_SetAttrString( + self->obj, NEWTYPE_INIT_KWARGS_STR, PyDict_New()) + < 0) + { + result = NULL; + goto done; + } + + args_combined = PyTuple_New(1); // Allocate tuple with one element + Py_INCREF(result); + PyTuple_SET_ITEM(args_combined, 0, result); + DEBUG_PRINT("`args_combined`: %s\n", + PyUnicode_AsUTF8(PyObject_Repr(args_combined))); + new_inst = + PyObject_Call((PyObject*)self->cls, args_combined, init_kwargs); + if (new_inst == NULL) { + DEBUG_PRINT("`new_inst` is NULL\n"); + Py_DECREF(result); + Py_DECREF(self->obj); + Py_DECREF(args_combined); + return NULL; + } + Py_DECREF(result); + Py_DECREF(self->obj); + Py_DECREF(args_combined); + DEBUG_PRINT("`new_inst`: %s\n", + PyUnicode_AsUTF8(PyObject_Repr(new_inst))); + return new_inst; + } + new_inst = PyObject_Call((PyObject*)self->cls, args_combined, init_kwargs); // Clean up - Py_DECREF(args_combined); // Decrement reference count of `args_combined` + Py_XDECREF(args_combined); // Decrement reference count of `args_combined` Py_XDECREF(init_args); Py_XDECREF(init_kwargs); - // Ensure proper error propagation - if (new_inst == NULL) { - return NULL; - } + DEBUG_PRINT("`new_inst`: %s\n", PyUnicode_AsUTF8(PyObject_Repr(new_inst))); // Only proceed if we have all required objects and dictionaries if (self->obj != NULL && result != NULL && new_inst != NULL @@ -427,6 +494,7 @@ static PyObject* NewTypeMethod_call(NewTypeMethodObject* self, done: Py_XINCREF(result); + DEBUG_PRINT("DONE! `result`: %s\n", PyUnicode_AsUTF8(PyObject_Repr(result))); return result; } diff --git a/newtype/newtype.py b/newtype/newtype.py index 47f1cfb..d2a820f 100644 --- a/newtype/newtype.py +++ b/newtype/newtype.py @@ -180,7 +180,7 @@ class BaseNewType(base_type): # type: ignore[valid-type, misc] if hasattr(base_type, "__slots__"): __slots__ = ( - *base_type.__slots__, + # *base_type.__slots__, NEWTYPE_INIT_ARGS_STR, NEWTYPE_INIT_KWARGS_STR, ) @@ -224,10 +224,14 @@ def __init_subclass__(cls, **init_subclass_context: Any) -> None: and not func_is_excluded(v) ): setattr(cls, k, NewTypeMethod(v, base_type)) + else: if k == "__dict__": continue - setattr(cls, k, v) + try: + setattr(cls, k, v) + except AttributeError: + continue cls.__init__ = NewTypeInit(constructor) # type: ignore[method-assign] def __new__(cls, value: Any = None, *_args: Any, **_kwargs: Any) -> "BaseNewType": diff --git a/pyproject.toml b/pyproject.toml index 3300418..fe7fe4a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "poetry_dynamic_versioning.backend" [tool.poetry] name = "python-newtype" -version = "0.1.5" +version = "0.1.6" homepage = "https://github.com/jymchng/python-newtype-dev" repository = "https://github.com/jymchng/python-newtype-dev" license = "MIT" @@ -146,6 +146,7 @@ exclude = [ "examples/newtype_enums.py", "examples/mutable.py", "examples/pydantic-compat.py", + "examples/newtype_enums_int.py", ] [tool.ruff.format] diff --git a/tests/build_test_pyvers_docker_images.sh b/tests/build_test_pyvers_docker_images.sh index c440127..804bd6e 100755 --- a/tests/build_test_pyvers_docker_images.sh +++ b/tests/build_test_pyvers_docker_images.sh @@ -2,7 +2,6 @@ # Create logs directory if it doesn't exist mkdir -p ./tests/logs -make build # Build Docker images in parallel with logging docker build -t python-newtype-test-mul-vers:3.8 -f ./tests/Dockerfile-test-py3.8 . > ./tests/logs/py3.8-test.log 2>&1 & diff --git a/tests/test_newtype_enums.py b/tests/test_newtype_enums.py new file mode 100644 index 0000000..ab98654 --- /dev/null +++ b/tests/test_newtype_enums.py @@ -0,0 +1,127 @@ +from enum import Enum +from weakref import WeakValueDictionary + +import pytest +from newtype import NewType + + +class ENV(NewType(str), Enum): # type: ignore[misc] + + LOCAL = "LOCAL" + DEV = "DEV" + SIT = "SIT" + UAT = "UAT" + PREPROD = "PREPROD" + PROD = "PROD" + + +# mypy doesn't raise errors here +def test_nt_env_replace() -> None: + + env = ENV.LOCAL + + assert env is ENV.LOCAL + assert env is not ENV.DEV + assert isinstance(env, ENV) + + # let's say now we want to replace the environment + # nevermind about the reason why we want to do so + env = env.replace(ENV.LOCAL, ENV.DEV) + # reveal_type(env) # Revealed type is "newtype_enums.ENV" + + # replacement is successful + assert env is ENV.DEV + assert env is not ENV.LOCAL + + # still an `ENV` + assert isinstance(env, ENV) + assert isinstance(env, str) + + with pytest.raises(ValueError): + # cannot replace with something that is not a `ENV` + env = env.replace(ENV.DEV, "NotAnEnv") + + # reveal_type(env) # Revealed type is "newtype_enums.ENV" + + with pytest.raises(ValueError): + # cannot even make 'DEV' -> 'dev' + env = env.lower() + + +class GenericWrappedBoundedInt(NewType(int)): + MAX_VALUE: int = 0 + + __CONCRETE_BOUNDED_INTS__ = WeakValueDictionary() + + def __new__(cls, value: int): + inst = super().__new__(cls, value % cls.MAX_VALUE) + return inst + + def __repr__(self) -> str: + return f"" + + def __str__(self) -> str: + return str(int(self)) + + def __class_getitem__(cls, idx=MAX_VALUE): + if not isinstance(idx, int): + raise TypeError(f"cannot make `BoundedInt[{idx}]`") + + if idx not in cls.__CONCRETE_BOUNDED_INTS__: + + class ConcreteBoundedInt(cls): + MAX_VALUE = idx + + cls.__CONCRETE_BOUNDED_INTS__[idx] = ConcreteBoundedInt + + return cls.__CONCRETE_BOUNDED_INTS__[idx] + + +class Severity(GenericWrappedBoundedInt[5], Enum): + DEBUG = 0 + INFO = 1 + WARNING = 2 + ERROR = 3 + CRITICAL = 4 + + +def test_severity(): + assert Severity.DEBUG == 0 + assert Severity.INFO == 1 + assert Severity.WARNING == 2 + assert Severity.ERROR == 3 + assert Severity.CRITICAL == 4 + + with pytest.raises(AttributeError, match=r"[c|C]annot\s+reassign\s+\w+"): + Severity.ERROR += 1 + + severity = Severity.ERROR + assert severity == 3 + + severity += 1 + assert severity == 4 + assert severity != 3 + assert isinstance(severity, int) + assert isinstance(severity, Severity) + assert severity is not Severity.ERROR + assert severity is Severity.CRITICAL + + severity -= 1 + assert severity == 3 + assert severity != 4 + assert isinstance(severity, int) + assert isinstance(severity, Severity) + assert severity is Severity.ERROR + assert severity is not Severity.CRITICAL + + severity = Severity.DEBUG + assert severity == 0 + assert str(severity.value) == "0" + with pytest.raises(ValueError, match=r"\d+ is not a valid Severity"): + severity -= 1 + + severity = Severity.CRITICAL + assert severity == 4 + assert str(severity.value) == "4" + with pytest.raises(ValueError, match=r"\d+ is not a valid Severity"): + severity += 1 diff --git a/tests/test_slots.py b/tests/test_slots.py index 42c851d..ccff6fd 100644 --- a/tests/test_slots.py +++ b/tests/test_slots.py @@ -25,6 +25,9 @@ def test_base_slots(): base = Base("TestName") assert base.name == "TestName" + with pytest.raises(AttributeError): + base.__dict__ + with pytest.raises(AttributeError): base.age = 30 # Should raise an error since 'age' is not a defined slot @@ -35,6 +38,9 @@ def test_derived_slots(): assert derived.name == "TestName" assert derived.age == 25 + with pytest.raises(AttributeError): + derived.__dict__ + with pytest.raises(AttributeError): derived.address = ( "123 Street" # Should raise an error since 'address' is not a defined slot