Initial commit

This commit is contained in:
Ram Rachum 2019-04-20 01:20:27 +03:00
commit cb2d96ddfa
10 changed files with 561 additions and 0 deletions

7
.gitignore vendored Normal file
View File

@ -0,0 +1,7 @@
*.pyc
*.pyo
__pycache__
.pytest_cache
*.wpu

21
LICENSE Normal file
View File

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2019 Ram Rachum
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

83
README.md Normal file
View File

@ -0,0 +1,83 @@
# PySnooper - Never use print for debugging again #
**PySnooper** is a poor man's debugger.
You're trying to figure out why your Python code isn't doing what you think it should be doing. You'd love to use a full-fledged debugger with breakpoints and watches, but you can't be bothered to set one up right now.
You're looking at a section of Python code. You want to know which lines are running and which aren't, and what the values of the local variables are.
Most people would use a `print` line. Probably several of them, in strategic locations, some of them showing the values of variables. Then they'd use the output of the prints to figure out which code ran when and what was in the variables.
**PySnooper** lets you do the same, except instead of carefully crafting the right `print` lines, you just add one decorator line to the function you're interested in. You'll get a play-by-play log of your function, including which lines ran and when, and exactly when local variables were changed.
What makes **PySnooper** stand out from all other code intelligence tools? You can use it in your shitty, sprawling enterprise codebase without having to do any setup. Just slap the decorator on, as shown below, and redirect the output to a dedicated log file by specifying its path as the first argument.
# Example #
We're writing a function that converts a number to binary, by returing a list of bits. Let's snoop on it by adding the `@pysnooper.snoop()` decorator:
import pysnooper
@pysnooper.snoop()
def number_to_bits(number):
if number:
bits = []
while number:
number, remainder = divmod(number, 2)
bits.insert(0, remainder)
return bits
else:
return [0]
number_to_bits(6)
The output to stderr is:
==> number = 6
00:24:15.284000 call 3 @pysnooper.snoop()
00:24:15.284000 line 5 if number:
00:24:15.284000 line 6 bits = []
==> bits = []
00:24:15.284000 line 7 while number:
00:24:15.284000 line 8 number, remainder = divmod(number, 2)
==> number = 3
==> remainder = 0
00:24:15.284000 line 9 bits.insert(0, remainder)
==> bits = [0]
00:24:15.284000 line 7 while number:
00:24:15.284000 line 8 number, remainder = divmod(number, 2)
==> number = 1
==> remainder = 1
00:24:15.284000 line 9 bits.insert(0, remainder)
==> bits = [1, 0]
00:24:15.284000 line 7 while number:
00:24:15.284000 line 8 number, remainder = divmod(number, 2)
==> number = 0
00:24:15.284000 line 9 bits.insert(0, remainder)
==> bits = [1, 1, 0]
00:24:15.284000 line 7 while number:
00:24:15.284000 line 10 return bits
00:24:15.284000 return 10 return bits
# Features #
If stderr is not easily accessible for you, you can redirect the output to a file easily:
@pysnooper.snoop('/my/log/file.log')
Want to see values of some variables that aren't local variables?
@pysnooper.snoop(variables=('foo.bar', 'self.whatever'))
# Installation #
Use `pip`:
pip install pysnooper
# Copyright #
Copyright (c) 2019 Ram Rachum, released under the MIT license.

View File

@ -0,0 +1,21 @@
#!wing
#!version=7.0
##################################################################
# Wing project file #
##################################################################
[project attributes]
proj.directory-list = [{'dirloc': loc('../..'),
'excludes': (),
'filter': u'*',
'include_hidden': False,
'recursive': True,
'watch_for_changes': True}]
proj.file-type = 'shared'
proj.home-dir = loc('../..')
proj.launch-config = {loc('../../../../../Dropbox/Scripts and shortcuts/_simplify3d_add_m600.py'): ('p'\
'roject',
(u'"C:\\Users\\Administrator\\Dropbox\\Desktop\\foo.gcode"',
''))}
testing.auto-test-file-specs = (('regex',
'pysnooper/tests.*/test[^./]*.py.?$'),)
testing.test-framework = {None: ':internal pytest'}

4
pysnooper/__init__.py Normal file
View File

@ -0,0 +1,4 @@
# Copyright 2019 Ram Rachum.
# This program is distributed under the MIT license.
from .pysnooper import snoop

177
pysnooper/pysnooper.py Normal file
View File

