Skip to content

Commit 3b3cae9

Browse files
authored
Merge pull request #12 from eriknw/use_trace
Use trace
2 parents a0b923e + e487a89 commit 3b3cae9

File tree

8 files changed

+159
-32
lines changed

8 files changed

+159
-32
lines changed

.travis.yml

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,15 +8,20 @@ python:
88
- 3.7
99
- 3.8
1010
- "3.9-dev"
11+
- "pypy3.6-7.1.1"
1112

1213
install:
13-
- pip install toolz coverage pytest flake8 black
14+
- pip install --upgrade pip
15+
- pip install toolz coverage pytest
1416
- pip install -e .
1517

1618
script:
17-
- coverage run --branch -m pytest --doctest-modules
18-
- flake8
19-
- black innerscope --check --diff
19+
- coverage run --branch -m pytest --doctest-modules --method bytecode
20+
- coverage run -a --branch -m pytest --doctest-modules --method trace
21+
- pytest --doctest-modules --method trace
22+
- if [[ $TRAVIS_PYTHON_VERSION != pypy* ]] ; then pip install flake8 black ; fi
23+
- if [[ $TRAVIS_PYTHON_VERSION != pypy* ]] ; then flake8 ; fi
24+
- if [[ $TRAVIS_PYTHON_VERSION != pypy* ]] ; then black innerscope --check --diff ; fi
2025

2126
after_success:
2227
- coverage report --show-missing

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# Innerscope
22

