Skip to content

Commit ce5cbc0

Browse files
Merge pull request #11 from NicolasLacroix/develop
Merge : Memory testing for first release
2 parents aa5d132 + 9d4be8e commit ce5cbc0

File tree

10 files changed

+468
-12
lines changed

10 files changed

+468
-12
lines changed

.github/workflows/pylint.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,6 @@ jobs:
1717
run: |
1818
python -m pip install --upgrade pip
1919
pip install pylint
20-
- name: Test with pytest
20+
- name: Lint with PyLint
2121
run: |
22-
pylint `ls -R|grep .py$|xargs`
22+
pylint -j 0 pyunitperf --exit-zero

.github/workflows/python-package.yml

Lines changed: 3 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ name: Python package
55

66
on:
77
push:
8-
branches: [ master ]
8+
branches: [ master, develop ]
99
pull_request:
1010
branches: [ master ]
1111

@@ -26,14 +26,8 @@ jobs:
2626
- name: Install dependencies
2727
run: |
2828
python -m pip install --upgrade pip
29-
pip install flake8 pytest
29+
pip install pytest
3030
if [ -f requirements.txt ]; then pip install -r requirements.txt; fi
31-
- name: Lint with flake8
32-
run: |
33-
# stop the build if there are Python syntax errors or undefined names
34-
flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics
35-
# exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide
36-
flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics
3731
- name: Test with pytest
3832
run: |
39-
pytest
33+
pytest --pyargs pyunitperf/tests

README.md

