Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions .github/workflows/build-and-test-pycobertura.yml
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,21 @@ jobs:
- name: Test with tox
run: tox

- name: Download coverage artifact from master (${{ matrix.python-version }})
# Only run on PRs targeting master
if: github.event_name == 'pull_request' && github.base_ref == 'master'
uses: actions/download-artifact@v4
with:
# Construct the specific artifact name using the target commit SHA
name: cobertura-coverage-${{ github.event.pull_request.base.sha }}-${{ matrix.python-version }}
# Specify the workflow, branch, and commit to find the artifact from the correct run
workflow: ${{ github.workflow }} # Assumes this workflow generated the artifact
branch: master
commit: ${{ github.event.pull_request.base.sha }}
# Specify where to download the artifact (optional, defaults to current dir)
path: base-coverage/
github-token: ${{ secrets.GITHUB_TOKEN }}

- name: Upload coverage artifact
if: ${{ github.event_name == 'push' }}
uses: actions/upload-artifact@v4
Expand Down
33 changes: 32 additions & 1 deletion pycobertura/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import re
import fnmatch
from functools import partial
import inspect # Add import

from typing import List, Tuple, Union

Expand Down Expand Up @@ -40,22 +41,52 @@ def add_to(self, arg):

def __init__(self, func):
self.target_func = func
# Store signature for later use
self._sig = inspect.signature(self.target_func)

def __get__(self, obj, objtype=None):
if obj is None:
return self.target_func
# Use partial to pre-bind the instance `obj`
return partial(self, obj)

def __call__(self, *args, **kw):
# `args[0]` is the instance pre-bound by `__get__` using `partial`
target_self = args[0]
# The actual arguments passed to the method start from args[1:]
method_args = args[1:]

try:
cache = target_self.__cache
except AttributeError:
cache = target_self.__cache = {}
key = (self.target_func, args[1:], frozenset(kw.items()))

# Bind arguments to the function signature to normalize them
try:
# We pass target_self explicitly here because the signature includes 'self'
# but it's not part of the *args received by __call__ in this context
# (it was bound by __get__ and is args[0])
bound_args = self._sig.bind(target_self, *method_args, **kw)
except TypeError:
# Handle cases where binding fails (e.g., incorrect arguments passed)
# In such cases, don't cache, just call the original function
# which will likely raise the TypeError again.
return self.target_func(*args, **kw)

bound_args.apply_defaults() # Apply defaults for consistent keys

# Create a hashable key from the bound arguments *excluding* 'self'
# Use tuple(bound_args.arguments.items()) which includes param names
# and values in order Skip the first item, which corresponds to 'self'
key_items = tuple(
item for item in bound_args.arguments.items() if item[0] != "self"
)
key = (self.target_func, key_items)

try:
res = cache[key]
except KeyError:
# Pass the original *args and **kw to the target function
res = cache[key] = self.target_func(*args, **kw)
return res

Expand Down
121 changes: 121 additions & 0 deletions tests/test_utils.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
import pytest

from pycobertura.utils import memoize


class TestMemoize:
def test_memoize_caches_method_results(self):
"""Tests that the memoize decorator caches method results."""

class MyClass:
def __init__(self):
self.call_count = 0

@memoize
def my_method(self, arg1, arg2):
self.call_count += 1
return arg1 + arg2

instance = MyClass()

# First call - should execute the method
result1 = instance.my_method(1, 2)
assert result1 == 3
assert instance.call_count == 1

# Second call with same args - should return cached result
result2 = instance.my_method(1, 2)
assert result2 == 3
assert instance.call_count == 1 # Count should not increase

# Call with different args - should execute the method again
result3 = instance.my_method(3, 4)
assert result3 == 7
assert instance.call_count == 2 # Count should increase

# Call again with the first set of args - should return cached result
result4 = instance.my_method(1, 2)
assert result4 == 3
assert instance.call_count == 2 # Count should not increase

# Call again with the second set of args - should return cached result
result5 = instance.my_method(3, 4)
assert result5 == 7
assert instance.call_count == 2 # Count should not increase

def test_memoize_different_instances(self):
"""Tests that memoization is instance-specific."""

class MyClass:
def __init__(self):
self.call_count = 0

@memoize
def my_method(self, arg):
self.call_count += 1
return arg * 2

instance1 = MyClass()
instance2 = MyClass()

# Call on instance1
result1_1 = instance1.my_method(5)
assert result1_1 == 10
assert instance1.call_count == 1
assert instance2.call_count == 0

# Call on instance2 with the same arg
result2_1 = instance2.my_method(5)
assert result2_1 == 10
assert instance1.call_count == 1
assert instance2.call_count == 1 # instance2 count increases

# Call on instance1 again
result1_2 = instance1.my_method(5)
assert result1_2 == 10
assert instance1.call_count == 1 # instance1 count stays the same
assert instance2.call_count == 1

def test_memoize_with_kwargs(self):
"""Tests that memoization works with keyword arguments."""

class MyClass:
def __init__(self):
self.call_count = 0

@memoize
def my_method(self, a, b=0):
self.call_count += 1
return a + b

instance = MyClass()

# Call with positional args
res1 = instance.my_method(1, b=2)
assert res1 == 3
assert instance.call_count == 1

# Call with same args using kwargs
res2 = instance.my_method(a=1, b=2)
assert res2 == 3
assert instance.call_count == 1 # Should be cached

# Call with different kwargs order
res3 = instance.my_method(b=2, a=1)
assert res3 == 3
assert instance.call_count == 1 # Should be cached

# Call with different value for kwarg
res4 = instance.my_method(a=1, b=3)
assert res4 == 4
assert instance.call_count == 2 # New call

# Call using default value
res5 = instance.my_method(5)
assert res5 == 5
assert instance.call_count == 3 # New call

# Call again using default value
res6 = instance.my_method(5, b=0)
assert res6 == 5
assert instance.call_count == 3 # Should be cached