@ -0,0 +1,177 @@
# Copyright 2019 Ram Rachum.
# This program is distributed under the MIT license.
from __future__ import annotations
import sys
import os
import pathlib
import inspect
import types
import typing
import datetime as datetime_module
import re
import collections
import decorator
from . import utils
def get_write_function(output) -> typing.Callable:
if output is None:
def write(s):
stderr = sys.stderr
stderr.write(s)
stderr.write('\n')
elif isinstance(output, (os.PathLike, str)):
output_path = pathlib.Path(output)
def write(s):
with output_path.open('a') as output_file:
output_file.write(s)
output_file.write('\n')
else:
assert isinstance(output, utils.WritableStream)
def write(s):
output.write(s)
output.write('\n')
return write
class Tracer:
def __init__(self, target_code_object: types.CodeType, write: callable, *,
variables: typing.Sequence=()):
self.target_code_object = target_code_object
self.write = write
self.variables = variables
self.old_local_reprs = {}
self.local_reprs = {}
def __enter__(self):
self.original_trace_function = sys.gettrace()
sys.settrace(self.trace)
def __exit__(self, exc_type, exc_value, exc_traceback):
sys.settrace(self.original_trace_function)
def trace(self: Tracer, frame: types.FrameType, event: str,
arg: typing.Any) -> typing.Callable:
if frame.f_code != self.target_code_object:
return self.trace
self.old_local_reprs, self.local_reprs = \
self.local_reprs, get_local_reprs(frame, variables=self.variables)
modified_local_reprs = {
key: value for key, value in self.local_reprs.items()
if (key not in self.old_local_reprs) or
(self.old_local_reprs[key] != value)
}
for name, value_repr in modified_local_reprs.items():
self.write(f' ==> {name} = {value_repr}')
# x = repr((frame.f_code.co_stacksize, frame, event, arg))
now_string = datetime_module.datetime.now().time().isoformat()
source_line = get_source_from_frame(frame)[frame.f_lineno - 1]
self.write(f'{now_string} {event:9} '
f'{frame.f_lineno:4} {source_line}')
return self.trace
source_cache_by_module_name = {}
source_cache_by_file_name = {}
def get_source_from_frame(frame: types.FrameType) -> str:
module_name = frame.f_globals.get('__name__') or ''
if module_name:
try:
return source_cache_by_module_name[module_name]
except KeyError:
pass
file_name = frame.f_code.co_filename
if file_name:
try:
return source_cache_by_file_name[file_name]
except KeyError:
pass
function = frame.f_code.co_name
loader = frame.f_globals.get('__loader__')
source: typing.Union[None, str] = None
if hasattr(loader, 'get_source'):
try:
source = loader.get_source(module_name)
except ImportError:
pass
if source is not None:
source = source.splitlines()
if source is None:
try:
with open(file_name, 'rb') as fp:
source = fp.read().splitlines()
except (OSError, IOError):
pass
if source is None:
raise NotImplementedError
# If we just read the source from a file, or if the loader did not
# apply tokenize.detect_encoding to decode the source into a
# string, then we should do that ourselves.
if isinstance(source[0], bytes):
encoding = 'ascii'
for line in source[:2]:
# File coding may be specified. Match pattern from PEP-263
# (https://www.python.org/dev/peps/pep-0263/)
match = re.search(br'coding[:=]\s*([-\w.]+)', line)
if match:
encoding = match.group(1).decode('ascii')
break
source = [str(sline, encoding, 'replace') for sline in source]
if module_name:
source_cache_by_module_name[module_name] = source
if file_name:
source_cache_by_file_name[file_name] = source
return source
def get_local_reprs(frame: types.FrameType, *, variables: typing.Sequence=()) -> dict:
result = {}
for key, value in frame.f_locals.items():
try:
result[key] = get_shortish_repr(value)
except Exception:
continue
locals_and_globals = collections.ChainMap(frame.f_locals, frame.f_globals)
for variable in variables:
steps = variable.split('.')
step_iterator = iter(steps)
try:
current = locals_and_globals[next(step_iterator)]
for step in step_iterator:
current = getattr(current, step)
except (KeyError, AttributeError):
continue
try:
result[variable] = get_shortish_repr(current)
except Exception:
continue
return result
def get_shortish_repr(item) -> str:
r = repr(item)
if len(r) > 100:
r = f'{r[:97]}...'
return r
def snoop(output=None, *, variables=()) -> typing.Callable:
write = get_write_function(output)
@decorator.decorator
def decorate(function, *args, **kwargs) -> typing.Callable:
target_code_object = function.__code__
with Tracer(target_code_object, write, variables=variables):
return function(*args, **kwargs)
return decorate

32
pysnooper/utils.py Normal file
View File

@ -0,0 +1,32 @@
# Copyright 2019 Ram Rachum.
# This program is distributed under the MIT license.
import abc
import sys
def _check_methods(C, *methods):
mro = C.__mro__
for method in methods:
for B in mro:
if method in B.__dict__:
if B.__dict__[method] is None:
return NotImplemented
break
else:
return NotImplemented
return True
class WritableStream(metaclass=abc.ABCMeta):
@abc.abstractmethod
def write(self, s):
pass
@classmethod
def __subclasshook__(cls, C):
if cls is WritableStream:
return _check_methods(C, 'write')
return NotImplemented

32
setup.py Normal file
View File