Lines changed: 77 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,78 @@
11
# PyUnitPerf
2-
A simple API for unit testing a Python project's performances :wheelchair: :zap:.
2+
3+
![Python package](https://github.com/NicolasLacroix/PyUnitPerf/workflows/Python%20package/badge.svg?branch=master)
4+
![Python versions](https://img.shields.io/badge/python-3.5,%203.6%2C%203.7%2C%203.8-blue?logo=python)
5+
![License](https://img.shields.io/badge/license-Apache%202.0-blue.svg)
6+
7+
A simple and lightweight API for unit testing a Python 🐍 project's performances :zap::wheelchair:.
8+
9+
From **Python 3.5 to 3.8**, easily improve your project's performances tests through the use of dedicated **decorators**.
10+
11+
### Table of contents
12+
13+
- [Memory testing](#Memory-testing)
14+
- [Memory overload assertions](#Memory-overload-assertions)
15+
- [Memory leaks assertions](#Memory-leaks-assertions)
16+
17+
### Memory testing
18+
19+
#### Memory overload assertions
20+
21+
The memory overload assertions are possible thanks to the `@memory_not_exceed` decorator.
22+
23+
A simple *TestCase* associated to this decorator does the job :
24+
25+
```python
26+
class TestLimitMemoryUsage(unittest.TestCase):
27+
"""
28+
This class illustrates the use of the memory_not_exceed decorator.
29+
"""
30+
@memory_not_exceed(threshold=0)
31+
def test_memory_usage_exceed(self):
32+
"""
33+
This test won't pass due to a very low threshold.
34+
"""
35+
return list(range(1000)) * 1
36+
37+
@memory_not_exceed(threshold=1000)
38+
def test_memory_usage_not_exceed(self):
39+
"""
40+
This test passes due to a very high threshold.
41+
"""
42+
return list(range(1000)) * 1
43+
```
44+
45+
#### Memory leaks assertions
46+
47+
The memory leaks assertions are possible thanks to the `@memory_not_leak` decorator.
48+
49+
Once again, a simple *TestCase* associated to this decorator does the job :
50+
51+
```python
52+
class TetsMemoryLeaksDetection(unittest.TestCase):
53+
"""
54+
This class illustrates the use of the memory_not_leak decorator.
55+
"""
56+
leaking_list = []
57+
58+
def leak(self):
59+
"""
60+
Generates a memory leak involving the leaking_list.
61+
"""
62+
self.leaking_list.append("will leak")
63+
64+
@memory_not_leak()
65+
def test_memory_leaks(self):
66+
"""
67+
This test won't pass due to a memory leak.
68+
"""
69+
self.leak()
70+
71+
@memory_not_leak()
72+
def test_memory_not_leak(self):
73+
"""
74+
This test passes due to the absence of memory leak.
75+
"""
76+
valid_list = []
77+
valid_list.append("won't leak")
78+
```

examples/limit_memory_usage.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
"""
2+
This file contains a class that illustrates the use of the
3+
memory limit assertions using the pyunitperf.memory package.
4+
"""
5+
import unittest
6+
7+
from pyunitperf.memory import memory_not_exceed
8+
9+
10+
class TestLimitMemoryUsage(unittest.TestCase):
11+
"""
12+
This class illustrates the use of the memory_not_exceed decorator.
13+
"""
14+
@memory_not_exceed(threshold=0)
15+
def test_memory_usage_exceed(self):
16+
"""
17+
This test won't pass due to a very low threshold.
18+
"""
19+
return list(range(1000)) * 1
20+
21+
@memory_not_exceed(threshold=1000)
22+
def test_memory_usage_not_exceed(self):
23+
"""
24+
This test passes due to a very high threshold.
25+
"""
26+
return list(range(1000)) * 1
27+
28+
29+
if __name__ == '__main__':
30+
unittest.main()

examples/memory_leaks_detection.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
"""
2+
This file contains a class that illustrates the use of the
3+
memory leaks assertions using the pyunitperf.memory package.
4+
"""
5+
import unittest
6+
7+
from pyunitperf.memory import memory_not_leak
8+
9+
10+
class TetsMemoryLeaksDetection(unittest.TestCase):
11+
"""
12+
This class illustrates the use of the memory_not_leak decorator.
13+
"""
14+
leaking_list = []
15+
16+
def leak(self):
17+
"""
18+
Generates a memory leak involving the leaking_list.
19+
"""
20+
self.leaking_list.append("will leak")
21+
22+
@memory_not_leak()
23+
def test_memory_leaks(self):
24+
"""
25+
This test won't pass due to a memory leak.
26+
"""
27+
self.leak()
28+
29+
@memory_not_leak()
30+
def test_memory_not_leak(self):
31+
"""
32+
This test passes due to the absence of memory leak.
33+
"""
34+
valid_list = []
35+
valid_list.append("won't leak")
36+
37+
38+
if __name__ == '__main__':
39+
unittest.main()

pyunitperf/__init__.py

Whitespace-only changes.

pyunitperf/memory.py

Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
"""
2+
This package is dedicated to all the memory aspects testing.
3+
"""
4+
import tracemalloc
5+
from tracemalloc import Snapshot, Statistic, StatisticDiff
6+
from copy import deepcopy
7+
from functools import wraps
8+
from typing import Callable, Iterable, Optional, List
9+
import collections.abc as abc
10+
11+
ExcludeType = Optional[Iterable[str]]
12+
13+
DEFAULT_FILTERS = {
14+
tracemalloc.Filter(False, "<frozen importlib._bootstrap>"),
15+
tracemalloc.Filter(False, "<unknown>"),
16+
tracemalloc.Filter(False, tracemalloc.__file__),
17+
tracemalloc.Filter(False, __file__)
18+
}
19+
20+
def _filter_snapshot(snapshot: Snapshot, exclude: ExcludeType = None) -> tracemalloc.Snapshot:
21+
"""
22+
Filters the given snapshot using the exclude value and the DEFAULT_FILTERS.
23+
:param snapshot: snapshot to be filtered
24+
:param exclude: element(s) to be excluded from the snapshot
25+
:return: the filtered snapshot
26+
"""
27+
filters = deepcopy(DEFAULT_FILTERS)
28+
if isinstance(exclude, str):
29+
filters.add(tracemalloc.Filter(False, exclude))
30+
elif isinstance(exclude, abc.Iterable):
31+
filters.update(
32+
{tracemalloc.Filter(False, e) for e in exclude if isinstance(e, str)}
33+
)
34+
return snapshot.filter_traces(tuple(filters))
35+
36+
37+
def _get_overload(snapshot: Snapshot, threshold: float, key_type: str = "lineno") -> \
38+
List[Statistic]:
39+
"""
40+
Returns a list of statistics that exceed the given threshold in KiB.
41+
:param snapshot: snapshot to analyze
42+
:param threshold: threshold not to exceed (in KiB)
43+
:param key_type: key used to order the snapshot's statistics
44+
45+
:return: a list of statistics that exceed the given threshold
46+
"""
47+
stats = snapshot.statistics(key_type)
48+
return [stat for stat in stats if stat.size / 1024 > threshold]
49+
50+
51+
def _get_leaks(snapshot1: Snapshot, snapshot2: Snapshot, threshold: float,
52+
key_type: str = "lineno") -> List[StatisticDiff]:
53+
"""
54+
Returns a list of statistics that contains detected memory leaks above
55+
the given threshold in bytes.
56+
:param snapshot1: first snapshot (before the tested function execution)
57+
:param snapshot2: second snapshot (after the tested function execution)
58+
:param threshold: threshold to be exceeded to retain the leaks (in B)
59+
:param key_type: key used to order the snapshot's statistics
60+
:return: a list of statistics that contains detected memory leaks
61+
"""
62+
return [s for s in snapshot2.compare_to(snapshot1, key_type) if s.size_diff / 1024 >= threshold]
63+
64+
65+
def _get_failure_details(stats: List[Statistic]) -> str:
66+
"""
67+
Returns a string containing details about the given stats which led to a failure.
68+
:param stats: statistics to describe
69+
:return: a string containing details about the given stats
70+
which led to a failure
71+
"""
72+
details = "\n"
73+
for stat in stats:
74+
formatted_traceback = "\n".join(stat.traceback.format())
75+
details += "{} failed with size : [{} KiB]\n".format(formatted_traceback, stat.size / 1024)
76+
return details
77+
78+
79+
def _get_leaks_details(stats: List[StatisticDiff]) -> str:
80+
"""
81+
Returns a string containing details about the given stats which led to a failure.
82+
:param stats: statistics to describe
83+
:return: a string containing details about the given stats
84+
which led to a failure
85+
"""
86+
failed_sentence = "{} failed with diff : [{} B]\n"
87+
details = "\n"
88+
for stat in stats:
89+
formatted_traceback = "\n".join(stat.traceback.format())
90+
details += failed_sentence.format(formatted_traceback, stat.size_diff / 1024)
91+
return details
92+
93+
94+
def memory_not_exceed(threshold: float, exclude: ExcludeType = None) -> Callable:
95+
"""
96+
Tests that the memory taken up by the given function
97+
doesn't exceed the given threshold in KiB.
98+
:param threshold: threshold in KiB
99+
:param exclude: element(s) to be excluded from the snapshot
100+
:return: the memeory_not_exceed's decorator
101+
"""
102+
103+
def memory_not_exceed_decorator(func: Callable) -> Callable:
104+
"""
105+
memory_not_exceed's decorator.
106+
:param func: function to call
107+
:return: the memory_not_exceed's wrapper
108+
"""
109+
110+
@wraps(func)
111+
def memory_not_exceed_wrapper(*args) -> None:
112+
"""
113+
memory_not_exceed's wrapper.
114+
:param args: wrapper's arguments
115+
"""
116+
tracemalloc.start()
117+
func(*args)
118+
snapshot = tracemalloc.take_snapshot()
119+
tracemalloc.stop()
120+
snapshot = _filter_snapshot(snapshot, exclude=exclude)
121+
overload = _get_overload(snapshot, threshold=threshold)
122+
assert not overload, _get_failure_details(overload)
123+
124+
return memory_not_exceed_wrapper
125+
126+
return memory_not_exceed_decorator
127+
128+
129+
def memory_not_leak(threshold: float = 0, exclude: ExcludeType = None) -> Callable:
130+
"""
131+
Tests that the memory taken up by the given function
132+
doesn't leak.
133+
:param threshold: threshold in B
134+
:param exclude: element(s) to be excluded from the snapshot
135+
:return: the memory_not_leak's decorator
136+
"""
137+
138+
def memory_not_leak_decorator(func: Callable) -> Callable:
139+
"""
140+
memory_not_leak's decorator.
141+
:param func: function to call
142+
:return: the memory_not_leak's wrapper
143+
"""
144+
@wraps(func)
145+
def memory_not_leak_wrapper(*args) -> None:
146+
"""
147+
memory_not_leak's wrapper.
148+
:param args: wrapper's arguments
149+
"""
150+
tracemalloc.start()
151+
snapshot1 = tracemalloc.take_snapshot()
152+
func(*args)
153+
snapshot2 = tracemalloc.take_snapshot()
154+
tracemalloc.stop()
155+
snapshot1 = _filter_snapshot(snapshot1, exclude=exclude)
156+
snapshot2 = _filter_snapshot(snapshot2, exclude=exclude)
157+
leaks = _get_leaks(snapshot1, snapshot2, threshold)
158+
assert not leaks, _get_leaks_details(leaks)
159+
160+
return memory_not_leak_wrapper
161+
162+
return memory_not_leak_decorator

pyunitperf/tests/__init__.py

Whitespace-only changes.

0 commit comments

Comments
 (0)