diff --git a/.gitignore b/.gitignore index a9fafeb..b9d2b56 100644 --- a/.gitignore +++ b/.gitignore @@ -17,3 +17,4 @@ uv.lock .idea/ .ropeproject node_modules +AGENTS.md diff --git a/docs/plans/1.md b/docs/plans/1.md new file mode 100644 index 0000000..0a398bf --- /dev/null +++ b/docs/plans/1.md @@ -0,0 +1,232 @@ +# Исправление привязки методов `@transfunction` + +## Краткое описание + +Исправить [сообщение об ошибке №17: методы `@transfunction` могут выполняться с `self` от другого экземпляра](https://github.com/mutating/transfunctions/issues/17). + +Декоратор `@transfunction` заменяет метод одним объектом `FunctionTransformer`, который хранится на классе и совместно используется всеми его экземплярами. Сейчас метод `__get__()` записывает текущий экземпляр в общее поле `base_object`, а `extract_context()` сохраняет в кеше уже связанный `MethodType`, используя в качестве ключа только имя контекста. + +В результате первый экземпляр, заполнивший кеш обычного, асинхронного или генераторного варианта, остаётся значением `self` для последующих обращений через другие экземпляры. Такой вызов может прочитать или изменить состояние постороннего объекта. Кроме того, сохранённый объект, полученный при обращении к дескриптору, нестабилен: последующее обращение через другой экземпляр меняет находящийся в нём `base_object`. + +Для исправления доступ через экземпляр должен возвращать поверхностную копию трансформера с собственным `base_object`. Общий кеш должен содержать только непривязанные функции, а `MethodType` должен создаваться после чтения кеша отдельно для текущего экземпляра. + +## 1. Сохранение плана и порядок работы по TDD + +1. До добавления тестов и изменения основного кода создать каталог `docs/plans/`. +2. Сохранить этот план целиком в `docs/plans/1.md`. +3. Работать строго по TDD: + - сначала добавить весь запланированный набор тестов; + - запустить его на исходной реализации; + - подтвердить, что тесты падают по ожидаемым причинам; + - только после этого изменять `transfunctions/transformer.py`. + +## 2. Фаза падающих тестов + +Все двенадцать тестов добавить в `tests/units/decorators/test_transfunction.py`. Каждый тест снабдить строкой документации, которая объясняет проверяемое поведение и его отличие от соседних тестов. + +### 2.1 `test_generated_usual_method_is_bound_to_the_current_instance` + +- **Файл:** `tests/units/decorators/test_transfunction.py` +- **Суть:** обычный вариант метода должен отдельно связываться с каждым экземпляром, даже если кеш был заполнен первым объектом. +- **Проверки:** + - `first_method.__self__ is first`; + - `second_method.__self__ is second`; + - `first_method() == "first"`; + - `second_method() == "second"`. + +### 2.2 `test_generated_async_method_is_bound_to_the_current_instance` + +- **Файл:** `tests/units/decorators/test_transfunction.py` +- **Суть:** отдельная запись кеша для асинхронного варианта не должна удерживать первый экземпляр. +- **Проверки:** + - `first_method.__self__ is first`; + - `second_method.__self__ is second`; + - `run(first_method()) == "first"`; + - `run(second_method()) == "second"`. + +### 2.3 `test_generated_generator_method_is_bound_to_the_current_instance` + +- **Файл:** `tests/units/decorators/test_transfunction.py` +- **Суть:** генераторный вариант должен корректно связываться с каждым экземпляром независимо от порядка заполнения его записи в кеше. +- **Проверки:** + - `first_method.__self__ is first`; + - `second_method.__self__ is second`; + - `list(first_method()) == ["first"]`; + - `list(second_method()) == ["second"]`. + +### 2.4 `test_saved_transfunction_views_remain_bound_to_their_instances` + +- **Файл:** `tests/units/decorators/test_transfunction.py` +- **Суть:** сохранённые объекты, полученные при обращении к дескриптору, не должны перенастраиваться последующими обращениями через другие экземпляры. +- **Сценарий:** + - сохранить `first_view = first.read`; + - сохранить `second_view = second.read`; + - сначала заполнить кеш через `second_view`; + - затем получить метод через `first_view`. +- **Проверки:** + - метод из `first_view` связан с `first`; + - метод из `second_view` связан с `second`; + - оба метода возвращают значения своих экземпляров. +- **Назначение:** тест не позволит ограничиться неполным исправлением, которое повторно связывает функцию из кеша, но продолжает возвращать из `__get__()` один общий изменяемый трансформер. + +### 2.5 `test_class_access_remains_unbound_after_instance_access` + +- **Файл:** `tests/units/decorators/test_transfunction.py` +- **Суть:** доступ через класс не должен наследовать устаревший `base_object` после заполнения кеша через экземпляр. +- **Сценарий:** + - получить обычный метод через `first`; + - получить непривязанную функцию через `Item.read`; + - получить метод через `second`. +- **Проверки:** + - `Item.read is Item.__dict__["read"]`; + - функция, полученная через класс, вызывается с явным `first` и возвращает `"first"`; + - та же функция вызывается с явным `second` и возвращает `"second"`; + - методы экземпляров имеют правильные значения `__self__`; + - `first_method.__func__ is unbound_function`; + - `second_method.__func__ is unbound_function`. +- **Назначение:** тест подтверждает, что кеш содержит общую непривязанную функцию, а связывание выполняется только после её получения из кеша. + +### 2.6 `test_class_cache_does_not_retain_instance` + +- **Файл:** `tests/units/decorators/test_transfunction.py` +- **Суть:** дескриптор класса и общий кеш не должны навсегда удерживать экземпляр. +- **Сценарий:** + - создать экземпляр и слабую ссылку с помощью `weakref.ref`; + - получить и выполнить обычный связанный метод; + - удалить прямую ссылку на экземпляр; + - убедиться, что сохранённый связанный метод закономерно продолжает удерживать объект; + - удалить связанный метод и вызвать `gc.collect()`. +- **Проверки:** + - пока связанный метод существует, слабая ссылка возвращает экземпляр; + - после удаления связанного метода и сборки мусора слабая ссылка возвращает `None`; + - класс и его дескриптор при этом продолжают существовать. +- **Назначение:** тест выявляет исправление, которое возвращает правильный `self`, но продолжает хранить экземпляр в кеше на уровне класса. + +### 2.7 `test_extract_context_binds_class_cached_function_to_each_instance` + +- **Файл:** `tests/units/decorators/test_transfunction.py` +- **Суть:** проверить одновременно отсутствующий порядок «класс → первый экземпляр → второй экземпляр» и прямое использование `extract_context('sync_context')`. Это подтверждает, что низкоуровневый путь использует общий кеш непривязанных функций и отдельно связывает результат с каждым экземпляром. +- **Проверки:** + - результат первого обращения через класс является обычной непривязанной функцией; + - функция вызывается с явно переданными первым и вторым экземплярами и возвращает их собственные значения; + - результаты прямого вызова `extract_context()` через экземпляры являются связанными методами; + - `first_method.__self__ is first`; + - `second_method.__self__ is second`; + - методы возвращают значения соответствующих экземпляров; + - `first_method.__func__` и `second_method.__func__` совпадают с функцией, ранее полученной через класс. + +### 2.8 `test_inherited_method_accessed_through_super_is_bound_to_child_instance` + +- **Файл:** `tests/units/decorators/test_transfunction.py` +- **Суть:** проверить наследование, порядок разрешения методов и доступ к унаследованному дескриптору через `super()`. Кеш сначала заполняется экземпляром базового класса, после чего тот же дескриптор используется дочерним экземпляром. +- **Проверки:** + - доступ через базовый и дочерний классы возвращает один исходный дескриптор; + - метод базового экземпляра имеет `__self__ is base_instance`; + - метод, полученный дочерним классом через `super()`, имеет `__self__ is child_instance`; + - методы возвращают состояние своих получателей; + - `__func__` обоих методов совпадает, подтверждая использование одной непривязанной функции из кеша. + +### 2.9 `test_concurrent_method_generation_keeps_each_instance_binding` + +- **Файл:** `tests/units/decorators/test_transfunction.py` +- **Суть:** проверить одновременное первоначальное получение метода разными экземплярами. Каждый поток сначала сохраняет своё представление дескриптора, затем потоки синхронизируются через `Barrier` и вызывают `get_usual_function()`. Это воспроизводит перекрывающиеся обращения без недетерминированных задержек и стрессовых циклов. +- **Проверки:** + - каждый поток завершается в установленный срок; + - каждый результат является методом, связанным с экземпляром соответствующего потока; + - каждый метод возвращает значение своего экземпляра; + - после завершения потоков функция из кеша класса остаётся непривязанной; + - функция класса вызывается с обоими экземплярами явно и возвращает правильные значения. +- **Ограничение:** не проверять количество выполненных компиляций и совпадение `__func__` результатов конкурентного промаха. Реализация не обещает единственную компиляцию и намеренно не использует блокировки. + +### 2.10 `test_instance_is_collected_despite_saved_view_and_bound_method_cycles` + +- **Файл:** `tests/units/decorators/test_transfunction.py` +- **Суть:** дополнить существующую проверку сборки мусора сценарием с настоящими циклами ссылок. Экземпляр сохраняет у себя собственное представление трансформера и созданный через него связанный метод. +- **Проверки:** + - сохранённое представление содержит текущий экземпляр в `base_object`; + - сохранённый метод связан с тем же экземпляром и возвращает его; + - после удаления внешней ссылки, но до сборки мусора слабая ссылка ещё разрешается; + - после `gc.collect()` слабая ссылка возвращает `None`; + - дескриптор класса продолжает существовать и сохраняет исходную идентичность. + +### 2.11 `test_method_binding_supports_slotted_unhashable_instances_without_weakref_support` + +- **Файл:** `tests/units/decorators/test_transfunction.py` +- **Суть:** одним сценарием проверить экземпляры с `__slots__`, `__hash__ = None` и без слота `__weakref__`. Тест защищает реализацию от неявной зависимости привязки или кеша от словаря экземпляра, хешируемости либо поддержки слабых ссылок. +- **Проверки:** + - у экземпляра отсутствует `__dict__`; + - вычисление его хеша завершается `TypeError`; + - создание `weakref.ref` завершается `TypeError`; + - методы двух экземпляров имеют правильные значения `__self__`; + - каждый метод возвращает значение собственного экземпляра. + +### 2.12 `test_generated_method_preserves_signature_and_metadata` + +- **Файл:** `tests/units/decorators/test_transfunction.py` +- **Суть:** с помощью параметризации проверить, что обычный, асинхронный и генераторный варианты одинаково сохраняют сигнатуру и метаданные метода. В каждом варианте функция сначала получается через класс, заполняя кеш непривязанным результатом, а затем через экземпляр. +- **Проверки:** + - функция, полученная через класс, имеет ожидаемый вид: обычная, корутинная или генераторная; + - её сигнатура совпадает с сигнатурой исходного шаблона и содержит `self`; + - результат через экземпляр является связанным методом с `__self__ is item`; + - его сигнатура совпадает с сигнатурой исходного шаблона, связанного стандартным дескрипторным механизмом, и не содержит `self`; + - у непривязанного и связанного результатов сохранены `__name__`, `__qualname__`, `__module__`, `__doc__` и `__annotations__`; + - их `__wrapped__` указывает непосредственно на исходный шаблон; + - `inspect.unwrap()` возвращает исходный шаблон. +- **Параметризация:** пары из `get_usual_function`, `get_async_function`, `get_generator_function` и соответствующей функции инспекции вида результата. + +### Проверка фазы падающих тестов + +Запустить только новые тесты на исходном коде и подтвердить: + +- первые три теста падают из-за неверного `__self__`; +- тест сохранённых объектов дескриптора падает из-за общего изменяемого трансформера; +- тест доступа через класс получает ранее связанный метод вместо непривязанной функции; +- тест сборки мусора показывает, что кеш дескриптора удерживает экземпляр. +- прямой вызов `extract_context()` после заполнения кеша через класс возвращает экземплярам непривязанную функцию; +- унаследованный метод, полученный через `super()`, остаётся связан с экземпляром базового класса; +- конкурентная генерация связывает результаты с одним общим экземпляром; +- циклы через сохранённое представление и связанный метод остаются достижимыми из кеша класса; +- метод второго ограниченного экземпляра остаётся связан с первым; +- после заполнения кеша через класс порождённые варианты не связываются с экземпляром и теряют ожидаемую сигнатуру метода. + +## 3. Фаза исправления + +В `transfunctions/transformer.py`: + +1. Импортировать `copy` из модуля `copy`. +2. Изменить `FunctionTransformer.__get__()`: + - указать, что `base_object` может быть равен `None`; + - при `None` возвращать исходный дескриптор; + - при доступе через экземпляр возвращать `copy(self)` с собственным `base_object`. +3. Изменить `extract_context()`: + - при наличии записи в кеше получить непривязанную функцию и затем применить `MethodType`, если трансформер связан с экземпляром; + - при отсутствии записи оставить существующее преобразование синтаксического дерева без изменений; + - после `rewrite_globals_and_closure()` и `wraps()` сохранить в кеше непривязанную функцию; + - только после сохранения функции создать `MethodType` для текущего экземпляра. +4. Не добавлять: + - блокировки или вызов `setdefault`; + - вспомогательные классы и классы-обёртки; + - `WeakKeyDictionary` и отдельный кеш для каждого экземпляра; + - новую типизацию на основе `Concatenate` или изменение существующей модели `ParamSpec`; + - изменения ключей кеша или поведения `addictional_transformers`. + +README не изменять: исправление восстанавливает уже заявленное поведение связанных методов. + +## 4. Проверка результата + +Во временном виртуальном окружении: + +1. Запустить двенадцать новых тестов. +2. Запустить весь файл `tests/units/decorators/test_transfunction.py`. +3. Запустить полный набор `pytest`, включая проверки README, типизации и `superfunction`. +4. Запустить `ruff check .`. +5. Собрать пакет в форматах wheel и sdist с помощью `python -m build`. +6. Проверить оба собранных дистрибутива командой `twine check dist/*`. +7. Удалить временное виртуальное окружение и промежуточные артефакты сборки. + +Итоговые изменения должны включать: + +- `docs/plans/1.md` с этим планом; +- минимальное исправление `FunctionTransformer`; +- двенадцать новых проверок; +- отсутствие изменений README и постороннего кода. diff --git a/pyproject.toml b/pyproject.toml index 2ab5314..c58f286 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "transfunctions" -version = "0.0.13" +version = "0.0.14" authors = [{ name = "Evgeniy Blinov", email = "zheni-b@yandex.ru" }] description = 'Say NO to Python fragmentation on sync and async' readme = "README.md" diff --git a/tests/units/decorators/test_transfunction.py b/tests/units/decorators/test_transfunction.py index ba6b5ce..95acd8d 100644 --- a/tests/units/decorators/test_transfunction.py +++ b/tests/units/decorators/test_transfunction.py @@ -1,7 +1,19 @@ +import gc import traceback +import weakref from asyncio import run +from concurrent.futures import ThreadPoolExecutor from contextlib import contextmanager -from inspect import getsourcelines, iscoroutinefunction, isfunction, isgeneratorfunction +from inspect import ( + getsourcelines, + iscoroutinefunction, + isfunction, + isgeneratorfunction, + signature, + unwrap, +) +from threading import Barrier +from types import MethodType import pytest from full_match import match @@ -1023,6 +1035,400 @@ def template(self, a, b=5): assert list(some_class_instance.template.get_generator_function()(2)) == [9] +def test_generated_usual_method_is_bound_to_the_current_instance(): + """ + A usual method must stay bound to the instance through which it was requested, even when another instance reuses the same context cache. + + The assertions inspect each method's __self__ attribute and return value, exercising the usual-function cache entry independently of the async and generator entries. + """ + class Item: + def __init__(self, value): + self.value = value + + @transfunction + def read(self): + return self.value + + first = Item('first') + second = Item('second') + + first_method = first.read.get_usual_function() + second_method = second.read.get_usual_function() + + assert first_method.__self__ is first + assert second_method.__self__ is second + assert first_method() == 'first' + assert second_method() == 'second' + + +def test_generated_async_method_is_bound_to_the_current_instance(): + """ + An async method must stay bound to the instance through which it was requested, even when another instance reuses the same context cache. + + The assertions inspect each method's __self__ attribute and the value returned by its coroutine, exercising the async-function cache entry independently of the usual and generator entries. + """ + class Item: + def __init__(self, value): + self.value = value + + @transfunction + def read(self): + return self.value + + first = Item('first') + second = Item('second') + + first_method = first.read.get_async_function() + second_method = second.read.get_async_function() + + assert first_method.__self__ is first + assert second_method.__self__ is second + assert run(first_method()) == 'first' + assert run(second_method()) == 'second' + + +def test_generated_generator_method_is_bound_to_the_current_instance(): + """ + A generator method must stay bound to the instance through which it was requested, even when another instance reuses the same context cache. + + The assertions inspect each method's __self__ attribute and yielded value, exercising the generator-function cache entry independently of the usual and async entries. + """ + class Item: + def __init__(self, value): + self.value = value + + @transfunction + def read(self): + yield self.value + + first = Item('first') + second = Item('second') + + first_method = first.read.get_generator_function() + second_method = second.read.get_generator_function() + + assert first_method.__self__ is first + assert second_method.__self__ is second + assert list(first_method()) == ['first'] + assert list(second_method()) == ['second'] + + +def test_saved_transfunction_views_remain_bound_to_their_instances(): + """ + A transformer view saved from one instance must keep that receiver after the descriptor is accessed through a second instance. + + Both views are saved before method generation, and the second view populates the usual-function cache first. The test then checks each generated method's __self__ and return value to prove that both saved views retain their own receivers. + """ + class Item: + def __init__(self, value): + self.value = value + + @transfunction + def read(self): + return self.value + + first = Item('first') + second = Item('second') + first_view = first.read + second_view = second.read + + second_method = second_view.get_usual_function() + first_method = first_view.get_usual_function() + + assert first_method.__self__ is first + assert second_method.__self__ is second + assert first_method() == 'first' + assert second_method() == 'second' + + +def test_class_access_remains_unbound_after_instance_access(): + """ + Class access must return the original transformer and expose an unbound usual function after an instance has populated the shared context cache. + + The class-level function is called with each instance explicitly and must return that instance's value. The test also checks that methods obtained before and after class access have the corresponding __self__ values and share the same unbound function through __func__. + """ + class Item: + def __init__(self, value): + self.value = value + + @transfunction + def read(self): + return self.value + + first = Item('first') + second = Item('second') + + first_method = first.read.get_usual_function() + unbound_function = Item.read.get_usual_function() + second_method = second.read.get_usual_function() + + assert Item.read is Item.__dict__['read'] + assert unbound_function(first) == 'first' + assert unbound_function(second) == 'second' + assert first_method.__self__ is first + assert second_method.__self__ is second + assert first_method.__func__ is unbound_function + assert second_method.__func__ is unbound_function + + +def test_class_cache_does_not_retain_instance(): + """ + The class descriptor and its shared generated-function cache must not retain an instance after the last bound method referencing it is discarded. + + After deleting the direct instance reference, the test confirms that the saved bound method still retains the receiver as bound_method.__self__. It then deletes the method, forces garbage collection, and expects the weak reference to clear while the class descriptor retains its identity. + """ + class Item: + @transfunction + def read(self): + return self + + item = Item() + item_weakref = weakref.ref(item) + class_descriptor = Item.__dict__['read'] + bound_method = item.read.get_usual_function() + + assert bound_method() is item + + del item + + assert item_weakref() is bound_method.__self__ + + del bound_method + gc.collect() + + assert item_weakref() is None + assert Item.__dict__['read'] is class_descriptor + + +def test_extract_context_binds_class_cached_function_to_each_instance(): + """ + Direct context extraction must bind a class-cached function independently to every requesting instance. + + The class populates the sync-context cache first, after which two instances call extract_context directly. The assertions distinguish the unbound cached function from both bound methods and verify their receivers and results. + """ + class Item: + def __init__(self, value): + self.value = value + + @transfunction + def read(self): + return self.value + + first = Item('first') + second = Item('second') + + unbound_function = Item.read.extract_context('sync_context') + first_method = first.read.extract_context('sync_context') + second_method = second.read.extract_context('sync_context') + + assert isfunction(unbound_function) + assert unbound_function(first) == 'first' + assert unbound_function(second) == 'second' + assert isinstance(first_method, MethodType) + assert isinstance(second_method, MethodType) + assert first_method.__self__ is first + assert second_method.__self__ is second + assert first_method() == 'first' + assert second_method() == 'second' + assert first_method.__func__ is unbound_function + assert second_method.__func__ is unbound_function + + +def test_inherited_method_accessed_through_super_is_bound_to_child_instance(): + """ + An inherited transfunction requested through super must bind to the child instance selected by descriptor lookup. + + A base instance populates the shared cache before the child requests the same descriptor through super. The test verifies class-level descriptor identity, each method's receiver and result, and their shared unbound function. + """ + class BaseItem: + def __init__(self, value): + self.value = value + + @transfunction + def read(self): + return self.value + + class ChildItem(BaseItem): + def get_parent_read(self): + return super().read.get_usual_function() + + base_instance = BaseItem('base') + child_instance = ChildItem('child') + class_descriptor = BaseItem.__dict__['read'] + + base_method = base_instance.read.get_usual_function() + child_method = child_instance.get_parent_read() + + assert BaseItem.read is class_descriptor + assert ChildItem.read is class_descriptor + assert base_method.__self__ is base_instance + assert child_method.__self__ is child_instance + assert base_method() == 'base' + assert child_method() == 'child' + assert base_method.__func__ is child_method.__func__ + + +def test_concurrent_method_generation_keeps_each_instance_binding(): + """ + Concurrent method requests must preserve the receiver belonging to each saved transformer view. + + Two workers save views for different instances before a barrier releases their requests against an initially empty cache. Each request may populate or reuse the cache; in either case, its method must retain the corresponding receiver, while the function eventually stored in the class cache remains unbound and usable with either instance. + """ + class Item: + def __init__(self, value): + self.value = value + + @transfunction + def read(self): + return self.value + + first = Item('first') + second = Item('second') + barrier = Barrier(2) + + def generate_method(item): + transformer_view = item.read + barrier.wait(timeout=10) + return transformer_view.get_usual_function() + + with ThreadPoolExecutor(max_workers=2) as executor: + first_future = executor.submit(generate_method, first) + second_future = executor.submit(generate_method, second) + first_method = first_future.result(timeout=10) + second_method = second_future.result(timeout=10) + + assert isinstance(first_method, MethodType) + assert isinstance(second_method, MethodType) + assert first_method.__self__ is first + assert second_method.__self__ is second + assert first_method() == 'first' + assert second_method() == 'second' + + unbound_function = Item.read.get_usual_function() + + assert isfunction(unbound_function) + assert unbound_function(first) == 'first' + assert unbound_function(second) == 'second' + + +def test_instance_is_collected_despite_saved_view_and_bound_method_cycles(): + """ + Cycles through a saved transformer view and its bound method must not make an instance permanently reachable from the class. + + The instance stores both objects on itself, forming cycles through their receiver references. After the sole external strong reference is deleted, garbage collection must clear the weak reference without removing the class descriptor. + """ + class Item: + def __init__(self): + self.saved_view = self.read + self.bound_method = self.saved_view.get_usual_function() + + @transfunction + def read(self): + return self + + item = Item() + item_weakref = weakref.ref(item) + class_descriptor = Item.__dict__['read'] + + assert item.saved_view.base_object is item + assert item.bound_method.__self__ is item + assert item.bound_method() is item + + del item + + assert item_weakref() is not None + + gc.collect() + + assert item_weakref() is None + assert Item.__dict__['read'] is class_descriptor + + +def test_method_binding_supports_slotted_unhashable_instances_without_weakref_support(): + """ + Method binding must support slotted, unhashable instances that cannot be weakly referenced. + + One class combines all three restrictions before two instances populate and reuse the shared cache. The test confirms those restrictions explicitly and then verifies each generated method's receiver and result. + """ + class Item: + __slots__ = ('value',) + __hash__ = None + + def __init__(self, value): + self.value = value + + @transfunction + def read(self): + return self.value + + first = Item('first') + second = Item('second') + + assert not hasattr(first, '__dict__') + with pytest.raises(TypeError): + hash(first) + with pytest.raises(TypeError): + weakref.ref(first) + + first_method = first.read.get_usual_function() + second_method = second.read.get_usual_function() + + assert first_method.__self__ is first + assert second_method.__self__ is second + assert first_method() == 'first' + assert second_method() == 'second' + + +@pytest.mark.parametrize( + ('function_getter_name', 'function_inspector'), + [ + ('get_usual_function', isfunction), + ('get_async_function', iscoroutinefunction), + ('get_generator_function', isgeneratorfunction), + ], +) +def test_generated_method_preserves_signature_and_metadata(function_getter_name, function_inspector): + """ + Every generated method variant must preserve the expected public and underlying signatures and the template's wrapper metadata after class-first cache population. + + The parameterized getter first creates an unbound function through the class, then requests its bound counterpart through an instance. The assertions verify the unbound callable kind, compare the public and underlying signatures with the corresponding original callables, check the bound receiver, and confirm that both generated callables retain the template's metadata and wrapper target. + """ + class Item: + @transfunction + def calculate(self, value: int = 1, *, multiplier: int = 2): + """Return the value multiplied by the requested factor.""" + with sync_context: + return value * multiplier + with async_context: + return value * multiplier + with generator_context: + yield value * multiplier + + item = Item() + original_function = Item.__dict__['calculate'].function + original_bound_method = original_function.__get__(item, Item) + + unbound_function = getattr(Item.calculate, function_getter_name)() + bound_method = getattr(item.calculate, function_getter_name)() + + assert function_inspector(unbound_function) + assert signature(unbound_function) == signature(original_function) + assert signature(unbound_function, follow_wrapped=False) == signature(original_function, follow_wrapped=False) + assert isinstance(bound_method, MethodType) + assert bound_method.__self__ is item + assert signature(bound_method) == signature(original_bound_method) + assert signature(bound_method, follow_wrapped=False) == signature(original_bound_method, follow_wrapped=False) + + for generated_callable in (unbound_function, bound_method): + assert generated_callable.__name__ == original_function.__name__ + assert generated_callable.__qualname__ == original_function.__qualname__ + assert generated_callable.__module__ == original_function.__module__ + assert generated_callable.__doc__ == original_function.__doc__ + assert generated_callable.__annotations__ == original_function.__annotations__ + assert generated_callable.__wrapped__ is original_function + assert unwrap(generated_callable) is original_function + + def test_combine_with_other_decorator_before(): """ Rejects a transfunction template that already has another source-level decorator. diff --git a/transfunctions/transformer.py b/transfunctions/transformer.py index f0fec29..8f46c09 100644 --- a/transfunctions/transformer.py +++ b/transfunctions/transformer.py @@ -20,6 +20,7 @@ increment_lineno, parse, ) +from copy import copy from functools import update_wrapper, wraps from inspect import getfile, iscoroutinefunction, isfunction from sys import version_info @@ -71,11 +72,15 @@ def __call__(self, *args: Any, **kwargs: Any) -> None: # noqa: ARG002 def __get__( self, - base_object: SomeClassInstance, + base_object: Optional[SomeClassInstance], owner: Type[SomeClassInstance], ) -> 'FunctionTransformer[FunctionParams, ReturnType]': - self.base_object = base_object - return self + if base_object is None: + return self + + bound_transformer = copy(self) + bound_transformer.base_object = base_object + return bound_transformer @staticmethod def is_lambda(function: Callable[FunctionParams, ReturnType]) -> bool: @@ -158,7 +163,15 @@ def visit_Call(self, node: Call) -> Optional[Union[AST, List[AST]]]: def extract_context(self, context_name: str, addictional_transformers: Optional[List[NodeTransformer]] = None) -> Callable[FunctionParams, Union[Coroutine[Any, Any, ReturnType], Generator[ReturnType, None, None], ReturnType]]: if context_name in self.cache: - return self.cache[context_name] + cached_result = self.cache[context_name] + + if self.base_object is not None: + cached_result = MethodType( + cached_result, + self.base_object, + ) + + return cached_result source_code: str = getclearsource(self.function) tree = parse(source_code) @@ -237,14 +250,14 @@ def visit_FunctionDef(self, node: FunctionDef) -> Optional[Union[AST, List[AST]] result = self.rewrite_globals_and_closure(result) result = wraps(self.function)(result) + self.cache[context_name] = result + if self.base_object is not None: result = MethodType( result, self.base_object, ) - self.cache[context_name] = result - return result # type: ignore[no-any-return] def wrap_ast_by_closures(self, tree: Module) -> Module: