-
Notifications
You must be signed in to change notification settings - Fork 25
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge pull request #70 from life4/memtest
Memtest
- Loading branch information
Showing
12 changed files
with
319 additions
and
7 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,118 @@ | ||
# built-in | ||
from contextlib import suppress | ||
import sys | ||
from argparse import ArgumentParser | ||
from importlib import import_module | ||
from pathlib import Path | ||
from typing import Dict, Iterator, Sequence, TextIO | ||
|
||
# app | ||
from .._testing import cases, TestCase | ||
from .._mem_test import MemoryTracker | ||
from .._state import state | ||
from ..linter._extractors.pre import format_call_args | ||
from ._common import get_paths | ||
from .._colors import COLORS | ||
from ._test import sys_path, get_func_names | ||
|
||
|
||
def run_tests(path: Path, root: Path, count: int, stream: TextIO = sys.stdout) -> int: | ||
names = list(get_func_names(path=path)) | ||
if not names: | ||
return 0 | ||
print('{magenta}running {path}{end}'.format(path=path, **COLORS), file=stream) | ||
module_name = '.'.join(path.relative_to(root).with_suffix('').parts) | ||
with sys_path(path=root): | ||
module = import_module(module_name) | ||
failed = 0 | ||
for func_name in names: | ||
func = getattr(module, func_name) | ||
ok = run_cases( | ||
cases=cases(func=func, count=count, check_types=False), | ||
func_name=func_name, | ||
stream=stream, | ||
colors=COLORS, | ||
) | ||
if not ok: | ||
failed += 1 | ||
return failed | ||
|
||
|
||
def run_cases( | ||
cases: Iterator[TestCase], | ||
func_name: str, | ||
stream: TextIO, | ||
colors: Dict[str, str], | ||
) -> bool: | ||
print(' {blue}running {name}{end}'.format(name=func_name, **colors), file=stream) | ||
for case in cases: | ||
tracker = MemoryTracker() | ||
debug = state.debug | ||
state.disable() | ||
try: | ||
with tracker, suppress(Exception): | ||
case() | ||
finally: | ||
state.debug = debug | ||
if not tracker.diff: | ||
continue | ||
|
||
# show the diff and stop testing the func | ||
line = ' {yellow}{name}({args}){end}'.format( | ||
name=func_name, | ||
args=format_call_args(args=case.args, kwargs=case.kwargs), | ||
**colors, | ||
) | ||
print(line, file=stream) | ||
longest_name_len = max(len(name) for name in tracker.diff) | ||
for name, count in tracker.diff.items(): | ||
line = ' {red}{name}{end} x{count}'.format( | ||
name=name.ljust(longest_name_len), | ||
count=count, | ||
**colors, | ||
) | ||
print(line, file=stream) | ||
return False | ||
return True | ||
|
||
|
||
def memtest_command( | ||
argv: Sequence[str], root: Path = None, stream: TextIO = sys.stdout, | ||
) -> int: | ||
"""Generate and run tests against pure functions and report memory leaks. | ||
```bash | ||
python3 -m deal memtest project/ | ||
``` | ||
Function must be decorated by one of the following to be run: | ||
+ `@deal.pure` | ||
+ `@deal.has()` (without arguments) | ||
Options: | ||
+ `--count`: how many input values combinations should be checked. | ||
Exit code is equal to count of leaked functions. | ||
See [memory leaks][leaks] documentation for more details. | ||
[leaks]: https://deal.readthedocs.io/details/tests.html#memory-leaks | ||
""" | ||
if root is None: # pragma: no cover | ||
root = Path() | ||
parser = ArgumentParser(prog='python3 -m deal test') | ||
parser.add_argument('--count', type=int, default=50) | ||
parser.add_argument('paths', nargs='+') | ||
args = parser.parse_args(argv) | ||
|
||
failed = 0 | ||
for arg in args.paths: | ||
for path in get_paths(Path(arg)): | ||
failed += run_tests( | ||
path=Path(path), | ||
root=root, | ||
count=args.count, | ||
stream=stream, | ||
) | ||
return failed |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,32 @@ | ||
import gc | ||
import typing | ||
from collections import Counter | ||
from._cached_property import cached_property | ||
|
||
|
||
class MemoryTracker: | ||
before: typing.Counter[str] | ||
after: typing.Counter[str] | ||
|
||
def __init__(self) -> None: | ||
self.before = Counter() | ||
self.after = Counter() | ||
|
||
def __enter__(self) -> None: | ||
self.before = self._dump() | ||
|
||
def __exit__(self, *exc) -> None: | ||
self.after = self._dump() | ||
|
||
@cached_property | ||
def diff(self) -> typing.Counter[str]: | ||
return self.after - self.before - Counter({'weakref': 1}) | ||
|
||
@classmethod | ||
def _dump(cls) -> typing.Counter[str]: | ||
counter: typing.Counter[str] = Counter() | ||
gc.collect() | ||
for obj in gc.get_objects(): | ||
name: str = type(obj).__qualname__ | ||
counter[name] += 1 | ||
return counter |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,34 @@ | ||
# More about testing | ||
|
||
This section assumes that you're familiar with [basic testing](../basic/tests.md) and describes how you can get more from deal testing mechanisms. | ||
|
||
## Finding memory leaks | ||
|
||
Sometimes, when a function is completed, it leaves in memory other objects except result. For example: | ||
|
||
```python | ||
cache = {} | ||
User = dict | ||
|
||
def get_user(name: str) -> User: | ||
if name not in cache: | ||
cache[name] = User(name=name) | ||
return cache[name] | ||
``` | ||
|
||
Here, `get_user` creates a `User` object and stores it in a global cache. In this case, this "leak" is a desired behavior and we don't want to fight it. This is why we can't a tool (or something right in the Python interpreter) that catches and reports such behavior, it would have too many false-positives. | ||
|
||
However, things are different with pure functions. A pure function can't store anything on a side because it is a side effect. The result of a pure function is only what it returns. | ||
|
||
The command `memtest` uses this idea to find memory leaks in pure functions. How it works: | ||
|
||
1. It finds all pure functions (as `test` does). | ||
1. For every function: | ||
1. It makes memory snapshot before running the function. | ||
1. It runs the function with different autogenerated input arguments (as `test` command does) without running contracts and checking the return value type (to avoid side-effects from deal itself). | ||
1. It makes memory snapshot after running the function. | ||
1. Snapshots "before" and "after" are comapared. If there is a difference it will be printed. | ||
|
||
The return code is equal to the amount of functions with memory leaks. | ||
|
||
If the function fails, the command will ignore it and still test the function for leaks. Side-effects shouldn't happen unconditionally, even if the function fails. If you want to find unexpected failures, use `test` command instead. |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,80 @@ | ||
import sys | ||
from pathlib import Path | ||
from deal._cli._memtest import memtest_command | ||
from textwrap import dedent | ||
from io import StringIO | ||
|
||
|
||
def test_has_side_effect(tmp_path: Path, capsys): | ||
if 'example' in sys.modules: | ||
del sys.modules['example'] | ||
text = """ | ||
import deal | ||
a = [] | ||
@deal.pure | ||
def func(b: int) -> float: | ||
a.append({b, b+b}) | ||
return None | ||
""" | ||
path = (tmp_path / 'example.py') | ||
path.write_text(dedent(text)) | ||
stream = StringIO() | ||
result = memtest_command(['--count', '1', str(path)], root=tmp_path, stream=stream) | ||
assert result == 1 | ||
|
||
stream.seek(0) | ||
captured = stream.read() | ||
assert '/example.py' in captured | ||
assert 'running func' in captured | ||
assert 'func(b=0)' in captured | ||
assert 'set' in captured | ||
assert 'x1' in captured | ||
|
||
|
||
def test_no_side_effects(tmp_path: Path, capsys): | ||
if 'example' in sys.modules: | ||
del sys.modules['example'] | ||
text = """ | ||
import deal | ||
@deal.pure | ||
def func(b: int) -> float: | ||
return b+b | ||
""" | ||
path = (tmp_path / 'example.py') | ||
path.write_text(dedent(text)) | ||
stream = StringIO() | ||
result = memtest_command(['--count', '1', str(path)], root=tmp_path, stream=stream) | ||
assert result == 0 | ||
|
||
stream.seek(0) | ||
captured = stream.read() | ||
assert '/example.py' in captured | ||
assert 'running func' in captured | ||
assert 'func(b=0)' not in captured | ||
|
||
|
||
def test_no_matching_funcs(tmp_path: Path): | ||
if 'example' in sys.modules: | ||
del sys.modules['example'] | ||
text = """ | ||
import deal | ||
def not_pure1(a: int, b: int) -> float: | ||
return a / b | ||
@deal.post(lambda result: result > 0) | ||
def not_pure2(a: int, b: int) -> float: | ||
return a / b | ||
""" | ||
path = (tmp_path / 'example.py') | ||
path.write_text(dedent(text)) | ||
stream = StringIO() | ||
result = memtest_command([str(path)], root=tmp_path, stream=stream) | ||
assert result == 0 | ||
|
||
stream.seek(0) | ||
captured = stream.read() | ||
assert '/example.py' not in captured |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.