3-
[![Python Version](https://img.shields.io/badge/python-3.6%20%7C%203.7%20%7C%203.8%20%7C%203.9-blue)](https://img.shields.io/badge/python-3.6%20%7C%203.7%20%7C%203.8%20%7C%203.9-blue)
3+
[![Python Version](https://img.shields.io/badge/python-3.6%20%7C%203.7%20%7C%203.8%20%7C%203.9%20%7C%20PyPy-blue)](https://img.shields.io/badge/python-3.6%20%7C%203.7%20%7C%203.8%20%7C%203.9%20%7C%20PyPy-blue)
44
[![Version](https://img.shields.io/pypi/v/innerscope.svg)](https://pypi.org/project/innerscope/)
55
[![License](https://img.shields.io/badge/License-BSD%203--Clause-blue.svg)](https://github.com/eriknw/innerscope/blob/master/LICENSE)
66
[![Build Status](https://travis-ci.org/eriknw/innerscope.svg?branch=master)](https://travis-ci.org/eriknw/innerscope)

conftest.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
def pytest_addoption(parser):
2+
parser.addoption(
3+
"--method",
4+
action="store",
5+
default="bytecode",
6+
help="Select the default method for obtaining the inner scope: bytecode or trace",
7+
)

innerscope/cfg.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
default_method = "bytecode"

innerscope/core.py

Lines changed: 92 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,11 @@
22
import dis
33
import functools
44
import inspect
5+
import sys
56
from collections.abc import Mapping
67
from types import CodeType, FunctionType
78
from tlz import concatv, merge
9+
from . import cfg
810

911
try:
1012
# Added in Python 3.8
@@ -350,9 +352,24 @@ class ScopedFunction:
350352
2
351353
"""
352354

353-
def __init__(self, func, *mappings, use_closures=True, use_globals=True):
355+
def __init__(self, func, *mappings, use_closures=True, use_globals=True, method="default"):
354356
self.use_closures = use_closures
355357
self.use_globals = use_globals
358+
if method == "default":
359+
method = cfg.default_method
360+
if method == "default":
361+
raise ValueError(
362+
'Who would set the default method to "default"? That\'s just silly!\n'
363+
'Please set ``innerscope.cfg.default_method`` back to "bytecode", '
364+
"and then please continue doing what you're doing, because it's probably "
365+
"something awesome :)"
366+
)
367+
if method not in {"bytecode", "trace"}:
368+
raise ValueError(
369+
'method= argument to ScopedFunc must be "bytecode", "trace", or "default". '
370+
f"Got {method!r}. Using the default method is recommended."
371+
)
372+
self.method = method
356373
if isinstance(func, ScopedFunction):
357374
self.func = func.func
358375
code = func.func.__code__
@@ -457,12 +474,42 @@ def __call__(self, *args, **kwargs):
457474
outer_scope["__builtins__"] = builtins
458475
outer_scope["_innerscope_locals_"] = locals
459476
outer_scope["_innerscope_secret_"] = secret = object()
477+
is_trace = self.method == "trace"
478+
if is_trace:
479+
code = self.func.__code__
480+
prev_trace = sys.gettrace()
481+
info = {}
482+
483+
# coverage uses sys.settrace, so I don't know how to cover this function
484+
def trace_func(frame, event, arg): # pragma: no cover
485+
if prev_trace is not None:
486+
prev = prev_trace(frame, event, arg)
487+
sys.settrace(trace_func)
488+
else:
489+
prev = None
490+
if event != "call" or frame.f_code is not func.__code__:
491+
return prev
492+
493+
def trace_returns(frame, event, arg):
494+
if event == "return":
495+
info["locals"] = frame.f_locals
496+
if prev is not None:
497+
prev(frame, event, arg)
498+
499+
frame.f_trace = trace_returns
500+
return trace_returns
501+
502+
else:
503+
code = self._code
504+
460505
func = FunctionType(
461-
self._code, outer_scope, argdefs=self.func.__defaults__, closure=self._closure
506+
code, outer_scope, argdefs=self.func.__defaults__, closure=self._closure
462507
)
463508
func.__kwdefaults__ = self.func.__kwdefaults__
464509
try:
465-
results = func(*args, **kwargs)
510+
if is_trace:
511+
sys.settrace(trace_func)
512+
return_value = func(*args, **kwargs)
466513
except UnboundLocalError as exc:
467514
message = exc.args and exc.args[0] or ""
468515
if message.startswith("local variable ") and message.endswith(
@@ -482,18 +529,25 @@ def __call__(self, *args, **kwargs):
482529
) from exc
483530
else:
484531
raise
485-
try:
486-
return_value, inner_scope, expect_secret = results
487-
if secret is expect_secret:
488-
del outer_scope["__builtins__"]
489-
del outer_scope["_innerscope_locals_"]
490-
del outer_scope["_innerscope_secret_"]
491-
# closures show up in locals, but we want them only in outer_scope
492-
for key in self._code.co_freevars:
493-
del inner_scope[key]
494-
return Scope(self, outer_scope, return_value, inner_scope)
495-
except Exception:
496-
pass
532+
finally:
533+
if is_trace:
534+
sys.settrace(prev_trace)
535+
if is_trace:
536+
inner_scope = info["locals"]
537+
else:
538+
try:
539+
return_value, inner_scope, expect_secret = return_value
540+
except Exception:
541+
expect_secret = None
542+
if is_trace or secret is expect_secret:
543+
del outer_scope["__builtins__"]
544+
del outer_scope["_innerscope_locals_"]
545+
del outer_scope["_innerscope_secret_"]
546+
# closures show up in locals, but we want them only in outer_scope
547+
for key in self._code.co_freevars:
548+
del inner_scope[key]
549+
return Scope(self, outer_scope, return_value, inner_scope)
550+
497551
return_indices = [
498552
inst.offset for inst in dis.get_instructions(self.func) if inst.opname == "RETURN_VALUE"
499553
]
@@ -523,7 +577,10 @@ def __call__(self, *args, **kwargs):
523577
f"\n\n{return_msg} The next closest return statement is {next_closest} bytes away."
524578
"\n\nA cute workaround is to add code such as `if bool(): return`."
525579
"\n\nIf it's important to you that this limitation is fixed, then please submit "
526-
"an issue (or a pull request!) to:\nhttps://github.com/eriknw/innerscope"
580+
"an issue (or a pull request!) to:\nhttps://github.com/eriknw/innerscope\n\n"
581+
'Another workaround is to use `method="trace"` in ScopedFunction. This will '
582+
"usually work, but it will be slower and may cause havoc if `sys.settrace` is "
583+
"used by anything else. The default method is preferred if possible."
527584
)
528585

529586
def bind(self, *mappings, **kwargs):
@@ -592,7 +649,7 @@ def _repr_html_(self):
592649
)
593650

594651

595-
def scoped_function(func=None, *mappings, use_closures=True, use_globals=True):
652+
def scoped_function(func=None, *mappings, use_closures=True, use_globals=True, method="default"):
596653
"""Use to expose the inner scope of a wrapped function after being called.
597654
598655
The wrapped function should have no return statements. Instead of a return value,
@@ -625,17 +682,32 @@ def scoped_function(func=None, *mappings, use_closures=True, use_globals=True):
625682

626683
def inner_scoped_func(func):
627684
return ScopedFunction(
628-
func, *mappings, use_closures=use_closures, use_globals=use_globals
685+
func,
686+
*mappings,
687+
use_closures=use_closures,
688+
use_globals=use_globals,
689+
method=method,
629690
)
630691

631692
return inner_scoped_func
632693

633694
if isinstance(func, Mapping):
634695
return scoped_function(
635-
None, func, *mappings, use_closures=use_closures, use_globals=use_globals
696+
None,
697+
func,
698+
*mappings,
699+
use_closures=use_closures,
700+
use_globals=use_globals,
701+
method=method,
636702
)
637703

638-
return ScopedFunction(func, *mappings, use_closures=use_closures, use_globals=use_globals)
704+
return ScopedFunction(
705+
func,
706+
*mappings,
707+
use_closures=use_closures,
708+
use_globals=use_globals,
709+
method=method,
710+
)
639711

640712

641713
def bindwith(*mappings, **kwargs):

innerscope/tests/conftest.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
import pytest
2+
3+
4+
def pytest_configure(config):
5+
from innerscope import cfg
6+
7+
cfg.default_method = config.getoption("--method", "bytecode")
8+
print(f'Running tests with "{cfg.default_method}" default method')

innerscope/tests/test_core.py

Lines changed: 40 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
import builtins
33
import innerscope
44
from pytest import raises
5-
from innerscope import scoped_function
5+
from innerscope import scoped_function, cfg
66

77
global_x = 1
88

@@ -298,8 +298,14 @@ def f1(arg):
298298
y = x
299299
return 2
300300

301-
with raises(ValueError, match="The first return statement is too far away"):
302-
f1(True)
301+
if cfg.default_method == 'bytecode':
302+
with raises(ValueError, match="The first return statement is too far away"):
303+
f1(True)
304+
if cfg.default_method == 'trace':
305+
scope = f1(True)
306+
assert scope == {'x': 1, 'arg': True}
307+
assert scope.return_value == 1
308+
303309
scope = f1(False)
304310
assert scope == {'arg': False, 'y': 1, 'x': 1}
305311
assert scope.return_value == 2
@@ -349,10 +355,18 @@ def f3(arg):
349355
y = x
350356
return 4
351357

352-
with raises(ValueError, match="The first 2 return statements are too far away"):
353-
f3(0)
354-
with raises(ValueError, match="The first 2 return statements are too far away"):
355-
f3(1)
358+
if cfg.default_method == 'bytecode':
359+
with raises(ValueError, match="The first 2 return statements are too far away"):
360+
f3(0)
361+
with raises(ValueError, match="The first 2 return statements are too far away"):
362+
f3(1)
363+
if cfg.default_method == 'trace':
364+
scope = f3(0)
365+
assert scope == {'arg': 0, 'x': 1}
366+
assert scope.return_value == 1
367+
scope = f3(1)
368+
assert scope == {'arg': 1, 'x': 1}
369+
assert scope.return_value == (1, 2, 3)
356370
scope = f3(2)
357371
assert scope == {'arg': 2, 'x': 1}
358372
assert scope.return_value == 3
@@ -470,3 +484,22 @@ def __init__(self):
470484
scope = scoped_f.bind(a=20, global_x=2)()
471485
assert scope["A"].x == 3
472486
assert scope["A"].z == 120
487+
488+
489+
def test_bad_method():
490+
def f():
491+
x = 1
492+
493+
with raises(ValueError, match="method= argument to ScopedFunc"):
494+
scoped_function(f, method="bad_method")
495+
old_default = cfg.default_method
496+
try:
497+
cfg.default_method = "bad_method"
498+
with raises(ValueError, match="method= argument to ScopedFunc"):
499+
scoped_function(f)
500+
cfg.default_method = "default"
501+
with raises(ValueError, match="silly"):
502+
scoped_function(f)
503+
finally:
504+
cfg.default_method = old_default
505+
assert innerscope.call(f) == {"x": 1}

setup.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,5 +32,6 @@
3232
"Programming Language :: Python :: 3.8",
3333
"Programming Language :: Python :: 3 :: Only",
3434
"Programming Language :: Python :: Implementation :: CPython",
35+
"Programming Language :: Python :: Implementation :: PyPy",
3536
],
3637
)

0 commit comments

Comments
 (0)