Skip to content

Commit

Permalink
Initial commit
Browse files Browse the repository at this point in the history
  • Loading branch information
cool-RR committed Apr 20, 2019
0 parents commit cb2d96d
Show file tree
Hide file tree
Showing 10 changed files with 561 additions and 0 deletions.
7 changes: 7 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
*.pyc
*.pyo
__pycache__

.pytest_cache

*.wpu
21 changes: 21 additions & 0 deletions LICENSE
Original file line number Diff line number Diff line change
@@ -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 changes: 83 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -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.
21 changes: 21 additions & 0 deletions misc/IDE files/pysnooper.wpr
Original file line number Diff line number Diff line change
@@ -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 changes: 4 additions & 0 deletions pysnooper/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
# Copyright 2019 Ram Rachum.
# This program is distributed under the MIT license.

from .pysnooper import snoop
177 changes: 177 additions & 0 deletions pysnooper/pysnooper.py
Original file line number Diff line number Diff line change
@@ -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 changes: 32 additions & 0 deletions pysnooper/utils.py
Original file line number Diff line number Diff line change
@@ -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 changes: 32 additions & 0 deletions setup.py
Original file line number Diff line number Diff line change
@@ -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',
],

)
Empty file added tests/__init__.py
Empty file.
Loading

0 comments on commit cb2d96d

Please sign in to comment.