@ -0,0 +1,32 @@
# Copyright 2019 Ram Rachum.
# This program is distributed under the MIT license.
import setuptools
with open('README.md', 'r') as readme_file:
long_description = readme_file.read()
setuptools.setup(
name='PySnooper',
version='0.0.1',
author='Ram Rachum',
author_email='ram@rachum.com',
description="A poor man's debugger for Python.",
long_description=long_description,
long_description_content_type='text/markdown',
url='https://github.com/cool-RR/PySnooper',
packages=setuptools.find_packages(),
install_requires=('decorator>=4.3.0',),
tests_require=(
'pytest>=4.4.1',
'python_toolbox>=0.9.3',
),
classifiers=[
'Programming Language :: Python :: 3.6',
'Programming Language :: Python :: 3.7',
'Programming Language :: Python :: 3.8',
'License :: OSI Approved :: MIT License',
'Operating System :: OS Independent',
],
)

0
tests/__init__.py Normal file
View File

184
tests/test_pysnooper.py Normal file
View File

@ -0,0 +1,184 @@
# Copyright 2019 Ram Rachum.
# This program is distributed under the MIT license.
import io
import re
import abc
from python_toolbox import caching
from python_toolbox import sys_tools
import pysnooper
class Entry(metaclass=abc.ABCMeta):
@abc.abstractmethod
def check(self, s: str) -> bool:
pass
class VariableEntry(Entry):
line_pattern = re.compile(
r"""^ ==> (?P<name>[^ ]*) = (?P<value>.*)$"""
)
def __init__(self, name=None, value=None, *,
name_regex=None, value_regex=None):
if name is not None:
assert name_regex is None
if value is not None:
assert value_regex is None
self.name = name
self.value = value
self.name_regex = (None if name_regex is None else
re.compile(name_regex))
self.value_regex = (None if value_regex is None else
re.compile(value_regex))
def _check_name(self, name: str) -> bool:
if self.name is not None:
return name == self.name
elif self.name_regex is not None:
return self.name_regex.fullmatch(name)
else:
return True
def _check_value(self, value: str) -> bool:
if self.value is not None:
return value == self.value
elif self.value_regex is not None:
return self.value_regex.fullmatch(value)
else:
return True
def check(self, s: str) -> bool:
match: re.Match = self.line_pattern.fullmatch(s)
if not match:
return False
name, value = match.groups()
return self._check_name(name) and self._check_value(value)
class EventEntry(Entry):
def __init__(self, source=None, *, source_regex=None):
if source is not None:
assert source_regex is None
self.source = source
self.source_regex = (None if source_regex is None else
re.compile(source_regex))
line_pattern = re.compile(
(r"""^[0-9:.]{15} (?P<event_name>[a-z]*) +"""
r"""(?P<line_number>[0-9]*) +(?P<source>.*)$""")
)
@caching.CachedProperty
def event_name(self):
return re.match('^[A-Z][a-z]*', type(self).__name__).group(0).lower()
def _check_source(self, source: str) -> bool:
if self.source is not None:
return source == self.source
elif self.source_regex is not None:
return self.source_regex.fullmatch(source)
else:
return True
def check(self, s: str) -> bool:
match: re.Match = self.line_pattern.fullmatch(s)
if not match:
return False
event_name, _, source = match.groups()
return event_name == self.event_name and self._check_source(source)
class CallEntry(EventEntry):
pass
class LineEntry(EventEntry):
pass
class ReturnEntry(EventEntry):
pass
class ExceptionEntry(EventEntry):
pass
class OpcodeEntry(EventEntry):
pass
def check_output(output, expected_entries):
lines = tuple(filter(None, output.split('\n')))
if len(lines) != len(expected_entries):
return False
return all(expected_entry.check(line) for
expected_entry, line in zip(expected_entries, lines))
def test_pysnooper():
string_io = io.StringIO()
@pysnooper.snoop(string_io)
def my_function(foo):
x = 7
y = 8
return y + x
result = my_function('baba')
assert result == 15
output = string_io.getvalue()
assert check_output(
output,
(
VariableEntry('foo', "'baba'"),
CallEntry(),
LineEntry('x = 7'),
VariableEntry('x', '7'),
LineEntry('y = 8'),
VariableEntry('y', '8'),
LineEntry('return y + x'),
ReturnEntry('return y + x'),
)
)
def test_variables():
class Foo:
def __init__(self):
self.x = 2
def square(self):
self.x **= 2
@pysnooper.snoop(variables=('foo.x', 're'))
def my_function():
foo = Foo()
for i in range(2):
foo.square()
with sys_tools.OutputCapturer(stdout=False,
stderr=True) as output_capturer:
result = my_function()
assert result is None
output = output_capturer.string_io.getvalue()
assert check_output(
output,
(
VariableEntry('Foo'),
VariableEntry('re'),
CallEntry(),
LineEntry('foo = Foo()'),
VariableEntry('foo'),
VariableEntry('foo.x', '2'),
LineEntry(),
VariableEntry('i', '0'),
LineEntry(),
VariableEntry('foo.x', '4'),
LineEntry(),
VariableEntry('i', '1'),
LineEntry(),
VariableEntry('foo.x', '16'),
LineEntry(),
ReturnEntry(),
)
)