diff --git a/.github/workflows/build_test.yaml b/.github/workflows/build_test.yaml new file mode 100644 index 0000000..1be36e4 --- /dev/null +++ b/.github/workflows/build_test.yaml @@ -0,0 +1,73 @@ +# This workflow will install Python dependencies, run tests and lint with a variety of Python versions +# For more information see: https://help.github.com/actions/language-and-framework-guides/using-python-with-github-actions + +name: build + +on: + push: + branches: [ master ] + pull_request: + branches: [ master ] + +jobs: + build: + strategy: + matrix: + python-version: [3.6, 3.7, 3.8, 3.9] + os: [ubuntu-latest, macos-latest, windows-latest] + runs-on: ${{ matrix.os }} + timeout-minutes: 30 + steps: + - uses: actions/checkout@v2 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v2 + with: + python-version: ${{ matrix.python-version }} + - name: Install minimal nightly + uses: actions-rs/toolchain@v1 + with: + profile: minimal + default: true + override: true + toolchain: nightly-2020-09-14 + - name: Install dependencies + if: matrix.os != 'windows-latest' + run: | + python -m pip install --upgrade pip + pip install flake8 setuptools wheel twine coverage + if [ -f requirements.txt ]; then pip install -r requirements.txt; fi + - name: Install dependencies on Windows + if: matrix.os == 'windows-latest' + run: | + python -m pip install --upgrade pip + pip install flake8 setuptools wheel twine coverage + if (Test-Path -Path '.\requirements.txt' -PathType Leaf) {pip install -r requirements.txt} + - name: Lint with flake8 + run: | + # stop the build if there are Python syntax errors or undefined names + flake8 src tests --count --select=E9,F63,F7,F82 --show-source --statistics + # exit-zero treats all errors as warnings. + flake8 src tests --exclude tests/data/ --count --exit-zero --statistic --ignore=E501,E122,E126,E127,E128,W503 + - name: Build dist and test with unittest + if: matrix.os != 'windows-latest' + run: | + python setup.py sdist bdist_wheel + pip install dist/*.whl + python -m unittest + - name: Build dist and test with unittest on Windows + if: matrix.os == 'windows-latest' + run: | + python setup.py sdist bdist_wheel + pip install (Get-ChildItem dist/*.whl) + python -m unittest + - name: Generate coverage report + run: | + coverage run --parallel-mode --pylib -m unittest + coverage combine + coverage xml -i --include=*watchpoints* --omit=*tests* + env: + COVERAGE_RUN: True + - name: Upload report to Codecov + uses: codecov/codecov-action@v1 + with: + file: ./coverage.xml \ No newline at end of file diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..ecb75c5 --- /dev/null +++ b/Makefile @@ -0,0 +1,33 @@ +refresh: clean build install lint + +build: + python setup.py build + +install: + python setup.py install + +build_dist: + make clean + python setup.py sdist bdist_wheel + pip install dist/*.whl + make test + +release: + python -m twine upload dist/* + +lint: + flake8 src tests --count --select=E9,F63,F7,F82 --show-source --statistics + flake8 src tests --exclude tests/data/ --count --exit-zero --statistic --ignore=E501,E122,E126,E127,E128,W503 + +test: + python -m unittest + +clean: + rm -rf __pycache__ + rm -rf tests/__pycache__ + rm -rf src/watchpoints/__pycache__ + rm -rf build + rm -rf dist + rm -rf watchpoints.egg-info + rm -rf src/watchpoints.egg-info + pip uninstall -y watchpoints diff --git a/NOTICE.txt b/NOTICE.txt new file mode 100644 index 0000000..2d10519 --- /dev/null +++ b/NOTICE.txt @@ -0,0 +1,13 @@ +Copyright 2020 Tian Gao + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. \ No newline at end of file diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..9c2c859 --- /dev/null +++ b/setup.py @@ -0,0 +1,34 @@ +import setuptools + +with open("README.md") as f: + long_description = f.read() + +with open("./src/watchpoints/__init__.py") as f: + for line in f.readlines(): + if line.startswith("__version__"): + # __version__ = "0.9" + delim = '"' if '"' in line else "'" + version = line.split(delim)[1] + break + else: + print("Can't find version! Stop Here!") + exit(1) + +setuptools.setup( + name="watchpoints", + version=version, + author="Tian Gao", + author_email="gaogaotiantian@hotmail.com", + description="watchpoints monitors read and write on variables", + long_description=long_description, + long_description_content_type="text/markdown", + url="https://github.com/gaogaotiantian/watchpoints", + packages=setuptools.find_packages("src"), + package_dir={"":"src"}, + classifiers=[ + "Development Status :: 3 - Alpha", + "Programming Language :: Python :: 3", + "License :: OSI Approved :: Apache Software License", + "Operating System :: OS Independent" + ], +) diff --git a/src/watchpoints/__init__.py b/src/watchpoints/__init__.py new file mode 100644 index 0000000..ef475b0 --- /dev/null +++ b/src/watchpoints/__init__.py @@ -0,0 +1,10 @@ +# Licensed under the Apache License: http://www.apache.org/licenses/LICENSE-2.0 +# For details: https://github.com/gaogaotiantian/watchpoints/blob/master/NOTICE.txt + + +from .watch import Watch + +__version__ = "0.0.1" + + +watch = Watch() diff --git a/src/watchpoints/ast_monkey.py b/src/watchpoints/ast_monkey.py new file mode 100644 index 0000000..5e5cca7 --- /dev/null +++ b/src/watchpoints/ast_monkey.py @@ -0,0 +1,39 @@ +# Licensed under the Apache License: http://www.apache.org/licenses/LICENSE-2.0 +# For details: https://github.com/gaogaotiantian/watchpoints/blob/master/NOTICE.txt + + +import ast +import copy + + +def to_store(node): + node.ctx = ast.Store() + return node + + +def ast_transform(node): + """ + :param ast.Node node: an ast node representing an expression of variable + + :return ast.Node: an ast node for ```var = transform(var)``` + """ + root = ast.Module( + body=[ + ast.Assign( + targets=[ + to_store(copy.deepcopy(node)) + ], + value=ast.Call( + func=ast.Name(id="_watch_transform", ctx=ast.Load()), + args=[ + node + ], + keywords=[] + ) + ) + ], + type_ignores=[] + ) + ast.fix_missing_locations(root) + + return root diff --git a/src/watchpoints/decorator.py b/src/watchpoints/decorator.py new file mode 100644 index 0000000..d02642f --- /dev/null +++ b/src/watchpoints/decorator.py @@ -0,0 +1,22 @@ +# Licensed under the Apache License: http://www.apache.org/licenses/LICENSE-2.0 +# For details: https://github.com/gaogaotiantian/watchpoints/blob/master/NOTICE.txt + + +import functools +import inspect + + +def add_callback(func): + + @functools.wraps(func) + def wrapper(self, *args, **kwargs): + if self._callback: + frame = inspect.currentframe().f_back + self._callback(frame, method=func, local_vars=locals(), when="pre", **self._callback_kwargs) + ret = func(self, *args, **kwargs) + if self._callback: + self._callback(frame, method=func, local_vars=locals(), when="post", **self._callback_kwargs) + + return ret + + return wrapper diff --git a/src/watchpoints/util.py b/src/watchpoints/util.py new file mode 100644 index 0000000..7e57e33 --- /dev/null +++ b/src/watchpoints/util.py @@ -0,0 +1,40 @@ +# Licensed under the Apache License: http://www.apache.org/licenses/LICENSE-2.0 +# For details: https://github.com/gaogaotiantian/watchpoints/blob/master/NOTICE.txt + + +import ast +from tokenize import generate_tokens, NEWLINE, INDENT, NL +from io import StringIO + + +def getline(frame): + """ + get the current logic line from the frame + """ + lineno = frame.f_lineno + filename = frame.f_code.co_filename + + with open(filename, "r") as f: + linesio = StringIO("".join(f.readlines()[lineno - 1:])) + lst = [] + code_string = "" + for toknum, tokval, _, _, _ in generate_tokens(linesio.readline): + if toknum == NEWLINE: + code_string = " ".join(lst) + break + elif toknum != INDENT and toknum != NL: + lst.append(tokval) + + return code_string + + +def getargnodes(frame): + """ + get the list of arguments of the current line function + """ + line = getline(frame) + try: + tree = ast.parse(line) + return tree.body[0].value.args + except Exception: + raise Exception("Unable to parse the line {}".format(line)) diff --git a/src/watchpoints/watch.py b/src/watchpoints/watch.py new file mode 100644 index 0000000..3d30988 --- /dev/null +++ b/src/watchpoints/watch.py @@ -0,0 +1,30 @@ +# Licensed under the Apache License: http://www.apache.org/licenses/LICENSE-2.0 +# For details: https://github.com/gaogaotiantian/watchpoints/blob/master/NOTICE.txt + + +import inspect +from .ast_monkey import ast_transform +from .util import getargnodes +from .watch_list import WatchList + + +class Watch: + def __call__(self, *args, **kwargs): + frame = inspect.currentframe().f_back + argnodes = getargnodes(frame) + if "alias" in kwargs: + self.alias = kwargs["alias"] + else: + self.alias = None + for node in argnodes: + self._instrument(frame, node) + + def _instrument(self, frame, node): + code = compile(ast_transform(node), "", "exec") + frame.f_locals["_watch_transform"] = self.transform + exec(code, {}, frame.f_locals) + frame.f_locals.pop("_watch_transform") + + def transform(self, val): + if type(val) is list: + return WatchList(val, alias=self.alias) diff --git a/src/watchpoints/watch_list.py b/src/watchpoints/watch_list.py new file mode 100644 index 0000000..76db039 --- /dev/null +++ b/src/watchpoints/watch_list.py @@ -0,0 +1,54 @@ +# Licensed under the Apache License: http://www.apache.org/licenses/LICENSE-2.0 +# For details: https://github.com/gaogaotiantian/watchpoints/blob/master/NOTICE.txt + + +from .decorator import add_callback +from .watch_print import WatchPrint + + +class WatchList(list): + def __init__(self, *args, **kwargs): + self._callback = WatchPrint("list") + self._callback_kwargs = {} + self._alias = kwargs.get("alias", None) + list.__init__(self, *args, **kwargs) + + @add_callback + def __setitem__(self, key, value): + list.__setitem__(self, key, value) + + @add_callback + def append(self, x): + list.append(self, x) + + @add_callback + def extend(self, iterable): + list.extend(self, iterable) + + @add_callback + def insert(self, i, x): + list.insert(self, i, x) + + @add_callback + def remove(self, x): + list.remove(self, x) + + @add_callback + def pop(self, i=-1): + return list.pop(self, i) + + @add_callback + def clear(self): + list.clear(self) + + @add_callback + def sort(self, *args, key=None, reverse=False): + list.sort(self, *args, key=key, reverse=reverse) + + @add_callback + def reverse(self): + list.reverse(self) + + def set_callback(self, cb, **kwargs): + self._callback = cb + self._callback_kwargs = kwargs diff --git a/src/watchpoints/watch_print.py b/src/watchpoints/watch_print.py new file mode 100644 index 0000000..b0bf4ae --- /dev/null +++ b/src/watchpoints/watch_print.py @@ -0,0 +1,57 @@ +# Licensed under the Apache License: http://www.apache.org/licenses/LICENSE-2.0 +# For details: https://github.com/gaogaotiantian/watchpoints/blob/master/NOTICE.txt + + +import sys + + +class WatchPrint: + def __init__(self, print_type, file=sys.stderr): + self.print_type = print_type + self.file = file + + def __call__(self, frame, method, local_vars, when): + if self.print_type == "list": + self._print_list(frame, method, local_vars, when) + + def _file_string(self, frame): + return f"{frame.f_code.co_name} ({frame.f_code.co_filename}:{frame.f_lineno}):" + + def _print_list(self, frame, method, local_vars, when): + if when == "pre": + self.p(self._file_string(frame)) + if local_vars['self']._alias: + self.p(f"{local_vars['self']._alias} = {local_vars['self']}") + else: + self.p(f"{local_vars['self']}") + + method_name = method.__name__ + args = local_vars['args'] + if method_name == "__setitem__": + self.p(f"setitem [{args[0]}] = {args[1]}") + elif method_name == "append": + self.p(f"append({args[0]})") + elif method_name == "extend": + self.p(f"extend({args[0]})") + elif method_name == "insert": + self.p(f"insert({args[0]}, {args[1]})") + elif method_name == "remove": + self.p(f"remove({args[0]})") + elif method_name == "pop": + if args: + self.p(f"pop({args[0]})") + else: + self.p("pop()") + elif method_name == "clear": + self.p("clear()") + elif method_name == "sort": + self.p("sort()") + elif method_name == "reverse": + self.p("reverse()") + + elif when == "post": + self.p(f"->{local_vars['self']}") + self.p("") + + def p(self, *objects): + print(*objects, sep=' ', end='\n', file=self.file, flush=False) diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..2004b6e --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1,2 @@ +# Licensed under the Apache License: http://www.apache.org/licenses/LICENSE-2.0 +# For details: https://github.com/gaogaotiantian/watchpoints/blob/master/NOTICE.txt diff --git a/tests/test_util.py b/tests/test_util.py new file mode 100644 index 0000000..a902c04 --- /dev/null +++ b/tests/test_util.py @@ -0,0 +1,24 @@ +# Licensed under the Apache License: http://www.apache.org/licenses/LICENSE-2.0 +# For details: https://github.com/gaogaotiantian/watchpoints/blob/master/NOTICE.txt + + +import unittest +import inspect +from watchpoints.util import getline + + +class TestUtil(unittest.TestCase): + def test_getline(self): + def watch(*args): + frame = inspect.currentframe().f_back + return getline(frame) + + a = [] + b = {} + line = watch(a) + self.assertEqual(line, "line = watch ( a )") + line = watch( + a, + b + ) + self.assertEqual(line, "line = watch ( a , b )") diff --git a/tests/test_watch_list.py b/tests/test_watch_list.py new file mode 100644 index 0000000..a777c05 --- /dev/null +++ b/tests/test_watch_list.py @@ -0,0 +1,55 @@ +# Licensed under the Apache License: http://www.apache.org/licenses/LICENSE-2.0 +# For details: https://github.com/gaogaotiantian/watchpoints/blob/master/NOTICE.txt + + +import unittest +from watchpoints.watch_list import WatchList + + +class TestWatchList(unittest.TestCase): + def test_setitem(self): + wl = WatchList([1, 2, 3]) + wl[0] = 2 + self.assertEqual(wl, [2, 2, 3]) + + def test_callback(self): + + def callback(frame, method, local_vars, when, arg): + arg["test"] += 1 + + counter = {"test": 0} + + wl = WatchList([1, 2, 3]) + wl.set_callback(callback, arg=counter) + wl[0] = 2 + wl.append(3) + wl.extend([4, 5]) + wl.insert(1, 2) + wl.remove(5) + elm = wl.pop() + self.assertEqual(elm, 4) + elm = wl.pop(0) + self.assertEqual(elm, 2) + wl.sort() + wl.reverse() + self.assertEqual(wl, [3, 3, 2, 2]) + wl.clear() + self.assertEqual(wl, []) + self.assertEqual(counter["test"], 20) + + def test_print(self): + wl = WatchList([1, 2, 3]) + wl[0] = 2 + wl.append(3) + wl.extend([4, 5]) + wl.insert(1, 2) + wl.remove(5) + elm = wl.pop() + self.assertEqual(elm, 4) + elm = wl.pop(0) + self.assertEqual(elm, 2) + wl.sort() + wl.reverse() + self.assertEqual(wl, [3, 3, 2, 2]) + wl.clear() + self.assertEqual(wl, [])