From 047ff68d7ff2ddccee6aad885262cfd59dfe9954 Mon Sep 17 00:00:00 2001 From: jelleas Date: Sun, 1 Oct 2017 13:17:12 +0200 Subject: [PATCH 001/269] python3 windows fix --- checkpy/tester.py | 25 +++++++++++++------------ 1 file changed, 13 insertions(+), 12 deletions(-) diff --git a/checkpy/tester.py b/checkpy/tester.py index 3de1d54..ac2bd83 100644 --- a/checkpy/tester.py +++ b/checkpy/tester.py @@ -25,7 +25,7 @@ def test(testName, module = ""): if testFilePath not in sys.path: sys.path.append(testFilePath) - return _runTests(importlib.import_module(testFileName[:-3]), os.path.join(filePath, fileName)) + return _runTests(testFileName[:-3], os.path.join(filePath, fileName)) def testModule(module): testNames = _getTestNames(module) @@ -66,10 +66,10 @@ def _getFilePath(completeFilePath): def _backslashToForwardslash(text): return re.sub("\\\\", "/", text) -def _runTests(module, fileName): +def _runTests(moduleName, fileName): signalQueue = multiprocessing.Queue() resultQueue = multiprocessing.Queue() - tester = _Tester(module, fileName, signalQueue, resultQueue) + tester = _Tester(moduleName, fileName, signalQueue, resultQueue) p = multiprocessing.Process(target=tester.run, name="Tester") p.start() @@ -116,29 +116,30 @@ def __init__(self, isTiming = False, resetTimer = False, description = None, tim self.timeout = timeout class _Tester(object): - def __init__(self, module, fileName, signalQueue, resultQueue): - self.module = module + def __init__(self, moduleName, fileName, signalQueue, resultQueue): + self.moduleName = moduleName self.fileName = fileName self.signalQueue = signalQueue self.resultQueue = resultQueue def run(self): + module = importlib.import_module(self.moduleName) result = TesterResult() - self.module._fileName = self.fileName + module._fileName = self.fileName self._sendSignal(_Signal(isTiming = False)) - result.addOutput(printer.displayTestName(os.path.basename(self.module._fileName))) + result.addOutput(printer.displayTestName(os.path.basename(module._fileName))) - if hasattr(self.module, "before"): + if hasattr(module, "before"): try: - self.module.before() + module.before() except Exception as e: result.addOutput(printer.displayError("Something went wrong at setup:\n{}".format(e))) return reservedNames = ["before", "after"] - testCreators = [method for method in self.module.__dict__.values() if callable(method) and method.__name__ not in reservedNames] + testCreators = [method for method in module.__dict__.values() if callable(method) and method.__name__ not in reservedNames] result.nTests = len(testCreators) @@ -151,9 +152,9 @@ def run(self): for testResult in testResults: result.addOutput(printer.display(testResult)) - if hasattr(self.module, "after"): + if hasattr(module, "after"): try: - self.module.after() + module.after() except Exception as e: result.addOutput(printer.displayError("Something went wrong at closing:\n{}".format(e))) From 38a3524d1ab921b4dbb98a53d7610fe1fea8a53d Mon Sep 17 00:00:00 2001 From: jelleas Date: Sun, 1 Oct 2017 13:38:22 +0200 Subject: [PATCH 002/269] fixes --- checkpy/__init__.py | 14 +++++++------- checkpy/__main__.py | 4 ++-- checkpy/downloader.py | 6 +++--- checkpy/lib.py | 4 ++-- checkpy/printer.py | 2 +- checkpy/tester.py | 4 ++-- checkpy/tests.py | 2 +- setup.py | 6 ++++-- 8 files changed, 22 insertions(+), 20 deletions(-) diff --git a/checkpy/__init__.py b/checkpy/__init__.py index 7ffcd8e..112e6cc 100644 --- a/checkpy/__init__.py +++ b/checkpy/__init__.py @@ -2,10 +2,10 @@ def testModule(moduleName): """ Test all files from module """ - import caches + import checkpy.caches as caches caches.clearAllCaches() - import tester - import downloader + import checkpy.tester as tester + import checkpy.downloader as downloader downloader.updateSilently() results = tester.testModule(moduleName) try: @@ -20,10 +20,10 @@ def test(fileName): """ Run tests for a single file """ - import caches + import checkpy.caches as caches caches.clearAllCaches() - import tester - import downloader + import checkpy.tester as tester + import checkpy.downloader as downloader downloader.updateSilently() result = tester.test(fileName) try: @@ -34,4 +34,4 @@ def test(fileName): pass return result -from downloader import download, update \ No newline at end of file +from checkpy.downloader import download, update \ No newline at end of file diff --git a/checkpy/__main__.py b/checkpy/__main__.py index f343c01..b4c5e86 100644 --- a/checkpy/__main__.py +++ b/checkpy/__main__.py @@ -1,8 +1,8 @@ import sys import os import argparse -import downloader -import tester +import checkpy.downloader as downloader +import checkpy.tester as tester import shutil import time diff --git a/checkpy/downloader.py b/checkpy/downloader.py index 8b82094..17f256f 100644 --- a/checkpy/downloader.py +++ b/checkpy/downloader.py @@ -4,9 +4,9 @@ import shutil import tinydb import time -import caches -import printer -import exception +import checkpy.caches as caches +import checkpy.printer as printer +import checkpy.exception as exception class Folder(object): def __init__(self, name, path): diff --git a/checkpy/lib.py b/checkpy/lib.py index ebe1b2f..6bb84fa 100644 --- a/checkpy/lib.py +++ b/checkpy/lib.py @@ -10,8 +10,8 @@ import importlib import imp import tokenize -import exception as excep -import caches +import checkpy.exception as excep +import checkpy.caches as caches @contextlib.contextmanager def _stdoutIO(stdout=None): diff --git a/checkpy/printer.py b/checkpy/printer.py index 057d245..a1eb74e 100644 --- a/checkpy/printer.py +++ b/checkpy/printer.py @@ -1,4 +1,4 @@ -import exception as excep +import checkpy.exception as excep import os import colorama colorama.init() diff --git a/checkpy/tester.py b/checkpy/tester.py index ac2bd83..00d9a75 100644 --- a/checkpy/tester.py +++ b/checkpy/tester.py @@ -1,5 +1,5 @@ -import printer -import caches +import checkpy.printer as printer +import checkpy.caches as caches import os import sys import importlib diff --git a/checkpy/tests.py b/checkpy/tests.py index d9aa81f..39c8a65 100644 --- a/checkpy/tests.py +++ b/checkpy/tests.py @@ -1,4 +1,4 @@ -import caches +import checkpy.caches as caches class Test(object): def __init__(self, priority): diff --git a/setup.py b/setup.py index b95809e..efbf8e0 100644 --- a/setup.py +++ b/setup.py @@ -11,7 +11,7 @@ setup( name='checkPy', - version='0.2.15', + version='0.3.1', description='A simple python testing framework for educational purposes', long_description=long_description, @@ -25,7 +25,7 @@ # See https://pypi.python.org/pypi?%3Aaction=list_classifiers classifiers=[ - 'Development Status :: 3 - Alpha', + 'Development Status :: 4 - Beta', 'Intended Audience :: Education', 'Topic :: Education :: Testing', @@ -34,6 +34,8 @@ 'Programming Language :: Python :: 2', 'Programming Language :: Python :: 2.7', + 'Programming Language :: Python :: 3', + 'Programming Language :: Python :: 3.6', ], keywords='new unexperienced programmers automatic testing minor programming', From 4ba6982b1273fdf3e6204ddd619ccbc6f3283efc Mon Sep 17 00:00:00 2001 From: jelleas Date: Sun, 1 Oct 2017 13:52:18 +0200 Subject: [PATCH 003/269] fix --- checkpy/assertlib.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/checkpy/assertlib.py b/checkpy/assertlib.py index d56e418..fa784ce 100644 --- a/checkpy/assertlib.py +++ b/checkpy/assertlib.py @@ -1,4 +1,4 @@ -import lib +import checkpy.lib as lib import re import os From e79690c043ae5d7055dafb25ea3631c2f2376b5a Mon Sep 17 00:00:00 2001 From: jelleas Date: Sun, 1 Oct 2017 14:01:28 +0200 Subject: [PATCH 004/269] fix --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index efbf8e0..ed65530 100644 --- a/setup.py +++ b/setup.py @@ -11,7 +11,7 @@ setup( name='checkPy', - version='0.3.1', + version='0.3.3', description='A simple python testing framework for educational purposes', long_description=long_description, From ac6794c4c0fa9c3d1c7fffbba17d09f0b775f3c8 Mon Sep 17 00:00:00 2001 From: jelleas Date: Sun, 1 Oct 2017 15:40:33 +0200 Subject: [PATCH 005/269] relative imports --- checkpy/__init__.py | 14 +++++++------- checkpy/__main__.py | 4 ++-- checkpy/assertlib.py | 2 +- checkpy/downloader.py | 6 +++--- checkpy/lib.py | 16 ++++++++-------- checkpy/printer.py | 4 ++-- checkpy/tester.py | 4 ++-- checkpy/tests.py | 2 +- setup.py | 2 +- 9 files changed, 27 insertions(+), 27 deletions(-) diff --git a/checkpy/__init__.py b/checkpy/__init__.py index 112e6cc..32b4070 100644 --- a/checkpy/__init__.py +++ b/checkpy/__init__.py @@ -2,10 +2,10 @@ def testModule(moduleName): """ Test all files from module """ - import checkpy.caches as caches + from . import caches caches.clearAllCaches() - import checkpy.tester as tester - import checkpy.downloader as downloader + from . import tester + from . import downloader downloader.updateSilently() results = tester.testModule(moduleName) try: @@ -20,10 +20,10 @@ def test(fileName): """ Run tests for a single file """ - import checkpy.caches as caches + from . import caches caches.clearAllCaches() - import checkpy.tester as tester - import checkpy.downloader as downloader + from . import tester + from . import downloader downloader.updateSilently() result = tester.test(fileName) try: @@ -34,4 +34,4 @@ def test(fileName): pass return result -from checkpy.downloader import download, update \ No newline at end of file +from .downloader import download, update \ No newline at end of file diff --git a/checkpy/__main__.py b/checkpy/__main__.py index b4c5e86..4706854 100644 --- a/checkpy/__main__.py +++ b/checkpy/__main__.py @@ -1,8 +1,8 @@ import sys import os import argparse -import checkpy.downloader as downloader -import checkpy.tester as tester +from . import downloader +from . import tester import shutil import time diff --git a/checkpy/assertlib.py b/checkpy/assertlib.py index fa784ce..f993534 100644 --- a/checkpy/assertlib.py +++ b/checkpy/assertlib.py @@ -1,4 +1,4 @@ -import checkpy.lib as lib +from . import lib import re import os diff --git a/checkpy/downloader.py b/checkpy/downloader.py index 17f256f..fbf78ea 100644 --- a/checkpy/downloader.py +++ b/checkpy/downloader.py @@ -4,9 +4,9 @@ import shutil import tinydb import time -import checkpy.caches as caches -import checkpy.printer as printer -import checkpy.exception as exception +from . import caches +from . import printer +from . import exception class Folder(object): def __init__(self, name, path): diff --git a/checkpy/lib.py b/checkpy/lib.py index 6bb84fa..a7e36a2 100644 --- a/checkpy/lib.py +++ b/checkpy/lib.py @@ -10,8 +10,8 @@ import importlib import imp import tokenize -import checkpy.exception as excep -import checkpy.caches as caches +from . import exception +from . import caches @contextlib.contextmanager def _stdoutIO(stdout=None): @@ -87,7 +87,7 @@ def module(fileName, src = None): def moduleAndOutputFromSource(fileName, source, stdinArgs = None): mod = None output = "" - exception = None + excep = None with _stdoutIO() as stdout, _stdinIO() as stdin: if stdinArgs: @@ -102,14 +102,14 @@ def moduleAndOutputFromSource(fileName, source, stdinArgs = None): sys.modules[moduleName] = mod except Exception as e: - exception = excep.SourceException(e, "while trying to import the code") + excep = exception.SourceException(e, "while trying to import the code") for name, func in [(name, f) for name, f in mod.__dict__.items() if callable(f)]: if func.__module__ == moduleName: setattr(mod, name, wrapFunctionWithExceptionHandler(func)) output = stdout.getvalue() - if exception: - raise exception + if excep: + raise excep return mod, output @@ -140,8 +140,8 @@ def exceptionWrapper(*args, **kwargs): argListRepr += ", {}={}".format(kwargName, kwargs[kwargName]) if not argListRepr: - raise excep.SourceException(e, "while trying to execute the function {}".format(func.__name__)) - raise excep.SourceException(e, "while trying to execute the function {} with arguments ({})".format(func.__name__, argListRepr)) + raise exception.SourceException(e, "while trying to execute the function {}".format(func.__name__)) + raise exception.SourceException(e, "while trying to execute the function {} with arguments ({})".format(func.__name__, argListRepr)) return exceptionWrapper def removeWhiteSpace(s): diff --git a/checkpy/printer.py b/checkpy/printer.py index a1eb74e..74c6ae1 100644 --- a/checkpy/printer.py +++ b/checkpy/printer.py @@ -1,4 +1,4 @@ -import checkpy.exception as excep +from . import exception import os import colorama colorama.init() @@ -55,6 +55,6 @@ def displayError(message): def _selectColorAndSmiley(testResult): if testResult.hasPassed: return _Colors.PASS, _Smileys.HAPPY - if type(testResult.message) is excep.SourceException: + if type(testResult.message) is exception.SourceException: return _Colors.WARNING, _Smileys.CONFUSED return _Colors.FAIL, _Smileys.SAD diff --git a/checkpy/tester.py b/checkpy/tester.py index 00d9a75..0d1527b 100644 --- a/checkpy/tester.py +++ b/checkpy/tester.py @@ -1,5 +1,5 @@ -import checkpy.printer as printer -import checkpy.caches as caches +from . import printer +from . import caches import os import sys import importlib diff --git a/checkpy/tests.py b/checkpy/tests.py index 39c8a65..36de19d 100644 --- a/checkpy/tests.py +++ b/checkpy/tests.py @@ -1,4 +1,4 @@ -import checkpy.caches as caches +from . import caches class Test(object): def __init__(self, priority): diff --git a/setup.py b/setup.py index ed65530..129ba93 100644 --- a/setup.py +++ b/setup.py @@ -11,7 +11,7 @@ setup( name='checkPy', - version='0.3.3', + version='0.3.4', description='A simple python testing framework for educational purposes', long_description=long_description, From 6347873146b98248f8cc5c271f42a50b0fc87dfb Mon Sep 17 00:00:00 2001 From: jelleas Date: Sun, 1 Oct 2017 17:55:25 +0200 Subject: [PATCH 006/269] 3.6 --- checkpy/tester.py | 3 +-- setup.py | 2 +- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/checkpy/tester.py b/checkpy/tester.py index 0d1527b..af402b4 100644 --- a/checkpy/tester.py +++ b/checkpy/tester.py @@ -36,12 +36,11 @@ def testModule(module): return [test(testName, module = module) for testName in testNames] - def _getTestNames(moduleName): moduleName = _backslashToForwardslash(moduleName) for (dirPath, dirNames, fileNames) in os.walk(os.path.join(HERE, "tests")): dirPath = _backslashToForwardslash(dirPath) - if moduleName in dirPath: + if moduleName in dirPath.split("/")[-1]: return [fileName[:-7] for fileName in fileNames if fileName.endswith(".py") and not fileName.startswith("_")] def _getTestDirPath(testFileName, module = ""): diff --git a/setup.py b/setup.py index 129ba93..db005bb 100644 --- a/setup.py +++ b/setup.py @@ -11,7 +11,7 @@ setup( name='checkPy', - version='0.3.4', + version='0.3.6', description='A simple python testing framework for educational purposes', long_description=long_description, From bc63237c51a4d1432e97da6296058627781f175f Mon Sep 17 00:00:00 2001 From: jelleas Date: Mon, 2 Oct 2017 13:47:11 +0200 Subject: [PATCH 007/269] bug fix py3 --- checkpy/lib.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/checkpy/lib.py b/checkpy/lib.py index a7e36a2..eedf93a 100644 --- a/checkpy/lib.py +++ b/checkpy/lib.py @@ -98,7 +98,12 @@ def moduleAndOutputFromSource(fileName, source, stdinArgs = None): moduleName = fileName[:-3] if fileName.endswith(".py") else fileName try: mod = imp.new_module(moduleName) - exec(source) in mod.__dict__ + # Python 3 + if sys.version_info > (3,0): + exec(source, mod.__dict__) + # Python 2 + else: + exec(source) in mod.__dict__ sys.modules[moduleName] = mod except Exception as e: From 9835446474460f2f2518950b88e4ea2b97ee34df Mon Sep 17 00:00:00 2001 From: jelleas Date: Mon, 2 Oct 2017 13:48:02 +0200 Subject: [PATCH 008/269] 3.7 --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index db005bb..3bc88b8 100644 --- a/setup.py +++ b/setup.py @@ -11,7 +11,7 @@ setup( name='checkPy', - version='0.3.6', + version='0.3.7', description='A simple python testing framework for educational purposes', long_description=long_description, From 5e6c2f4253b11e1cbf6897e9fcb57f22010934f8 Mon Sep 17 00:00:00 2001 From: jelleas Date: Thu, 5 Oct 2017 22:32:38 +0200 Subject: [PATCH 009/269] if name is main + exit() --- checkpy/exception.py | 13 ++++++++++--- checkpy/lib.py | 14 ++++++++++---- checkpy/tests.py | 2 ++ 3 files changed, 22 insertions(+), 7 deletions(-) diff --git a/checkpy/exception.py b/checkpy/exception.py index 6201011..7769ca0 100644 --- a/checkpy/exception.py +++ b/checkpy/exception.py @@ -1,15 +1,19 @@ import traceback class CheckpyError(Exception): - def __init__(self, exception = None, message = ""): + def __init__(self, exception = None, message = "", output = ""): self._exception = exception self._message = message + self._output = output + + def output(self): + return self._output def __str__(self): if self._exception: return "\"{}\" occured {}".format(repr(self._exception), self._message) return "{} -> {}".format(self.__class__.__name__, self._message) - + def __repr__(self): return self.__str__() @@ -17,4 +21,7 @@ class SourceException(CheckpyError): pass class DownloadError(CheckpyError): - pass \ No newline at end of file + pass + +class ExitError(CheckpyError): + pass diff --git a/checkpy/lib.py b/checkpy/lib.py index eedf93a..159979d 100644 --- a/checkpy/lib.py +++ b/checkpy/lib.py @@ -46,8 +46,8 @@ def outputOf(fileName, stdinArgs = ()): _, output = moduleAndOutputFromSource(fileName, source(fileName), stdinArgs = tuple(stdinArgs)) return output -def outputOfSource(fileName, source): - _, output = moduleAndOutputFromSource(fileName, source) +def outputOfSource(fileName, source, stdinArgs = ()): + _, output = moduleAndOutputFromSource(fileName, source, stdinArgs = tuple(stdinArgs)) return output def source(fileName): @@ -105,9 +105,15 @@ def moduleAndOutputFromSource(fileName, source, stdinArgs = None): else: exec(source) in mod.__dict__ sys.modules[moduleName] = mod - except Exception as e: - excep = exception.SourceException(e, "while trying to import the code") + excep = exception.SourceException( + exception = e, + message = "while trying to import the code", + output = stdout.getvalue()) + except SystemExit as e: + excep = exception.ExitError( + message = "exit({}) while trying to import the code".format(int(e.args[0])), + output = stdout.getvalue()) for name, func in [(name, f) for name, f in mod.__dict__.items() if callable(f)]: if func.__module__ == moduleName: diff --git a/checkpy/tests.py b/checkpy/tests.py index 36de19d..b06f86a 100644 --- a/checkpy/tests.py +++ b/checkpy/tests.py @@ -18,6 +18,8 @@ def run(self): hasPassed, info = result, "" except Exception as e: return TestResult(False, self.description(), self.exception(e)) + except SystemExit as e: + return TestResult(False, self.description(), self.exception(e)) return TestResult(hasPassed, self.description(), self.success(info) if hasPassed else self.fail(info)) From 4d35d6a2274d24ea1bece306a71559ae21627b53 Mon Sep 17 00:00:00 2001 From: jelleas Date: Thu, 5 Oct 2017 23:30:58 +0200 Subject: [PATCH 010/269] refactored previous fix --- checkpy/caches.py | 9 ++++++++- checkpy/lib.py | 48 ++++++++++++++++++++++++++++++++--------------- 2 files changed, 41 insertions(+), 16 deletions(-) diff --git a/checkpy/caches.py b/checkpy/caches.py index 39db273..b4c0429 100644 --- a/checkpy/caches.py +++ b/checkpy/caches.py @@ -37,7 +37,14 @@ def cachedFuncWrapper(*args, **kwargs): if keys: key = keys else: - key = args + tuple(kwargs.values()) + tuple(sys.argv) + # treat all collections in kwargs as tuples for hashing purposes + values = list(kwargs.values()) + for i in range(len(values)): + try: + values[i] = tuple(values[i]) + except TypeError: + pass + key = args + tuple(values) + tuple(sys.argv) if key not in localCache: localCache[key] = func(*args, **kwargs) diff --git a/checkpy/lib.py b/checkpy/lib.py index 159979d..4455860 100644 --- a/checkpy/lib.py +++ b/checkpy/lib.py @@ -42,14 +42,6 @@ def new_input(prompt = None): def getFunction(functionName, fileName): return getattr(module(fileName), functionName) -def outputOf(fileName, stdinArgs = ()): - _, output = moduleAndOutputFromSource(fileName, source(fileName), stdinArgs = tuple(stdinArgs)) - return output - -def outputOfSource(fileName, source, stdinArgs = ()): - _, output = moduleAndOutputFromSource(fileName, source, stdinArgs = tuple(stdinArgs)) - return output - def source(fileName): source = "" with open(fileName) as f: @@ -77,14 +69,33 @@ def sourceOfDefinitions(fileName): insideDefinition = False return newSource -def module(fileName, src = None): - if not src: - src = source(fileName) - mod, _ = moduleAndOutputFromSource(fileName, src) +def outputOf(*args, **kwargs): + _, output = moduleAndOutputOf(*args, **kwargs) + return output + +def module(*args, **kwargs): + mod, _ = moduleAndOutputOf(*args, **kwargs) return mod @caches.cache() -def moduleAndOutputFromSource(fileName, source, stdinArgs = None): +def moduleAndOutputOf( + fileName, + src = None, + stdinArgs = None, + ignoreExceptions = (), + overwriteAttributes = () + ): + """ + This function handles most of checkpy's under the hood functionality + fileName: the name of the file to run + source: the source code to be run + stdinArgs: optional arguments passed to stdin + ignoredExceptions: a collection of Exceptions that will silently pass + overwriteAttributes: a list of tuples [(attribute, value), ...] + """ + if src == None: + src = source(fileName) + mod = None output = "" excep = None @@ -98,13 +109,19 @@ def moduleAndOutputFromSource(fileName, source, stdinArgs = None): moduleName = fileName[:-3] if fileName.endswith(".py") else fileName try: mod = imp.new_module(moduleName) + + for attr, value in overwriteAttributes: + setattr(mod, attr, value) + # Python 3 if sys.version_info > (3,0): - exec(source, mod.__dict__) + exec(src, mod.__dict__) # Python 2 else: - exec(source) in mod.__dict__ + exec(src) in mod.__dict__ sys.modules[moduleName] = mod + except tuple(ignoreExceptions) as e: + pass except Exception as e: excep = exception.SourceException( exception = e, @@ -118,6 +135,7 @@ def moduleAndOutputFromSource(fileName, source, stdinArgs = None): for name, func in [(name, f) for name, f in mod.__dict__.items() if callable(f)]: if func.__module__ == moduleName: setattr(mod, name, wrapFunctionWithExceptionHandler(func)) + output = stdout.getvalue() if excep: raise excep From 4b6a1f9fe7e31b12e4f6cf14ea0f5e42ce16014a Mon Sep 17 00:00:00 2001 From: jelleas Date: Fri, 6 Oct 2017 00:43:52 +0200 Subject: [PATCH 011/269] added python version print in help --- .DS_Store | Bin 6148 -> 6148 bytes checkpy/.DS_Store | Bin 0 -> 6148 bytes checkpy/__main__.py | 13 +++++++++---- 3 files changed, 9 insertions(+), 4 deletions(-) create mode 100644 checkpy/.DS_Store diff --git a/.DS_Store b/.DS_Store index 5cb0c05dc42df1edc43884ca32b076ff0d671aa9..ed1f7e037d575cdbe4f85e6ee9ac7b89f4df96cd 100644 GIT binary patch delta 92 zcmZoMXfc@J&&abeU^g=(&tx8!^FqePraB5HwK@vb7Di?|3g(suli#wqaHbR|=OpFl c=P+)TXDw%(9L~~$U8U*dWcKdO>>Pjj0lDxRfdBvi delta 116 zcmZoMXfc@J&&a(oU^g=(_hcTH^FrnZItu0nwK@vb7M5l@3MS?jli#wqZ1!PIWn?@# zIfA7@Q=+=s*w|D@!Ppe6%p9b{#MrF1mV-lF)zH>6A-A%sx~8^n=H&k@@{F@4cd^M# Qp2}V{u|aY(JI7ys0EjFi^8f$< diff --git a/checkpy/.DS_Store b/checkpy/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..a29040b42f70cc4f36551f4c65419149aa40fa56 GIT binary patch literal 6148 zcmeHK%}T>S5Z>*NCWx4WsK>o{>!G#&9)u9pZvYb#gd{DJBRTByoyTkfS zq!VgNM2id{178{7vmc1T`~UIJ^S?+!gA5=8^T_~h^z5DmXH&g(=9IkGGSEIK7oJy2 moR)wiS~2AER=fc!1mcW4fTqGyB1Ay&kAR>74Knbj415A$LRN Date: Fri, 6 Oct 2017 01:05:05 +0200 Subject: [PATCH 012/269] checkpy version info in help --- checkpy/__main__.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/checkpy/__main__.py b/checkpy/__main__.py index 113ce45..38e6cdc 100644 --- a/checkpy/__main__.py +++ b/checkpy/__main__.py @@ -5,12 +5,16 @@ from . import tester import shutil import time +import pkg_resources def main(): parser = argparse.ArgumentParser( description = - "checkPy: a python testing framework for education. You are running Python version {}.{}.{}." - .format(*sys.version_info[:3]) + """ + checkPy: a python testing framework for education. + You are running Python version {}.{}.{} and checkpy version {}. + """ + .format(*sys.version_info[:3], pkg_resources.get_distribution("checkpy").version) ) parser.add_argument("-module", action="store", dest="module", help="provide a module name or path to run all tests from the module, or target a module for a specific test") From 71bd65f14ddd607c641ae4a7b801a3e3faa7adb7 Mon Sep 17 00:00:00 2001 From: jelleas Date: Mon, 9 Oct 2017 08:28:58 +0200 Subject: [PATCH 013/269] v0.3.8 --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 3bc88b8..74c82e0 100644 --- a/setup.py +++ b/setup.py @@ -11,7 +11,7 @@ setup( name='checkPy', - version='0.3.7', + version='0.3.8', description='A simple python testing framework for educational purposes', long_description=long_description, From d52f41179e7ed4a5bc4487094ddd882e5157e05b Mon Sep 17 00:00:00 2001 From: jelleas Date: Mon, 30 Oct 2017 10:03:05 +0100 Subject: [PATCH 014/269] python 2 bugfix --- checkpy/__main__.py | 2 +- monopoly.py | 27 +++++++++++++ monopolyData.py | 45 ++++++++++++++++++++++ monopolyVisualisation.py | 83 ++++++++++++++++++++++++++++++++++++++++ setup.py | 2 +- trump.py | 30 +++++++++++++++ 6 files changed, 187 insertions(+), 2 deletions(-) create mode 100644 monopoly.py create mode 100644 monopolyData.py create mode 100644 monopolyVisualisation.py create mode 100644 trump.py diff --git a/checkpy/__main__.py b/checkpy/__main__.py index 38e6cdc..3110a0a 100644 --- a/checkpy/__main__.py +++ b/checkpy/__main__.py @@ -14,7 +14,7 @@ def main(): checkPy: a python testing framework for education. You are running Python version {}.{}.{} and checkpy version {}. """ - .format(*sys.version_info[:3], pkg_resources.get_distribution("checkpy").version) + .format(sys.version_info[0], sys.version_info[1], sys.version_info[2], pkg_resources.get_distribution("checkpy").version) ) parser.add_argument("-module", action="store", dest="module", help="provide a module name or path to run all tests from the module, or target a module for a specific test") diff --git a/monopoly.py b/monopoly.py new file mode 100644 index 0000000..18cf065 --- /dev/null +++ b/monopoly.py @@ -0,0 +1,27 @@ +import monopolyVisualisation +import monopolyData + +class Piece(object): + def __init__(self): + self.location = 0 + + def move(self, distance): + self.location = (self.location + distance) % len(monopolyData.names) + +class Board(object): + def __init__(self): + self.names = monopolyData.names[:] + self.values = monopolyData.values[:] + +def draw(board, *pieces): + monopolyVisualisation.draw(board, pieces) + +if __name__ == "__main__": + import time + board = Board() + piece = Piece() + + for i in range(10): + draw(board, piece) + time.sleep(1) + piece.move(1) \ No newline at end of file diff --git a/monopolyData.py b/monopolyData.py new file mode 100644 index 0000000..136eff0 --- /dev/null +++ b/monopolyData.py @@ -0,0 +1,45 @@ +names = ["start",\ + "dorpstraat",\ + "algemeen fonds",\ + "brink",\ + "inkomstenbelasting",\ + "station zuid",\ + "steenstraat",\ + "kans",\ + "ketelstraat",\ + "velperplein",\ + "gevangenis",\ + "barteljorisstraat",\ + "elecriciteitsbedrijf",\ + "zijlweg",\ + "houtstraat",\ + "station west",\ + "neude",\ + "algemeen fonds",\ + "biltstraat",\ + "vreeburg",\ + "vrij parkeren",\ + "a-kerkhof",\ + "kans",\ + "groote markt",\ + "heerestraat",\ + "station noord",\ + "spui",\ + "plein",\ + "waterleiding",\ + "lange poten",\ + "naar de gevangenis",\ + "hofplein",\ + "blaak",\ + "algemeen fonds",\ + "coolsingel",\ + "station oost",\ + "kans",\ + "leidschestraat",\ + "extra belasting",\ + "kalverstraat"] + +values = [0, 60, 0, 60, 0, 200, 100, 0, 100, 120,\ + 0, 140, 120, 140, 160, 200, 180, 0, 180, 200,\ + 0, 220, 0, 220, 240, 200, 260, 260, 150, 280,\ + 0, 300, 300, 0, 320, 200, 0, 380, 0, 400] \ No newline at end of file diff --git a/monopolyVisualisation.py b/monopolyVisualisation.py new file mode 100644 index 0000000..093e298 --- /dev/null +++ b/monopolyVisualisation.py @@ -0,0 +1,83 @@ +import tkinter +import multiprocessing + +class MonopolyVisualisation(object): + def __init__(self, boardQueue): + self._boardQueue = boardQueue + self._updateTime = 100 + self._width = 450 + self._height = 450 + self._padding = 0.042 * self._width + + rowLength = 11 + self._cellWidth = (self._width - 2 * self._padding) / float(rowLength) + self._cellHeight = (self._height - 2 * self._padding) / float(rowLength) + + bottomRow = list(zip(\ + [self._padding + i * self._cellWidth for i in range(rowLength)],\ + [rowLength * self._cellHeight - self._padding] * rowLength + )) + bottomRow.reverse() + topRow = list(zip(\ + [self._padding + i * self._cellWidth for i in range(rowLength)],\ + [self._padding] * rowLength + )) + leftColumn = list(zip(\ + [self._padding] * (rowLength - 2),\ + [self._padding + self._cellHeight * i for i in range(1, rowLength - 1)] + )) + leftColumn.reverse() + rightColumn = list(zip(\ + [self._padding + (rowLength - 1) * self._cellWidth] * (rowLength - 2),\ + [self._padding + self._cellHeight * i for i in range(1, rowLength - 1)] + )) + self._cellPositions = bottomRow + leftColumn + topRow + rightColumn + + def run(self): + self._tk = tkinter.Tk() + self._canvas = tkinter.Canvas(self._tk, height=self._height, width=self._width) + self._canvas.pack() + + carImage = tkinter.PhotoImage(file="car.gif") + self._canvas.pawn = carImage + + backgroundImage = tkinter.PhotoImage(file="board.gif") + self._canvas.create_image(0, 0, image = backgroundImage, anchor = "nw") + self._canvas.background = backgroundImage + + self._tk.after(0, self._update) + self._tk.mainloop() + + def _reset(self): + self._canvas.delete("pawn") + + def _update(self): + if not self._boardQueue.empty(): + self._reset() + + board, pieces = self._boardQueue.get() + + for piece in pieces: + x0, y0 = self._cellPositions[piece.location] + self._canvas.create_image(x0 + 18, y0 + 18, image=self._canvas.pawn, tag="pawn") + + self._tk.after(self._updateTime, self._update) + +_boardQueue = None +_visualisationProcess = None +def draw(board, pieces): + global _boardQueue + global _visualisationProcess + + # if visualisation has not started, start + if not _boardQueue or not _visualisationProcess or not _visualisationProcess.is_alive(): + _boardQueue = multiprocessing.Queue() + _visualisationProcess = multiprocessing.Process(target=_visualize, args=(_boardQueue,), name="monopolyVisualisation") + _visualisationProcess.start() + + # add board to the "to be drawed" queue + _boardQueue.put((board, pieces)) + +def _visualize(boardQueue): + visualisation = MonopolyVisualisation(boardQueue) + visualisation.run() \ No newline at end of file diff --git a/setup.py b/setup.py index 74c82e0..29359c8 100644 --- a/setup.py +++ b/setup.py @@ -11,7 +11,7 @@ setup( name='checkPy', - version='0.3.8', + version='0.3.9', description='A simple python testing framework for educational purposes', long_description=long_description, diff --git a/trump.py b/trump.py new file mode 100644 index 0000000..c120f42 --- /dev/null +++ b/trump.py @@ -0,0 +1,30 @@ +import random +import monopoly + +def throw(): + return random.randint(1,6) + random.randint(1,6) + +def possession(board): + d = {} + for i in range(len(board.values)): + if board.values[i] > 0: + d[board.names[i]] = False + return d + +if __name__ == "__main__": + board = monopoly.Board() + piece = monopoly.Piece() + nTrials = 1000 + nThrows = 0 + + for i in range(nTrials): + d = possession(board) + while not all(d.values()): + piece.move(throw()) + name = board.names[piece.location] + if name in d and not d[name]: + d[name] = True + + nThrows += 1 + + print(nThrows // nTrials) \ No newline at end of file From 60a1e2e757c6004b5172c44cb5d84aa58d02e590 Mon Sep 17 00:00:00 2001 From: jelleas Date: Mon, 30 Oct 2017 10:03:26 +0100 Subject: [PATCH 015/269] - --- monopoly.py | 27 ------------- monopolyData.py | 45 ---------------------- monopolyVisualisation.py | 83 ---------------------------------------- trump.py | 30 --------------- 4 files changed, 185 deletions(-) delete mode 100644 monopoly.py delete mode 100644 monopolyData.py delete mode 100644 monopolyVisualisation.py delete mode 100644 trump.py diff --git a/monopoly.py b/monopoly.py deleted file mode 100644 index 18cf065..0000000 --- a/monopoly.py +++ /dev/null @@ -1,27 +0,0 @@ -import monopolyVisualisation -import monopolyData - -class Piece(object): - def __init__(self): - self.location = 0 - - def move(self, distance): - self.location = (self.location + distance) % len(monopolyData.names) - -class Board(object): - def __init__(self): - self.names = monopolyData.names[:] - self.values = monopolyData.values[:] - -def draw(board, *pieces): - monopolyVisualisation.draw(board, pieces) - -if __name__ == "__main__": - import time - board = Board() - piece = Piece() - - for i in range(10): - draw(board, piece) - time.sleep(1) - piece.move(1) \ No newline at end of file diff --git a/monopolyData.py b/monopolyData.py deleted file mode 100644 index 136eff0..0000000 --- a/monopolyData.py +++ /dev/null @@ -1,45 +0,0 @@ -names = ["start",\ - "dorpstraat",\ - "algemeen fonds",\ - "brink",\ - "inkomstenbelasting",\ - "station zuid",\ - "steenstraat",\ - "kans",\ - "ketelstraat",\ - "velperplein",\ - "gevangenis",\ - "barteljorisstraat",\ - "elecriciteitsbedrijf",\ - "zijlweg",\ - "houtstraat",\ - "station west",\ - "neude",\ - "algemeen fonds",\ - "biltstraat",\ - "vreeburg",\ - "vrij parkeren",\ - "a-kerkhof",\ - "kans",\ - "groote markt",\ - "heerestraat",\ - "station noord",\ - "spui",\ - "plein",\ - "waterleiding",\ - "lange poten",\ - "naar de gevangenis",\ - "hofplein",\ - "blaak",\ - "algemeen fonds",\ - "coolsingel",\ - "station oost",\ - "kans",\ - "leidschestraat",\ - "extra belasting",\ - "kalverstraat"] - -values = [0, 60, 0, 60, 0, 200, 100, 0, 100, 120,\ - 0, 140, 120, 140, 160, 200, 180, 0, 180, 200,\ - 0, 220, 0, 220, 240, 200, 260, 260, 150, 280,\ - 0, 300, 300, 0, 320, 200, 0, 380, 0, 400] \ No newline at end of file diff --git a/monopolyVisualisation.py b/monopolyVisualisation.py deleted file mode 100644 index 093e298..0000000 --- a/monopolyVisualisation.py +++ /dev/null @@ -1,83 +0,0 @@ -import tkinter -import multiprocessing - -class MonopolyVisualisation(object): - def __init__(self, boardQueue): - self._boardQueue = boardQueue - self._updateTime = 100 - self._width = 450 - self._height = 450 - self._padding = 0.042 * self._width - - rowLength = 11 - self._cellWidth = (self._width - 2 * self._padding) / float(rowLength) - self._cellHeight = (self._height - 2 * self._padding) / float(rowLength) - - bottomRow = list(zip(\ - [self._padding + i * self._cellWidth for i in range(rowLength)],\ - [rowLength * self._cellHeight - self._padding] * rowLength - )) - bottomRow.reverse() - topRow = list(zip(\ - [self._padding + i * self._cellWidth for i in range(rowLength)],\ - [self._padding] * rowLength - )) - leftColumn = list(zip(\ - [self._padding] * (rowLength - 2),\ - [self._padding + self._cellHeight * i for i in range(1, rowLength - 1)] - )) - leftColumn.reverse() - rightColumn = list(zip(\ - [self._padding + (rowLength - 1) * self._cellWidth] * (rowLength - 2),\ - [self._padding + self._cellHeight * i for i in range(1, rowLength - 1)] - )) - self._cellPositions = bottomRow + leftColumn + topRow + rightColumn - - def run(self): - self._tk = tkinter.Tk() - self._canvas = tkinter.Canvas(self._tk, height=self._height, width=self._width) - self._canvas.pack() - - carImage = tkinter.PhotoImage(file="car.gif") - self._canvas.pawn = carImage - - backgroundImage = tkinter.PhotoImage(file="board.gif") - self._canvas.create_image(0, 0, image = backgroundImage, anchor = "nw") - self._canvas.background = backgroundImage - - self._tk.after(0, self._update) - self._tk.mainloop() - - def _reset(self): - self._canvas.delete("pawn") - - def _update(self): - if not self._boardQueue.empty(): - self._reset() - - board, pieces = self._boardQueue.get() - - for piece in pieces: - x0, y0 = self._cellPositions[piece.location] - self._canvas.create_image(x0 + 18, y0 + 18, image=self._canvas.pawn, tag="pawn") - - self._tk.after(self._updateTime, self._update) - -_boardQueue = None -_visualisationProcess = None -def draw(board, pieces): - global _boardQueue - global _visualisationProcess - - # if visualisation has not started, start - if not _boardQueue or not _visualisationProcess or not _visualisationProcess.is_alive(): - _boardQueue = multiprocessing.Queue() - _visualisationProcess = multiprocessing.Process(target=_visualize, args=(_boardQueue,), name="monopolyVisualisation") - _visualisationProcess.start() - - # add board to the "to be drawed" queue - _boardQueue.put((board, pieces)) - -def _visualize(boardQueue): - visualisation = MonopolyVisualisation(boardQueue) - visualisation.run() \ No newline at end of file diff --git a/trump.py b/trump.py deleted file mode 100644 index c120f42..0000000 --- a/trump.py +++ /dev/null @@ -1,30 +0,0 @@ -import random -import monopoly - -def throw(): - return random.randint(1,6) + random.randint(1,6) - -def possession(board): - d = {} - for i in range(len(board.values)): - if board.values[i] > 0: - d[board.names[i]] = False - return d - -if __name__ == "__main__": - board = monopoly.Board() - piece = monopoly.Piece() - nTrials = 1000 - nThrows = 0 - - for i in range(nTrials): - d = possession(board) - while not all(d.values()): - piece.move(throw()) - name = board.names[piece.location] - if name in d and not d[name]: - d[name] = True - - nThrows += 1 - - print(nThrows // nTrials) \ No newline at end of file From 3c2cd02227fa92fe098c6a479258450756ac8b7c Mon Sep 17 00:00:00 2001 From: jelleas Date: Tue, 7 Nov 2017 23:11:17 +0100 Subject: [PATCH 016/269] devmode --- checkpy/__main__.py | 7 ++++--- checkpy/exception.py | 6 +++++- checkpy/lib.py | 7 +++++-- checkpy/printer.py | 6 ++++++ checkpy/tester.py | 18 +++++++++++------- checkpy/tests.py | 11 ++++++++--- 6 files changed, 39 insertions(+), 16 deletions(-) diff --git a/checkpy/__main__.py b/checkpy/__main__.py index 3110a0a..c70b86d 100644 --- a/checkpy/__main__.py +++ b/checkpy/__main__.py @@ -22,6 +22,7 @@ def main(): parser.add_argument("-update", action="store_true", help="update all downloaded tests and exit") parser.add_argument("-list", action="store_true", help="list all download locations and exit") parser.add_argument("-clean", action="store_true", help="remove all tests from the tests folder and exit") + parser.add_argument("-dev", action="store_true", help="get extra information to support the development of tests") parser.add_argument("file", action="store", nargs="?", help="name of file to be tested") args = parser.parse_args() @@ -47,13 +48,13 @@ def main(): if args.file and args.module: downloader.updateSilently() - tester.test(args.file, module = args.module) + tester.test(args.file, module = args.module, debugMode = args.dev) elif args.file and not args.module: downloader.updateSilently() - tester.test(args.file) + tester.test(args.file, debugMode = args.dev) elif not args.file and args.module: downloader.updateSilently() - tester.testModule(args.module) + tester.testModule(args.module, debugMode = args.dev) else: parser.print_help() return diff --git a/checkpy/exception.py b/checkpy/exception.py index 7769ca0..6f62215 100644 --- a/checkpy/exception.py +++ b/checkpy/exception.py @@ -1,14 +1,18 @@ import traceback class CheckpyError(Exception): - def __init__(self, exception = None, message = "", output = ""): + def __init__(self, exception = None, message = "", output = "", stacktrace = ""): self._exception = exception self._message = message self._output = output + self._stacktrace = stacktrace def output(self): return self._output + def stacktrace(self): + return self._stacktrace + def __str__(self): if self._exception: return "\"{}\" occured {}".format(repr(self._exception), self._message) diff --git a/checkpy/lib.py b/checkpy/lib.py index 4455860..b0d6867 100644 --- a/checkpy/lib.py +++ b/checkpy/lib.py @@ -10,6 +10,7 @@ import importlib import imp import tokenize +import traceback from . import exception from . import caches @@ -126,11 +127,13 @@ def moduleAndOutputOf( excep = exception.SourceException( exception = e, message = "while trying to import the code", - output = stdout.getvalue()) + output = stdout.getvalue(), + stacktrace = traceback.format_exc()) except SystemExit as e: excep = exception.ExitError( message = "exit({}) while trying to import the code".format(int(e.args[0])), - output = stdout.getvalue()) + output = stdout.getvalue(), + stacktrace = traceback.format_exc()) for name, func in [(name, f) for name, f in mod.__dict__.items() if callable(f)]: if func.__module__ == moduleName: diff --git a/checkpy/printer.py b/checkpy/printer.py index 74c6ae1..b420e39 100644 --- a/checkpy/printer.py +++ b/checkpy/printer.py @@ -3,6 +3,8 @@ import colorama colorama.init() +DEBUG_MODE = False + class _Colors: PASS = '\033[92m' WARNING = '\033[93m' @@ -20,6 +22,10 @@ def display(testResult): msg = "{}{} {}{}".format(color, smiley, testResult.description, _Colors.ENDC) if testResult.message: msg += "\n - {}".format(testResult.message) + + if DEBUG_MODE and testResult.exception: + msg += "\n {}".format(testResult.exception.stacktrace()) + print(msg) return msg diff --git a/checkpy/tester.py b/checkpy/tester.py index af402b4..c91e740 100644 --- a/checkpy/tester.py +++ b/checkpy/tester.py @@ -10,7 +10,7 @@ HERE = os.path.abspath(os.path.dirname(__file__)) -def test(testName, module = ""): +def test(testName, module = "", debugMode = False): fileName = _getFileName(testName) filePath = _getFilePath(testName) if filePath not in sys.path: @@ -25,16 +25,16 @@ def test(testName, module = ""): if testFilePath not in sys.path: sys.path.append(testFilePath) - return _runTests(testFileName[:-3], os.path.join(filePath, fileName)) + return _runTests(testFileName[:-3], os.path.join(filePath, fileName), debugMode = debugMode) -def testModule(module): +def testModule(module, debugMode = False): testNames = _getTestNames(module) if not testNames: printer.displayError("no tests found in module: {}".format(module)) return - return [test(testName, module = module) for testName in testNames] + return [test(testName, module = module, debugMode = debugMode) for testName in testNames] def _getTestNames(moduleName): moduleName = _backslashToForwardslash(moduleName) @@ -65,10 +65,10 @@ def _getFilePath(completeFilePath): def _backslashToForwardslash(text): return re.sub("\\\\", "/", text) -def _runTests(moduleName, fileName): +def _runTests(moduleName, fileName, debugMode = False): signalQueue = multiprocessing.Queue() resultQueue = multiprocessing.Queue() - tester = _Tester(moduleName, fileName, signalQueue, resultQueue) + tester = _Tester(moduleName, fileName, debugMode, signalQueue, resultQueue) p = multiprocessing.Process(target=tester.run, name="Tester") p.start() @@ -115,13 +115,17 @@ def __init__(self, isTiming = False, resetTimer = False, description = None, tim self.timeout = timeout class _Tester(object): - def __init__(self, moduleName, fileName, signalQueue, resultQueue): + def __init__(self, moduleName, fileName, debugMode, signalQueue, resultQueue): self.moduleName = moduleName self.fileName = fileName + self.debugMode = debugMode self.signalQueue = signalQueue self.resultQueue = resultQueue def run(self): + if self.debugMode: + printer.DEBUG_MODE = True + module = importlib.import_module(self.moduleName) result = TesterResult() diff --git a/checkpy/tests.py b/checkpy/tests.py index b06f86a..ff26da4 100644 --- a/checkpy/tests.py +++ b/checkpy/tests.py @@ -17,9 +17,9 @@ def run(self): else: hasPassed, info = result, "" except Exception as e: - return TestResult(False, self.description(), self.exception(e)) + return TestResult(False, self.description(), self.exception(e), exception = e) except SystemExit as e: - return TestResult(False, self.description(), self.exception(e)) + return TestResult(False, self.description(), self.exception(e), exception = e) return TestResult(hasPassed, self.description(), self.success(info) if hasPassed else self.fail(info)) @@ -53,10 +53,11 @@ def timeout(): class TestResult(object): - def __init__(self, hasPassed, description, message): + def __init__(self, hasPassed, description, message, exception = None): self._hasPassed = hasPassed self._description = description self._message = message + self._exception = exception @property def description(self): @@ -70,6 +71,10 @@ def message(self): def hasPassed(self): return self._hasPassed + @property + def exception(self): + return self._exception + def test(priority): def testDecorator(testCreator): @caches.cache(testCreator) From cc6c240539ce6363094779a724fc6182eaef2700 Mon Sep 17 00:00:00 2001 From: jelleas Date: Wed, 8 Nov 2017 12:46:38 +0100 Subject: [PATCH 017/269] use spawn for multiprocessing if possible --- .DS_Store | Bin 6148 -> 6148 bytes checkpy/.DS_Store | Bin 6148 -> 6148 bytes checkpy/downloader.py | 3 ++- checkpy/tester.py | 30 ++++++++++++++++++------------ 4 files changed, 20 insertions(+), 13 deletions(-) diff --git a/.DS_Store b/.DS_Store index ed1f7e037d575cdbe4f85e6ee9ac7b89f4df96cd..97dd8f813e95fbb670d4f6dfedc89f86c34c8ea2 100644 GIT binary patch delta 14 VcmZoMXffDe$;!yE*^2d)5C9<}1SS9g delta 14 VcmZoMXffDe$;!yM*^2d)5C9=41SbFh diff --git a/checkpy/.DS_Store b/checkpy/.DS_Store index a29040b42f70cc4f36551f4c65419149aa40fa56..c83a4ed1917a14ab6bf29122e354b73afaa48f00 100644 GIT binary patch delta 78 zcmZoMXfc@JFUrEez`)4BAi%(o$dJyEno^vcla#+%k$E|z3`mNdp_rkBA)lcLSvo(5 WVKNWXcSeTIEX*%hHnVg5Y>?i} I&heKY09DNnQUCw| diff --git a/checkpy/downloader.py b/checkpy/downloader.py index fbf78ea..18c51d4 100644 --- a/checkpy/downloader.py +++ b/checkpy/downloader.py @@ -159,7 +159,6 @@ def _newReleaseAvailable(githubUserName, githubRepoName): # unknown/new download if not _isKnownDownloadLocation(githubUserName, githubRepoName): return True - releaseJson = _getReleaseJson(githubUserName, githubRepoName) # new release id found @@ -209,6 +208,8 @@ def _download(githubUserName, githubRepoName): githubLink = "https://github.com/{}/{}".format(githubUserName, githubRepoName) zipLink = githubLink + "/archive/{}.zip".format(_releaseTag(githubUserName, githubRepoName)) + print(zipLink) + try: r = requests.get(zipLink) except requests.exceptions.ConnectionError as e: diff --git a/checkpy/tester.py b/checkpy/tester.py index c91e740..d4dd0ed 100644 --- a/checkpy/tester.py +++ b/checkpy/tester.py @@ -21,10 +21,10 @@ def test(testName, module = "", debugMode = False): if testFilePath is None: printer.displayError("No test found for {}".format(fileName)) return - + if testFilePath not in sys.path: sys.path.append(testFilePath) - + return _runTests(testFileName[:-3], os.path.join(filePath, fileName), debugMode = debugMode) def testModule(module, debugMode = False): @@ -55,7 +55,7 @@ def _getFileName(completeFilePath): if not fileName.endswith(".py"): fileName += ".py" return fileName - + def _getFilePath(completeFilePath): filePath = os.path.dirname(completeFilePath) if not filePath: @@ -66,15 +66,21 @@ def _backslashToForwardslash(text): return re.sub("\\\\", "/", text) def _runTests(moduleName, fileName, debugMode = False): - signalQueue = multiprocessing.Queue() - resultQueue = multiprocessing.Queue() + if sys.version_info > (3,4): + ctx = multiprocessing.get_context("spawn") + else: + ctx = multiprocessing + + signalQueue = ctx.Queue() + resultQueue = ctx.Queue() tester = _Tester(moduleName, fileName, debugMode, signalQueue, resultQueue) - p = multiprocessing.Process(target=tester.run, name="Tester") + p = ctx.Process(target=tester.run, name="Tester") p.start() + start = time.time() isTiming = False - + while p.is_alive(): while not signalQueue.empty(): signal = signalQueue.get() @@ -90,7 +96,7 @@ def _runTests(moduleName, fileName, debugMode = False): p.terminate() p.join() return result - + time.sleep(0.1) if not resultQueue.empty(): @@ -143,7 +149,7 @@ def run(self): reservedNames = ["before", "after"] testCreators = [method for method in module.__dict__.values() if callable(method) and method.__name__ not in reservedNames] - + result.nTests = len(testCreators) testResults = self._runTests(testCreators) @@ -168,13 +174,13 @@ def _runTests(self, testCreators): # run tests in noncolliding execution order for test in self._getTestsInExecutionOrder([tc() for tc in testCreators]): - self._sendSignal(_Signal(isTiming = True, resetTimer = True, description = test.description(), timeout = test.timeout())) + self._sendSignal(_Signal(isTiming = True, resetTimer = True, description = test.description(), timeout = test.timeout())) cachedResults[test] = test.run() self._sendSignal(_Signal(isTiming = False)) # return test results in specified order return [cachedResults[test] for test in sorted(cachedResults.keys()) if cachedResults[test] != None] - + def _sendResult(self, result): self.resultQueue.put(result) @@ -186,4 +192,4 @@ def _getTestsInExecutionOrder(self, tests): for i, test in enumerate(tests): dependencies = self._getTestsInExecutionOrder([tc() for tc in test.dependencies()]) + [test] testsInExecutionOrder.extend([t for t in dependencies if t not in testsInExecutionOrder]) - return testsInExecutionOrder \ No newline at end of file + return testsInExecutionOrder From c56353928899d4fb52bf39076080bbeaaecbdbae Mon Sep 17 00:00:00 2001 From: jelleas Date: Wed, 8 Nov 2017 12:47:32 +0100 Subject: [PATCH 018/269] remove debug print --- checkpy/downloader.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/checkpy/downloader.py b/checkpy/downloader.py index 18c51d4..d138b85 100644 --- a/checkpy/downloader.py +++ b/checkpy/downloader.py @@ -208,8 +208,6 @@ def _download(githubUserName, githubRepoName): githubLink = "https://github.com/{}/{}".format(githubUserName, githubRepoName) zipLink = githubLink + "/archive/{}.zip".format(_releaseTag(githubUserName, githubRepoName)) - print(zipLink) - try: r = requests.get(zipLink) except requests.exceptions.ConnectionError as e: From 319cc7fff8e640d339a28f29ab85845515219715 Mon Sep 17 00:00:00 2001 From: jelleas Date: Wed, 8 Nov 2017 15:34:37 +0100 Subject: [PATCH 019/269] readme update --- README.rst | 41 +++++++++++++++++++++++++---------------- 1 file changed, 25 insertions(+), 16 deletions(-) diff --git a/README.rst b/README.rst index 5211387..6497f6b 100644 --- a/README.rst +++ b/README.rst @@ -23,28 +23,33 @@ Usage ----- :: + usage: __main__.py [-h] [-module MODULE] [-download GITHUBLINK] [-update] + [-list] [-clean] [-dev] + [file] - usage: checkpy [-h] [-m MODULE] [-d GITHUBLINK] [-clean] [file] - - checkPy: a simple python testing framework + checkPy: a python testing framework for education. You are running Python + version 3.6.2 and checkpy version 0.3.7. positional arguments: - file name of file to be tested + file name of file to be tested optional arguments: - -h, --help show this help message and exit - -m MODULE provide a module name or path to run all tests from the - module, or target a module for a specific test - -d GITHUBLINK download tests from a Github repository and exit - -clean remove all tests from the tests folder and exit - + -h, --help show this help message and exit + -module MODULE provide a module name or path to run all tests from + the module, or target a module for a specific test + -download GITHUBLINK download tests from a Github repository and exit + -update update all downloaded tests and exit + -list list all download locations and exit + -clean remove all tests from the tests folder and exit + -dev get extra information to support the development of + tests To simply test a single file, call: :: checkpy YOUR_FILE_NAME - + If you are unsure whether multiple tests exist with the same name, you can target a specific test by specifying its module: :: @@ -71,8 +76,11 @@ Features wrapped by ``if __name__ == "__main__"`` - Support for overriding functions from imports in order to for instance prevent blocking function calls -- Support for grouping tests in modules, +- Support for grouping tests in modules, allowing the user to target tests from a specific module or run all tests in a module with a single command. +- No infinite loops, automatically kills tests after a user defined timeout. +- Tests are kept up to date as checkpy will periodically look for updates from the downloaded test repos. + An example ---------- @@ -157,13 +165,14 @@ Effectively all other parts of code are wrapped by provides a collection of assertions that one may find usefull when implementing tests. -For inspiration inspect some existing collections of tests like the tests for `progNS2016 `__. +For inspiration inspect some existing collections like the tests for `progNS2016 `__, `progIK `__, `Semester of Code `__ or `ProgBG `__. Distributing tests ------------------ -CheckPy can download tests directly from Github repos. -The only requirement is that a folder called ``tests`` exists within the repo that contains only tests and folders (which checkpy treats as modules). +CheckPy can download tests directly from Github repos. +The requirement is that a folder called ``tests`` exists within the repo that contains only tests and folders (which checkpy treats as modules). +There must also be at least one release in the Github repo. Checkpy will automatically target the latest release. Simply call checkPy with the optional ``-d`` argument and pass your github repo url. -Tests will then be automatically downloaded and installed. +Tests will then be automatically downloaded and installed. From 64ea0f4161321a415ac923a7f67c0a030fe48b43 Mon Sep 17 00:00:00 2001 From: jelleas Date: Wed, 8 Nov 2017 15:36:53 +0100 Subject: [PATCH 020/269] readme update --- README.rst | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/README.rst b/README.rst index 6497f6b..d563f61 100644 --- a/README.rst +++ b/README.rst @@ -158,14 +158,12 @@ take a closer look at ``lib.py`` and ``assertlib.py``. ``lib.py`` provides a collection of useful functions to help implement tests. Most notably ``getFunction`` and ``outputOf``. These provide the tester with a function from the source file and the complete print output -respectively. Calling ``getFunction`` makes checkPy evaluate only import -statements and code inside definitions of the to be tested file. -Effectively all other parts of code are wrapped by -``if __name__ == "__main__"`` and thus ignored. ``assertlib.py`` -provides a collection of assertions that one may find usefull when +respectively. Calling ``getFunction`` has checkpy import the to be +tested code and retrieves only said function from the resulting module. +``assertlib.py`` provides a collection of assertions that one may find usefull when implementing tests. -For inspiration inspect some existing collections like the tests for `progNS2016 `__, `progIK `__, `Semester of Code `__ or `ProgBG `__. +For inspiration inspect some existing collections like the tests for `progNS `__, `progIK `__, `Semester of Code `__ or `ProgBG `__. Distributing tests From da0f198355862e1a1131368e402195143aea576b Mon Sep 17 00:00:00 2001 From: jelleas Date: Wed, 8 Nov 2017 15:37:39 +0100 Subject: [PATCH 021/269] typos --- README.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.rst b/README.rst index d563f61..9f76f3d 100644 --- a/README.rst +++ b/README.rst @@ -160,10 +160,10 @@ notably ``getFunction`` and ``outputOf``. These provide the tester with a function from the source file and the complete print output respectively. Calling ``getFunction`` has checkpy import the to be tested code and retrieves only said function from the resulting module. -``assertlib.py`` provides a collection of assertions that one may find usefull when +``assertlib.py`` provides a collection of assertions that one may find useful when implementing tests. -For inspiration inspect some existing collections like the tests for `progNS `__, `progIK `__, `Semester of Code `__ or `ProgBG `__. +For inspiration inspect some existing collections like the tests for `progNS `__, `progIK `__, `Semester of Code `__ or `progBG `__. Distributing tests From a6c6f3766104c77a6e178674a15178377d64792c Mon Sep 17 00:00:00 2001 From: jelleas Date: Wed, 8 Nov 2017 15:44:24 +0100 Subject: [PATCH 022/269] bugfix readme --- README.rst | 1 + 1 file changed, 1 insertion(+) diff --git a/README.rst b/README.rst index 9f76f3d..12ef07a 100644 --- a/README.rst +++ b/README.rst @@ -23,6 +23,7 @@ Usage ----- :: + usage: __main__.py [-h] [-module MODULE] [-download GITHUBLINK] [-update] [-list] [-clean] [-dev] [file] From 7403acca942537468da7ea573eb707a7590c2676 Mon Sep 17 00:00:00 2001 From: jelleas Date: Thu, 9 Nov 2017 13:27:17 +0100 Subject: [PATCH 023/269] also use spawn in 3.4 --- checkpy/tester.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/checkpy/tester.py b/checkpy/tester.py index d4dd0ed..a2061bb 100644 --- a/checkpy/tester.py +++ b/checkpy/tester.py @@ -66,7 +66,7 @@ def _backslashToForwardslash(text): return re.sub("\\\\", "/", text) def _runTests(moduleName, fileName, debugMode = False): - if sys.version_info > (3,4): + if sys.version_info >= (3,4): ctx = multiprocessing.get_context("spawn") else: ctx = multiprocessing From 9f73b9e352696fa5c9d429a3df856cb458073dd4 Mon Sep 17 00:00:00 2001 From: jelleas Date: Thu, 9 Nov 2017 13:28:23 +0100 Subject: [PATCH 024/269] 3.4* --- checkpy/tester.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/checkpy/tester.py b/checkpy/tester.py index a2061bb..6a97f64 100644 --- a/checkpy/tester.py +++ b/checkpy/tester.py @@ -66,7 +66,7 @@ def _backslashToForwardslash(text): return re.sub("\\\\", "/", text) def _runTests(moduleName, fileName, debugMode = False): - if sys.version_info >= (3,4): + if sys.version_info[:2] >= (3,4): ctx = multiprocessing.get_context("spawn") else: ctx = multiprocessing From 594d0ab5ab7a202d2cd6ea710c6d73a83fe5501b Mon Sep 17 00:00:00 2001 From: jelleas Date: Thu, 9 Nov 2017 21:42:05 +0100 Subject: [PATCH 025/269] --dev --- checkpy/__main__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/checkpy/__main__.py b/checkpy/__main__.py index c70b86d..8dd0389 100644 --- a/checkpy/__main__.py +++ b/checkpy/__main__.py @@ -22,7 +22,7 @@ def main(): parser.add_argument("-update", action="store_true", help="update all downloaded tests and exit") parser.add_argument("-list", action="store_true", help="list all download locations and exit") parser.add_argument("-clean", action="store_true", help="remove all tests from the tests folder and exit") - parser.add_argument("-dev", action="store_true", help="get extra information to support the development of tests") + parser.add_argument("--dev", action="store_true", help="get extra information to support the development of tests") parser.add_argument("file", action="store", nargs="?", help="name of file to be tested") args = parser.parse_args() From 674752108ab0bfce5a58da4e02c3d9c6deb52fa1 Mon Sep 17 00:00:00 2001 From: jelleas Date: Thu, 9 Nov 2017 21:42:22 +0100 Subject: [PATCH 026/269] --dev readme --- README.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.rst b/README.rst index 12ef07a..897b86b 100644 --- a/README.rst +++ b/README.rst @@ -42,7 +42,7 @@ Usage -update update all downloaded tests and exit -list list all download locations and exit -clean remove all tests from the tests folder and exit - -dev get extra information to support the development of + --dev get extra information to support the development of tests To simply test a single file, call: From 186ec429ed5e13c5c8b9577d55aa2c554c2be34f Mon Sep 17 00:00:00 2001 From: jelleas Date: Thu, 9 Nov 2017 21:53:02 +0100 Subject: [PATCH 027/269] only catch checkpy exceptions --- README.rst | 2 +- checkpy/tests.py | 5 ++--- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/README.rst b/README.rst index 897b86b..0bfde09 100644 --- a/README.rst +++ b/README.rst @@ -42,7 +42,7 @@ Usage -update update all downloaded tests and exit -list list all download locations and exit -clean remove all tests from the tests folder and exit - --dev get extra information to support the development of + --dev get extra information to support the development of tests To simply test a single file, call: diff --git a/checkpy/tests.py b/checkpy/tests.py index ff26da4..8836c7e 100644 --- a/checkpy/tests.py +++ b/checkpy/tests.py @@ -1,4 +1,5 @@ from . import caches +from . import exception class Test(object): def __init__(self, priority): @@ -16,9 +17,7 @@ def run(self): hasPassed, info = result else: hasPassed, info = result, "" - except Exception as e: - return TestResult(False, self.description(), self.exception(e), exception = e) - except SystemExit as e: + except (exception.SourceException, exception.ExitError) as e: return TestResult(False, self.description(), self.exception(e), exception = e) return TestResult(hasPassed, self.description(), self.success(info) if hasPassed else self.fail(info)) From aa795526955e799b5dcbc08aef6ce1d30a2c1a3a Mon Sep 17 00:00:00 2001 From: jelleas Date: Wed, 22 Nov 2017 12:33:08 +0100 Subject: [PATCH 028/269] add testResults to testerResult --- .DS_Store | Bin 6148 -> 6148 bytes checkpy/tester.py | 5 +++++ 2 files changed, 5 insertions(+) diff --git a/.DS_Store b/.DS_Store index 97dd8f813e95fbb670d4f6dfedc89f86c34c8ea2..12fdb34f2f01b17671fe3690004b5be0bc352cc4 100644 GIT binary patch delta 48 zcmZoMXfc@J&&azmU^g=(?`9qrT}DR6%~q_@OdKi2$vH{+`8ksV*<>eAW%u38&heKY E07|3|5C8xG delta 38 ucmZoMXfc@J&&abeU^g=(&t@JLT}DQR%~q_@Op|x9`%Y|7-OSGMmmdJp_X`IA diff --git a/checkpy/tester.py b/checkpy/tester.py index 6a97f64..fc0680b 100644 --- a/checkpy/tester.py +++ b/checkpy/tester.py @@ -109,10 +109,14 @@ def __init__(self): self.nFailedTests = 0 self.nRunTests = 0 self.output = [] + self.testResults = [] def addOutput(self, output): self.output.append(output) + def addResult(self, testResult): + self.testResults.append(testResult) + class _Signal(object): def __init__(self, isTiming = False, resetTimer = False, description = None, timeout = None): self.isTiming = isTiming @@ -159,6 +163,7 @@ def run(self): result.nFailedTests = len([tr for tr in testResults if not tr.hasPassed]) for testResult in testResults: + result.addResult(testResult) result.addOutput(printer.display(testResult)) if hasattr(module, "after"): From 09df3d19101bca70690325a60bfd5fd31976d8fc Mon Sep 17 00:00:00 2001 From: jelleas Date: Wed, 22 Nov 2017 12:34:09 +0100 Subject: [PATCH 029/269] v0.3.10 --- checkpy/.DS_Store | Bin 6148 -> 6148 bytes setup.py | 2 +- 2 files changed, 1 insertion(+), 1 deletion(-) diff --git a/checkpy/.DS_Store b/checkpy/.DS_Store index c83a4ed1917a14ab6bf29122e354b73afaa48f00..5008ddfcf53c02e82d7eee2e57c38e5672ef89f6 100644 GIT binary patch delta 70 zcmZoMXfc=|#>AjHu~2NHo+1YW5HK<@2yAv_KE|>+fcX{EW_AvK4xj>{$am(+{342+ UKzW7)kiy9(Jj$D6L{=~Z044qo@Bjb+ delta 385 zcmZoMXfc=|#>B)qF;Q%yo}wrV0|Nsi1A_nqLn1>uLuyKKa!ykI#>C}}^&lB`hGK>i zhJ1!1WZC>2pbP^mP$Cs9U7S>2T#%HLp9B;=o>Y*NSzKaZaGjBfnT3^&or9B;gOisd zHaH`{Jh&vWq_o&6u_zkM%S=g4g0e&M^K;L{4h>L^rO7@6rPm|Ggu)^c!&s~XyR zCgfIDRoB$kO$T`a=yRag!GIr1!>Aby1}I*HgbRkx%YuvYa`N-if!Y{1CZ1!NY#_q2 inVW-~0~o*?6TdT0<`+?9Wdh17Og0pe-W(vZg&6>3Xkyy{ diff --git a/setup.py b/setup.py index 29359c8..8e6f198 100644 --- a/setup.py +++ b/setup.py @@ -11,7 +11,7 @@ setup( name='checkPy', - version='0.3.9', + version='0.3.10', description='A simple python testing framework for educational purposes', long_description=long_description, From 371545b496ebea64bf0281dead1d179a1a2c8dbe Mon Sep 17 00:00:00 2001 From: jelleas Date: Wed, 29 Nov 2017 13:26:08 +0100 Subject: [PATCH 030/269] importerror --- checkpy/downloader.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/checkpy/downloader.py b/checkpy/downloader.py index d138b85..ebef05d 100644 --- a/checkpy/downloader.py +++ b/checkpy/downloader.py @@ -220,7 +220,7 @@ def _download(githubUserName, githubRepoName): # Python 2 import StringIO f = StringIO.StringIO(r.content) - except ModuleNotFoundError: + except ImportError: # Python 3 import io f = io.BytesIO(r.content) From 7710914401ef33c6f4540c4bc35e1ee8cafc5b92 Mon Sep 17 00:00:00 2001 From: jelleas Date: Wed, 29 Nov 2017 13:26:25 +0100 Subject: [PATCH 031/269] .11 --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 8e6f198..f58f24e 100644 --- a/setup.py +++ b/setup.py @@ -11,7 +11,7 @@ setup( name='checkPy', - version='0.3.10', + version='0.3.11', description='A simple python testing framework for educational purposes', long_description=long_description, From 230fe4ae000ffeea7caff6164405ca6cc7a67d94 Mon Sep 17 00:00:00 2001 From: jelleas Date: Thu, 30 Nov 2017 13:59:11 +0100 Subject: [PATCH 032/269] .12 do not let checkpy errors through --- .DS_Store | Bin 6148 -> 6148 bytes checkpy/tests.py | 2 ++ setup.py | 2 +- 3 files changed, 3 insertions(+), 1 deletion(-) diff --git a/.DS_Store b/.DS_Store index 12fdb34f2f01b17671fe3690004b5be0bc352cc4..1d96f7cc27341e6e25dff2e9c61b7adab46bdce3 100644 GIT binary patch delta 14 VcmZoMXffC@mz|Mu^E~!60RSb41dRXy delta 16 XcmZoMXffC@mwj>|o9yOY>|O!@Gq?qk diff --git a/checkpy/tests.py b/checkpy/tests.py index 8836c7e..708a5ad 100644 --- a/checkpy/tests.py +++ b/checkpy/tests.py @@ -19,6 +19,8 @@ def run(self): hasPassed, info = result, "" except (exception.SourceException, exception.ExitError) as e: return TestResult(False, self.description(), self.exception(e), exception = e) + except Exception as e: + return TestResult(False, self.description(), self.exception(e), exception = e) return TestResult(hasPassed, self.description(), self.success(info) if hasPassed else self.fail(info)) diff --git a/setup.py b/setup.py index f58f24e..f198330 100644 --- a/setup.py +++ b/setup.py @@ -11,7 +11,7 @@ setup( name='checkPy', - version='0.3.11', + version='0.3.12', description='A simple python testing framework for educational purposes', long_description=long_description, From bfd266ea93e76b0d14a92ce23afdb44a844b04ba Mon Sep 17 00:00:00 2001 From: jelleas Date: Sun, 3 Dec 2017 15:02:40 +0100 Subject: [PATCH 033/269] TestError --- .DS_Store | Bin 6148 -> 6148 bytes checkpy/exception.py | 3 +++ checkpy/printer.py | 2 +- checkpy/tests.py | 5 +++++ 4 files changed, 9 insertions(+), 1 deletion(-) diff --git a/.DS_Store b/.DS_Store index 1d96f7cc27341e6e25dff2e9c61b7adab46bdce3..0a487db79dcf8c84b4a11ca09b5e72ac4d54e514 100644 GIT binary patch delta 56 zcmV-80LTA?FoZC$IRya#vpNNL0RhO9SOkCy7khg%H8Lz9I5Lyr1RVjGlm7%C0hyDY O2ON`?2XV6p2>lP;IT4@$ delta 31 ncmZoMXffDe$;!yM*^0HCadJ4z>B)=OB_~f~FWk(=@sA$>ov#W& diff --git a/checkpy/exception.py b/checkpy/exception.py index 6f62215..447eb99 100644 --- a/checkpy/exception.py +++ b/checkpy/exception.py @@ -24,6 +24,9 @@ def __repr__(self): class SourceException(CheckpyError): pass +class TestError(CheckpyError): + pass + class DownloadError(CheckpyError): pass diff --git a/checkpy/printer.py b/checkpy/printer.py index b420e39..0f8d3ab 100644 --- a/checkpy/printer.py +++ b/checkpy/printer.py @@ -22,7 +22,7 @@ def display(testResult): msg = "{}{} {}{}".format(color, smiley, testResult.description, _Colors.ENDC) if testResult.message: msg += "\n - {}".format(testResult.message) - + if DEBUG_MODE and testResult.exception: msg += "\n {}".format(testResult.exception.stacktrace()) diff --git a/checkpy/tests.py b/checkpy/tests.py index 708a5ad..2e0dd16 100644 --- a/checkpy/tests.py +++ b/checkpy/tests.py @@ -1,3 +1,4 @@ +import traceback from . import caches from . import exception @@ -20,6 +21,10 @@ def run(self): except (exception.SourceException, exception.ExitError) as e: return TestResult(False, self.description(), self.exception(e), exception = e) except Exception as e: + e = exception.TestError( + exception = e, + message = "while testing", + stacktrace = traceback.format_exc()) return TestResult(False, self.description(), self.exception(e), exception = e) return TestResult(hasPassed, self.description(), self.success(info) if hasPassed else self.fail(info)) From a4119cef0c390bdcfc44cd28277357468567dab5 Mon Sep 17 00:00:00 2001 From: jelleas Date: Sun, 3 Dec 2017 15:54:54 +0100 Subject: [PATCH 034/269] refactored file discovery --- checkpy/__main__.py | 19 +++++++++++-------- checkpy/tester.py | 45 +++++++++++++++++++++++++++++---------------- 2 files changed, 40 insertions(+), 24 deletions(-) diff --git a/checkpy/__main__.py b/checkpy/__main__.py index 8dd0389..dbc093b 100644 --- a/checkpy/__main__.py +++ b/checkpy/__main__.py @@ -46,18 +46,21 @@ def main(): downloader.clean() return - if args.file and args.module: + if args.file: downloader.updateSilently() - tester.test(args.file, module = args.module, debugMode = args.dev) - elif args.file and not args.module: - downloader.updateSilently() - tester.test(args.file, debugMode = args.dev) - elif not args.file and args.module: + + if args.module: + tester.test(args.file, module = args.module, debugMode = args.dev) + else: + tester.test(args.file, debugMode = args.dev) + return + + if args.module: downloader.updateSilently() tester.testModule(args.module, debugMode = args.dev) - else: - parser.print_help() return + parser.print_help() + if __name__ == "__main__": main() diff --git a/checkpy/tester.py b/checkpy/tester.py index fc0680b..a100b3a 100644 --- a/checkpy/tester.py +++ b/checkpy/tester.py @@ -1,5 +1,6 @@ from . import printer from . import caches +from . import exception import os import sys import importlib @@ -11,12 +12,18 @@ HERE = os.path.abspath(os.path.dirname(__file__)) def test(testName, module = "", debugMode = False): - fileName = _getFileName(testName) - filePath = _getFilePath(testName) + path = _getPath(testName) + if not path: + printer.displayError("File not found: {}".format(testName)) + return + + fileName = os.path.basename(path) + filePath = os.path.dirname(path) + if filePath not in sys.path: sys.path.append(filePath) - testFileName = fileName[:-3] + "Test.py" + testFileName = fileName.split(".")[0] + "Test.py" testFilePath = _getTestDirPath(testFileName, module = module) if testFilePath is None: printer.displayError("No test found for {}".format(fileName)) @@ -25,7 +32,7 @@ def test(testName, module = "", debugMode = False): if testFilePath not in sys.path: sys.path.append(testFilePath) - return _runTests(testFileName[:-3], os.path.join(filePath, fileName), debugMode = debugMode) + return _runTests(testFileName.split(".")[0], path, debugMode = debugMode) def testModule(module, debugMode = False): testNames = _getTestNames(module) @@ -43,6 +50,24 @@ def _getTestNames(moduleName): if moduleName in dirPath.split("/")[-1]: return [fileName[:-7] for fileName in fileNames if fileName.endswith(".py") and not fileName.startswith("_")] +def _getPath(testName): + filePath = os.path.dirname(testName) + if not filePath: + filePath = os.path.dirname(os.path.abspath(testName)) + + fileName = os.path.basename(testName) + + if "." in fileName: + path = os.path.join(filePath, fileName) + return path if os.path.exists(path) else None + + for extension in [".py", ".ipynb"]: + path = os.path.join(filePath, fileName + extension) + if os.path.exists(path): + return path + + return None + def _getTestDirPath(testFileName, module = ""): module = _backslashToForwardslash(module) testFileName = _backslashToForwardslash(testFileName) @@ -50,18 +75,6 @@ def _getTestDirPath(testFileName, module = ""): if module in _backslashToForwardslash(dirPath) and testFileName in fileNames: return dirPath -def _getFileName(completeFilePath): - fileName = os.path.basename(completeFilePath) - if not fileName.endswith(".py"): - fileName += ".py" - return fileName - -def _getFilePath(completeFilePath): - filePath = os.path.dirname(completeFilePath) - if not filePath: - filePath = os.path.dirname(os.path.abspath(_getFileName(completeFilePath))) - return filePath - def _backslashToForwardslash(text): return re.sub("\\\\", "/", text) From c2d7393cbdb1f6a1d9d19a9efee8425203b1905e Mon Sep 17 00:00:00 2001 From: jelleas Date: Sun, 3 Dec 2017 16:14:51 +0100 Subject: [PATCH 035/269] support for ipynb --- checkpy/tester.py | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/checkpy/tester.py b/checkpy/tester.py index a100b3a..fe06d70 100644 --- a/checkpy/tester.py +++ b/checkpy/tester.py @@ -2,6 +2,7 @@ from . import caches from . import exception import os +import subprocess import sys import importlib import re @@ -32,6 +33,19 @@ def test(testName, module = "", debugMode = False): if testFilePath not in sys.path: sys.path.append(testFilePath) + if path.endswith(".ipynb"): + if subprocess.call(['jupyter', 'nbconvert', '--to', 'script', path]) != 0: + printer.displayError("Failed to convert Jupyter notebook to .py") + return + + path = path.replace(".ipynb", ".py") + + testerResult = _runTests(testFileName.split(".")[0], path, debugMode = debugMode) + + os.remove(path) + + return testerResult + return _runTests(testFileName.split(".")[0], path, debugMode = debugMode) def testModule(module, debugMode = False): @@ -90,7 +104,6 @@ def _runTests(moduleName, fileName, debugMode = False): p = ctx.Process(target=tester.run, name="Tester") p.start() - start = time.time() isTiming = False From 2fc2ad1d0b8727cc2575e25aa697f6430685d80f Mon Sep 17 00:00:00 2001 From: jelleas Date: Sun, 3 Dec 2017 16:18:53 +0100 Subject: [PATCH 036/269] .13 --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index f198330..214a4d0 100644 --- a/setup.py +++ b/setup.py @@ -11,7 +11,7 @@ setup( name='checkPy', - version='0.3.12', + version='0.3.13', description='A simple python testing framework for educational purposes', long_description=long_description, From ff55fd0d3b70abb3164979a642cafb2a4bade18a Mon Sep 17 00:00:00 2001 From: jelleas Date: Sun, 3 Dec 2017 16:19:49 +0100 Subject: [PATCH 037/269] .14 --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 214a4d0..c3c51e1 100644 --- a/setup.py +++ b/setup.py @@ -11,7 +11,7 @@ setup( name='checkPy', - version='0.3.13', + version='0.3.14', description='A simple python testing framework for educational purposes', long_description=long_description, From 51dfe598bee204250d80b1ecc744a9612ceedba3 Mon Sep 17 00:00:00 2001 From: jelleas Date: Sun, 3 Dec 2017 16:58:50 +0100 Subject: [PATCH 038/269] better error message on too many input requests --- checkpy/exception.py | 3 +++ checkpy/lib.py | 11 ++++++++++- checkpy/tests.py | 2 +- 3 files changed, 14 insertions(+), 2 deletions(-) diff --git a/checkpy/exception.py b/checkpy/exception.py index 447eb99..f893f29 100644 --- a/checkpy/exception.py +++ b/checkpy/exception.py @@ -24,6 +24,9 @@ def __repr__(self): class SourceException(CheckpyError): pass +class InputError(CheckpyError): + pass + class TestError(CheckpyError): pass diff --git a/checkpy/lib.py b/checkpy/lib.py index b0d6867..3f39b11 100644 --- a/checkpy/lib.py +++ b/checkpy/lib.py @@ -27,7 +27,14 @@ def _stdoutIO(stdout=None): def _stdinIO(stdin=None): old_input = input def new_input(prompt = None): - return old_input() + try: + return old_input() + except EOFError as e: + e = exception.InputError( + message = "You requested too much user input", + stacktrace = traceback.format_exc()) + raise e + __builtins__["input"] = new_input old = sys.stdin @@ -123,6 +130,8 @@ def moduleAndOutputOf( sys.modules[moduleName] = mod except tuple(ignoreExceptions) as e: pass + except exception.CheckpyError as e: + excep = e except Exception as e: excep = exception.SourceException( exception = e, diff --git a/checkpy/tests.py b/checkpy/tests.py index 2e0dd16..3059639 100644 --- a/checkpy/tests.py +++ b/checkpy/tests.py @@ -18,7 +18,7 @@ def run(self): hasPassed, info = result else: hasPassed, info = result, "" - except (exception.SourceException, exception.ExitError) as e: + except exception.CheckpyError as e: return TestResult(False, self.description(), self.exception(e), exception = e) except Exception as e: e = exception.TestError( From a9fd3e075d414b995437eb91f3116370a1ec25dc Mon Sep 17 00:00:00 2001 From: jelleas Date: Sun, 3 Dec 2017 17:01:35 +0100 Subject: [PATCH 039/269] .15 --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index c3c51e1..c8367e2 100644 --- a/setup.py +++ b/setup.py @@ -11,7 +11,7 @@ setup( name='checkPy', - version='0.3.14', + version='0.3.15', description='A simple python testing framework for educational purposes', long_description=long_description, From 7a8fd3deed790b8beda5e0bc44991aaea6cad56a Mon Sep 17 00:00:00 2001 From: jelleas Date: Mon, 4 Dec 2017 20:01:31 +0100 Subject: [PATCH 040/269] function.py --- checkpy/function.py | 27 +++++++++++++++++++++++++++ checkpy/lib.py | 3 ++- checkpy/tester.py | 5 +++++ 3 files changed, 34 insertions(+), 1 deletion(-) create mode 100644 checkpy/function.py diff --git a/checkpy/function.py b/checkpy/function.py new file mode 100644 index 0000000..0d6aeb1 --- /dev/null +++ b/checkpy/function.py @@ -0,0 +1,27 @@ +from . import exception + +class Function(object): + def __init__(self, function): + self._function = function + + def __call__(self, *args, **kwargs): + try: + return self._function(*args, **kwargs) + except Exception as e: + argumentNames = self.arguments() + nArgs = len(args) + len(kwargs) + + message = "while trying to execute {}()".format(self.name()) + if nArgs > 0: + argsRepr = ", ".join("{}={}".format(argumentNames[i], args[i]) for i in range(len(args))) + kwargsRepr = ", ".join("{}={}".format(kwargName, kwargs[kwargName]) for kwargName in argumentNames[len(args):nArgs]) + representation = ", ".join(s for s in [argsRepr, kwargsRepr] if s) + message = "while trying to execute {}({})".format(self.name(), representation) + + raise exception.SourceException(exception = e, message = message) + + def name(self): + return self._function.__name__ + + def arguments(self): + return list(self._function.__code__.co_varnames) \ No newline at end of file diff --git a/checkpy/lib.py b/checkpy/lib.py index 3f39b11..86d3a15 100644 --- a/checkpy/lib.py +++ b/checkpy/lib.py @@ -13,6 +13,7 @@ import traceback from . import exception from . import caches +from . import function @contextlib.contextmanager def _stdoutIO(stdout=None): @@ -146,7 +147,7 @@ def moduleAndOutputOf( for name, func in [(name, f) for name, f in mod.__dict__.items() if callable(f)]: if func.__module__ == moduleName: - setattr(mod, name, wrapFunctionWithExceptionHandler(func)) + setattr(mod, name, function.Function(func)) output = stdout.getvalue() if excep: diff --git a/checkpy/tester.py b/checkpy/tester.py index fe06d70..c33d0e2 100644 --- a/checkpy/tester.py +++ b/checkpy/tester.py @@ -123,6 +123,11 @@ def _runTests(moduleName, fileName, debugMode = False): p.join() return result + if not resultQueue.empty(): + p.terminate() + p.join() + break + time.sleep(0.1) if not resultQueue.empty(): From a9b07f8fc14dde7068549adb4ebc97da793bf832 Mon Sep 17 00:00:00 2001 From: jelleas Date: Mon, 4 Dec 2017 20:01:52 +0100 Subject: [PATCH 041/269] .16 --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index c8367e2..9410fdc 100644 --- a/setup.py +++ b/setup.py @@ -11,7 +11,7 @@ setup( name='checkPy', - version='0.3.15', + version='0.3.16', description='A simple python testing framework for educational purposes', long_description=long_description, From 88bf7c1bfb369cfbeab749bebbdb20d0d588cd76 Mon Sep 17 00:00:00 2001 From: jelleas Date: Mon, 4 Dec 2017 20:07:08 +0100 Subject: [PATCH 042/269] -wrapWithExceptionHandler --- checkpy/lib.py | 17 ----------------- 1 file changed, 17 deletions(-) diff --git a/checkpy/lib.py b/checkpy/lib.py index 86d3a15..dec2ad7 100644 --- a/checkpy/lib.py +++ b/checkpy/lib.py @@ -169,23 +169,6 @@ def neutralizeFunctionFromImport(mod, functionName, importedModuleName): if hasattr(mod, functionName): neutralizeFunction(getattr(mod, functionName)) -def wrapFunctionWithExceptionHandler(func): - def exceptionWrapper(*args, **kwargs): - try: - return func(*args, **kwargs) - except Exception as e: - argListRepr = "" - if args: - for i in range(len(args)): - argListRepr += ", " + "{}={}".format(func.__code__.co_varnames[i], args[i]) - for kwargName in func.__code__.co_varnames[len(args):func.func_code.co_argcount]: - argListRepr += ", {}={}".format(kwargName, kwargs[kwargName]) - - if not argListRepr: - raise exception.SourceException(e, "while trying to execute the function {}".format(func.__name__)) - raise exception.SourceException(e, "while trying to execute the function {} with arguments ({})".format(func.__name__, argListRepr)) - return exceptionWrapper - def removeWhiteSpace(s): return re.sub(r"\s+", "", s, flags=re.UNICODE) From 9610f24a46974afc325e573c556356676f165343 Mon Sep 17 00:00:00 2001 From: jelleas Date: Mon, 4 Dec 2017 23:03:32 +0100 Subject: [PATCH 043/269] entities --- checkpy/__main__.py | 4 ++-- checkpy/assertlib.py | 2 +- checkpy/downloader.py | 6 +++--- checkpy/{ => entities}/exception.py | 0 checkpy/{ => entities}/function.py | 0 checkpy/lib.py | 6 +++--- checkpy/printer.py | 2 +- checkpy/tester.py | 6 +++--- checkpy/tests.py | 4 ++-- 9 files changed, 15 insertions(+), 15 deletions(-) rename checkpy/{ => entities}/exception.py (100%) rename checkpy/{ => entities}/function.py (100%) diff --git a/checkpy/__main__.py b/checkpy/__main__.py index dbc093b..ee583f1 100644 --- a/checkpy/__main__.py +++ b/checkpy/__main__.py @@ -1,8 +1,8 @@ import sys import os import argparse -from . import downloader -from . import tester +from checkpy import downloader +from checkpy import tester import shutil import time import pkg_resources diff --git a/checkpy/assertlib.py b/checkpy/assertlib.py index f993534..c13ba7a 100644 --- a/checkpy/assertlib.py +++ b/checkpy/assertlib.py @@ -1,4 +1,4 @@ -from . import lib +from checkpy import lib import re import os diff --git a/checkpy/downloader.py b/checkpy/downloader.py index ebef05d..405a5cf 100644 --- a/checkpy/downloader.py +++ b/checkpy/downloader.py @@ -4,9 +4,9 @@ import shutil import tinydb import time -from . import caches -from . import printer -from . import exception +from checkpy import caches +from checkpy import printer +from checkpy.entities import exception class Folder(object): def __init__(self, name, path): diff --git a/checkpy/exception.py b/checkpy/entities/exception.py similarity index 100% rename from checkpy/exception.py rename to checkpy/entities/exception.py diff --git a/checkpy/function.py b/checkpy/entities/function.py similarity index 100% rename from checkpy/function.py rename to checkpy/entities/function.py diff --git a/checkpy/lib.py b/checkpy/lib.py index dec2ad7..6817ef3 100644 --- a/checkpy/lib.py +++ b/checkpy/lib.py @@ -11,9 +11,9 @@ import imp import tokenize import traceback -from . import exception -from . import caches -from . import function +from checkpy.entities import exception +from checkpy.entities import function +from checkpy import caches @contextlib.contextmanager def _stdoutIO(stdout=None): diff --git a/checkpy/printer.py b/checkpy/printer.py index 0f8d3ab..b5ef66c 100644 --- a/checkpy/printer.py +++ b/checkpy/printer.py @@ -1,4 +1,4 @@ -from . import exception +from checkpy.entities import exception import os import colorama colorama.init() diff --git a/checkpy/tester.py b/checkpy/tester.py index c33d0e2..3a05949 100644 --- a/checkpy/tester.py +++ b/checkpy/tester.py @@ -1,6 +1,6 @@ -from . import printer -from . import caches -from . import exception +from checkpy import printer +from checkpy import caches +from checkpy.entities import exception import os import subprocess import sys diff --git a/checkpy/tests.py b/checkpy/tests.py index 3059639..3c57dc0 100644 --- a/checkpy/tests.py +++ b/checkpy/tests.py @@ -1,6 +1,6 @@ import traceback -from . import caches -from . import exception +from checkpy import caches +from checkpy.entities import exception class Test(object): def __init__(self, priority): From d8707ed4db8bf22db448ad5e2dfe5ba618180756 Mon Sep 17 00:00:00 2001 From: jelleas Date: Mon, 4 Dec 2017 23:07:21 +0100 Subject: [PATCH 044/269] __init__ --- checkpy/entities/__init__.py | 0 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 checkpy/entities/__init__.py diff --git a/checkpy/entities/__init__.py b/checkpy/entities/__init__.py new file mode 100644 index 0000000..e69de29 From 668f0ee7c480a30aa905b0d6a516a03d3acf4e26 Mon Sep 17 00:00:00 2001 From: jelleas Date: Tue, 5 Dec 2017 12:29:47 +0100 Subject: [PATCH 045/269] .17: argv -> moduleAndOutputOf --- checkpy/caches.py | 2 +- checkpy/lib.py | 20 +++++++++++++++++--- setup.py | 2 +- 3 files changed, 19 insertions(+), 5 deletions(-) diff --git a/checkpy/caches.py b/checkpy/caches.py index b4c0429..635ea05 100644 --- a/checkpy/caches.py +++ b/checkpy/caches.py @@ -48,7 +48,7 @@ def cachedFuncWrapper(*args, **kwargs): if key not in localCache: localCache[key] = func(*args, **kwargs) - + return localCache[key] return cachedFuncWrapper return cacheWrapper diff --git a/checkpy/lib.py b/checkpy/lib.py index 6817ef3..7694abe 100644 --- a/checkpy/lib.py +++ b/checkpy/lib.py @@ -90,6 +90,7 @@ def module(*args, **kwargs): def moduleAndOutputOf( fileName, src = None, + argv = None, stdinArgs = None, ignoreExceptions = (), overwriteAttributes = () @@ -110,24 +111,32 @@ def moduleAndOutputOf( excep = None with _stdoutIO() as stdout, _stdinIO() as stdin: + # fill stdin with args if stdinArgs: for arg in stdinArgs: stdin.write(str(arg) + "\n") stdin.seek(0) - moduleName = fileName[:-3] if fileName.endswith(".py") else fileName + # if argv given, overwrite sys.argv + if argv: + sys.argv, argv = argv, sys.argv + + moduleName = fileName.split(".")[0] + try: mod = imp.new_module(moduleName) + # overwrite attributes for attr, value in overwriteAttributes: setattr(mod, attr, value) - # Python 3 + # execute code in mod if sys.version_info > (3,0): exec(src, mod.__dict__) - # Python 2 else: exec(src) in mod.__dict__ + + # add resulting module to sys sys.modules[moduleName] = mod except tuple(ignoreExceptions) as e: pass @@ -145,10 +154,15 @@ def moduleAndOutputOf( output = stdout.getvalue(), stacktrace = traceback.format_exc()) + # wrap every function in mod with Function for name, func in [(name, f) for name, f in mod.__dict__.items() if callable(f)]: if func.__module__ == moduleName: setattr(mod, name, function.Function(func)) + # reset sys.argv + if argv: + sys.argv = argv + output = stdout.getvalue() if excep: raise excep diff --git a/setup.py b/setup.py index 9410fdc..7f54a67 100644 --- a/setup.py +++ b/setup.py @@ -11,7 +11,7 @@ setup( name='checkPy', - version='0.3.16', + version='0.3.17', description='A simple python testing framework for educational purposes', long_description=long_description, From 38739f2512b082ee536226df31d386b5be482831 Mon Sep 17 00:00:00 2001 From: jelleas Date: Tue, 5 Dec 2017 12:31:25 +0100 Subject: [PATCH 046/269] .18 --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 7f54a67..bd45a65 100644 --- a/setup.py +++ b/setup.py @@ -11,7 +11,7 @@ setup( name='checkPy', - version='0.3.17', + version='0.3.18', description='A simple python testing framework for educational purposes', long_description=long_description, From ba87e9f0d19dd6d4e72716d5b65b90795f07153f Mon Sep 17 00:00:00 2001 From: jelleas Date: Thu, 7 Dec 2017 18:39:27 +0100 Subject: [PATCH 047/269] staticlib? --- checkpy/entities/function.py | 2 ++ checkpy/staticlib.py | 34 ++++++++++++++++++++++++++++++++++ 2 files changed, 36 insertions(+) create mode 100644 checkpy/staticlib.py diff --git a/checkpy/entities/function.py b/checkpy/entities/function.py index 0d6aeb1..212adca 100644 --- a/checkpy/entities/function.py +++ b/checkpy/entities/function.py @@ -20,8 +20,10 @@ def __call__(self, *args, **kwargs): raise exception.SourceException(exception = e, message = message) + @property def name(self): return self._function.__name__ + @property def arguments(self): return list(self._function.__code__.co_varnames) \ No newline at end of file diff --git a/checkpy/staticlib.py b/checkpy/staticlib.py new file mode 100644 index 0000000..2dd9ef2 --- /dev/null +++ b/checkpy/staticlib.py @@ -0,0 +1,34 @@ +from checkpy import caches +import redbaron + +def source(fileName): + with open(fileName) as f: + return f.read() + +@caches.cache() +def fullSyntaxTree(fileName): + return fstFromSource(source(fileName)) + +@caches.cache() +def fstFromSource(source): + return redbaron.RedBaron(source) + +@caches.cache() +def functionCode(functionName, fileName): + definitions = [d for d in fullSyntaxTree(fileName).find_all("def") if d.name == functionName] + if definitions: + return definitions[0] + return None + +def functionLOC(functionName, fileName): + code = functionCode(functionName, fileName) + ignoreNodes = [] + ignoreNodeTypes = [redbaron.EndlNode, redbaron.StringNode] + for node in code.value: + if any(isinstance(node, t) for t in ignoreNodeTypes): + ignoreNodes.append(node) + + for ignoreNode in ignoreNodes: + code.value.remove(ignoreNode) + + return len(code.value) + 1 \ No newline at end of file From 04a54a0e1ea2867bdd3b9e95d7dc725eb68c4f9f Mon Sep 17 00:00:00 2001 From: jelleas Date: Sun, 10 Dec 2017 17:18:04 +0100 Subject: [PATCH 048/269] test.fileName --- checkpy/tester.py | 4 ++-- checkpy/tests.py | 19 ++++++++++++------- 2 files changed, 14 insertions(+), 9 deletions(-) diff --git a/checkpy/tester.py b/checkpy/tester.py index 3a05949..de92baa 100644 --- a/checkpy/tester.py +++ b/checkpy/tester.py @@ -209,7 +209,7 @@ def _runTests(self, testCreators): cachedResults = {} # run tests in noncolliding execution order - for test in self._getTestsInExecutionOrder([tc() for tc in testCreators]): + for test in self._getTestsInExecutionOrder([tc(self.fileName) for tc in testCreators]): self._sendSignal(_Signal(isTiming = True, resetTimer = True, description = test.description(), timeout = test.timeout())) cachedResults[test] = test.run() self._sendSignal(_Signal(isTiming = False)) @@ -226,6 +226,6 @@ def _sendSignal(self, signal): def _getTestsInExecutionOrder(self, tests): testsInExecutionOrder = [] for i, test in enumerate(tests): - dependencies = self._getTestsInExecutionOrder([tc() for tc in test.dependencies()]) + [test] + dependencies = self._getTestsInExecutionOrder([tc(self.fileName) for tc in test.dependencies()]) + [test] testsInExecutionOrder.extend([t for t in dependencies if t not in testsInExecutionOrder]) return testsInExecutionOrder diff --git a/checkpy/tests.py b/checkpy/tests.py index 3c57dc0..da0e428 100644 --- a/checkpy/tests.py +++ b/checkpy/tests.py @@ -3,12 +3,17 @@ from checkpy.entities import exception class Test(object): - def __init__(self, priority): + def __init__(self, fileName, priority): + self._fileName = fileName self._priority = priority def __lt__(self, other): return self._priority < other._priority + @property + def fileName(self): + return self._fileName + @caches.cache() def run(self): try: @@ -84,8 +89,8 @@ def exception(self): def test(priority): def testDecorator(testCreator): @caches.cache(testCreator) - def testWrapper(): - t = Test(priority) + def testWrapper(fileName): + t = Test(fileName, priority) testCreator(t) return t return testWrapper @@ -94,8 +99,8 @@ def testWrapper(): def failed(*precondTestCreators): def failedDecorator(testCreator): - def testWrapper(): - test = testCreator() + def testWrapper(fileName): + test = testCreator(fileName) dependencies = test.dependencies test.dependencies = lambda : dependencies() | set(precondTestCreators) run = test.run @@ -110,8 +115,8 @@ def runMethod(): def passed(*precondTestCreators): def passedDecorator(testCreator): - def testWrapper(): - test = testCreator() + def testWrapper(fileName): + test = testCreator(fileName) dependencies = test.dependencies test.dependencies = lambda : dependencies() | set(precondTestCreators) run = test.run From 9a59a4c3d40d44f6acf11121d3edfc993a4a70d3 Mon Sep 17 00:00:00 2001 From: jelleas Date: Sun, 10 Dec 2017 17:19:44 +0100 Subject: [PATCH 049/269] .19 --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index bd45a65..4fd9d03 100644 --- a/setup.py +++ b/setup.py @@ -11,7 +11,7 @@ setup( name='checkPy', - version='0.3.18', + version='0.3.19', description='A simple python testing framework for educational purposes', long_description=long_description, From 67f36f11de74e9ab143317252f314d31dbb5fdf4 Mon Sep 17 00:00:00 2001 From: jelleas Date: Tue, 12 Dec 2017 15:52:03 +0100 Subject: [PATCH 050/269] .20 (remove magic from .ipynb) --- .DS_Store | Bin 6148 -> 8196 bytes .gitignore | 3 ++- checkpy/.DS_Store | Bin 6148 -> 6148 bytes checkpy/tester.py | 10 ++++++++-- setup.py | 2 +- 5 files changed, 11 insertions(+), 4 deletions(-) diff --git a/.DS_Store b/.DS_Store index 0a487db79dcf8c84b4a11ca09b5e72ac4d54e514..1ad3e1a8484edb759f276dee94e14a0a991d3309 100644 GIT binary patch delta 209 zcmZoMXmOBWU|?W$DortDU;r^WfEYvza8E20o2aMA$iFdQH}hr%jz7$c**Q2SHn1@A zZ{}gqWn^TWY{jZ*FTjw@kin1&B(oU;7%Ca`fb4W2)MdzI$YV%j$WJLw&PmG8&jFgr zz`~FM6fR~cfeUVqV7%)xG5`S+hyVf*2Z#k2kmNTeE@200 K-ptMMlOF(mOc4G6 delta 51 vcmZoMXfc?e&B4IH0LBvwMFg0D92j6^U=Y|?IE{T`gVbaL5thx|96$L1%DD*$ diff --git a/checkpy/tester.py b/checkpy/tester.py index de92baa..cd88441 100644 --- a/checkpy/tester.py +++ b/checkpy/tester.py @@ -40,6 +40,12 @@ def test(testName, module = "", debugMode = False): path = path.replace(".ipynb", ".py") + # remove all magic lines from notebook + with open(path, "r") as f: + lines = f.readlines() + with open(path, "w") as f: + f.write("".join([l for l in lines if "get_ipython" not in l])) + testerResult = _runTests(testFileName.split(".")[0], path, debugMode = debugMode) os.remove(path) @@ -126,8 +132,8 @@ def _runTests(moduleName, fileName, debugMode = False): if not resultQueue.empty(): p.terminate() p.join() - break - + break + time.sleep(0.1) if not resultQueue.empty(): diff --git a/setup.py b/setup.py index 4fd9d03..08aa4d5 100644 --- a/setup.py +++ b/setup.py @@ -11,7 +11,7 @@ setup( name='checkPy', - version='0.3.19', + version='0.3.20', description='A simple python testing framework for educational purposes', long_description=long_description, From 858189e4ff7dec5adc0ebef5ff31e971592f3f45 Mon Sep 17 00:00:00 2001 From: Tom Kooij Date: Mon, 1 Jan 2018 09:37:36 +0100 Subject: [PATCH 051/269] _Cache is a minimal subclass of dict() Create an object that can be added to a list by reference, but preserve dict() functionality. --- checkpy/caches.py | 27 ++++++--------------------- 1 file changed, 6 insertions(+), 21 deletions(-) diff --git a/checkpy/caches.py b/checkpy/caches.py index 635ea05..f3a010c 100644 --- a/checkpy/caches.py +++ b/checkpy/caches.py @@ -2,28 +2,13 @@ _caches = [] -class _Cache(object): - def __init__(self): - self._cache = {} - _caches.append(self) - - def __setitem__(self, key, value): - self._cache[key] = value - - def __getitem__(self, key): - return self._cache.get(key, None) - def __contains__(self, key): - return key in self._cache - - def delete(self, key): - if key not in self._cache: - return False - del self._cache[key] - return True +class _Cache(dict): + """A dict() subclass that appends a self-reference to _caches""" + def __init__(self, *args, **kwargs): + super(_Cache, self).__init__(*args, **kwargs) + _caches.append(self) - def clear(self): - self._cache.clear() """ cache decorator @@ -48,7 +33,7 @@ def cachedFuncWrapper(*args, **kwargs): if key not in localCache: localCache[key] = func(*args, **kwargs) - + return localCache[key] return cachedFuncWrapper return cacheWrapper From f56dc575d003b5611a6b502db80dfd3a914f6c62 Mon Sep 17 00:00:00 2001 From: Tom Kooij Date: Mon, 1 Jan 2018 11:07:40 +0100 Subject: [PATCH 052/269] Refactor caches() decorator Preserve docstrings of cached functions Use str() to create hashable keys Update docstring / PEP8 --- checkpy/caches.py | 33 ++++++++++++++++----------------- 1 file changed, 16 insertions(+), 17 deletions(-) diff --git a/checkpy/caches.py b/checkpy/caches.py index f3a010c..4ff35bc 100644 --- a/checkpy/caches.py +++ b/checkpy/caches.py @@ -1,4 +1,5 @@ import sys +from functools import wraps _caches = [] @@ -10,34 +11,32 @@ def __init__(self, *args, **kwargs): _caches.append(self) -""" -cache decorator -Caches input and output of a function. If arguments are passed to -the decorator, take those as key for the cache, otherwise the -function arguments. -""" def cache(*keys): - def cacheWrapper(func, localCache = _Cache()): + """cache decorator + + Caches input and output of a function. If arguments are passed to + the decorator, take those as key for the cache. Otherwise use the + function arguments and sys.argv as key. + + """ + def cacheWrapper(func): + localCache = _Cache() + + @wraps(func) def cachedFuncWrapper(*args, **kwargs): if keys: - key = keys + key = str(keys) else: - # treat all collections in kwargs as tuples for hashing purposes - values = list(kwargs.values()) - for i in range(len(values)): - try: - values[i] = tuple(values[i]) - except TypeError: - pass - key = args + tuple(values) + tuple(sys.argv) + key = str(args) + str(kwargs) + str(sys.argv) if key not in localCache: localCache[key] = func(*args, **kwargs) - return localCache[key] return cachedFuncWrapper + return cacheWrapper + def clearAllCaches(): for cache in _caches: cache.clear() From fbff926582f59c7e4d6f7b54c3bd7f6c47d0db8b Mon Sep 17 00:00:00 2001 From: jelleas Date: Tue, 2 Jan 2018 10:25:46 +0100 Subject: [PATCH 053/269] getFunction() supports extra args, tester sets argv --- checkpy/caches.py | 9 +++++++++ checkpy/lib.py | 7 ++++--- checkpy/tester.py | 9 ++++++--- checkpy/tests.py | 4 ++++ 4 files changed, 23 insertions(+), 6 deletions(-) diff --git a/checkpy/caches.py b/checkpy/caches.py index 4ff35bc..97df41f 100644 --- a/checkpy/caches.py +++ b/checkpy/caches.py @@ -18,6 +18,15 @@ def cache(*keys): the decorator, take those as key for the cache. Otherwise use the function arguments and sys.argv as key. + sys.argv is used here because of user-written code like this: + + import sys + my_variable = sys.argv[1] + def my_function(): + print(my_variable) + + Depending on the state of sys.argv during execution of the module, + the outcome of my_function() changes. """ def cacheWrapper(func): localCache = _Cache() diff --git a/checkpy/lib.py b/checkpy/lib.py index 7694abe..f7d7c07 100644 --- a/checkpy/lib.py +++ b/checkpy/lib.py @@ -48,9 +48,6 @@ def new_input(prompt = None): __builtins__["input"] = old_input sys.stdin = old -def getFunction(functionName, fileName): - return getattr(module(fileName), functionName) - def source(fileName): source = "" with open(fileName) as f: @@ -78,6 +75,10 @@ def sourceOfDefinitions(fileName): insideDefinition = False return newSource + +def getFunction(functionName, *args, **kwargs): + return getattr(module(*args, **kwargs), functionName) + def outputOf(*args, **kwargs): _, output = moduleAndOutputOf(*args, **kwargs) return output diff --git a/checkpy/tester.py b/checkpy/tester.py index cd88441..23d332f 100644 --- a/checkpy/tester.py +++ b/checkpy/tester.py @@ -173,12 +173,15 @@ def run(self): if self.debugMode: printer.DEBUG_MODE = True - module = importlib.import_module(self.moduleName) - result = TesterResult() + # overwrite argv so that it seems the file was run directly + sys.argv = [self.fileName] + module = importlib.import_module(self.moduleName) module._fileName = self.fileName - self._sendSignal(_Signal(isTiming = False)) + self._sendSignal(_Signal(isTiming = False)) + + result = TesterResult() result.addOutput(printer.displayTestName(os.path.basename(module._fileName))) if hasattr(module, "before"): diff --git a/checkpy/tests.py b/checkpy/tests.py index da0e428..9e4f815 100644 --- a/checkpy/tests.py +++ b/checkpy/tests.py @@ -1,4 +1,5 @@ import traceback +from functools import wraps from checkpy import caches from checkpy.entities import exception @@ -89,6 +90,7 @@ def exception(self): def test(priority): def testDecorator(testCreator): @caches.cache(testCreator) + @wraps(testCreator) def testWrapper(fileName): t = Test(fileName, priority) testCreator(t) @@ -99,6 +101,7 @@ def testWrapper(fileName): def failed(*precondTestCreators): def failedDecorator(testCreator): + @wraps(testCreator) def testWrapper(fileName): test = testCreator(fileName) dependencies = test.dependencies @@ -115,6 +118,7 @@ def runMethod(): def passed(*precondTestCreators): def passedDecorator(testCreator): + @wraps(testCreator) def testWrapper(fileName): test = testCreator(fileName) dependencies = test.dependencies From 36d2140d745a4dad48b9acd6e54e79283f80dab1 Mon Sep 17 00:00:00 2001 From: jelleas Date: Tue, 2 Jan 2018 10:38:55 +0100 Subject: [PATCH 054/269] -> .21 --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 08aa4d5..036107a 100644 --- a/setup.py +++ b/setup.py @@ -11,7 +11,7 @@ setup( name='checkPy', - version='0.3.20', + version='0.3.21', description='A simple python testing framework for educational purposes', long_description=long_description, From 7eca7bbea69b1d3e046f5c4ebe864b1c08c7b7cb Mon Sep 17 00:00:00 2001 From: Tom Kooij Date: Thu, 4 Jan 2018 12:21:48 +0100 Subject: [PATCH 055/269] Add fileName Fix for omission in 04a54a0e1ea2867bdd3b9e95d7dc725eb68c4f9f --- checkpy/tests.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/checkpy/tests.py b/checkpy/tests.py index 9e4f815..060e09b 100644 --- a/checkpy/tests.py +++ b/checkpy/tests.py @@ -108,7 +108,7 @@ def testWrapper(fileName): test.dependencies = lambda : dependencies() | set(precondTestCreators) run = test.run def runMethod(): - testResults = [t().run() for t in precondTestCreators] + testResults = [t(fileName).run() for t in precondTestCreators] return run() if not any(t is None for t in testResults) and not any(t.hasPassed for t in testResults) else None test.run = runMethod return test @@ -125,7 +125,7 @@ def testWrapper(fileName): test.dependencies = lambda : dependencies() | set(precondTestCreators) run = test.run def runMethod(): - testResults = [t().run() for t in precondTestCreators] + testResults = [t(fileName).run() for t in precondTestCreators] return run() if not any(t is None for t in testResults) and all(t.hasPassed for t in testResults) else None test.run = runMethod return test From 1b1308e29b3360a58ff9db9c898817d3c931069f Mon Sep 17 00:00:00 2001 From: jelleas Date: Thu, 4 Jan 2018 12:36:52 +0100 Subject: [PATCH 056/269] -> .22 --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 036107a..876fa0d 100644 --- a/setup.py +++ b/setup.py @@ -11,7 +11,7 @@ setup( name='checkPy', - version='0.3.21', + version='0.3.22', description='A simple python testing framework for educational purposes', long_description=long_description, From 2e37b561d24c69edf4306171750325f0e348b729 Mon Sep 17 00:00:00 2001 From: jelleas Date: Thu, 4 Jan 2018 16:08:55 +0100 Subject: [PATCH 057/269] unittests for lib.py --- checkpy/lib.py | 2 +- unittests/lib_test.py | 329 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 330 insertions(+), 1 deletion(-) create mode 100644 unittests/lib_test.py diff --git a/checkpy/lib.py b/checkpy/lib.py index f7d7c07..95ba50c 100644 --- a/checkpy/lib.py +++ b/checkpy/lib.py @@ -191,7 +191,7 @@ def getPositiveIntegersFromString(s): return [int(i) for i in re.findall(r"\d+", s)] def getNumbersFromString(s): - return [eval(n) for n in re.findall(r"[-+]?\d*\.\d+|\d+", s)] + return [eval(n) for n in re.findall(r"[-+]?\d*\.\d+|[-+]?\d+", s)] def getLine(text, lineNumber): lines = text.split("\n") diff --git a/unittests/lib_test.py b/unittests/lib_test.py new file mode 100644 index 0000000..749c858 --- /dev/null +++ b/unittests/lib_test.py @@ -0,0 +1,329 @@ +import unittest +import os +import checkpy.lib as lib +import checkpy.caches as caches +import checkpy.entities.exception as exception + + +class Base(unittest.TestCase): + def setUp(self): + self.fileName = "dummy.py" + self.source = "def f(x):" +\ + " return x * 2" + self.write(self.source) + + def tearDown(self): + os.remove(self.fileName) + caches.clearAllCaches() + + def write(self, source): + with open(self.fileName, "w") as f: + f.write(source) + + +class TestSource(Base): + def test_expectedOutput(self): + source = lib.source(self.fileName) + self.assertEqual(source, self.source) + + +class TestSourceOfDefinitions(Base): + def test_noDefinitions(self): + source = \ +""" +height = int(input("Height: ")) + +#retry while height > 23 +while height > 23: + height = int(input("Height: ")) + +#prints the # and blanks +for i in range(height): + for j in range(height - i - 1): + print(" ", end="") + for k in range(i + 2): + print("#", end="") + print("") +""" + self.write(source) + self.assertEqual(lib.sourceOfDefinitions(self.fileName), "") + + def test_oneDefinition(self): + source = \ +""" +def main(): + pass +if __name__ == "__main__": + main() +""" + expectedOutcome = \ +"""def main(): + pass +""" + self.write(source) + self.assertEqual(lib.sourceOfDefinitions(self.fileName), expectedOutcome) + + def test_comments(self): + source = \ +""" +# foo +\"\"\"bar\"\"\" +""" + self.write(source) + self.assertEqual(lib.sourceOfDefinitions(self.fileName), "") + + @unittest.expectedFailure + def test_multilineString(self): + source = \ +""" +x = \"\"\"foo\"\"\" +""" + self.write(source) + self.assertEqual(lib.sourceOfDefinitions(self.fileName), source) + + def test_import(self): + source = \ +"""import os +from os import path +""" + self.write(source) + self.assertEqual(lib.sourceOfDefinitions(self.fileName), source) + + +class TestGetFunction(Base): + def test_sameName(self): + func = lib.getFunction("f", self.fileName) + self.assertEqual(func.name, "f") + + def test_sameArgs(self): + func = lib.getFunction("f", self.fileName) + self.assertEqual(func.arguments, ["x"]) + + def test_expectedOutput(self): + func = lib.getFunction("f", self.fileName) + self.assertEqual(func(2), 4) + + +class TestOutputOf(Base): + def test_helloWorld(self): + source = \ +""" +print("Hello, world!") +""" + self.write(source) + self.assertEqual(lib.outputOf(self.fileName), "Hello, world!\n") + + def test_function(self): + source = \ +""" +def f(x): + print(x) +f(1) +""" + self.write(source) + self.assertEqual(lib.outputOf(self.fileName), "1\n") + + def test_input(self): + source = \ +""" +x = input("foo") +print(x) +""" + self.write(source) + output = lib.outputOf(self.fileName, stdinArgs = ["3"]) + self.assertEqual(int(output), 3) + + def test_noInput(self): + source = \ +""" +x = input("foo") +print(x) +""" + self.write(source) + with self.assertRaises(exception.InputError): + lib.outputOf(self.fileName) + + def test_argv(self): + source = \ +""" +import sys +print(sys.argv[1]) +""" + self.write(source) + output = lib.outputOf(self.fileName, argv = [self.fileName, "foo"]) + self.assertEqual(output, "foo\n") + + def test_ValueError(self): + source = \ +""" +print("bar") +raise ValueError +print("foo") +""" + self.write(source) + with self.assertRaises(exception.SourceException): + output = lib.outputOf(self.fileName, argv = [self.fileName, "foo"]) + self.assertEqual(output, "bar\n") + + def test_ignoreValueError(self): + source = \ +""" +print("foo") +raise ValueError +print("bar") +""" + self.write(source) + output = lib.outputOf(self.fileName, ignoreExceptions = [ValueError]) + self.assertEqual(output, "foo\n") + + def test_ignoreSystemExit(self): + source = \ +""" +import sys +print("foo") +sys.exit(1) +print("bar") +""" + self.write(source) + output = lib.outputOf(self.fileName, ignoreExceptions = [SystemExit]) + self.assertEqual(output, "foo\n") + + def test_src(self): + source = \ +""" +print("bar") +""" + self.write(source) + output = lib.outputOf(self.fileName, src = "print(\"foo\")") + self.assertEqual(output, "foo\n") + + +class TestModule(Base): + def test_function(self): + source = \ +""" +def f(): + pass +""" + self.write(source) + self.assertTrue(hasattr(lib.module(self.fileName), "f")) + + def test_class(self): + source = \ +""" +class C: + pass +""" + self.write(source) + self.assertTrue(hasattr(lib.module(self.fileName), "C")) + + def test_import(self): + source = \ +""" +import os +""" + self.write(source) + self.assertTrue(hasattr(lib.module(self.fileName), "os")) + + def test_global(self): + source = \ +""" +x = 3 +""" + self.write(source) + self.assertTrue(hasattr(lib.module(self.fileName), "x")) + + def test_indirectGlobal(self): + source = \ +""" +def f(): + global x + x = 3 +f() +""" + self.write(source) + self.assertTrue(hasattr(lib.module(self.fileName), "x")) + + def test_local(self): + source = \ +""" +def f(): + x = 3 +f() +""" + self.write(source) + self.assertTrue(not hasattr(lib.module(self.fileName), "x")) + + +class TestNeutralizeFunction(unittest.TestCase): + def test_dummy(self): + def dummy(): + return "foo" + lib.neutralizeFunction(dummy) + self.assertEqual(dummy(), None) + + +class TestRemoveWhiteSpace(unittest.TestCase): + def test_remove(self): + s = lib.removeWhiteSpace(" \t foo\t\t bar ") + self.assertEqual(s, "foobar") + + +class TestGetPositiveIntegersFromString(unittest.TestCase): + def test_only(self): + s = "foo1bar 2 baz" + self.assertEqual(lib.getPositiveIntegersFromString(s), [1,2]) + + def test_order(self): + s = "3 1 2" + self.assertEqual(lib.getPositiveIntegersFromString(s), [3,1,2]) + + def test_negatives(self): + s = "-2" + self.assertEqual(lib.getPositiveIntegersFromString(s), [2]) + + def test_floats(self): + s = "2.0" + self.assertEqual(lib.getPositiveIntegersFromString(s), [2, 0]) + + +class TestGetNumbersFromString(unittest.TestCase): + def test_only(self): + s = "foo1bar 2 baz" + self.assertEqual(lib.getNumbersFromString(s), [1,2]) + + def test_order(self): + s = "3 1 2" + self.assertEqual(lib.getNumbersFromString(s), [3,1,2]) + + def test_negatives(self): + s = "-2" + self.assertEqual(lib.getNumbersFromString(s), [-2]) + + def test_floats(self): + s = "2.0" + self.assertEqual(lib.getNumbersFromString(s), [2.0]) + + +class TestGetLine(unittest.TestCase): + def test_empty(self): + s = "" + with self.assertRaises(IndexError): + lib.getLine(s, 1) + + def test_oneLine(self): + s = "foo" + self.assertEqual(lib.getLine(s, 0), s) + + def test_multiLine(self): + s = "foo\nbar" + self.assertEqual(lib.getLine(s, 1), "bar") + + def test_oneLineTooFar(self): + s = "foo\nbar" + with self.assertRaises(IndexError): + lib.getLine(s, 2) + + +if __name__ == '__main__': + unittest.main() \ No newline at end of file From 276ccd6cc8aee2c4e310a19bd007a493646a7bee Mon Sep 17 00:00:00 2001 From: jelleas Date: Thu, 4 Jan 2018 16:11:53 +0100 Subject: [PATCH 058/269] docs --- README.rst | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/README.rst b/README.rst index 0bfde09..925ecb6 100644 --- a/README.rst +++ b/README.rst @@ -29,7 +29,7 @@ Usage [file] checkPy: a python testing framework for education. You are running Python - version 3.6.2 and checkpy version 0.3.7. + version 3.6.2 and checkpy version 0.3.21. positional arguments: file name of file to be tested @@ -175,3 +175,12 @@ The requirement is that a folder called ``tests`` exists within the repo that co There must also be at least one release in the Github repo. Checkpy will automatically target the latest release. Simply call checkPy with the optional ``-d`` argument and pass your github repo url. Tests will then be automatically downloaded and installed. + + +Testing CheckPy +--------------- + +:: + + python -m unittest discover unittests "*_test.py" + python3 -m unittest discover unittests "*_test.py" \ No newline at end of file From 6f4408040058899d12ddf8573e567d4acba40548 Mon Sep 17 00:00:00 2001 From: jelleas Date: Thu, 4 Jan 2018 16:21:38 +0100 Subject: [PATCH 059/269] +2 --- README.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.rst b/README.rst index 925ecb6..82bb601 100644 --- a/README.rst +++ b/README.rst @@ -182,5 +182,5 @@ Testing CheckPy :: - python -m unittest discover unittests "*_test.py" + python2 -m unittest discover unittests "*_test.py" python3 -m unittest discover unittests "*_test.py" \ No newline at end of file From a231a12af5c6d39937146780692c2c10744018ec Mon Sep 17 00:00:00 2001 From: jelleas Date: Fri, 5 Jan 2018 00:31:18 +0100 Subject: [PATCH 060/269] start downloader tests, bugfix in upload --- .DS_Store | Bin 8196 -> 8196 bytes checkpy/downloader.py | 5 +- tests/integrationtests/downloader_test.py | 78 +++++++++++++++++++++ {unittests => tests/unittests}/lib_test.py | 24 +++---- 4 files changed, 94 insertions(+), 13 deletions(-) create mode 100644 tests/integrationtests/downloader_test.py rename {unittests => tests/unittests}/lib_test.py (96%) diff --git a/.DS_Store b/.DS_Store index 1ad3e1a8484edb759f276dee94e14a0a991d3309..3df6a6d36f7b56c4709d3586632e2a2a7833ab6b 100644 GIT binary patch delta 23 ecmZp1XmQw3Axz0RWHj2$TQ- diff --git a/checkpy/downloader.py b/checkpy/downloader.py index 405a5cf..37a7c0d 100644 --- a/checkpy/downloader.py +++ b/checkpy/downloader.py @@ -332,7 +332,10 @@ def _extractFile(zipfile, path, filePath): zipPathString = path.asString().replace("\\", "/") if os.path.isfile(filePath.asString()): with zipfile.open(zipPathString) as new, open(filePath.asString(), "r") as existing: - if new.read().strip() != existing.read().strip(): + # read file, decode, strip trailing whitespace, remove carrier return + newText = ''.join(new.read().decode('utf-8').strip().splitlines()) + existingText = ''.join(existing.read().strip().splitlines()) + if newText != existingText: printer.displayUpdate(path.asString()) with zipfile.open(zipPathString) as source, open(filePath.asString(), "wb+") as target: diff --git a/tests/integrationtests/downloader_test.py b/tests/integrationtests/downloader_test.py new file mode 100644 index 0000000..78ef196 --- /dev/null +++ b/tests/integrationtests/downloader_test.py @@ -0,0 +1,78 @@ +import unittest +import os +import sys +from contextlib import contextmanager +try: + # Python 2 + import StringIO +except: + # Python 3 + import io as StringIO +import checkpy +import checkpy.downloader as downloader +import checkpy.caches as caches +import checkpy.entities.exception as exception + +@contextmanager +def capturedOutput(): + new_out, new_err = StringIO.StringIO(), StringIO.StringIO() + old_out, old_err = sys.stdout, sys.stderr + try: + sys.stdout, sys.stderr = new_out, new_err + yield sys.stdout, sys.stderr + finally: + sys.stdout, sys.stderr = old_out, old_err + +class Base(unittest.TestCase): + def tearDown(self): + caches.clearAllCaches() + +class BaseClean(unittest.TestCase): + def setUp(self): + downloader.clean() + + def tearDown(self): + downloader.clean() + caches.clearAllCaches() + +class TestDownload(BaseClean): + def setUp(self): + super().setUp() + self.fileName = "some.py" + with open(self.fileName, "w") as f: + f.write("print(\"foo\")") + + def tearDown(self): + super().tearDown() + os.remove(self.fileName) + + def test_spelledOutLink(self): + downloader.download("https://github.com/jelleas/tests") + testerResult = checkpy.test(self.fileName) + self.assertTrue(testerResult.testResults[0].hasPassed) + + def test_incompleteLink(self): + downloader.download("jelleas/tests") + testerResult = checkpy.test(self.fileName) + self.assertTrue(testerResult.testResults[0].hasPassed) + + def test_deadLink(self): + with capturedOutput() as (out, err): + downloader.download("jelleas/doesnotexist") + self.assertTrue("DownloadError" in out.getvalue().strip()) + +class TestUpdate(BaseClean): + def test_clean(self): + downloader.update() + with capturedOutput() as (out, err): + downloader.list() + self.assertEqual(out.getvalue().strip(), "") + + def test_oneDownloaded(self): + downloader.download("jelleas/tests") + with capturedOutput() as (out, err): + downloader.update() + self.assertEqual(\ + out.getvalue().split("\n")[0].strip(), + "Finished downloading: https://github.com/jelleas/tests" + ) diff --git a/unittests/lib_test.py b/tests/unittests/lib_test.py similarity index 96% rename from unittests/lib_test.py rename to tests/unittests/lib_test.py index 749c858..754c0e4 100644 --- a/unittests/lib_test.py +++ b/tests/unittests/lib_test.py @@ -11,7 +11,7 @@ def setUp(self): self.source = "def f(x):" +\ " return x * 2" self.write(self.source) - + def tearDown(self): os.remove(self.fileName) caches.clearAllCaches() @@ -71,7 +71,7 @@ def test_comments(self): """ self.write(source) self.assertEqual(lib.sourceOfDefinitions(self.fileName), "") - + @unittest.expectedFailure def test_multilineString(self): source = \ @@ -79,7 +79,7 @@ def test_multilineString(self): x = \"\"\"foo\"\"\" """ self.write(source) - self.assertEqual(lib.sourceOfDefinitions(self.fileName), source) + self.assertEqual(lib.sourceOfDefinitions(self.fileName), source) def test_import(self): source = \ @@ -87,7 +87,7 @@ def test_import(self): from os import path """ self.write(source) - self.assertEqual(lib.sourceOfDefinitions(self.fileName), source) + self.assertEqual(lib.sourceOfDefinitions(self.fileName), source) class TestGetFunction(Base): @@ -142,13 +142,13 @@ def test_noInput(self): self.write(source) with self.assertRaises(exception.InputError): lib.outputOf(self.fileName) - + def test_argv(self): source = \ """ import sys print(sys.argv[1]) -""" +""" self.write(source) output = lib.outputOf(self.fileName, argv = [self.fileName, "foo"]) self.assertEqual(output, "foo\n") @@ -159,7 +159,7 @@ def test_ValueError(self): print("bar") raise ValueError print("foo") -""" +""" self.write(source) with self.assertRaises(exception.SourceException): output = lib.outputOf(self.fileName, argv = [self.fileName, "foo"]) @@ -171,7 +171,7 @@ def test_ignoreValueError(self): print("foo") raise ValueError print("bar") -""" +""" self.write(source) output = lib.outputOf(self.fileName, ignoreExceptions = [ValueError]) self.assertEqual(output, "foo\n") @@ -276,7 +276,7 @@ def test_only(self): def test_order(self): s = "3 1 2" - self.assertEqual(lib.getPositiveIntegersFromString(s), [3,1,2]) + self.assertEqual(lib.getPositiveIntegersFromString(s), [3,1,2]) def test_negatives(self): s = "-2" @@ -294,7 +294,7 @@ def test_only(self): def test_order(self): s = "3 1 2" - self.assertEqual(lib.getNumbersFromString(s), [3,1,2]) + self.assertEqual(lib.getNumbersFromString(s), [3,1,2]) def test_negatives(self): s = "-2" @@ -302,7 +302,7 @@ def test_negatives(self): def test_floats(self): s = "2.0" - self.assertEqual(lib.getNumbersFromString(s), [2.0]) + self.assertEqual(lib.getNumbersFromString(s), [2.0]) class TestGetLine(unittest.TestCase): @@ -326,4 +326,4 @@ def test_oneLineTooFar(self): if __name__ == '__main__': - unittest.main() \ No newline at end of file + unittest.main() From 0ec34e7b6b38d254cfb15366c5fe22eb7f547755 Mon Sep 17 00:00:00 2001 From: jelleas Date: Fri, 5 Jan 2018 00:37:38 +0100 Subject: [PATCH 061/269] TestList --- tests/integrationtests/downloader_test.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/tests/integrationtests/downloader_test.py b/tests/integrationtests/downloader_test.py index 78ef196..d4b9f88 100644 --- a/tests/integrationtests/downloader_test.py +++ b/tests/integrationtests/downloader_test.py @@ -76,3 +76,16 @@ def test_oneDownloaded(self): out.getvalue().split("\n")[0].strip(), "Finished downloading: https://github.com/jelleas/tests" ) + +class TestList(BaseClean): + def test_clean(self): + with capturedOutput() as (out, err): + downloader.list() + self.assertEqual(out.getvalue().strip(), "") + + def test_oneDownloaded(self): + downloader.download("jelleas/tests") + with capturedOutput() as (out, err): + downloader.list() + output = out.getvalue() + self.assertTrue("tests" in output and "jelleas" in output) From 276fde95fe53be59f64efa40ba304bd6d6a9f05f Mon Sep 17 00:00:00 2001 From: jelleas Date: Fri, 5 Jan 2018 00:41:36 +0100 Subject: [PATCH 062/269] TestClean --- tests/integrationtests/downloader_test.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/tests/integrationtests/downloader_test.py b/tests/integrationtests/downloader_test.py index d4b9f88..21d23a6 100644 --- a/tests/integrationtests/downloader_test.py +++ b/tests/integrationtests/downloader_test.py @@ -89,3 +89,17 @@ def test_oneDownloaded(self): downloader.list() output = out.getvalue() self.assertTrue("tests" in output and "jelleas" in output) + +class TestClean(Base): + def test_clean(self): + downloader.clean() + with capturedOutput() as (out, err): + downloader.list() + self.assertEqual(out.getvalue().strip(), "") + + def test_cleanAfterDownload(self): + downloader.download("jelleas/tests") + downloader.clean() + with capturedOutput() as (out, err): + downloader.list() + self.assertEqual(out.getvalue().strip(), "") From 18e3941a8ad0c9a84a5a3d87c0562e3bc97a82cc Mon Sep 17 00:00:00 2001 From: jelleas Date: Fri, 5 Jan 2018 10:09:23 +0100 Subject: [PATCH 063/269] bugfixes downloader --- checkpy/downloader.py | 55 +++++++++++------------ tests/integrationtests/downloader_test.py | 9 ++-- 2 files changed, 32 insertions(+), 32 deletions(-) diff --git a/checkpy/downloader.py b/checkpy/downloader.py index 37a7c0d..5f9b237 100644 --- a/checkpy/downloader.py +++ b/checkpy/downloader.py @@ -14,7 +14,7 @@ def __init__(self, name, path): self.path = path def pathAsString(self): - return self.path.asString() + return str(self.path) class File(object): def __init__(self, name, path): @@ -22,7 +22,7 @@ def __init__(self, name, path): self.path = path def pathAsString(self): - return self.path.asString() + return str(self.path) class Path(object): def __init__(self, path): @@ -30,24 +30,21 @@ def __init__(self, path): @property def fileName(self): - return os.path.basename(self.asString()) + return os.path.basename(str(self)) @property def folderName(self): - _, name = os.path.split(os.path.dirname(self.asString())) + _, name = os.path.split(os.path.dirname(str(self))) return name - def asString(self): - return self._path - def isPythonFile(self): return self.fileName.endswith(".py") def exists(self): - return os.path.exists(self.asString()) + return os.path.exists(str(self)) def walk(self): - for path, subdirs, files in os.walk(self.asString()): + for path, subdirs, files in os.walk(str(self)): yield Path(path), [Path(sd) for sd in subdirs], [Path(f) for f in files] def pathFromFolder(self, folderName): @@ -64,12 +61,12 @@ def __add__(self, other): try: # Python 3 if isinstance(other, bytes) or isinstance(other, str): - return Path(os.path.join(self.asString(), other)) + return Path(os.path.join(str(self), other)) except NameError: # Python 2 if isinstance(other, str) or isinstance(other, unicode): - return Path(os.path.join(self.asString(), other)) - return Path(os.path.join(self.asString(), other.asString())) + return Path(os.path.join(str(self), other)) + return Path(os.path.join(str(self), str(other))) def __sub__(self, other): my_items = [item for item in self] @@ -80,12 +77,9 @@ def __sub__(self, other): return Path(total) def __iter__(self): - for item in self.asString().split(os.path.sep): + for item in str(self).split(os.path.sep): yield item - def __repr__(self): - return self.asString() - def __hash__(self): return hash(repr(self)) @@ -96,7 +90,13 @@ def __contains__(self, item): return item in [item for item in self] def __nonzero__ (self): - return len(self.asString()) != 0 + return len(str(self)) != 0 + + def __str__(self): + return self._path + + def __repr__(self): + return "/".join([item for item in self]) HERE = Path(os.path.abspath(os.path.dirname(__file__))) @@ -239,19 +239,18 @@ def _download(githubUserName, githubRepoName): newTests.add(path.pathFromFolder("tests")) for filePath in [fp for fp in existingTests - newTests if fp.isPythonFile()]: - printer.displayRemoved(filePath.asString()) + printer.displayRemoved(str(filePath)) for filePath in [fp for fp in newTests - existingTests if fp.isPythonFile()]: - printer.displayAdded(filePath.asString()) + printer.displayAdded(str(filePath)) for filePath in existingTests - newTests: - os.remove((destFolder.path + filePath).asString()) + os.remove(str((destFolder.path + filePath))) _extractTests(z, destFolder) printer.displayCustom("Finished downloading: {}".format(githubLink)) -@caches.cache() def _downloadLocationsDatabase(): if not DBFOLDER.path.exists(): os.makedirs(DBFOLDER.pathAsString()) @@ -325,18 +324,18 @@ def _extractTest(zipfile, path, destFolder): if path.isPythonFile(): _extractFile(zipfile, path, filePath) - elif subfolderPath and not os.path.exists(filePath.asString()): - os.makedirs(filePath.asString()) + elif subfolderPath and not os.path.exists(str(filePath)): + os.makedirs(str(filePath)) def _extractFile(zipfile, path, filePath): - zipPathString = path.asString().replace("\\", "/") - if os.path.isfile(filePath.asString()): - with zipfile.open(zipPathString) as new, open(filePath.asString(), "r") as existing: + zipPathString = str(path).replace("\\", "/") + if os.path.isfile(str(filePath)): + with zipfile.open(zipPathString) as new, open(str(filePath), "r") as existing: # read file, decode, strip trailing whitespace, remove carrier return newText = ''.join(new.read().decode('utf-8').strip().splitlines()) existingText = ''.join(existing.read().strip().splitlines()) if newText != existingText: - printer.displayUpdate(path.asString()) + printer.displayUpdate(str(path)) - with zipfile.open(zipPathString) as source, open(filePath.asString(), "wb+") as target: + with zipfile.open(zipPathString) as source, open(str(filePath), "wb+") as target: shutil.copyfileobj(source, target) diff --git a/tests/integrationtests/downloader_test.py b/tests/integrationtests/downloader_test.py index 21d23a6..ca82c81 100644 --- a/tests/integrationtests/downloader_test.py +++ b/tests/integrationtests/downloader_test.py @@ -37,13 +37,13 @@ def tearDown(self): class TestDownload(BaseClean): def setUp(self): - super().setUp() + super(TestDownload, self).setUp() self.fileName = "some.py" with open(self.fileName, "w") as f: f.write("print(\"foo\")") def tearDown(self): - super().tearDown() + super(TestDownload, self).tearDown() os.remove(self.fileName) def test_spelledOutLink(self): @@ -55,12 +55,12 @@ def test_incompleteLink(self): downloader.download("jelleas/tests") testerResult = checkpy.test(self.fileName) self.assertTrue(testerResult.testResults[0].hasPassed) - + def test_deadLink(self): with capturedOutput() as (out, err): downloader.download("jelleas/doesnotexist") self.assertTrue("DownloadError" in out.getvalue().strip()) - + class TestUpdate(BaseClean): def test_clean(self): downloader.update() @@ -103,3 +103,4 @@ def test_cleanAfterDownload(self): with capturedOutput() as (out, err): downloader.list() self.assertEqual(out.getvalue().strip(), "") + \ No newline at end of file From d019921bd9216249e4bb27efd09442cc5b144514 Mon Sep 17 00:00:00 2001 From: jelleas Date: Fri, 5 Jan 2018 13:23:43 +0100 Subject: [PATCH 064/269] tester tests start --- checkpy/tester.py | 15 ++-- tests/integrationtests/tester_test.py | 112 ++++++++++++++++++++++++++ 2 files changed, 121 insertions(+), 6 deletions(-) create mode 100644 tests/integrationtests/tester_test.py diff --git a/checkpy/tester.py b/checkpy/tester.py index 23d332f..3c1c1c2 100644 --- a/checkpy/tester.py +++ b/checkpy/tester.py @@ -15,8 +15,9 @@ def test(testName, module = "", debugMode = False): path = _getPath(testName) if not path: - printer.displayError("File not found: {}".format(testName)) - return + tr = TesterResult() + tr.addOutput(printer.displayError("File not found: {}".format(testName))) + return tr fileName = os.path.basename(path) filePath = os.path.dirname(path) @@ -27,16 +28,18 @@ def test(testName, module = "", debugMode = False): testFileName = fileName.split(".")[0] + "Test.py" testFilePath = _getTestDirPath(testFileName, module = module) if testFilePath is None: - printer.displayError("No test found for {}".format(fileName)) - return + tr = TesterResult() + tr.addOutput(printer.displayError("No test found for {}".format(fileName))) + return tr if testFilePath not in sys.path: sys.path.append(testFilePath) if path.endswith(".ipynb"): if subprocess.call(['jupyter', 'nbconvert', '--to', 'script', path]) != 0: - printer.displayError("Failed to convert Jupyter notebook to .py") - return + tr = TesterResult() + tr.addOutput(printer.displayError("Failed to convert Jupyter notebook to .py")) + return tr path = path.replace(".ipynb", ".py") diff --git a/tests/integrationtests/tester_test.py b/tests/integrationtests/tester_test.py new file mode 100644 index 0000000..2cc97ea --- /dev/null +++ b/tests/integrationtests/tester_test.py @@ -0,0 +1,112 @@ +import unittest +import os +import sys +from contextlib import contextmanager +try: + # Python 2 + import StringIO +except: + # Python 3 + import io as StringIO +import checkpy +import checkpy.tester as tester +import checkpy.downloader as downloader +import checkpy.caches as caches +import checkpy.entities.exception as exception + +@contextmanager +def capturedOutput(): + new_out, new_err = StringIO.StringIO(), StringIO.StringIO() + old_out, old_err = sys.stdout, sys.stderr + try: + sys.stdout, sys.stderr = new_out, new_err + yield sys.stdout, sys.stderr + finally: + sys.stdout, sys.stderr = old_out, old_err + +class Base(unittest.TestCase): + #downloader.clean() + #downloader.download("jelleas/tests") + + def setUp(self): + self.fileName = "some.py" + self.source = "print(\"foo\")" + self.write(self.source) + + def tearDown(self): + if os.path.isfile(self.fileName): + os.remove(self.fileName) + caches.clearAllCaches() + + def write(self, source): + with open(self.fileName, "w") as f: + f.write(source) + +class TestTest(Base): + def test_oneTest(self): + testerResult = tester.test(self.fileName) + self.assertTrue(len(testerResult.testResults) == 1) + self.assertTrue(testerResult.testResults[0].hasPassed) + self.assertTrue("Testing: some.py".lower() in testerResult.output[0].lower()) + self.assertTrue(":)" in testerResult.output[1]) + self.assertTrue("prints exactly: foo".lower() in testerResult.output[1].lower()) + + def test_notebookOneTest(self): + fileName = "some.ipynb" + with open(fileName, "w") as f: + src = r"""{ + "cells": [ + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "print(\"foo\")" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.6.2" + } + }, + "nbformat": 4, + "nbformat_minor": 2 +}""" + f.write(src) + + testerResult = tester.test(fileName) + self.assertTrue(len(testerResult.testResults) == 1) + self.assertTrue(testerResult.testResults[0].hasPassed) + self.assertTrue("Testing: some.py".lower() in testerResult.output[0].lower()) + self.assertTrue(":)" in testerResult.output[1]) + self.assertTrue("prints exactly: foo".lower() in testerResult.output[1].lower()) + os.remove(fileName) + + def test_fileMising(self): + os.remove(self.fileName) + testerResult = tester.test(self.fileName) + self.assertTrue("file not found".lower() in testerResult.output[0].lower()) + self.assertTrue("some.py".lower() in testerResult.output[0].lower()) + + def test_testMissing(self): + downloader.clean() + testerResult = tester.test(self.fileName) + self.assertTrue("No test found for".lower() in testerResult.output[0].lower()) + self.assertTrue("some.py".lower() in testerResult.output[0].lower()) + downloader.download("jelleas/tests") From 7114fec46048027d40618ca3fcff98482543cc59 Mon Sep 17 00:00:00 2001 From: jelleas Date: Fri, 5 Jan 2018 13:41:53 +0100 Subject: [PATCH 065/269] tester_test update --- tests/integrationtests/tester_test.py | 34 ++++++++++++++------------- 1 file changed, 18 insertions(+), 16 deletions(-) diff --git a/tests/integrationtests/tester_test.py b/tests/integrationtests/tester_test.py index 2cc97ea..9d09f86 100644 --- a/tests/integrationtests/tester_test.py +++ b/tests/integrationtests/tester_test.py @@ -25,8 +25,8 @@ def capturedOutput(): sys.stdout, sys.stderr = old_out, old_err class Base(unittest.TestCase): - #downloader.clean() - #downloader.download("jelleas/tests") + downloader.clean() + downloader.download("jelleas/tests") def setUp(self): self.fileName = "some.py" @@ -43,13 +43,13 @@ def write(self, source): f.write(source) class TestTest(Base): - def test_oneTest(self): - testerResult = tester.test(self.fileName) - self.assertTrue(len(testerResult.testResults) == 1) - self.assertTrue(testerResult.testResults[0].hasPassed) - self.assertTrue("Testing: some.py".lower() in testerResult.output[0].lower()) - self.assertTrue(":)" in testerResult.output[1]) - self.assertTrue("prints exactly: foo".lower() in testerResult.output[1].lower()) + def test_oneTest(self): + for testerResult in [tester.test(self.fileName), tester.test(self.fileName.split(".")[0])]: + self.assertTrue(len(testerResult.testResults) == 1) + self.assertTrue(testerResult.testResults[0].hasPassed) + self.assertTrue("Testing: some.py".lower() in testerResult.output[0].lower()) + self.assertTrue(":)" in testerResult.output[1]) + self.assertTrue("prints exactly: foo".lower() in testerResult.output[1].lower()) def test_notebookOneTest(self): fileName = "some.ipynb" @@ -89,13 +89,15 @@ def test_notebookOneTest(self): "nbformat_minor": 2 }""" f.write(src) - - testerResult = tester.test(fileName) - self.assertTrue(len(testerResult.testResults) == 1) - self.assertTrue(testerResult.testResults[0].hasPassed) - self.assertTrue("Testing: some.py".lower() in testerResult.output[0].lower()) - self.assertTrue(":)" in testerResult.output[1]) - self.assertTrue("prints exactly: foo".lower() in testerResult.output[1].lower()) + + for testerResult in [tester.test(fileName), tester.test(fileName.split(".")[0])]: + self.assertTrue(len(testerResult.testResults) == 1) + self.assertTrue(testerResult.testResults[0].hasPassed) + self.assertTrue("Testing: some.py".lower() in testerResult.output[0].lower()) + self.assertTrue(":)" in testerResult.output[1]) + self.assertTrue("prints exactly: foo".lower() in testerResult.output[1].lower()) + self.assertFalse(os.path.isfile(self.fileName)) + os.remove(fileName) def test_fileMising(self): From 6fac1134591024154ac5ed2f8e72c801906dbd55 Mon Sep 17 00:00:00 2001 From: jelleas Date: Fri, 5 Jan 2018 14:37:03 +0100 Subject: [PATCH 066/269] run_tests.py --- README.rst | 4 +-- run_tests.py | 39 +++++++++++++++++++++++ tests/integrationtests/downloader_test.py | 7 ++-- tests/integrationtests/tester_test.py | 3 ++ 4 files changed, 49 insertions(+), 4 deletions(-) create mode 100644 run_tests.py diff --git a/README.rst b/README.rst index 82bb601..f73cd7f 100644 --- a/README.rst +++ b/README.rst @@ -182,5 +182,5 @@ Testing CheckPy :: - python2 -m unittest discover unittests "*_test.py" - python3 -m unittest discover unittests "*_test.py" \ No newline at end of file + python2 run_tests.py + python3 run_tests.py \ No newline at end of file diff --git a/run_tests.py b/run_tests.py new file mode 100644 index 0000000..9f49f51 --- /dev/null +++ b/run_tests.py @@ -0,0 +1,39 @@ +import unittest +import argparse + +def unittests(): + test_loader = unittest.TestLoader() + test_suite = test_loader.discover('tests/unittests', pattern='*_test.py') + return test_suite + +def integrationtests(): + test_loader = unittest.TestLoader() + test_suite = test_loader.discover('tests/integrationtests', pattern='*_test.py') + return test_suite + +def main(): + parser = argparse.ArgumentParser(description = "tests for checkpy") + parser.add_argument("-integration", action="store_true", help="run integration tests") + parser.add_argument("-unit", action="store_true", help="run unittests") + parser.add_argument("-all", action="store_true", help="run all tests") + args = parser.parse_args() + + runner = unittest.TextTestRunner(verbosity=1) + + if args.all: + runner.run(unittests()) + runner.run(integrationtests()) + return + + if args.unit: + runner.run(unittests()) + return + + if args.integration: + runner.run(integrationtests()) + return + + parser.print_help() + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/tests/integrationtests/downloader_test.py b/tests/integrationtests/downloader_test.py index ca82c81..4ab6de8 100644 --- a/tests/integrationtests/downloader_test.py +++ b/tests/integrationtests/downloader_test.py @@ -44,7 +44,8 @@ def setUp(self): def tearDown(self): super(TestDownload, self).tearDown() - os.remove(self.fileName) + if os.path.isfile(self.fileName): + os.remove(self.fileName) def test_spelledOutLink(self): downloader.download("https://github.com/jelleas/tests") @@ -103,4 +104,6 @@ def test_cleanAfterDownload(self): with capturedOutput() as (out, err): downloader.list() self.assertEqual(out.getvalue().strip(), "") - \ No newline at end of file + +if __name__ == '__main__': + unittest.main() \ No newline at end of file diff --git a/tests/integrationtests/tester_test.py b/tests/integrationtests/tester_test.py index 9d09f86..6d582d2 100644 --- a/tests/integrationtests/tester_test.py +++ b/tests/integrationtests/tester_test.py @@ -112,3 +112,6 @@ def test_testMissing(self): self.assertTrue("No test found for".lower() in testerResult.output[0].lower()) self.assertTrue("some.py".lower() in testerResult.output[0].lower()) downloader.download("jelleas/tests") + +if __name__ == '__main__': + unittest.main() \ No newline at end of file From 0929dec8c7f520692f1bdac7f5b5b32de6f6c45d Mon Sep 17 00:00:00 2001 From: jelleas Date: Fri, 5 Jan 2018 14:58:55 +0100 Subject: [PATCH 067/269] tester.testExists --- checkpy/tester.py | 5 +++++ tests/integrationtests/downloader_test.py | 6 ++++++ tests/integrationtests/tester_test.py | 23 +++++++++++++---------- 3 files changed, 24 insertions(+), 10 deletions(-) diff --git a/checkpy/tester.py b/checkpy/tester.py index 3c1c1c2..541f443 100644 --- a/checkpy/tester.py +++ b/checkpy/tester.py @@ -66,6 +66,11 @@ def testModule(module, debugMode = False): return [test(testName, module = module, debugMode = debugMode) for testName in testNames] +def testExists(testName, module = ""): + testFileName = testName.split(".")[0] + "Test.py" + testFilePath = _getTestDirPath(testFileName, module = module) + return bool(testFilePath) + def _getTestNames(moduleName): moduleName = _backslashToForwardslash(moduleName) for (dirPath, dirNames, fileNames) in os.walk(os.path.join(HERE, "tests")): diff --git a/tests/integrationtests/downloader_test.py b/tests/integrationtests/downloader_test.py index 4ab6de8..73c83b9 100644 --- a/tests/integrationtests/downloader_test.py +++ b/tests/integrationtests/downloader_test.py @@ -24,11 +24,15 @@ def capturedOutput(): sys.stdout, sys.stderr = old_out, old_err class Base(unittest.TestCase): + def setUp(self): + caches.clearAllCaches() + def tearDown(self): caches.clearAllCaches() class BaseClean(unittest.TestCase): def setUp(self): + caches.clearAllCaches() downloader.clean() def tearDown(self): @@ -50,11 +54,13 @@ def tearDown(self): def test_spelledOutLink(self): downloader.download("https://github.com/jelleas/tests") testerResult = checkpy.test(self.fileName) + self.assertTrue(len(testerResult.testResults) == 1) self.assertTrue(testerResult.testResults[0].hasPassed) def test_incompleteLink(self): downloader.download("jelleas/tests") testerResult = checkpy.test(self.fileName) + self.assertTrue(len(testerResult.testResults) == 1) self.assertTrue(testerResult.testResults[0].hasPassed) def test_deadLink(self): diff --git a/tests/integrationtests/tester_test.py b/tests/integrationtests/tester_test.py index 6d582d2..2efd9c4 100644 --- a/tests/integrationtests/tester_test.py +++ b/tests/integrationtests/tester_test.py @@ -25,14 +25,15 @@ def capturedOutput(): sys.stdout, sys.stderr = old_out, old_err class Base(unittest.TestCase): - downloader.clean() - downloader.download("jelleas/tests") - def setUp(self): + caches.clearAllCaches() self.fileName = "some.py" self.source = "print(\"foo\")" self.write(self.source) - + if not tester.testExists(self.fileName): + downloader.clean() + downloader.download("jelleas/tests") + def tearDown(self): if os.path.isfile(self.fileName): os.remove(self.fileName) @@ -93,7 +94,7 @@ def test_notebookOneTest(self): for testerResult in [tester.test(fileName), tester.test(fileName.split(".")[0])]: self.assertTrue(len(testerResult.testResults) == 1) self.assertTrue(testerResult.testResults[0].hasPassed) - self.assertTrue("Testing: some.py".lower() in testerResult.output[0].lower()) + self.assertTrue("Testing: {}".format(fileName.split(".")[0]).lower() in testerResult.output[0].lower()) self.assertTrue(":)" in testerResult.output[1]) self.assertTrue("prints exactly: foo".lower() in testerResult.output[1].lower()) self.assertFalse(os.path.isfile(self.fileName)) @@ -104,14 +105,16 @@ def test_fileMising(self): os.remove(self.fileName) testerResult = tester.test(self.fileName) self.assertTrue("file not found".lower() in testerResult.output[0].lower()) - self.assertTrue("some.py".lower() in testerResult.output[0].lower()) + self.assertTrue(self.fileName.lower() in testerResult.output[0].lower()) def test_testMissing(self): - downloader.clean() - testerResult = tester.test(self.fileName) + fileName = "foo.py" + with open(fileName, "w") as f: + pass + testerResult = tester.test(fileName) self.assertTrue("No test found for".lower() in testerResult.output[0].lower()) - self.assertTrue("some.py".lower() in testerResult.output[0].lower()) - downloader.download("jelleas/tests") + self.assertTrue(fileName.lower() in testerResult.output[0].lower()) + os.remove(fileName) if __name__ == '__main__': unittest.main() \ No newline at end of file From dc81e71d4bf4bfb2acbaaad393941ecf3988abc7 Mon Sep 17 00:00:00 2001 From: jelleas Date: Fri, 5 Jan 2018 15:01:41 +0100 Subject: [PATCH 068/269] run_tests buffers print output --- run_tests.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/run_tests.py b/run_tests.py index 9f49f51..7d68a61 100644 --- a/run_tests.py +++ b/run_tests.py @@ -18,7 +18,7 @@ def main(): parser.add_argument("-all", action="store_true", help="run all tests") args = parser.parse_args() - runner = unittest.TextTestRunner(verbosity=1) + runner = unittest.TextTestRunner(verbosity=1, buffer=True) if args.all: runner.run(unittests()) From 33ff76e7a5e5ac270ca9046c7be214d877d5070f Mon Sep 17 00:00:00 2001 From: jelleas Date: Thu, 11 Jan 2018 12:29:34 +0100 Subject: [PATCH 069/269] test.fileName in readme --- README.rst | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/README.rst b/README.rst index f73cd7f..02dd025 100644 --- a/README.rst +++ b/README.rst @@ -95,7 +95,7 @@ following: 0| @t.failed(exact) 1| @t.test(1) 2| def contains(test): - 3| test.test = lambda : assertlib.contains(lib.outputOf(_fileName), "100") + 3| test.test = lambda : assertlib.contains(lib.outputOf(test.fileName), "100") 4| test.description = lambda : "contains 100 in the output" 5| test.fail = lambda info : "the correct answer (100) cannot be found in the output" @@ -112,8 +112,8 @@ From top to bottom: the previous line. - On line 3 the ``test`` method is bound to a lambda which describes the test that is to be executed. In this case asserting that the - print output of ``_fileName`` contains the number ``100``. - ``_fileName`` is a magic variable that refers to the to be tested + print output of ``test.fileName`` contains the number ``100``. + ``test.fileName`` refers to the to be tested source file. Besides resulting in a boolean indicating passing or failing the test, the test method may also return a message. This message can be used in other methods to provide valuable information @@ -181,6 +181,6 @@ Testing CheckPy --------------- :: - - python2 run_tests.py - python3 run_tests.py \ No newline at end of file + + python2 run_tests.py + python3 run_tests.py From 259604dfd56c583d79c92c2b5bdfa56b05d525d0 Mon Sep 17 00:00:00 2001 From: jelleas Date: Thu, 11 Jan 2018 15:31:16 +0100 Subject: [PATCH 070/269] small readme update --- README.rst | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/README.rst b/README.rst index 02dd025..d272d3c 100644 --- a/README.rst +++ b/README.rst @@ -130,8 +130,8 @@ From top to bottom: Writing tests ------------- -Test methods are discovered in checkPy by filename. If one wants to test -a file ``foo.py``, the corresponding test must be named ``fooTest.py``. +Test methods are discovered in checkPy by filename. If you want to test +a file called ``foo.py``, the corresponding test must be named ``fooTest.py``. checkPy assumes that all methods in the test file are tests, as such one should not use the ``from ... import ...`` statement when importing modules. @@ -140,7 +140,9 @@ A test minimally consists of the following: .. code-block:: python - import check.test as t + import checkpy.test as t + import checkpy.assertlib as assertlib + import checkpy.lib as lib @t.test(0) def someTest(test): test.test = lambda : False From 24d4acfe34d5bfacedab389620179228b2df6c20 Mon Sep 17 00:00:00 2001 From: jelleas Date: Thu, 11 Jan 2018 15:32:37 +0100 Subject: [PATCH 071/269] small readme update --- README.rst | 3 +++ 1 file changed, 3 insertions(+) diff --git a/README.rst b/README.rst index d272d3c..df9e1d7 100644 --- a/README.rst +++ b/README.rst @@ -92,6 +92,9 @@ following: .. code-block:: python + import checkpy.test as t + import checkpy.assertlib as assertlib + import checkpy.lib as lib 0| @t.failed(exact) 1| @t.test(1) 2| def contains(test): From f50d4bd28e016402fba3ce338300f11ea0c6f885 Mon Sep 17 00:00:00 2001 From: jelleas Date: Thu, 11 Jan 2018 15:34:06 +0100 Subject: [PATCH 072/269] small readme update --- README.rst | 30 +++++++++++++++--------------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/README.rst b/README.rst index df9e1d7..86fd400 100644 --- a/README.rst +++ b/README.rst @@ -92,28 +92,28 @@ following: .. code-block:: python - import checkpy.test as t - import checkpy.assertlib as assertlib - import checkpy.lib as lib - 0| @t.failed(exact) - 1| @t.test(1) - 2| def contains(test): - 3| test.test = lambda : assertlib.contains(lib.outputOf(test.fileName), "100") - 4| test.description = lambda : "contains 100 in the output" - 5| test.fail = lambda info : "the correct answer (100) cannot be found in the output" + 0| import checkpy.test as t + 1| import checkpy.assertlib as assertlib + 2| import checkpy.lib as lib + 3| @t.failed(exact) + 4| @t.test(1) + 5| def contains(test): + 6| test.test = lambda : assertlib.contains(lib.outputOf(test.fileName), "100") + 7| test.description = lambda : "contains 100 in the output" + 8| test.fail = lambda info : "the correct answer (100) cannot be found in the output" From top to bottom: -- The decorator ``failed`` on line 0 defines a precondition. The test +- The decorator ``failed`` on line 3 defines a precondition. The test ``exact`` must have failed for the following tests to execute. -- The decorator ``test`` on line 1 prescribes that the following method +- The decorator ``test`` on line 4 prescribes that the following method creates a test with order number ``1``. Tests are executed in order, lowest first. -- The method definition on line 2 describes the name of the test +- The method definition on line 5 describes the name of the test (``contains``), and takes in an instance of ``Test`` found in ``test.py``. This instance is provided by the decorator ``test`` on the previous line. -- On line 3 the ``test`` method is bound to a lambda which describes +- On line 6 the ``test`` method is bound to a lambda which describes the test that is to be executed. In this case asserting that the print output of ``test.fileName`` contains the number ``100``. ``test.fileName`` refers to the to be tested @@ -121,9 +121,9 @@ From top to bottom: failing the test, the test method may also return a message. This message can be used in other methods to provide valuable information to the user. In this case however, no message is provided. -- On line 4 the ``description`` method is bound to a lambda which when +- On line 7 the ``description`` method is bound to a lambda which when called produces a string message describing the intent of the test. -- On line 5 the ``fail`` method is bound to a lambda. This method is +- On line 8 the ``fail`` method is bound to a lambda. This method is used to provide information that should be shown to the user in case the test fails. The method takes in a message (``info``) which comes from the second returned value of the From 04ba3146b14d8173e0a96472b873b1d91062f3a8 Mon Sep 17 00:00:00 2001 From: jelleas Date: Mon, 15 Jan 2018 16:10:45 +0100 Subject: [PATCH 073/269] refactoring, lib and assertlib are packages, unify file discovery --- .DS_Store | Bin 8196 -> 8196 bytes checkpy/.DS_Store | Bin 6148 -> 6148 bytes checkpy/__init__.py | 5 +- checkpy/assertlib/__init__.py | 1 + checkpy/{assertlib.py => assertlib/basic.py} | 0 checkpy/downloader.py | 121 +--------- checkpy/entities/path.py | 95 ++++++++ checkpy/entities/tests/tests/someTest.py | 12 + checkpy/lib.py | 238 ------------------- checkpy/staticlib.py | 34 --- checkpy/tester.py | 55 +---- tests/integrationtests/tester_test.py | 9 +- 12 files changed, 136 insertions(+), 434 deletions(-) create mode 100644 checkpy/assertlib/__init__.py rename checkpy/{assertlib.py => assertlib/basic.py} (100%) create mode 100644 checkpy/entities/path.py create mode 100644 checkpy/entities/tests/tests/someTest.py delete mode 100644 checkpy/lib.py delete mode 100644 checkpy/staticlib.py diff --git a/.DS_Store b/.DS_Store index 3df6a6d36f7b56c4709d3586632e2a2a7833ab6b..434e6fb1d592f3a8db719a15699b923ac1cd7570 100644 GIT binary patch delta 18 ZcmZp1XmQwZM}XDRP)EUF^Fsk;egHl=1?B(% delta 18 ZcmZp1XmQwZM}XDXR7b&N^Fsk;egHl=1?T_( diff --git a/checkpy/.DS_Store b/checkpy/.DS_Store index 0b9a44bd2ada453415acdcb4f1a3b8e052beac74..079bf2866e117fd844f83b3442832166af711571 100644 GIT binary patch literal 6148 zcmeHK&2G~`5T0#Pa6pJ05OCZJw;U3XfF6*n5Dwfb8Ns1ctsPs9CC3}Z4iSVPiXAG|hs8K!wMuepExkmj*j z4$UHu3vOpWEGHPt9UXNau2#?X20h(h_w?%J)8{??Vz0kmd)}ibgI8~lr=P#9zOKLh zu*@nvh*)T1bveaK!$jj^oJBZP3TrTDCxc*Yrn6d04PH|-8pVrp4Dj>&;Tiijdc~LU znoTa#kqb+06*epl%uCE)Z13VN zm*9RWp{Ngvd$~@TY!&bBbsDrAt7E`1a3u`T`Jgco`ht~3J~}YtCjep_)ht-1w*N_yKLSjH%Nzq&%D``KaobJ+ delta 79 zcmZoMXfc=|#>AjHu~2xjHM@);69WV=0x2LCV3_Q|zJId<2RqBghL=p6**W+*fT}kO ba(ri=%rBzI$T- (3,0): - exec(src, mod.__dict__) - else: - exec(src) in mod.__dict__ - - # add resulting module to sys - sys.modules[moduleName] = mod - except tuple(ignoreExceptions) as e: - pass - except exception.CheckpyError as e: - excep = e - except Exception as e: - excep = exception.SourceException( - exception = e, - message = "while trying to import the code", - output = stdout.getvalue(), - stacktrace = traceback.format_exc()) - except SystemExit as e: - excep = exception.ExitError( - message = "exit({}) while trying to import the code".format(int(e.args[0])), - output = stdout.getvalue(), - stacktrace = traceback.format_exc()) - - # wrap every function in mod with Function - for name, func in [(name, f) for name, f in mod.__dict__.items() if callable(f)]: - if func.__module__ == moduleName: - setattr(mod, name, function.Function(func)) - - # reset sys.argv - if argv: - sys.argv = argv - - output = stdout.getvalue() - if excep: - raise excep - - return mod, output - -def neutralizeFunction(function): - def dummy(*args, **kwargs): - pass - setattr(function, "__code__", dummy.__code__) - -def neutralizeFunctionFromImport(mod, functionName, importedModuleName): - for attr in [getattr(mod, name) for name in dir(mod)]: - if getattr(attr, "__name__", None) == importedModuleName: - if hasattr(attr, functionName): - neutralizeFunction(getattr(attr, functionName)) - if getattr(attr, "__name__", None) == functionName and getattr(attr, "__module__", None) == importedModuleName: - if hasattr(mod, functionName): - neutralizeFunction(getattr(mod, functionName)) - -def removeWhiteSpace(s): - return re.sub(r"\s+", "", s, flags=re.UNICODE) - -def getPositiveIntegersFromString(s): - return [int(i) for i in re.findall(r"\d+", s)] - -def getNumbersFromString(s): - return [eval(n) for n in re.findall(r"[-+]?\d*\.\d+|[-+]?\d+", s)] - -def getLine(text, lineNumber): - lines = text.split("\n") - try: - return lines[lineNumber] - except IndexError: - raise IndexError("Expected to have atleast {} lines in:\n{}".format(lineNumber + 1, text)) - -# inspiration from http://stackoverflow.com/questions/1769332/script-to-remove-python-comments-docstrings -def removeComments(source): - io_obj = StringIO.StringIO(source) - out = "" - prev_toktype = tokenize.INDENT - last_lineno = -1 - last_col = 0 - indentation = "\t" - for token_type, token_string, (start_line, start_col), (end_line, end_col), ltext in tokenize.generate_tokens(io_obj.readline): - if start_line > last_lineno: - last_col = 0 - - # figure out type of indentation used - if token_type == tokenize.INDENT: - indentation = "\t" if "\t" in token_string else " " - - # write indentation - if start_col > last_col and last_col == 0: - out += indentation * (start_col - last_col) - # write other whitespace - elif start_col > last_col: - out += " " * (start_col - last_col) - - # ignore comments - if token_type == tokenize.COMMENT: - pass - # put all docstrings on a single line - elif token_type == tokenize.STRING: - out += re.sub("\n", " ", token_string) - else: - out += token_string - - prev_toktype = token_type - last_col = end_col - last_lineno = end_line - return out diff --git a/checkpy/staticlib.py b/checkpy/staticlib.py deleted file mode 100644 index 2dd9ef2..0000000 --- a/checkpy/staticlib.py +++ /dev/null @@ -1,34 +0,0 @@ -from checkpy import caches -import redbaron - -def source(fileName): - with open(fileName) as f: - return f.read() - -@caches.cache() -def fullSyntaxTree(fileName): - return fstFromSource(source(fileName)) - -@caches.cache() -def fstFromSource(source): - return redbaron.RedBaron(source) - -@caches.cache() -def functionCode(functionName, fileName): - definitions = [d for d in fullSyntaxTree(fileName).find_all("def") if d.name == functionName] - if definitions: - return definitions[0] - return None - -def functionLOC(functionName, fileName): - code = functionCode(functionName, fileName) - ignoreNodes = [] - ignoreNodeTypes = [redbaron.EndlNode, redbaron.StringNode] - for node in code.value: - if any(isinstance(node, t) for t in ignoreNodeTypes): - ignoreNodes.append(node) - - for ignoreNode in ignoreNodes: - code.value.remove(ignoreNode) - - return len(code.value) + 1 \ No newline at end of file diff --git a/checkpy/tester.py b/checkpy/tester.py index 541f443..5d7aef7 100644 --- a/checkpy/tester.py +++ b/checkpy/tester.py @@ -1,6 +1,7 @@ from checkpy import printer from checkpy import caches from checkpy.entities import exception +from checkpy.lib import discovery import os import subprocess import sys @@ -10,14 +11,13 @@ import time import dill -HERE = os.path.abspath(os.path.dirname(__file__)) - def test(testName, module = "", debugMode = False): - path = _getPath(testName) + path = discovery.getPath(testName) if not path: tr = TesterResult() tr.addOutput(printer.displayError("File not found: {}".format(testName))) return tr + path = str(path) fileName = os.path.basename(path) filePath = os.path.dirname(path) @@ -26,11 +26,12 @@ def test(testName, module = "", debugMode = False): sys.path.append(filePath) testFileName = fileName.split(".")[0] + "Test.py" - testFilePath = _getTestDirPath(testFileName, module = module) + testFilePath = discovery.getTestFilePath(testFileName, module = module) if testFilePath is None: tr = TesterResult() tr.addOutput(printer.displayError("No test found for {}".format(fileName))) return tr + testFilePath = str(testFilePath) if testFilePath not in sys.path: sys.path.append(testFilePath) @@ -58,7 +59,7 @@ def test(testName, module = "", debugMode = False): return _runTests(testFileName.split(".")[0], path, debugMode = debugMode) def testModule(module, debugMode = False): - testNames = _getTestNames(module) + testNames = discovery.getTestNames(module) if not testNames: printer.displayError("no tests found in module: {}".format(module)) @@ -66,46 +67,6 @@ def testModule(module, debugMode = False): return [test(testName, module = module, debugMode = debugMode) for testName in testNames] -def testExists(testName, module = ""): - testFileName = testName.split(".")[0] + "Test.py" - testFilePath = _getTestDirPath(testFileName, module = module) - return bool(testFilePath) - -def _getTestNames(moduleName): - moduleName = _backslashToForwardslash(moduleName) - for (dirPath, dirNames, fileNames) in os.walk(os.path.join(HERE, "tests")): - dirPath = _backslashToForwardslash(dirPath) - if moduleName in dirPath.split("/")[-1]: - return [fileName[:-7] for fileName in fileNames if fileName.endswith(".py") and not fileName.startswith("_")] - -def _getPath(testName): - filePath = os.path.dirname(testName) - if not filePath: - filePath = os.path.dirname(os.path.abspath(testName)) - - fileName = os.path.basename(testName) - - if "." in fileName: - path = os.path.join(filePath, fileName) - return path if os.path.exists(path) else None - - for extension in [".py", ".ipynb"]: - path = os.path.join(filePath, fileName + extension) - if os.path.exists(path): - return path - - return None - -def _getTestDirPath(testFileName, module = ""): - module = _backslashToForwardslash(module) - testFileName = _backslashToForwardslash(testFileName) - for (dirPath, dirNames, fileNames) in os.walk(os.path.join(HERE, "tests")): - if module in _backslashToForwardslash(dirPath) and testFileName in fileNames: - return dirPath - -def _backslashToForwardslash(text): - return re.sub("\\\\", "/", text) - def _runTests(moduleName, fileName, debugMode = False): if sys.version_info[:2] >= (3,4): ctx = multiprocessing.get_context("spawn") @@ -188,9 +149,9 @@ def run(self): module._fileName = self.fileName self._sendSignal(_Signal(isTiming = False)) - + result = TesterResult() - result.addOutput(printer.displayTestName(os.path.basename(module._fileName))) + result.addOutput(printer.displayTestName(os.path.basename(self.fileName))) if hasattr(module, "before"): try: diff --git a/tests/integrationtests/tester_test.py b/tests/integrationtests/tester_test.py index 2efd9c4..cbb2ba2 100644 --- a/tests/integrationtests/tester_test.py +++ b/tests/integrationtests/tester_test.py @@ -13,6 +13,7 @@ import checkpy.downloader as downloader import checkpy.caches as caches import checkpy.entities.exception as exception +import checkpy.lib.discovery as discovery @contextmanager def capturedOutput(): @@ -23,17 +24,17 @@ def capturedOutput(): yield sys.stdout, sys.stderr finally: sys.stdout, sys.stderr = old_out, old_err - + class Base(unittest.TestCase): def setUp(self): caches.clearAllCaches() self.fileName = "some.py" self.source = "print(\"foo\")" self.write(self.source) - if not tester.testExists(self.fileName): + if not discovery.testExists(self.fileName): downloader.clean() downloader.download("jelleas/tests") - + def tearDown(self): if os.path.isfile(self.fileName): os.remove(self.fileName) @@ -117,4 +118,4 @@ def test_testMissing(self): os.remove(fileName) if __name__ == '__main__': - unittest.main() \ No newline at end of file + unittest.main() From 98ba542fb588b5c6c7227f27165c38c8409396ee Mon Sep 17 00:00:00 2001 From: jelleas Date: Mon, 15 Jan 2018 16:12:56 +0100 Subject: [PATCH 074/269] lib package --- .gitignore | 1 - checkpy/lib/__init__.py | 1 + checkpy/lib/basic.py | 244 +++++++++++++++++++++++++++++++++++++++ checkpy/lib/discovery.py | 39 +++++++ checkpy/lib/static.py | 34 ++++++ 5 files changed, 318 insertions(+), 1 deletion(-) create mode 100644 checkpy/lib/__init__.py create mode 100644 checkpy/lib/basic.py create mode 100644 checkpy/lib/discovery.py create mode 100644 checkpy/lib/static.py diff --git a/.gitignore b/.gitignore index 47aab8a..c8fb5a8 100644 --- a/.gitignore +++ b/.gitignore @@ -22,7 +22,6 @@ dist/ downloads/ eggs/ .eggs/ -lib/ lib64/ parts/ sdist/ diff --git a/checkpy/lib/__init__.py b/checkpy/lib/__init__.py new file mode 100644 index 0000000..98e0fc2 --- /dev/null +++ b/checkpy/lib/__init__.py @@ -0,0 +1 @@ +from checkpy.lib.basic import * diff --git a/checkpy/lib/basic.py b/checkpy/lib/basic.py new file mode 100644 index 0000000..c328113 --- /dev/null +++ b/checkpy/lib/basic.py @@ -0,0 +1,244 @@ +import sys +import os +import re +try: + # Python 2 + import StringIO +except: + # Python 3 + import io as StringIO +import contextlib +import importlib +import imp +import tokenize +import traceback +from checkpy.entities import exception +from checkpy.entities import function +from checkpy import caches + +HERE = os.path.abspath(os.path.dirname(__file__)) + +def require(fileName, downloadLocation = None): + return False + +def source(fileName): + source = "" + with open(fileName) as f: + source = f.read() + return source + +def sourceOfDefinitions(fileName): + newSource = "" + + with open(fileName) as f: + insideDefinition = False + for line in removeComments(f.read()).split("\n"): + line += "\n" + if not line.strip(): + continue + + if (line.startswith(" ") or line.startswith("\t")) and insideDefinition: + newSource += line + elif line.startswith("def ") or line.startswith("class "): + newSource += line + insideDefinition = True + elif line.startswith("import ") or line.startswith("from "): + newSource += line + else: + insideDefinition = False + return newSource + + +def getFunction(functionName, *args, **kwargs): + return getattr(module(*args, **kwargs), functionName) + +def outputOf(*args, **kwargs): + _, output = moduleAndOutputOf(*args, **kwargs) + return output + +def module(*args, **kwargs): + mod, _ = moduleAndOutputOf(*args, **kwargs) + return mod + +@caches.cache() +def moduleAndOutputOf( + fileName, + src = None, + argv = None, + stdinArgs = None, + ignoreExceptions = (), + overwriteAttributes = () + ): + """ + This function handles most of checkpy's under the hood functionality + fileName: the name of the file to run + source: the source code to be run + stdinArgs: optional arguments passed to stdin + ignoredExceptions: a collection of Exceptions that will silently pass + overwriteAttributes: a list of tuples [(attribute, value), ...] + """ + if src == None: + src = source(fileName) + + mod = None + output = "" + excep = None + + with _stdoutIO() as stdout, _stdinIO() as stdin: + # fill stdin with args + if stdinArgs: + for arg in stdinArgs: + stdin.write(str(arg) + "\n") + stdin.seek(0) + + # if argv given, overwrite sys.argv + if argv: + sys.argv, argv = argv, sys.argv + + moduleName = fileName.split(".")[0] + + try: + mod = imp.new_module(moduleName) + + # overwrite attributes + for attr, value in overwriteAttributes: + setattr(mod, attr, value) + + # execute code in mod + if sys.version_info > (3,0): + exec(src, mod.__dict__) + else: + exec(src) in mod.__dict__ + + # add resulting module to sys + sys.modules[moduleName] = mod + except tuple(ignoreExceptions) as e: + pass + except exception.CheckpyError as e: + excep = e + except Exception as e: + excep = exception.SourceException( + exception = e, + message = "while trying to import the code", + output = stdout.getvalue(), + stacktrace = traceback.format_exc()) + except SystemExit as e: + excep = exception.ExitError( + message = "exit({}) while trying to import the code".format(int(e.args[0])), + output = stdout.getvalue(), + stacktrace = traceback.format_exc()) + + # wrap every function in mod with Function + for name, func in [(name, f) for name, f in mod.__dict__.items() if callable(f)]: + if func.__module__ == moduleName: + setattr(mod, name, function.Function(func)) + + # reset sys.argv + if argv: + sys.argv = argv + + output = stdout.getvalue() + if excep: + raise excep + + return mod, output + +def neutralizeFunction(function): + def dummy(*args, **kwargs): + pass + setattr(function, "__code__", dummy.__code__) + +def neutralizeFunctionFromImport(mod, functionName, importedModuleName): + for attr in [getattr(mod, name) for name in dir(mod)]: + if getattr(attr, "__name__", None) == importedModuleName: + if hasattr(attr, functionName): + neutralizeFunction(getattr(attr, functionName)) + if getattr(attr, "__name__", None) == functionName and getattr(attr, "__module__", None) == importedModuleName: + if hasattr(mod, functionName): + neutralizeFunction(getattr(mod, functionName)) + +def removeWhiteSpace(s): + return re.sub(r"\s+", "", s, flags=re.UNICODE) + +def getPositiveIntegersFromString(s): + return [int(i) for i in re.findall(r"\d+", s)] + +def getNumbersFromString(s): + return [eval(n) for n in re.findall(r"[-+]?\d*\.\d+|[-+]?\d+", s)] + +def getLine(text, lineNumber): + lines = text.split("\n") + try: + return lines[lineNumber] + except IndexError: + raise IndexError("Expected to have atleast {} lines in:\n{}".format(lineNumber + 1, text)) + +# inspiration from http://stackoverflow.com/questions/1769332/script-to-remove-python-comments-docstrings +def removeComments(source): + io_obj = StringIO.StringIO(source) + out = "" + prev_toktype = tokenize.INDENT + last_lineno = -1 + last_col = 0 + indentation = "\t" + for token_type, token_string, (start_line, start_col), (end_line, end_col), ltext in tokenize.generate_tokens(io_obj.readline): + if start_line > last_lineno: + last_col = 0 + + # figure out type of indentation used + if token_type == tokenize.INDENT: + indentation = "\t" if "\t" in token_string else " " + + # write indentation + if start_col > last_col and last_col == 0: + out += indentation * (start_col - last_col) + # write other whitespace + elif start_col > last_col: + out += " " * (start_col - last_col) + + # ignore comments + if token_type == tokenize.COMMENT: + pass + # put all docstrings on a single line + elif token_type == tokenize.STRING: + out += re.sub("\n", " ", token_string) + else: + out += token_string + + prev_toktype = token_type + last_col = end_col + last_lineno = end_line + return out + +@contextlib.contextmanager +def _stdoutIO(stdout=None): + old = sys.stdout + if stdout is None: + stdout = StringIO.StringIO() + sys.stdout = stdout + yield stdout + sys.stdout = old + +@contextlib.contextmanager +def _stdinIO(stdin=None): + old_input = input + def new_input(prompt = None): + try: + return old_input() + except EOFError as e: + e = exception.InputError( + message = "You requested too much user input", + stacktrace = traceback.format_exc()) + raise e + + __builtins__["input"] = new_input + + old = sys.stdin + if stdin is None: + stdin = StringIO.StringIO() + sys.stdin = stdin + + yield stdin + + __builtins__["input"] = old_input + sys.stdin = old diff --git a/checkpy/lib/discovery.py b/checkpy/lib/discovery.py new file mode 100644 index 0000000..0f24ddb --- /dev/null +++ b/checkpy/lib/discovery.py @@ -0,0 +1,39 @@ +import os +import re +from checkpy.entities.path import Path, TESTSFOLDER + +def testExists(testName, module = ""): + testFileName = testName.split(".")[0] + "Test.py" + testFilePath = getTestFilePath(testFileName, module = module) + return bool(testFilePath) + +def getPath(path): + filePath = os.path.dirname(path) + if not filePath: + filePath = os.path.dirname(os.path.abspath(path)) + + fileName = os.path.basename(path) + + if "." in fileName: + path = Path(os.path.join(filePath, fileName)) + return path if path.exists() else None + + for extension in [".py", ".ipynb"]: + path = Path(os.path.join(filePath, fileName + extension)) + if path.exists(): + return path + + return None + +def getTestNames(moduleName): + for (dirPath, subdirs, files) in TESTSFOLDER.path.walk(): + if Path(moduleName) in dirPath: + return [f.fileName[:-7] for f in files if f.fileName.endswith(".py") and not f.fileName.startswith("_")] + +def getTestFilePath(testFileName, module = ""): + for (dirPath, dirNames, fileNames) in TESTSFOLDER.path.walk(): + if Path(testFileName) in fileNames and (not module or Path(module) in dirPath): + return dirPath + +def _backslashToForwardslash(text): + return re.sub("\\\\", "/", text) diff --git a/checkpy/lib/static.py b/checkpy/lib/static.py new file mode 100644 index 0000000..2dd9ef2 --- /dev/null +++ b/checkpy/lib/static.py @@ -0,0 +1,34 @@ +from checkpy import caches +import redbaron + +def source(fileName): + with open(fileName) as f: + return f.read() + +@caches.cache() +def fullSyntaxTree(fileName): + return fstFromSource(source(fileName)) + +@caches.cache() +def fstFromSource(source): + return redbaron.RedBaron(source) + +@caches.cache() +def functionCode(functionName, fileName): + definitions = [d for d in fullSyntaxTree(fileName).find_all("def") if d.name == functionName] + if definitions: + return definitions[0] + return None + +def functionLOC(functionName, fileName): + code = functionCode(functionName, fileName) + ignoreNodes = [] + ignoreNodeTypes = [redbaron.EndlNode, redbaron.StringNode] + for node in code.value: + if any(isinstance(node, t) for t in ignoreNodeTypes): + ignoreNodes.append(node) + + for ignoreNode in ignoreNodes: + code.value.remove(ignoreNode) + + return len(code.value) + 1 \ No newline at end of file From e71ec1917858c4ead0d4b2e98ee5d732354b717f Mon Sep 17 00:00:00 2001 From: jelleas Date: Mon, 15 Jan 2018 16:16:50 +0100 Subject: [PATCH 075/269] rm someTest --- checkpy/entities/tests/tests/someTest.py | 12 ------------ 1 file changed, 12 deletions(-) delete mode 100644 checkpy/entities/tests/tests/someTest.py diff --git a/checkpy/entities/tests/tests/someTest.py b/checkpy/entities/tests/tests/someTest.py deleted file mode 100644 index da20c75..0000000 --- a/checkpy/entities/tests/tests/someTest.py +++ /dev/null @@ -1,12 +0,0 @@ -import checkpy.tests as t -import checkpy.lib as lib -import checkpy.assertlib as asserts - -@t.test(10) -def exactlyFoo(test): - def testMethod(): - output = lib.outputOf(_fileName, overwriteAttributes = [("__name__", "__main__")]) - return asserts.exact(output.strip(), "foo") - - test.test = testMethod - test.description = lambda : "prints exactly: foo" From df365912b15961ff2ea5847fe59de1a539fe1ca9 Mon Sep 17 00:00:00 2001 From: jelleas Date: Tue, 16 Jan 2018 09:59:12 +0100 Subject: [PATCH 076/269] tests/storage folder moved to root again --- checkpy/entities/path.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/checkpy/entities/path.py b/checkpy/entities/path.py index af99033..9a7214b 100644 --- a/checkpy/entities/path.py +++ b/checkpy/entities/path.py @@ -90,6 +90,7 @@ def __str__(self): def __repr__(self): return "/".join([item for item in self]) -TESTSFOLDER = Folder("tests", Path(os.path.join(os.path.abspath(os.path.dirname(__file__)), "tests"))) -DBFOLDER = Folder("storage", Path(os.path.join(os.path.abspath(os.path.dirname(__file__)), "storage"))) +CHECKPYPATH = Path(os.path.abspath(os.path.dirname(__file__)).lower().split("checkpy")[0] + "checkpy") +TESTSFOLDER = Folder("tests", CHECKPYPATH + "tests") +DBFOLDER = Folder("storage", CHECKPYPATH + "storage") DBFILE = File("downloadLocations.json", DBFOLDER.path + "downloadLocations.json") From 0c71ac01c535c4b70f1618368426151d1b3a2ce6 Mon Sep 17 00:00:00 2001 From: jelleas Date: Tue, 16 Jan 2018 10:00:59 +0100 Subject: [PATCH 077/269] -.lower() --- checkpy/entities/path.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/checkpy/entities/path.py b/checkpy/entities/path.py index 9a7214b..11f22fd 100644 --- a/checkpy/entities/path.py +++ b/checkpy/entities/path.py @@ -90,7 +90,7 @@ def __str__(self): def __repr__(self): return "/".join([item for item in self]) -CHECKPYPATH = Path(os.path.abspath(os.path.dirname(__file__)).lower().split("checkpy")[0] + "checkpy") +CHECKPYPATH = Path(os.path.abspath(os.path.dirname(__file__)).split("checkpy")[0] + "checkpy") TESTSFOLDER = Folder("tests", CHECKPYPATH + "tests") DBFOLDER = Folder("storage", CHECKPYPATH + "storage") DBFILE = File("downloadLocations.json", DBFOLDER.path + "downloadLocations.json") From 2beb13fe4fc839e11582475a8d07e48626228fa9 Mon Sep 17 00:00:00 2001 From: jelleas Date: Tue, 16 Jan 2018 10:41:36 +0100 Subject: [PATCH 078/269] lib.require --- checkpy/entities/path.py | 1 + checkpy/lib/basic.py | 28 +++++++++++++++++++++++----- checkpy/lib/discovery.py | 3 +++ 3 files changed, 27 insertions(+), 5 deletions(-) diff --git a/checkpy/entities/path.py b/checkpy/entities/path.py index 11f22fd..8a1c424 100644 --- a/checkpy/entities/path.py +++ b/checkpy/entities/path.py @@ -90,6 +90,7 @@ def __str__(self): def __repr__(self): return "/".join([item for item in self]) +CWDPATH = Path(os.getcwd()) CHECKPYPATH = Path(os.path.abspath(os.path.dirname(__file__)).split("checkpy")[0] + "checkpy") TESTSFOLDER = Folder("tests", CHECKPYPATH + "tests") DBFOLDER = Folder("storage", CHECKPYPATH + "storage") diff --git a/checkpy/lib/basic.py b/checkpy/lib/basic.py index c328113..a59a5a0 100644 --- a/checkpy/lib/basic.py +++ b/checkpy/lib/basic.py @@ -1,5 +1,4 @@ import sys -import os import re try: # Python 2 @@ -12,14 +11,19 @@ import imp import tokenize import traceback +import requests +from checkpy.entities import path from checkpy.entities import exception from checkpy.entities import function +from checkpy.lib import discovery from checkpy import caches -HERE = os.path.abspath(os.path.dirname(__file__)) - -def require(fileName, downloadLocation = None): - return False +def require(fileName, source = None): + fileExists = discovery.fileExists(fileName) + if source and not fileExists: + download(source, fileName) + fileExists = True + return fileExists def source(fileName): source = "" @@ -157,6 +161,20 @@ def neutralizeFunctionFromImport(mod, functionName, importedModuleName): if hasattr(mod, functionName): neutralizeFunction(getattr(mod, functionName)) +def download(source, destination = None): + try: + r = requests.get(source) + except requests.exceptions.ConnectionError as e: + raise exception.DownloadError(message = "Oh no! It seems like there is no internet connection available?!") + + if not r.ok: + raise exception.DownloadError(message = "Failed to download {} because: {}".format(source, r.reason)) + + if not destination: + destination = path.CWDPATH + with open(str(destination), "wb+") as target: + target.write(r.content) + def removeWhiteSpace(s): return re.sub(r"\s+", "", s, flags=re.UNICODE) diff --git a/checkpy/lib/discovery.py b/checkpy/lib/discovery.py index 0f24ddb..b56ef0d 100644 --- a/checkpy/lib/discovery.py +++ b/checkpy/lib/discovery.py @@ -2,6 +2,9 @@ import re from checkpy.entities.path import Path, TESTSFOLDER +def fileExists(fileName): + return Path(fileName).exists() + def testExists(testName, module = ""): testFileName = testName.split(".")[0] + "Test.py" testFilePath = getTestFilePath(testFileName, module = module) From 2fdf96572f7eedeca6d3cf1f98508747560ca2f1 Mon Sep 17 00:00:00 2001 From: jelleas Date: Tue, 16 Jan 2018 11:02:48 +0100 Subject: [PATCH 079/269] moved managers into packages --- checkpy/downloader/__init__.py | 1 + checkpy/{ => downloader}/downloader.py | 0 checkpy/lib/basic.py | 5 ++--- checkpy/printer/__init__.py | 1 + checkpy/{ => printer}/printer.py | 0 checkpy/tester/__init__.py | 1 + checkpy/{lib => tester}/discovery.py | 3 --- checkpy/{ => tester}/tester.py | 2 +- setup.py | 2 +- tests/integrationtests/tester_test.py | 2 +- 10 files changed, 8 insertions(+), 9 deletions(-) create mode 100644 checkpy/downloader/__init__.py rename checkpy/{ => downloader}/downloader.py (100%) create mode 100644 checkpy/printer/__init__.py rename checkpy/{ => printer}/printer.py (100%) create mode 100644 checkpy/tester/__init__.py rename checkpy/{lib => tester}/discovery.py (95%) rename checkpy/{ => tester}/tester.py (99%) diff --git a/checkpy/downloader/__init__.py b/checkpy/downloader/__init__.py new file mode 100644 index 0000000..3f4b639 --- /dev/null +++ b/checkpy/downloader/__init__.py @@ -0,0 +1 @@ +from checkpy.downloader.downloader import * diff --git a/checkpy/downloader.py b/checkpy/downloader/downloader.py similarity index 100% rename from checkpy/downloader.py rename to checkpy/downloader/downloader.py diff --git a/checkpy/lib/basic.py b/checkpy/lib/basic.py index a59a5a0..8c06178 100644 --- a/checkpy/lib/basic.py +++ b/checkpy/lib/basic.py @@ -15,13 +15,12 @@ from checkpy.entities import path from checkpy.entities import exception from checkpy.entities import function -from checkpy.lib import discovery from checkpy import caches def require(fileName, source = None): - fileExists = discovery.fileExists(fileName) + fileExists = path.Path(fileName).exists() if source and not fileExists: - download(source, fileName) + download(source, destination = fileName) fileExists = True return fileExists diff --git a/checkpy/printer/__init__.py b/checkpy/printer/__init__.py new file mode 100644 index 0000000..b5b8eef --- /dev/null +++ b/checkpy/printer/__init__.py @@ -0,0 +1 @@ +from checkpy.printer.printer import * diff --git a/checkpy/printer.py b/checkpy/printer/printer.py similarity index 100% rename from checkpy/printer.py rename to checkpy/printer/printer.py diff --git a/checkpy/tester/__init__.py b/checkpy/tester/__init__.py new file mode 100644 index 0000000..4dd0f14 --- /dev/null +++ b/checkpy/tester/__init__.py @@ -0,0 +1 @@ +from checkpy.tester.tester import * diff --git a/checkpy/lib/discovery.py b/checkpy/tester/discovery.py similarity index 95% rename from checkpy/lib/discovery.py rename to checkpy/tester/discovery.py index b56ef0d..0f24ddb 100644 --- a/checkpy/lib/discovery.py +++ b/checkpy/tester/discovery.py @@ -2,9 +2,6 @@ import re from checkpy.entities.path import Path, TESTSFOLDER -def fileExists(fileName): - return Path(fileName).exists() - def testExists(testName, module = ""): testFileName = testName.split(".")[0] + "Test.py" testFilePath = getTestFilePath(testFileName, module = module) diff --git a/checkpy/tester.py b/checkpy/tester/tester.py similarity index 99% rename from checkpy/tester.py rename to checkpy/tester/tester.py index 5d7aef7..1d9411d 100644 --- a/checkpy/tester.py +++ b/checkpy/tester/tester.py @@ -1,7 +1,7 @@ from checkpy import printer from checkpy import caches from checkpy.entities import exception -from checkpy.lib import discovery +from checkpy.tester import discovery import os import subprocess import sys diff --git a/setup.py b/setup.py index 876fa0d..d6c3448 100644 --- a/setup.py +++ b/setup.py @@ -44,7 +44,7 @@ include_package_data=True, - install_requires=["requests", "tinydb", "dill", "colorama"], + install_requires=["requests", "tinydb", "dill", "colorama", "redbaron"], extras_require={ 'dev': [], diff --git a/tests/integrationtests/tester_test.py b/tests/integrationtests/tester_test.py index cbb2ba2..be7187d 100644 --- a/tests/integrationtests/tester_test.py +++ b/tests/integrationtests/tester_test.py @@ -13,7 +13,7 @@ import checkpy.downloader as downloader import checkpy.caches as caches import checkpy.entities.exception as exception -import checkpy.lib.discovery as discovery +import checkpy.tester.discovery as discovery @contextmanager def capturedOutput(): From 03fd48634d2bfc151a11fb3f3579143200064542 Mon Sep 17 00:00:00 2001 From: jelleas Date: Tue, 16 Jan 2018 12:33:10 +0100 Subject: [PATCH 080/269] require and download tests --- checkpy/lib/basic.py | 8 +++----- tests/unittests/lib_test.py | 27 +++++++++++++++++++++++++++ 2 files changed, 30 insertions(+), 5 deletions(-) diff --git a/checkpy/lib/basic.py b/checkpy/lib/basic.py index 8c06178..a5053ab 100644 --- a/checkpy/lib/basic.py +++ b/checkpy/lib/basic.py @@ -20,7 +20,7 @@ def require(fileName, source = None): fileExists = path.Path(fileName).exists() if source and not fileExists: - download(source, destination = fileName) + download(fileName, source) fileExists = True return fileExists @@ -160,7 +160,7 @@ def neutralizeFunctionFromImport(mod, functionName, importedModuleName): if hasattr(mod, functionName): neutralizeFunction(getattr(mod, functionName)) -def download(source, destination = None): +def download(fileName, source): try: r = requests.get(source) except requests.exceptions.ConnectionError as e: @@ -169,9 +169,7 @@ def download(source, destination = None): if not r.ok: raise exception.DownloadError(message = "Failed to download {} because: {}".format(source, r.reason)) - if not destination: - destination = path.CWDPATH - with open(str(destination), "wb+") as target: + with open(str(fileName), "wb+") as target: target.write(r.content) def removeWhiteSpace(s): diff --git a/tests/unittests/lib_test.py b/tests/unittests/lib_test.py index 754c0e4..a5ef50c 100644 --- a/tests/unittests/lib_test.py +++ b/tests/unittests/lib_test.py @@ -21,6 +21,19 @@ def write(self, source): f.write(source) +class TestRequire(Base): + def test_fileDoesNotExist(self): + self.assertFalse(lib.require("idonotexist.random")) + + def test_fileExists(self): + self.assertTrue(lib.require(self.fileName)) + + def test_fileDownload(self): + fileName = "inowexist.random" + self.assertTrue(lib.require(fileName, "https://raw.githubusercontent.com/Jelleas/tests/master/tests/someTest.py")) + self.assertTrue(os.path.isfile(fileName)) + os.remove(fileName) + class TestSource(Base): def test_expectedOutput(self): source = lib.source(self.fileName) @@ -263,6 +276,20 @@ def dummy(): self.assertEqual(dummy(), None) +class TestDownload(unittest.TestCase): + def test_fileDownload(self): + fileName = "someTest.py" + lib.download(fileName, "https://raw.githubusercontent.com/Jelleas/tests/master/tests/{}".format(fileName)) + self.assertTrue(os.path.isfile(fileName)) + os.remove(fileName) + + def test_fileDownloadRename(self): + fileName = "someRandomFileName.name" + lib.download(fileName, "https://raw.githubusercontent.com/Jelleas/tests/master/tests/someTest.py") + self.assertTrue(os.path.isfile(fileName)) + os.remove(fileName) + + class TestRemoveWhiteSpace(unittest.TestCase): def test_remove(self): s = lib.removeWhiteSpace(" \t foo\t\t bar ") From ce8f8eeb55c40a15990c70dcb7e65582879b34d0 Mon Sep 17 00:00:00 2001 From: jelleas Date: Tue, 16 Jan 2018 13:13:46 +0100 Subject: [PATCH 081/269] warning when more than 1 test is found --- checkpy/printer/printer.py | 7 ++++++- checkpy/tester/discovery.py | 10 ++++++---- checkpy/tester/tester.py | 38 +++++++++++++++++++++---------------- 3 files changed, 34 insertions(+), 21 deletions(-) diff --git a/checkpy/printer/printer.py b/checkpy/printer/printer.py index b5ef66c..4b7ce71 100644 --- a/checkpy/printer/printer.py +++ b/checkpy/printer/printer.py @@ -45,7 +45,7 @@ def displayRemoved(fileName): return msg def displayAdded(fileName): - msg = "{}Added: {}{}".format(_Colors.WARNING, os.path.basename(fileName), _Colors.ENDC) + msg = "{}Added: {}{}".format(_Colors.WARNING, os.path.basename(fileName), _Colors.ENDC) print(msg) return msg @@ -53,6 +53,11 @@ def displayCustom(message): print(message) return message +def displayWarning(message): + msg = "{}Warning: {}{}".format(_Colors.WARNING, message, _Colors.ENDC) + print(msg) + return msg + def displayError(message): msg = "{}{} {}{}".format(_Colors.WARNING, _Smileys.CONFUSED, message, _Colors.ENDC) print(msg) diff --git a/checkpy/tester/discovery.py b/checkpy/tester/discovery.py index 0f24ddb..f946711 100644 --- a/checkpy/tester/discovery.py +++ b/checkpy/tester/discovery.py @@ -4,8 +4,8 @@ def testExists(testName, module = ""): testFileName = testName.split(".")[0] + "Test.py" - testFilePath = getTestFilePath(testFileName, module = module) - return bool(testFilePath) + testPaths = getTestPaths(testFileName, module = module) + return len(testPaths) > 0 def getPath(path): filePath = os.path.dirname(path) @@ -30,10 +30,12 @@ def getTestNames(moduleName): if Path(moduleName) in dirPath: return [f.fileName[:-7] for f in files if f.fileName.endswith(".py") and not f.fileName.startswith("_")] -def getTestFilePath(testFileName, module = ""): +def getTestPaths(testFileName, module = ""): + testFilePaths = [] for (dirPath, dirNames, fileNames) in TESTSFOLDER.path.walk(): if Path(testFileName) in fileNames and (not module or Path(module) in dirPath): - return dirPath + testFilePaths.append(dirPath) + return testFilePaths def _backslashToForwardslash(text): return re.sub("\\\\", "/", text) diff --git a/checkpy/tester/tester.py b/checkpy/tester/tester.py index 1d9411d..09bfa48 100644 --- a/checkpy/tester/tester.py +++ b/checkpy/tester/tester.py @@ -12,11 +12,12 @@ import dill def test(testName, module = "", debugMode = False): + result = TesterResult() + path = discovery.getPath(testName) if not path: - tr = TesterResult() - tr.addOutput(printer.displayError("File not found: {}".format(testName))) - return tr + result.addOutput(printer.displayError("File not found: {}".format(testName))) + return result path = str(path) fileName = os.path.basename(path) @@ -26,21 +27,24 @@ def test(testName, module = "", debugMode = False): sys.path.append(filePath) testFileName = fileName.split(".")[0] + "Test.py" - testFilePath = discovery.getTestFilePath(testFileName, module = module) - if testFilePath is None: - tr = TesterResult() - tr.addOutput(printer.displayError("No test found for {}".format(fileName))) - return tr - testFilePath = str(testFilePath) + testPaths = discovery.getTestPaths(testFileName, module = module) + + if not testPaths: + result.addOutput(printer.displayError("No test found for {}".format(fileName))) + return result + + if len(testPaths) > 1: + result.addOutput(printer.displayWarning("Found {} tests: {}, using: {}".format(len(testPaths), testPaths, testPaths[0]))) + + testFilePath = str(testPaths[0]) if testFilePath not in sys.path: sys.path.append(testFilePath) if path.endswith(".ipynb"): if subprocess.call(['jupyter', 'nbconvert', '--to', 'script', path]) != 0: - tr = TesterResult() - tr.addOutput(printer.displayError("Failed to convert Jupyter notebook to .py")) - return tr + result.addOutput(printer.displayError("Failed to convert Jupyter notebook to .py")) + return result path = path.replace(".ipynb", ".py") @@ -53,10 +57,12 @@ def test(testName, module = "", debugMode = False): testerResult = _runTests(testFileName.split(".")[0], path, debugMode = debugMode) os.remove(path) - - return testerResult - - return _runTests(testFileName.split(".")[0], path, debugMode = debugMode) + else: + testerResult = _runTests(testFileName.split(".")[0], path, debugMode = debugMode) + + testerResult.output = result.output + testerResult.output + return testerResult + def testModule(module, debugMode = False): testNames = discovery.getTestNames(module) From ba1f459631d296f5fc855f4d16cc5441aa75385c Mon Sep 17 00:00:00 2001 From: jelleas Date: Thu, 18 Jan 2018 23:14:56 +0100 Subject: [PATCH 082/269] sandbox --- checkpy/entities/path.py | 10 +++++++++- checkpy/lib/basic.py | 16 ++++++++++++---- checkpy/tester/sandbox.py | 24 ++++++++++++++++++++++++ checkpy/tester/tester.py | 17 +++++++++++++---- 4 files changed, 58 insertions(+), 9 deletions(-) create mode 100644 checkpy/tester/sandbox.py diff --git a/checkpy/entities/path.py b/checkpy/entities/path.py index 8a1c424..c2afb38 100644 --- a/checkpy/entities/path.py +++ b/checkpy/entities/path.py @@ -1,4 +1,5 @@ import os +import shutil class Folder(object): def __init__(self, name, path): @@ -39,6 +40,9 @@ def walk(self): for path, subdirs, files in os.walk(str(self)): yield Path(path), [Path(sd) for sd in subdirs], [Path(f) for f in files] + def copyTo(self, destination): + shutil.copyfile(str(self), str(destination)) + def pathFromFolder(self, folderName): path = "" seen = False @@ -90,7 +94,11 @@ def __str__(self): def __repr__(self): return "/".join([item for item in self]) -CWDPATH = Path(os.getcwd()) +def current(): + return Path(os.getcwd()) + +userFolder = Folder(os.path.basename(os.getcwd()), Path(os.getcwd())) + CHECKPYPATH = Path(os.path.abspath(os.path.dirname(__file__)).split("checkpy")[0] + "checkpy") TESTSFOLDER = Folder("tests", CHECKPYPATH + "tests") DBFOLDER = Folder("storage", CHECKPYPATH + "storage") diff --git a/checkpy/lib/basic.py b/checkpy/lib/basic.py index a5053ab..0a92f35 100644 --- a/checkpy/lib/basic.py +++ b/checkpy/lib/basic.py @@ -18,11 +18,19 @@ from checkpy import caches def require(fileName, source = None): - fileExists = path.Path(fileName).exists() - if source and not fileExists: + if source: download(fileName, source) - fileExists = True - return fileExists + return + + filePath = path.userFolder.path + fileName + + if not fileExists(str(filePath)): + raise exception.CheckpyError("Required file {} does not exist".format(fileName)) + + filePath.copyTo(path.current() + fileName) + +def fileExists(fileName): + return path.Path(fileName).exists() def source(fileName): source = "" diff --git a/checkpy/tester/sandbox.py b/checkpy/tester/sandbox.py new file mode 100644 index 0000000..78dfea0 --- /dev/null +++ b/checkpy/tester/sandbox.py @@ -0,0 +1,24 @@ +import os +import shutil +import uuid +import checkpy.entities.path as path + +class Sandbox(): + def __init__(self, filePath): + self.id = "sandbox_" + str(uuid.uuid4()) + self.path = path.Path(os.path.abspath(os.path.dirname(__file__))) + self.id + self._filePath = filePath + os.makedirs(str(self.path)) + + def _clear(self): + if self.path.exists(): + shutil.rmtree(str(self.path)) + + def __enter__(self): + self._oldCWD = os.getcwd() + os.chdir(str(self.path)) + self._filePath.copyTo(self.path + self._filePath.fileName) + + def __exit__(self, exc_type, exc_val, exc_tb): + os.chdir(str(self._oldCWD)) + self._clear() \ No newline at end of file diff --git a/checkpy/tester/tester.py b/checkpy/tester/tester.py index 09bfa48..5e36cf6 100644 --- a/checkpy/tester/tester.py +++ b/checkpy/tester/tester.py @@ -1,7 +1,8 @@ from checkpy import printer from checkpy import caches -from checkpy.entities import exception +from checkpy.entities import exception, path from checkpy.tester import discovery +from checkpy.tester.sandbox import Sandbox import os import subprocess import sys @@ -143,7 +144,8 @@ def __init__(self, moduleName, fileName, debugMode, signalQueue, resultQueue): self.debugMode = debugMode self.signalQueue = signalQueue self.resultQueue = resultQueue - + self.reservedNames = ["before", "after", "sandbox"] + def run(self): if self.debugMode: printer.DEBUG_MODE = True @@ -154,6 +156,14 @@ def run(self): module = importlib.import_module(self.moduleName) module._fileName = self.fileName + if hasattr(module, "sandbox"): + with Sandbox(path.Path(self.fileName)): + module.sandbox() + return self._runTestsFromModule(module) + + return self._runTestsFromModule(module) + + def _runTestsFromModule(self, module): self._sendSignal(_Signal(isTiming = False)) result = TesterResult() @@ -166,8 +176,7 @@ def run(self): result.addOutput(printer.displayError("Something went wrong at setup:\n{}".format(e))) return - reservedNames = ["before", "after"] - testCreators = [method for method in module.__dict__.values() if callable(method) and method.__name__ not in reservedNames] + testCreators = [method for method in module.__dict__.values() if callable(method) and method.__name__ not in self.reservedNames] result.nTests = len(testCreators) From 2027303c28b4584fbb767c4b62763f81a32d1de7 Mon Sep 17 00:00:00 2001 From: jelleas Date: Thu, 18 Jan 2018 23:28:41 +0100 Subject: [PATCH 083/269] update unittests --- tests/unittests/lib_test.py | 23 +++++++++++++++++++---- 1 file changed, 19 insertions(+), 4 deletions(-) diff --git a/tests/unittests/lib_test.py b/tests/unittests/lib_test.py index a5ef50c..2343d42 100644 --- a/tests/unittests/lib_test.py +++ b/tests/unittests/lib_test.py @@ -1,5 +1,6 @@ import unittest import os +import shutil import checkpy.lib as lib import checkpy.caches as caches import checkpy.entities.exception as exception @@ -21,19 +22,33 @@ def write(self, source): f.write(source) -class TestRequire(Base): +class TestFileExists(Base): def test_fileDoesNotExist(self): - self.assertFalse(lib.require("idonotexist.random")) + self.assertFalse(lib.fileExists("idonotexist.random")) def test_fileExists(self): - self.assertTrue(lib.require(self.fileName)) + self.assertTrue(lib.fileExists(self.fileName)) +class TestRequire(Base): def test_fileDownload(self): fileName = "inowexist.random" - self.assertTrue(lib.require(fileName, "https://raw.githubusercontent.com/Jelleas/tests/master/tests/someTest.py")) + lib.require(fileName, "https://raw.githubusercontent.com/Jelleas/tests/master/tests/someTest.py") self.assertTrue(os.path.isfile(fileName)) os.remove(fileName) + def test_fileLocalCopy(self): + cwd = os.getcwd() + name = "testrequire" + + os.mkdir(name) + os.chdir(os.path.join(cwd, name)) + + lib.require(self.fileName) + self.assertTrue(os.path.isfile(self.fileName)) + + os.chdir(cwd) + shutil.rmtree(name) + class TestSource(Base): def test_expectedOutput(self): source = lib.source(self.fileName) From af44d67974e510d586605140ba20074e43825199 Mon Sep 17 00:00:00 2001 From: jelleas Date: Fri, 19 Jan 2018 23:06:47 +0100 Subject: [PATCH 084/269] bugfix CHECKPYPATH --- checkpy/entities/path.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/checkpy/entities/path.py b/checkpy/entities/path.py index c2afb38..d66512e 100644 --- a/checkpy/entities/path.py +++ b/checkpy/entities/path.py @@ -65,6 +65,8 @@ def __add__(self, other): return Path(os.path.join(str(self), str(other))) def __sub__(self, other): + if isinstance(other, str): + other = Path(other) my_items = [item for item in self] other_items = [item for item in other] total = "" @@ -99,7 +101,7 @@ def current(): userFolder = Folder(os.path.basename(os.getcwd()), Path(os.getcwd())) -CHECKPYPATH = Path(os.path.abspath(os.path.dirname(__file__)).split("checkpy")[0] + "checkpy") +CHECKPYPATH = Path(os.path.abspath(os.path.dirname(__file__))[:-len("/entities")]) TESTSFOLDER = Folder("tests", CHECKPYPATH + "tests") DBFOLDER = Folder("storage", CHECKPYPATH + "storage") DBFILE = File("downloadLocations.json", DBFOLDER.path + "downloadLocations.json") From 239aaf6b2ae51947e41f0e7421fa82403226ded8 Mon Sep 17 00:00:00 2001 From: jelleas Date: Fri, 19 Jan 2018 23:43:40 +0100 Subject: [PATCH 085/269] removed Folder/File from path --- .DS_Store | Bin 8196 -> 0 bytes checkpy/downloader/downloader.py | 38 +++++++++++++++---------------- checkpy/entities/path.py | 26 +++++---------------- checkpy/lib/basic.py | 2 +- checkpy/tester/discovery.py | 6 ++--- 5 files changed, 29 insertions(+), 43 deletions(-) delete mode 100644 .DS_Store diff --git a/.DS_Store b/.DS_Store deleted file mode 100644 index 434e6fb1d592f3a8db719a15699b923ac1cd7570..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 8196 zcmeHM-EPw`6h7VtO&N$@AiB%kAaPNqc8IYFA$47gR0+^TC%6E#HB0|QNUA2KDy675 zJOkqqcoKL69)t&g@7P|IrY%=BVD_1yx|1`%0EjG1+03yj-2 z&Dn~sxe65E6TPA?4Xa*o;&^l3rot#-6fg=H1&jhlfqy{(?AdJAl6_ybn%pR06!QAT(9zBZg3PwA<_sENYx6 zRCE%GPC}0?^a(}C(ZRE&JBgw~lN$w$0`m&+**!%k)TRdV*!ewh(89k=_mFP>kcQHn zw=)R4jwijpI9;z3M)~|Vv9h{$;bO{4r>*oA>t(GS3~Et5>W8Iz@40;HxD7{_4}-v) zmg~;bPGfMCxwaqpQN80gdNKDG&pN&v3`#-YcSG6FK|N}ND+fBtTpx{Y?-p`)ew?#M zJ6qd1`|fUjJhrUtjl%uv@$2E+(Yx{ck8&9#^p}KKg1DXHAt8wyo_G$X$&^IXn_AEJ z0>6b8Zq3BBJfIF*sG@FDrf=gj zXz{nPPLF60o>h7PMvV?JB1W2JiEM&(iJriFkIG zNv#CqdxBxjFs3&{XKPuhC;Tt-EYQD;aeS1TSc!*nI6IDv$kSLvF6G5y^XuP^%`9T` z&Q{Le+4^O0a`3^H2InUkr;~_CknZW>*X#CH2XgbV$3LT zjuco`bKT(ce+65Yxd%8$FV9TXDDayU5NqXHxrEN-uD+mC-sNqiV Date: Fri, 19 Jan 2018 23:45:03 +0100 Subject: [PATCH 086/269] .DS_Store --- checkpy/.DS_Store | Bin 6148 -> 0 bytes 1 file changed, 0 insertions(+), 0 deletions(-) delete mode 100644 checkpy/.DS_Store diff --git a/checkpy/.DS_Store b/checkpy/.DS_Store deleted file mode 100644 index 079bf2866e117fd844f83b3442832166af711571..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 6148 zcmeHK&2G~`5T0#Pa6pJ05OCZJw;U3XfF6*n5Dwfb8Ns1ctsPs9CC3}Z4iSVPiXAG|hs8K!wMuepExkmj*j z4$UHu3vOpWEGHPt9UXNau2#?X20h(h_w?%J)8{??Vz0kmd)}ibgI8~lr=P#9zOKLh zu*@nvh*)T1bveaK!$jj^oJBZP3TrTDCxc*Yrn6d04PH|-8pVrp4Dj>&;Tiijdc~LU znoTa#kqb+06*epl%uCE)Z13VN zm*9RWp{Ngvd$~@TY!&bBbsDrAt7E`1a3u`T`Jgco`ht~3J~}YtCjep_)ht-1w*N_yKLSjH%Nzq&%D``KaobJ+ From 4dc0962aed7185f909c924765d57e35b057ed055 Mon Sep 17 00:00:00 2001 From: jelleas Date: Fri, 19 Jan 2018 23:49:58 +0100 Subject: [PATCH 087/269] check whether file exists before copying --- checkpy/tester/sandbox.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/checkpy/tester/sandbox.py b/checkpy/tester/sandbox.py index 78dfea0..6b937f7 100644 --- a/checkpy/tester/sandbox.py +++ b/checkpy/tester/sandbox.py @@ -17,8 +17,9 @@ def _clear(self): def __enter__(self): self._oldCWD = os.getcwd() os.chdir(str(self.path)) - self._filePath.copyTo(self.path + self._filePath.fileName) + if self._filePath.exists(): + self._filePath.copyTo(self.path + self._filePath.fileName) def __exit__(self, exc_type, exc_val, exc_tb): os.chdir(str(self._oldCWD)) - self._clear() \ No newline at end of file + self._clear() From 9dce172c2bc528cfc4d9b812e0e5209519ab283a Mon Sep 17 00:00:00 2001 From: jelleas Date: Sun, 21 Jan 2018 12:30:35 +0100 Subject: [PATCH 088/269] caputeInput/output + function.printOutput --- checkpy/entities/function.py | 17 ++++++++++++++-- checkpy/lib/basic.py | 6 +++--- tests/unittests/lib_test.py | 38 ++++++++++++++++++++++++++++++++++++ 3 files changed, 56 insertions(+), 5 deletions(-) diff --git a/checkpy/entities/function.py b/checkpy/entities/function.py index 212adca..4b40cfc 100644 --- a/checkpy/entities/function.py +++ b/checkpy/entities/function.py @@ -1,12 +1,18 @@ +import contextlib from . import exception class Function(object): def __init__(self, function): self._function = function + self._stdoutOutput = "" def __call__(self, *args, **kwargs): + import checkpy.lib as lib try: - return self._function(*args, **kwargs) + with lib.captureStdout() as stdout: + outcome = self._function(*args, **kwargs) + self._stdoutOutput = stdout.getvalue() + return outcome except Exception as e: argumentNames = self.arguments() nArgs = len(args) + len(kwargs) @@ -22,8 +28,15 @@ def __call__(self, *args, **kwargs): @property def name(self): + """gives the name of the function""" return self._function.__name__ @property def arguments(self): - return list(self._function.__code__.co_varnames) \ No newline at end of file + """gives the argument names of the function""" + return list(self._function.__code__.co_varnames) + + @property + def printOutput(self): + """stateful function that returns the print (stdout) output of the latest function call as a string""" + return self._stdoutOutput \ No newline at end of file diff --git a/checkpy/lib/basic.py b/checkpy/lib/basic.py index a08e71e..3916cdb 100644 --- a/checkpy/lib/basic.py +++ b/checkpy/lib/basic.py @@ -95,7 +95,7 @@ def moduleAndOutputOf( output = "" excep = None - with _stdoutIO() as stdout, _stdinIO() as stdin: + with captureStdout() as stdout, captureStdin() as stdin: # fill stdin with args if stdinArgs: for arg in stdinArgs: @@ -234,7 +234,7 @@ def removeComments(source): return out @contextlib.contextmanager -def _stdoutIO(stdout=None): +def captureStdout(stdout=None): old = sys.stdout if stdout is None: stdout = StringIO.StringIO() @@ -243,7 +243,7 @@ def _stdoutIO(stdout=None): sys.stdout = old @contextlib.contextmanager -def _stdinIO(stdin=None): +def captureStdin(stdin=None): old_input = input def new_input(prompt = None): try: diff --git a/tests/unittests/lib_test.py b/tests/unittests/lib_test.py index 2343d42..6b9be74 100644 --- a/tests/unittests/lib_test.py +++ b/tests/unittests/lib_test.py @@ -367,5 +367,43 @@ def test_oneLineTooFar(self): lib.getLine(s, 2) +class TestCaptureStdout(unittest.TestCase): + def test_blank(self): + with lib.captureStdout() as stdout: + self.assertTrue(len(stdout.getvalue()) == 0) + + def test_noOutput(self): + with lib.captureStdout() as stdout: + print("foo") + self.assertEqual("foo\n", stdout.getvalue()) + + def test_noLeakage(self): + import sys + with lib.captureStdout() as stdout: + print("foo") + self.assertTrue(len(sys.stdout.getvalue()) == 0) + +class TestCaptureStdin(unittest.TestCase): + def test_noInput(self): + with lib.captureStdin() as stdin: + with self.assertRaises(exception.InputError): + input() + + def test_oneInput(self): + with lib.captureStdin() as stdin: + stdin.write("foo\n") + stdin.seek(0) + self.assertEqual(input(), "foo") + with self.assertRaises(exception.InputError): + input() + + def test_noLeakage(self): + with lib.captureStdin() as stdin, lib.captureStdout() as stdout: + stdin.write("foo\n") + stdin.seek(0) + self.assertEqual(input("hello!"), "foo") + self.assertTrue(len(stdout.read()) == 0) + + if __name__ == '__main__': unittest.main() From 2ee4695e73d88ff66185bb1899da403c6cb3579a Mon Sep 17 00:00:00 2001 From: jelleas Date: Sun, 21 Jan 2018 13:20:05 +0100 Subject: [PATCH 089/269] function tests --- checkpy/entities/function.py | 8 +-- tests/unittests/function_test.py | 99 ++++++++++++++++++++++++++++++++ 2 files changed, 103 insertions(+), 4 deletions(-) create mode 100644 tests/unittests/function_test.py diff --git a/checkpy/entities/function.py b/checkpy/entities/function.py index 4b40cfc..e565a7f 100644 --- a/checkpy/entities/function.py +++ b/checkpy/entities/function.py @@ -1,5 +1,5 @@ import contextlib -from . import exception +import checkpy.entities.exception as exception class Function(object): def __init__(self, function): @@ -14,15 +14,15 @@ def __call__(self, *args, **kwargs): self._stdoutOutput = stdout.getvalue() return outcome except Exception as e: - argumentNames = self.arguments() + argumentNames = self.arguments nArgs = len(args) + len(kwargs) - message = "while trying to execute {}()".format(self.name()) + message = "while trying to execute {}()".format(self.name) if nArgs > 0: argsRepr = ", ".join("{}={}".format(argumentNames[i], args[i]) for i in range(len(args))) kwargsRepr = ", ".join("{}={}".format(kwargName, kwargs[kwargName]) for kwargName in argumentNames[len(args):nArgs]) representation = ", ".join(s for s in [argsRepr, kwargsRepr] if s) - message = "while trying to execute {}({})".format(self.name(), representation) + message = "while trying to execute {}({})".format(self.name, representation) raise exception.SourceException(exception = e, message = message) diff --git a/tests/unittests/function_test.py b/tests/unittests/function_test.py new file mode 100644 index 0000000..822fd2f --- /dev/null +++ b/tests/unittests/function_test.py @@ -0,0 +1,99 @@ +import unittest +import os +import shutil +import checkpy.lib as lib +import checkpy.entities.exception as exception +from checkpy.entities.function import Function + +class TestFunctionName(unittest.TestCase): + def test_name(self): + def foo(): + pass + self.assertEqual(Function(foo).name, "foo") + +class TestFunctionArguments(unittest.TestCase): + def test_noArgs(self): + def foo(): + pass + self.assertEqual(Function(foo).arguments, []) + + def test_args(self): + def foo(bar, baz): + pass + self.assertEqual(Function(foo).arguments, ["bar", "baz"]) + + def test_kwargs(self): + def foo(bar = None, baz = None): + pass + self.assertEqual(Function(foo).arguments, ["bar", "baz"]) + + def test_argsAndKwargs(self): + def foo(bar, baz = None): + pass + self.assertEqual(Function(foo).arguments, ["bar", "baz"]) + +class TestFunctionCall(unittest.TestCase): + def test_dummy(self): + def foo(): + return None + f = Function(foo) + self.assertEqual(f(), None) + + def test_arg(self): + def foo(bar): + return bar + 1 + f = Function(foo) + self.assertEqual(f(1), 2) + self.assertEqual(f(bar = 1), 2) + + def test_kwarg(self): + def foo(bar=0): + return bar + 1 + f = Function(foo) + self.assertEqual(f(), 1) + + self.assertEqual(f(1), 2) + + self.assertEqual(f(bar = 1), 2) + + def test_exception(self): + def foo(): + raise ValueError("baz") + f = Function(foo) + with self.assertRaises(exception.SourceException): + f() + + def test_noStdoutSideEfects(self): + def foo(): + print("bar") + f = Function(foo) + with lib.captureStdout() as stdout: + f() + self.assertTrue(len(stdout.read()) == 0) + +class TestFunctionPrintOutput(unittest.TestCase): + def test_noOutput(self): + def foo(): + pass + f = Function(foo) + f() + self.assertEqual(f.printOutput, "") + + def test_oneLineOutput(self): + def foo(): + print("bar") + f = Function(foo) + f() + self.assertEqual(f.printOutput, "bar\n") + + def test_twoLineOutput(self): + def foo(): + print("bar") + print("baz") + f = Function(foo) + f() + self.assertEqual(f.printOutput, "bar\nbaz\n") + + +if __name__ == '__main__': + unittest.main() From d00ff3ad537b43c59caff16434aa5f1efbe06a97 Mon Sep 17 00:00:00 2001 From: jelleas Date: Mon, 22 Jan 2018 14:45:02 +0100 Subject: [PATCH 090/269] fixed Function.printOutput with nested calls --- checkpy/entities/function.py | 86 +++++++++++++++++++++++++++++--- checkpy/lib/basic.py | 2 +- tests/unittests/function_test.py | 29 ++++++++++- 3 files changed, 108 insertions(+), 9 deletions(-) diff --git a/checkpy/entities/function.py b/checkpy/entities/function.py index e565a7f..8ae77b2 100644 --- a/checkpy/entities/function.py +++ b/checkpy/entities/function.py @@ -1,17 +1,21 @@ +import sys import contextlib import checkpy.entities.exception as exception +if sys.version_info >= (3,0): + import io +else: + import StringIO as io class Function(object): def __init__(self, function): self._function = function - self._stdoutOutput = "" + self._printOutput = "" def __call__(self, *args, **kwargs): - import checkpy.lib as lib try: - with lib.captureStdout() as stdout: + with self._captureStdout() as listener: outcome = self._function(*args, **kwargs) - self._stdoutOutput = stdout.getvalue() + self._printOutput = listener.content return outcome except Exception as e: argumentNames = self.arguments @@ -19,11 +23,11 @@ def __call__(self, *args, **kwargs): message = "while trying to execute {}()".format(self.name) if nArgs > 0: - argsRepr = ", ".join("{}={}".format(argumentNames[i], args[i]) for i in range(len(args))) + argsRepr = ", ".join("{}={}".format(argumentNames[i], args[i]) for i in range(len(args))) kwargsRepr = ", ".join("{}={}".format(kwargName, kwargs[kwargName]) for kwargName in argumentNames[len(args):nArgs]) representation = ", ".join(s for s in [argsRepr, kwargsRepr] if s) message = "while trying to execute {}({})".format(self.name, representation) - + raise exception.SourceException(exception = e, message = message) @property @@ -39,4 +43,72 @@ def arguments(self): @property def printOutput(self): """stateful function that returns the print (stdout) output of the latest function call as a string""" - return self._stdoutOutput \ No newline at end of file + return self._printOutput + + @contextlib.contextmanager + def _captureStdout(self): + """ + capture sys.stdout in _outStream + (a _Stream that is an instance of StringIO extended with the Observer pattern) + returns a _StreamListener on said stream + """ + outStreamListener = _StreamListener(_outStream) + old = sys.stdout + + outStreamListener.start() + sys.stdout = outStreamListener.stream + + yield outStreamListener + + sys.stdout = old + outStreamListener.stop() + +class _Stream(io.StringIO): + def __init__(self, *args, **kwargs): + super(_Stream, self).__init__(*args, **kwargs) + self._listeners = [] + + def register(self, listener): + self._listeners.append(listener) + + def unregister(self, listener): + self._listeners.remove(listener) + + def write(self, text): + """Overwrites StringIO.write to update all listeners""" + super(_Stream, self).write(text) + self._onUpdate(text) + + def writelines(self, sequence): + """Overwrites StringIO.writelines to update all listeners""" + super(_Stream, self).writelines(sequence) + for item in sequence: + self._onUpdate(item) + + def _onUpdate(self, content): + for listener in self._listeners: + listener.update(content) + +class _StreamListener(object): + def __init__(self, stream): + self._stream = stream + self._content = "" + + def start(self): + self.stream.register(self) + + def stop(self): + self.stream.unregister(self) + + def update(self, content): + self._content += content + + @property + def content(self): + return self._content + + @property + def stream(self): + return self._stream + +_outStream = _Stream() diff --git a/checkpy/lib/basic.py b/checkpy/lib/basic.py index 3916cdb..2cca7ba 100644 --- a/checkpy/lib/basic.py +++ b/checkpy/lib/basic.py @@ -116,7 +116,7 @@ def moduleAndOutputOf( setattr(mod, attr, value) # execute code in mod - if sys.version_info > (3,0): + if sys.version_info >= (3,0): exec(src, mod.__dict__) else: exec(src) in mod.__dict__ diff --git a/tests/unittests/function_test.py b/tests/unittests/function_test.py index 822fd2f..3c00407 100644 --- a/tests/unittests/function_test.py +++ b/tests/unittests/function_test.py @@ -92,8 +92,35 @@ def foo(): print("baz") f = Function(foo) f() - self.assertEqual(f.printOutput, "bar\nbaz\n") + self.assertEqual(f.printOutput, "bar\nbaz\n") + def test_indirectPrint(self): + def foo(): + Function(bar)() + def bar(): + print("baz") + foo = Function(foo) + foo() + self.assertEqual(foo.printOutput, "baz\n") + + def test_indirectPrintWithOrder(self): + def foo(): + print("foo") + Function(bar)() + print("baz") + def bar(): + print("bar") + foo = Function(foo) + foo() + self.assertEqual(foo.printOutput, "foo\nbar\nbaz\n") + + def test_multipleCalls(self): + def foo(): + print("foo") + foo = Function(foo) + foo() + foo() + self.assertEqual(foo.printOutput, "foo\n") if __name__ == '__main__': unittest.main() From ca0250cda0e7cff0fe617d987cd942a7222654c1 Mon Sep 17 00:00:00 2001 From: jelleas Date: Mon, 22 Jan 2018 16:00:46 +0100 Subject: [PATCH 091/269] start path tests --- tests/unittests/path_test.py | 121 +++++++++++++++++++++++++++++++++++ 1 file changed, 121 insertions(+) create mode 100644 tests/unittests/path_test.py diff --git a/tests/unittests/path_test.py b/tests/unittests/path_test.py new file mode 100644 index 0000000..9ce2901 --- /dev/null +++ b/tests/unittests/path_test.py @@ -0,0 +1,121 @@ +import unittest +import os +import shutil +from checkpy.entities.path import Path +import checkpy.entities.exception as exception + +class TestPathFileName(unittest.TestCase): + def test_name(self): + path = Path("foo.txt") + self.assertEqual(path.fileName, "foo.txt") + + def test_nestedName(self): + path = Path("/foo/bar/baz.txt") + self.assertEqual(path.fileName, "baz.txt") + + def test_extraSlash(self): + path = Path("/foo/bar/baz.txt/") + self.assertEqual(path.fileName, "baz.txt") + +class TestPathFolderName(unittest.TestCase): + def test_name(self): + path = Path("/foo/bar/baz.txt") + self.assertEqual(path.folderName, "bar") + + def test_extraSlash(self): + path = Path("/foo/bar/baz.txt/") + self.assertEqual(path.folderName, "bar") + +class TestPathContainingFolder(unittest.TestCase): + def test_empty(self): + path = Path("") + self.assertEqual(str(path.containingFolder()), ".") + + def test_file(self): + path = Path("/foo/bar/baz.txt") + self.assertEqual(str(path.containingFolder()), "/foo/bar") + + def test_folder(self): + path = Path("/foo/bar/baz/") + self.assertEqual(str(path.containingFolder()), "/foo/bar") + +class TestPathIsPythonFile(unittest.TestCase): + def test_noPythonFile(self): + path = Path("/foo/bar/baz.txt") + self.assertFalse(path.isPythonFile()) + + def test_pythonFile(self): + path = Path("/foo/bar/baz.py") + self.assertTrue(path.isPythonFile()) + + def test_folder(self): + path = Path("/foo/bar/baz/") + self.assertFalse(path.isPythonFile()) + + path = Path("/foo/bar/baz") + self.assertFalse(path.isPythonFile()) + +class TestPathExists(unittest.TestCase): + def test_doesNotExist(self): + path = Path("foo/bar/baz.py") + self.assertFalse(path.exists()) + + def test_exists(self): + fileName = "dummy.py" + with open(fileName, "w") as f: + pass + path = Path("dummy.py") + self.assertTrue(path.isPythonFile()) + os.remove(fileName) + +class TestPathWalk(unittest.TestCase): + def setUp(self): + self.dirName = "dummy" + os.mkdir(self.dirName) + + def tearDown(self): + if os.path.exists(self.dirName): + shutil.rmtree(self.dirName) + + def test_oneDir(self): + paths = [] + for path, subdirs, files in Path(self.dirName).walk(): + paths.append(path) + self.assertTrue(len(paths) == 1) + self.assertEqual(str(paths[0]), self.dirName) + + def test_oneDirOneFile(self): + fileName = "dummy.py" + with open(os.path.join(self.dirName, fileName), "w") as f: + pass + ps = [] + fs = [] + for path, subdirs, files in Path(self.dirName).walk(): + ps.append(path) + fs.extend(files) + self.assertTrue(len(ps) == 1) + self.assertEqual(str(ps[0]), self.dirName) + self.assertTrue(len(fs) == 1) + self.assertEqual(str(fs[0]), fileName) + + def test_nestedDirs(self): + otherDir = os.path.join(self.dirName, "dummy2") + os.mkdir(otherDir) + fileName = "dummy.py" + with open(os.path.join(self.dirName, fileName), "w") as f: + pass + ps = [] + fs = [] + for path, subdirs, files in Path(self.dirName).walk(): + ps.append(path) + fs.extend(files) + self.assertTrue(len(ps) == 2) + self.assertEqual(str(ps[0]), self.dirName) + self.assertEqual(str(ps[1]), otherDir) + self.assertTrue(len(fs) == 1) + self.assertEqual(str(fs[0]), fileName) + + + +if __name__ == '__main__': + unittest.main() From 8da5afcf1cacccca7040e3b4a337b8072c4b474e Mon Sep 17 00:00:00 2001 From: jelleas Date: Mon, 22 Jan 2018 16:02:42 +0100 Subject: [PATCH 092/269] setUp/tearDown --- tests/unittests/path_test.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/tests/unittests/path_test.py b/tests/unittests/path_test.py index 9ce2901..a4f6140 100644 --- a/tests/unittests/path_test.py +++ b/tests/unittests/path_test.py @@ -56,17 +56,21 @@ def test_folder(self): self.assertFalse(path.isPythonFile()) class TestPathExists(unittest.TestCase): + def setUp(self): + self.fileName = "dummy.py" + with open(self.fileName, "w") as f: + pass + + def tearDown(self): + os.remove(self.fileName) + def test_doesNotExist(self): path = Path("foo/bar/baz.py") self.assertFalse(path.exists()) def test_exists(self): - fileName = "dummy.py" - with open(fileName, "w") as f: - pass path = Path("dummy.py") self.assertTrue(path.isPythonFile()) - os.remove(fileName) class TestPathWalk(unittest.TestCase): def setUp(self): From cee479c9c6514f0dc036fe352d993ce6f61ae2a1 Mon Sep 17 00:00:00 2001 From: jelleas Date: Mon, 22 Jan 2018 16:11:15 +0100 Subject: [PATCH 093/269] copyTo tests --- tests/unittests/path_test.py | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/tests/unittests/path_test.py b/tests/unittests/path_test.py index a4f6140..ff1fa9c 100644 --- a/tests/unittests/path_test.py +++ b/tests/unittests/path_test.py @@ -119,6 +119,31 @@ def test_nestedDirs(self): self.assertTrue(len(fs) == 1) self.assertEqual(str(fs[0]), fileName) +class TestPathCopyTo(unittest.TestCase): + def setUp(self): + self.fileName = "dummy.txt" + self.content = "foo" + with open(self.fileName, "w") as f: + f.write(self.content) + self.target = "dummy.py" + + def tearDown(self): + os.remove(self.fileName) + if os.path.exists(self.target): + os.remove(self.target) + + def test_noFile(self): + fileName = "idonotexist.py" + path = Path(fileName) + with self.assertRaises(FileNotFoundError): + path.copyTo(".") + self.assertFalse(os.path.exists(fileName)) + + def test_file(self): + path = Path(self.fileName) + path.copyTo(self.target) + self.assertTrue(os.path.exists(self.fileName)) + self.assertTrue(os.path.exists(self.target)) if __name__ == '__main__': From 8ed16624cbba54528fcf93cdb662ff2a1a1b4be9 Mon Sep 17 00:00:00 2001 From: jelleas Date: Mon, 22 Jan 2018 16:23:29 +0100 Subject: [PATCH 094/269] pathFromFolder --- checkpy/entities/exception.py | 3 +++ checkpy/entities/path.py | 4 ++++ tests/unittests/path_test.py | 15 +++++++++++++++ 3 files changed, 22 insertions(+) diff --git a/checkpy/entities/exception.py b/checkpy/entities/exception.py index f893f29..9dd107b 100644 --- a/checkpy/entities/exception.py +++ b/checkpy/entities/exception.py @@ -35,3 +35,6 @@ class DownloadError(CheckpyError): class ExitError(CheckpyError): pass + +class PathError(CheckpyError): + pass diff --git a/checkpy/entities/path.py b/checkpy/entities/path.py index 682cff3..2ecbf36 100644 --- a/checkpy/entities/path.py +++ b/checkpy/entities/path.py @@ -1,5 +1,6 @@ import os import shutil +import checkpy.entities.exception as exception class Path(object): def __init__(self, path): @@ -38,6 +39,9 @@ def pathFromFolder(self, folderName): path = os.path.join(path, item) if item == folderName: seen = True + + if not seen: + raise exception.PathError(message = "folder {} does not exist in {}".format(folderName, self)) return Path(path) def __add__(self, other): diff --git a/tests/unittests/path_test.py b/tests/unittests/path_test.py index ff1fa9c..576dac1 100644 --- a/tests/unittests/path_test.py +++ b/tests/unittests/path_test.py @@ -145,6 +145,21 @@ def test_file(self): self.assertTrue(os.path.exists(self.fileName)) self.assertTrue(os.path.exists(self.target)) +class TestPathPathFromFolder(unittest.TestCase): + def test_empty(self): + path = Path("") + with self.assertRaises(exception.PathError): + path.pathFromFolder("idonotexist") + + def test_folderNotInpath(self): + path = Path("/foo/bar/baz") + with self.assertRaises(exception.PathError): + path.pathFromFolder("quux") + + def test_folderInpath(self): + path = Path("/foo/bar/baz") + self.assertEqual(str(path.pathFromFolder("bar")), "baz") + self.assertEqual(str(path.pathFromFolder("foo")), "bar/baz") if __name__ == '__main__': unittest.main() From 49aeab1a850d43b752c1f853dbaf513478d7c038 Mon Sep 17 00:00:00 2001 From: jelleas Date: Tue, 23 Jan 2018 13:27:50 +0100 Subject: [PATCH 095/269] path add and sub --- checkpy/entities/path.py | 58 +++++++++++++++++++++++-------- tests/unittests/lib_test.py | 28 ++++++++------- tests/unittests/path_test.py | 67 ++++++++++++++++++++++++++++++++++++ 3 files changed, 127 insertions(+), 26 deletions(-) diff --git a/checkpy/entities/path.py b/checkpy/entities/path.py index 2ecbf36..4e3b828 100644 --- a/checkpy/entities/path.py +++ b/checkpy/entities/path.py @@ -1,4 +1,5 @@ import os +import sys import shutil import checkpy.entities.exception as exception @@ -45,28 +46,57 @@ def pathFromFolder(self, folderName): return Path(path) def __add__(self, other): - try: - # Python 3 - if isinstance(other, bytes) or isinstance(other, str): - return Path(os.path.join(str(self), other)) - except NameError: - # Python 2 - if isinstance(other, str) or isinstance(other, unicode): - return Path(os.path.join(str(self), other)) - return Path(os.path.join(str(self), str(other))) + if sys.version_info >= (3,0): + supportedTypes = [str, bytes, Path] + else: + supportedTypes = [str, unicode, Path] + + if not any(isinstance(other, t) for t in supportedTypes): + raise exception.PathError(message = "can't add {} to Path only {}".format(type(other), supportedTypes)) + + if not isinstance(other, Path): + other = Path(other) + + result = str(self) + for item in other: + if item != os.path.sep: + result = os.path.join(result, item) + + return Path(result) def __sub__(self, other): - if isinstance(other, str): + if sys.version_info >= (3,0): + supportedTypes = [str, bytes, Path] + else: + supportedTypes = [str, unicode, Path] + + if not any(isinstance(other, t) for t in supportedTypes): + raise exception.PathError(message = "can't subtract {} from Path only {}".format(type(other), supportedTypes)) + + if not isinstance(other, Path): other = Path(other) - my_items = [item for item in self] - other_items = [item for item in other] + + myItems = [item for item in self] + otherItems = [item for item in other] + + for items in (myItems, otherItems): + if len(items) >= 1 and items[0] != os.path.sep and items[0] != ".": + items.insert(0, ".") + print(myItems, otherItems) + for i in range(min(len(myItems), len(otherItems))): + if myItems[i] != otherItems[i]: + raise exception.PathError(message = "tried subtracting, but root does not match: {} and {}".format(self, other)) + total = "" - for item in my_items[len(other_items):]: + for item in myItems[len(otherItems):]: total = os.path.join(total, item) return Path(total) def __iter__(self): - for item in str(self).split(os.path.sep): + items = str(self).split(os.path.sep) + if len(items) > 0 and items[0] == "": + items[0] = os.path.sep + for item in items: yield item def __hash__(self): diff --git a/tests/unittests/lib_test.py b/tests/unittests/lib_test.py index 6b9be74..8632394 100644 --- a/tests/unittests/lib_test.py +++ b/tests/unittests/lib_test.py @@ -30,6 +30,17 @@ def test_fileExists(self): self.assertTrue(lib.fileExists(self.fileName)) class TestRequire(Base): + def setUp(self): + super(TestRequire, self).setUp() + self.cwd = os.getcwd() + self.dirname = "testrequire" + os.mkdir(self.dirname) + + def tearDown(self): + super(TestRequire, self).tearDown() + os.chdir(self.cwd) + shutil.rmtree(self.dirname) + def test_fileDownload(self): fileName = "inowexist.random" lib.require(fileName, "https://raw.githubusercontent.com/Jelleas/tests/master/tests/someTest.py") @@ -37,18 +48,11 @@ def test_fileDownload(self): os.remove(fileName) def test_fileLocalCopy(self): - cwd = os.getcwd() - name = "testrequire" - - os.mkdir(name) - os.chdir(os.path.join(cwd, name)) - + os.chdir(self.dirname) lib.require(self.fileName) self.assertTrue(os.path.isfile(self.fileName)) - - os.chdir(cwd) - shutil.rmtree(name) - + + class TestSource(Base): def test_expectedOutput(self): source = lib.source(self.fileName) @@ -403,7 +407,7 @@ def test_noLeakage(self): stdin.seek(0) self.assertEqual(input("hello!"), "foo") self.assertTrue(len(stdout.read()) == 0) - - + + if __name__ == '__main__': unittest.main() diff --git a/tests/unittests/path_test.py b/tests/unittests/path_test.py index 576dac1..280dc61 100644 --- a/tests/unittests/path_test.py +++ b/tests/unittests/path_test.py @@ -161,5 +161,72 @@ def test_folderInpath(self): self.assertEqual(str(path.pathFromFolder("bar")), "baz") self.assertEqual(str(path.pathFromFolder("foo")), "bar/baz") +class TestPathAdd(unittest.TestCase): + def test_string(self): + path = Path("foo/bar") + self.assertEqual(str(path + "baz"), "foo/bar/baz") + self.assertEqual(str(path + "/baz"), "foo/bar/baz") + + path = Path("foo/bar/") + self.assertEqual(str(path + "/baz"), "foo/bar/baz") + self.assertEqual(str(path + "baz"), "foo/bar/baz") + + def test_path(self): + path = Path("foo/bar") + self.assertEqual(str(path + Path("baz")), "foo/bar/baz") + self.assertEqual(str(path + Path("/baz")), "foo/bar/baz") + + path = Path("foo/bar/") + self.assertEqual(str(path + Path("baz")), "foo/bar/baz") + self.assertEqual(str(path + Path("/baz")), "foo/bar/baz") + + def test_unsupportedType(self): + path = Path("foo/bar") + with self.assertRaises(exception.PathError): + path + 1 + +class TestPathSub(unittest.TestCase): + def test_empty(self): + path = Path("") + self.assertEqual(str(path - ""), ".") + self.assertEqual(str(path - Path("")), ".") + + path = Path("foo/bar/baz") + self.assertEqual(str(path - ""), "foo/bar/baz") + self.assertEqual(str(path - Path("")), "foo/bar/baz") + + path = Path("./foo/bar/baz") + self.assertEqual(str(path - ""), "foo/bar/baz") + self.assertEqual(str(path - Path("")), "foo/bar/baz") + + path = Path("/foo/bar/baz") + with self.assertRaises(exception.PathError): + path - "" + with self.assertRaises(exception.PathError): + path - Path("") + + def test_string(self): + path = Path("foo/bar/baz") + self.assertEqual(str(path - "foo/bar"), "baz") + with self.assertRaises(exception.PathError): + path - "/foo/bar" + + path = Path("/foo/bar/baz") + with self.assertRaises(exception.PathError): + path - "foo/bar" + self.assertEqual(str(path - "/foo/bar"), "baz") + + def test_path(self): + path = Path("foo/bar/baz") + self.assertEqual(str(path - Path("foo/bar")), "baz") + with self.assertRaises(exception.PathError): + path - "/foo/bar" + + path = Path("/foo/bar/baz") + with self.assertRaises(exception.PathError): + path - Path("foo/bar") + self.assertEqual(str(path - Path("/foo/bar")), "baz") + + if __name__ == '__main__': unittest.main() From deb00c5b80a94a2135e6f9ded48b8a09b5bf1e14 Mon Sep 17 00:00:00 2001 From: jelleas Date: Tue, 23 Jan 2018 13:29:16 +0100 Subject: [PATCH 096/269] remove print --- checkpy/entities/path.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/checkpy/entities/path.py b/checkpy/entities/path.py index 4e3b828..77167ca 100644 --- a/checkpy/entities/path.py +++ b/checkpy/entities/path.py @@ -82,7 +82,7 @@ def __sub__(self, other): for items in (myItems, otherItems): if len(items) >= 1 and items[0] != os.path.sep and items[0] != ".": items.insert(0, ".") - print(myItems, otherItems) + for i in range(min(len(myItems), len(otherItems))): if myItems[i] != otherItems[i]: raise exception.PathError(message = "tried subtracting, but root does not match: {} and {}".format(self, other)) From 0250d0bea29cb1d9378256f0eaac5255b05ddfed Mon Sep 17 00:00:00 2001 From: jelleas Date: Tue, 23 Jan 2018 14:49:06 +0100 Subject: [PATCH 097/269] refactored path --- checkpy/entities/path.py | 57 +++++++++++++++++++++--------------- tests/unittests/path_test.py | 24 ++++++++++++--- 2 files changed, 53 insertions(+), 28 deletions(-) diff --git a/checkpy/entities/path.py b/checkpy/entities/path.py index 77167ca..a279859 100644 --- a/checkpy/entities/path.py +++ b/checkpy/entities/path.py @@ -5,19 +5,27 @@ class Path(object): def __init__(self, path): - self._path = os.path.normpath(path) + path = os.path.normpath(path) + self._drive = os.path.splitdrive(path)[0] + + items = str(path).split(os.path.sep) + + # if path started with root, add root + if len(items) > 0 and items[0] == "": + items[0] = os.path.sep + # remove any empty items (for instance because of "/") + self._items = [item for item in items if item] @property def fileName(self): - return os.path.basename(str(self)) + return list(self)[-1] @property def folderName(self): - _, name = os.path.split(os.path.dirname(str(self))) - return name + return list(self)[-2] def containingFolder(self): - return Path(os.path.dirname(str(self))) + return Path(self._join(self._drive, list(self)[:-1])) def isPythonFile(self): return self.fileName.endswith(".py") @@ -35,15 +43,16 @@ def copyTo(self, destination): def pathFromFolder(self, folderName): path = "" seen = False + items = [] for item in self: if seen: - path = os.path.join(path, item) + items.append(item) if item == folderName: seen = True if not seen: raise exception.PathError(message = "folder {} does not exist in {}".format(folderName, self)) - return Path(path) + return Path(self._join(self._drive, items)) def __add__(self, other): if sys.version_info >= (3,0): @@ -57,12 +66,11 @@ def __add__(self, other): if not isinstance(other, Path): other = Path(other) - result = str(self) - for item in other: - if item != os.path.sep: - result = os.path.join(result, item) + # if other path starts with root, throw error + if list(other)[0] == os.path.sep: + raise exception.PathError(message = "can't add {} to Path because it starts at root") - return Path(result) + return Path(self._join(self._drive, list(self) + list(other))) def __sub__(self, other): if sys.version_info >= (3,0): @@ -76,8 +84,8 @@ def __sub__(self, other): if not isinstance(other, Path): other = Path(other) - myItems = [item for item in self] - otherItems = [item for item in other] + myItems = list(self) + otherItems = list(other) for items in (myItems, otherItems): if len(items) >= 1 and items[0] != os.path.sep and items[0] != ".": @@ -85,18 +93,12 @@ def __sub__(self, other): for i in range(min(len(myItems), len(otherItems))): if myItems[i] != otherItems[i]: - raise exception.PathError(message = "tried subtracting, but root does not match: {} and {}".format(self, other)) + raise exception.PathError(message = "tried subtracting, but subdirs do not match: {} and {}".format(self, other)) - total = "" - for item in myItems[len(otherItems):]: - total = os.path.join(total, item) - return Path(total) + return Path(self._join(self._drive, myItems[len(otherItems):])) def __iter__(self): - items = str(self).split(os.path.sep) - if len(items) > 0 and items[0] == "": - items[0] = os.path.sep - for item in items: + for item in self._items: yield item def __hash__(self): @@ -112,11 +114,18 @@ def __nonzero__ (self): return len(str(self)) != 0 def __str__(self): - return self._path + return self._join(self._drive, list(self)) def __repr__(self): return "/".join([item for item in self]) + def _join(self, drive, items): + result = drive + for item in items: + result = os.path.join(result, item) + return result + + def current(): return Path(os.getcwd()) diff --git a/tests/unittests/path_test.py b/tests/unittests/path_test.py index 280dc61..69c40ef 100644 --- a/tests/unittests/path_test.py +++ b/tests/unittests/path_test.py @@ -165,20 +165,24 @@ class TestPathAdd(unittest.TestCase): def test_string(self): path = Path("foo/bar") self.assertEqual(str(path + "baz"), "foo/bar/baz") - self.assertEqual(str(path + "/baz"), "foo/bar/baz") + with self.assertRaises(exception.PathError): + path + "/baz" path = Path("foo/bar/") - self.assertEqual(str(path + "/baz"), "foo/bar/baz") self.assertEqual(str(path + "baz"), "foo/bar/baz") + with self.assertRaises(exception.PathError): + path + "/baz" def test_path(self): path = Path("foo/bar") self.assertEqual(str(path + Path("baz")), "foo/bar/baz") - self.assertEqual(str(path + Path("/baz")), "foo/bar/baz") + with self.assertRaises(exception.PathError): + path + Path("/baz") path = Path("foo/bar/") self.assertEqual(str(path + Path("baz")), "foo/bar/baz") - self.assertEqual(str(path + Path("/baz")), "foo/bar/baz") + with self.assertRaises(exception.PathError): + path + Path("/baz") def test_unsupportedType(self): path = Path("foo/bar") @@ -227,6 +231,18 @@ def test_path(self): path - Path("foo/bar") self.assertEqual(str(path - Path("/foo/bar")), "baz") +class TestPathIter(unittest.TestCase): + def test_empty(self): + path = Path("") + self.assertEqual(list(path), ["."]) + + def test_root(self): + path = Path("/") + self.assertEqual(list(path), [os.path.sep]) + + def test_path(self): + path = Path("foo/bar/baz") + self.assertEqual(list(path), ["foo", "bar", "baz"]) if __name__ == '__main__': unittest.main() From b662eed7f648c3c37c72e630ce50885e82f5b1ae Mon Sep 17 00:00:00 2001 From: jelleas Date: Tue, 23 Jan 2018 15:08:13 +0100 Subject: [PATCH 098/269] contains tests --- checkpy/entities/path.py | 10 ++++++---- tests/unittests/path_test.py | 26 ++++++++++++++++++++++++++ 2 files changed, 32 insertions(+), 4 deletions(-) diff --git a/checkpy/entities/path.py b/checkpy/entities/path.py index a279859..d5f028a 100644 --- a/checkpy/entities/path.py +++ b/checkpy/entities/path.py @@ -10,9 +10,11 @@ def __init__(self, path): items = str(path).split(os.path.sep) - # if path started with root, add root - if len(items) > 0 and items[0] == "": - items[0] = os.path.sep + if len(items) > 0: + # if path started with root, add root + if items[0] == "": + items[0] = os.path.sep + # remove any empty items (for instance because of "/") self._items = [item for item in items if item] @@ -108,7 +110,7 @@ def __eq__(self, other): return isinstance(other, type(self)) and repr(self) == repr(other) def __contains__(self, item): - return str(item) in [item for item in self] + return str(item) in list(self) def __nonzero__ (self): return len(str(self)) != 0 diff --git a/tests/unittests/path_test.py b/tests/unittests/path_test.py index 69c40ef..be2fa6b 100644 --- a/tests/unittests/path_test.py +++ b/tests/unittests/path_test.py @@ -244,5 +244,31 @@ def test_path(self): path = Path("foo/bar/baz") self.assertEqual(list(path), ["foo", "bar", "baz"]) +class TestPathContains(unittest.TestCase): + def test_root(self): + path = Path("/") + self.assertTrue("/" in path) + self.assertFalse("." in path) + + def test_current(self): + path = Path(".") + self.assertTrue("." in path) + self.assertFalse("/" in path) + + path = Path("") + self.assertTrue("." in path) + self.assertFalse("/" in path) + + def test_localPath(self): + path = Path("foo/bar/baz") + for d in ["foo", "bar", "baz"]: + self.assertTrue(d in path) + + def test_absPath(self): + path = Path("/foo/bar/baz") + self.assertTrue("/" in path) + for d in ["foo", "bar", "baz"]: + self.assertTrue(d in path) + if __name__ == '__main__': unittest.main() From 88353431d0c155c5b51283bf01d1833a4a9b5a66 Mon Sep 17 00:00:00 2001 From: jelleas Date: Tue, 23 Jan 2018 15:14:38 +0100 Subject: [PATCH 099/269] replaced path.__nonzero__ with path.__len__ --- checkpy/entities/path.py | 4 ++-- tests/unittests/path_test.py | 23 +++++++++++++++++++++++ 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/checkpy/entities/path.py b/checkpy/entities/path.py index d5f028a..facf13d 100644 --- a/checkpy/entities/path.py +++ b/checkpy/entities/path.py @@ -112,8 +112,8 @@ def __eq__(self, other): def __contains__(self, item): return str(item) in list(self) - def __nonzero__ (self): - return len(str(self)) != 0 + def __len__(self): + return len(self._items) def __str__(self): return self._join(self._drive, list(self)) diff --git a/tests/unittests/path_test.py b/tests/unittests/path_test.py index be2fa6b..1bd5dbc 100644 --- a/tests/unittests/path_test.py +++ b/tests/unittests/path_test.py @@ -270,5 +270,28 @@ def test_absPath(self): for d in ["foo", "bar", "baz"]: self.assertTrue(d in path) +class TestPathLen(unittest.TestCase): + def test_root(self): + path = Path("/") + self.assertEqual(len(path), 1) + + def test_current(self): + path = Path(".") + self.assertEqual(len(path), 1) + + def test_localPath(self): + path = Path("foo/bar/baz") + self.assertEqual(len(path), 3) + path = Path("foo/bar/baz/") + self.assertEqual(len(path), 3) + + def test_absPath(self): + path = Path("/foo/bar/baz") + self.assertEqual(len(path), 4) + + path = Path("/foo/bar/baz/") + self.assertEqual(len(path), 4) + + if __name__ == '__main__': unittest.main() From f94761689639fbe062fc3607eae374ea083deb6f Mon Sep 17 00:00:00 2001 From: jelleas Date: Sat, 27 Jan 2018 13:39:28 +0100 Subject: [PATCH 100/269] tests passing under windows py2/3 --- checkpy/entities/function.py | 6 +++--- checkpy/entities/path.py | 2 +- checkpy/lib/basic.py | 31 +++++++++++++++++++------------ tests/unittests/lib_test.py | 14 ++++++++++---- tests/unittests/path_test.py | 28 ++++++++++++++-------------- 5 files changed, 47 insertions(+), 34 deletions(-) diff --git a/checkpy/entities/function.py b/checkpy/entities/function.py index 8ae77b2..5612d0f 100644 --- a/checkpy/entities/function.py +++ b/checkpy/entities/function.py @@ -65,7 +65,7 @@ def _captureStdout(self): class _Stream(io.StringIO): def __init__(self, *args, **kwargs): - super(_Stream, self).__init__(*args, **kwargs) + io.StringIO.__init__(self, *args, **kwargs) self._listeners = [] def register(self, listener): @@ -76,12 +76,12 @@ def unregister(self, listener): def write(self, text): """Overwrites StringIO.write to update all listeners""" - super(_Stream, self).write(text) + io.StringIO.write(self, text) self._onUpdate(text) def writelines(self, sequence): """Overwrites StringIO.writelines to update all listeners""" - super(_Stream, self).writelines(sequence) + io.StringIO.writelines(self, sequence) for item in sequence: self._onUpdate(item) diff --git a/checkpy/entities/path.py b/checkpy/entities/path.py index facf13d..096edb8 100644 --- a/checkpy/entities/path.py +++ b/checkpy/entities/path.py @@ -6,7 +6,7 @@ class Path(object): def __init__(self, path): path = os.path.normpath(path) - self._drive = os.path.splitdrive(path)[0] + self._drive, path = os.path.splitdrive(path) items = str(path).split(os.path.sep) diff --git a/checkpy/lib/basic.py b/checkpy/lib/basic.py index 2cca7ba..5d4de98 100644 --- a/checkpy/lib/basic.py +++ b/checkpy/lib/basic.py @@ -244,17 +244,22 @@ def captureStdout(stdout=None): @contextlib.contextmanager def captureStdin(stdin=None): - old_input = input - def new_input(prompt = None): - try: - return old_input() - except EOFError as e: - e = exception.InputError( - message = "You requested too much user input", - stacktrace = traceback.format_exc()) - raise e - - __builtins__["input"] = new_input + def newInput(oldInput): + def input(prompt = None): + try: + return oldInput() + except EOFError as e: + e = exception.InputError( + message = "You requested too much user input", + stacktrace = traceback.format_exc()) + raise e + return input + + oldInput = input + __builtins__["input"] = newInput(oldInput) + if sys.version_info < (3,0): + oldRawInput = raw_input + __builtins__["raw_input"] = newInput(oldRawInput) old = sys.stdin if stdin is None: @@ -263,5 +268,7 @@ def new_input(prompt = None): yield stdin - __builtins__["input"] = old_input sys.stdin = old + __builtins__["input"] = oldInput + if sys.version_info < (3,0): + __builtins__["raw_input"] = oldRawInput diff --git a/tests/unittests/lib_test.py b/tests/unittests/lib_test.py index 8632394..3ebaeab 100644 --- a/tests/unittests/lib_test.py +++ b/tests/unittests/lib_test.py @@ -1,6 +1,7 @@ import unittest import os import shutil +import sys import checkpy.lib as lib import checkpy.caches as caches import checkpy.entities.exception as exception @@ -48,6 +49,8 @@ def test_fileDownload(self): os.remove(fileName) def test_fileLocalCopy(self): + import checkpy.entities.path as path + print(path.userPath) os.chdir(self.dirname) lib.require(self.fileName) self.assertTrue(os.path.isfile(self.fileName)) @@ -388,24 +391,27 @@ def test_noLeakage(self): self.assertTrue(len(sys.stdout.getvalue()) == 0) class TestCaptureStdin(unittest.TestCase): + def setUp(self): + self.getInput = lambda : input if sys.version_info >= (3,0) else raw_input + def test_noInput(self): with lib.captureStdin() as stdin: with self.assertRaises(exception.InputError): - input() + self.getInput()() def test_oneInput(self): with lib.captureStdin() as stdin: stdin.write("foo\n") stdin.seek(0) - self.assertEqual(input(), "foo") + self.assertEqual(self.getInput()(), "foo") with self.assertRaises(exception.InputError): - input() + self.getInput()() def test_noLeakage(self): with lib.captureStdin() as stdin, lib.captureStdout() as stdout: stdin.write("foo\n") stdin.seek(0) - self.assertEqual(input("hello!"), "foo") + self.assertEqual(self.getInput()("hello!"), "foo") self.assertTrue(len(stdout.read()) == 0) diff --git a/tests/unittests/path_test.py b/tests/unittests/path_test.py index 1bd5dbc..8e0ebd6 100644 --- a/tests/unittests/path_test.py +++ b/tests/unittests/path_test.py @@ -33,11 +33,11 @@ def test_empty(self): def test_file(self): path = Path("/foo/bar/baz.txt") - self.assertEqual(str(path.containingFolder()), "/foo/bar") + self.assertEqual(str(path.containingFolder()), os.path.normpath("/foo/bar")) def test_folder(self): path = Path("/foo/bar/baz/") - self.assertEqual(str(path.containingFolder()), "/foo/bar") + self.assertEqual(str(path.containingFolder()), os.path.normpath("/foo/bar")) class TestPathIsPythonFile(unittest.TestCase): def test_noPythonFile(self): @@ -135,7 +135,7 @@ def tearDown(self): def test_noFile(self): fileName = "idonotexist.py" path = Path(fileName) - with self.assertRaises(FileNotFoundError): + with self.assertRaises(IOError): path.copyTo(".") self.assertFalse(os.path.exists(fileName)) @@ -159,28 +159,28 @@ def test_folderNotInpath(self): def test_folderInpath(self): path = Path("/foo/bar/baz") self.assertEqual(str(path.pathFromFolder("bar")), "baz") - self.assertEqual(str(path.pathFromFolder("foo")), "bar/baz") + self.assertEqual(str(path.pathFromFolder("foo")), os.path.normpath("bar/baz")) class TestPathAdd(unittest.TestCase): def test_string(self): path = Path("foo/bar") - self.assertEqual(str(path + "baz"), "foo/bar/baz") + self.assertEqual(str(path + "baz"), os.path.normpath("foo/bar/baz")) with self.assertRaises(exception.PathError): path + "/baz" path = Path("foo/bar/") - self.assertEqual(str(path + "baz"), "foo/bar/baz") + self.assertEqual(str(path + "baz"), os.path.normpath("foo/bar/baz")) with self.assertRaises(exception.PathError): path + "/baz" def test_path(self): path = Path("foo/bar") - self.assertEqual(str(path + Path("baz")), "foo/bar/baz") + self.assertEqual(str(path + Path("baz")), os.path.normpath("foo/bar/baz")) with self.assertRaises(exception.PathError): path + Path("/baz") path = Path("foo/bar/") - self.assertEqual(str(path + Path("baz")), "foo/bar/baz") + self.assertEqual(str(path + Path("baz")), os.path.normpath("foo/bar/baz")) with self.assertRaises(exception.PathError): path + Path("/baz") @@ -196,12 +196,12 @@ def test_empty(self): self.assertEqual(str(path - Path("")), ".") path = Path("foo/bar/baz") - self.assertEqual(str(path - ""), "foo/bar/baz") - self.assertEqual(str(path - Path("")), "foo/bar/baz") + self.assertEqual(str(path - ""), os.path.normpath("foo/bar/baz")) + self.assertEqual(str(path - Path("")), os.path.normpath("foo/bar/baz")) path = Path("./foo/bar/baz") - self.assertEqual(str(path - ""), "foo/bar/baz") - self.assertEqual(str(path - Path("")), "foo/bar/baz") + self.assertEqual(str(path - ""), os.path.normpath("foo/bar/baz")) + self.assertEqual(str(path - Path("")), os.path.normpath("foo/bar/baz")) path = Path("/foo/bar/baz") with self.assertRaises(exception.PathError): @@ -247,7 +247,7 @@ def test_path(self): class TestPathContains(unittest.TestCase): def test_root(self): path = Path("/") - self.assertTrue("/" in path) + self.assertTrue(os.path.normpath("/") in path) self.assertFalse("." in path) def test_current(self): @@ -266,7 +266,7 @@ def test_localPath(self): def test_absPath(self): path = Path("/foo/bar/baz") - self.assertTrue("/" in path) + self.assertTrue(os.path.normpath("/") in path) for d in ["foo", "bar", "baz"]: self.assertTrue(d in path) From e68359b48da8bf8778f120af357ad563741d106a Mon Sep 17 00:00:00 2001 From: jelleas Date: Sat, 27 Jan 2018 14:55:18 +0100 Subject: [PATCH 101/269] path string tests --- tests/unittests/path_test.py | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/tests/unittests/path_test.py b/tests/unittests/path_test.py index 8e0ebd6..e86538e 100644 --- a/tests/unittests/path_test.py +++ b/tests/unittests/path_test.py @@ -288,10 +288,29 @@ def test_localPath(self): def test_absPath(self): path = Path("/foo/bar/baz") self.assertEqual(len(path), 4) - path = Path("/foo/bar/baz/") self.assertEqual(len(path), 4) +class TestPathStr(unittest.TestCase): + def test_root(self): + path = Path("/") + self.assertEqual(str(path), os.path.normpath("/")) + + def test_current(self): + path = Path(".") + self.assertEqual(str(path), os.path.normpath(".")) + + def test_localPath(self): + path = Path("foo/bar/baz") + self.assertEqual(str(path), os.path.normpath("foo/bar/baz")) + path = Path("foo/bar/baz/") + self.assertEqual(str(path), os.path.normpath("foo/bar/baz/")) + + def test_absPath(self): + path = Path("/foo/bar/baz") + self.assertEqual(str(path), os.path.normpath("/foo/bar/baz")) + path = Path("/foo/bar/baz/") + self.assertEqual(str(path), os.path.normpath("/foo/bar/baz/")) if __name__ == '__main__': unittest.main() From 8b489bf01d4b2e6237d7613d58abde28a4c1317b Mon Sep 17 00:00:00 2001 From: jelleas Date: Sat, 27 Jan 2018 15:26:57 +0100 Subject: [PATCH 102/269] sandbox integration test --- tests/integrationtests/tester_test.py | 240 +++++++++++++++----------- 1 file changed, 141 insertions(+), 99 deletions(-) diff --git a/tests/integrationtests/tester_test.py b/tests/integrationtests/tester_test.py index be7187d..0ad5ed5 100644 --- a/tests/integrationtests/tester_test.py +++ b/tests/integrationtests/tester_test.py @@ -17,105 +17,147 @@ @contextmanager def capturedOutput(): - new_out, new_err = StringIO.StringIO(), StringIO.StringIO() - old_out, old_err = sys.stdout, sys.stderr - try: - sys.stdout, sys.stderr = new_out, new_err - yield sys.stdout, sys.stderr - finally: - sys.stdout, sys.stderr = old_out, old_err - -class Base(unittest.TestCase): - def setUp(self): - caches.clearAllCaches() - self.fileName = "some.py" - self.source = "print(\"foo\")" - self.write(self.source) - if not discovery.testExists(self.fileName): - downloader.clean() - downloader.download("jelleas/tests") - - def tearDown(self): - if os.path.isfile(self.fileName): - os.remove(self.fileName) - caches.clearAllCaches() - - def write(self, source): - with open(self.fileName, "w") as f: - f.write(source) - -class TestTest(Base): - def test_oneTest(self): - for testerResult in [tester.test(self.fileName), tester.test(self.fileName.split(".")[0])]: - self.assertTrue(len(testerResult.testResults) == 1) - self.assertTrue(testerResult.testResults[0].hasPassed) - self.assertTrue("Testing: some.py".lower() in testerResult.output[0].lower()) - self.assertTrue(":)" in testerResult.output[1]) - self.assertTrue("prints exactly: foo".lower() in testerResult.output[1].lower()) - - def test_notebookOneTest(self): - fileName = "some.ipynb" - with open(fileName, "w") as f: - src = r"""{ - "cells": [ - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "print(\"foo\")" - ] - } - ], - "metadata": { - "kernelspec": { - "display_name": "Python 3", - "language": "python", - "name": "python3" - }, - "language_info": { - "codemirror_mode": { - "name": "ipython", - "version": 3 - }, - "file_extension": ".py", - "mimetype": "text/x-python", - "name": "python", - "nbconvert_exporter": "python", - "pygments_lexer": "ipython3", - "version": "3.6.2" - } - }, - "nbformat": 4, - "nbformat_minor": 2 + new_out, new_err = StringIO.StringIO(), StringIO.StringIO() + old_out, old_err = sys.stdout, sys.stderr + try: + sys.stdout, sys.stderr = new_out, new_err + yield sys.stdout, sys.stderr + finally: + sys.stdout, sys.stderr = old_out, old_err + +class TestTest(unittest.TestCase): + def setUp(self): + caches.clearAllCaches() + self.fileName = "some.py" + self.source = "print(\"foo\")" + self.write(self.source) + if not discovery.testExists(self.fileName): + downloader.clean() + downloader.download("jelleas/tests") + + def tearDown(self): + if os.path.isfile(self.fileName): + os.remove(self.fileName) + caches.clearAllCaches() + + def write(self, source): + with open(self.fileName, "w") as f: + f.write(source) + + def test_oneTest(self): + for testerResult in [tester.test(self.fileName), tester.test(self.fileName.split(".")[0])]: + self.assertTrue(len(testerResult.testResults) == 1) + self.assertTrue(testerResult.testResults[0].hasPassed) + self.assertTrue("Testing: some.py".lower() in testerResult.output[0].lower()) + self.assertTrue(":)" in testerResult.output[1]) + self.assertTrue("prints exactly: foo".lower() in testerResult.output[1].lower()) + + def test_fileMising(self): + os.remove(self.fileName) + testerResult = tester.test(self.fileName) + self.assertTrue("file not found".lower() in testerResult.output[0].lower()) + self.assertTrue(self.fileName.lower() in testerResult.output[0].lower()) + + def test_testMissing(self): + fileName = "foo.py" + with open(fileName, "w") as f: + pass + testerResult = tester.test(fileName) + self.assertTrue("No test found for".lower() in testerResult.output[0].lower()) + self.assertTrue(fileName.lower() in testerResult.output[0].lower()) + os.remove(fileName) + + +class TestTestNotebook(unittest.TestCase): + def setUp(self): + caches.clearAllCaches() + self.fileName = "some.ipynb" + self.source = r"""{ +"cells": [ +{ +"cell_type": "code", +"execution_count": null, +"metadata": {}, +"outputs": [], +"source": [ +"print(\"foo\")" +] +} +], +"metadata": { +"kernelspec": { +"display_name": "Python 3", +"language": "python", +"name": "python3" +}, +"language_info": { +"codemirror_mode": { +"name": "ipython", +"version": 3 +}, +"file_extension": ".py", +"mimetype": "text/x-python", +"name": "python", +"nbconvert_exporter": "python", +"pygments_lexer": "ipython3", +"version": "3.6.2" +} +}, +"nbformat": 4, +"nbformat_minor": 2 }""" - f.write(src) - - for testerResult in [tester.test(fileName), tester.test(fileName.split(".")[0])]: - self.assertTrue(len(testerResult.testResults) == 1) - self.assertTrue(testerResult.testResults[0].hasPassed) - self.assertTrue("Testing: {}".format(fileName.split(".")[0]).lower() in testerResult.output[0].lower()) - self.assertTrue(":)" in testerResult.output[1]) - self.assertTrue("prints exactly: foo".lower() in testerResult.output[1].lower()) - self.assertFalse(os.path.isfile(self.fileName)) - - os.remove(fileName) - - def test_fileMising(self): - os.remove(self.fileName) - testerResult = tester.test(self.fileName) - self.assertTrue("file not found".lower() in testerResult.output[0].lower()) - self.assertTrue(self.fileName.lower() in testerResult.output[0].lower()) - - def test_testMissing(self): - fileName = "foo.py" - with open(fileName, "w") as f: - pass - testerResult = tester.test(fileName) - self.assertTrue("No test found for".lower() in testerResult.output[0].lower()) - self.assertTrue(fileName.lower() in testerResult.output[0].lower()) - os.remove(fileName) + self.write(self.source) + if not discovery.testExists(self.fileName): + downloader.clean() + downloader.download("jelleas/tests") + + def tearDown(self): + if os.path.isfile(self.fileName): + os.remove(self.fileName) + caches.clearAllCaches() + + def write(self, source): + with open(self.fileName, "w") as f: + f.write(source) + + def test_oneTest(self): + for testerResult in [tester.test(self.fileName), tester.test(self.fileName.split(".")[0])]: + self.assertTrue(len(testerResult.testResults) == 1) + self.assertTrue(testerResult.testResults[0].hasPassed) + self.assertTrue("Testing: {}".format(self.fileName.split(".")[0]).lower() in testerResult.output[0].lower()) + self.assertTrue(":)" in testerResult.output[1]) + self.assertTrue("prints exactly: foo".lower() in testerResult.output[1].lower()) + + +class TestTestSandbox(unittest.TestCase): + def setUp(self): + caches.clearAllCaches() + self.fileName = "sandbox.py" + self.source = "print(\"foo\")" + self.write(self.source) + if not discovery.testExists(self.fileName): + downloader.clean() + downloader.download("jelleas/tests") + + def tearDown(self): + if os.path.isfile(self.fileName): + os.remove(self.fileName) + caches.clearAllCaches() + + def write(self, source): + with open(self.fileName, "w") as f: + f.write(source) + + def test_oneTest(self): + for testerResult in [tester.test(self.fileName), tester.test(self.fileName.split(".")[0])]: + self.assertTrue(len(testerResult.testResults) == 2) + self.assertTrue(testerResult.testResults[0].hasPassed) + self.assertTrue(testerResult.testResults[1].hasPassed) + self.assertTrue("Testing: sandbox.py".lower() in testerResult.output[0].lower()) + self.assertTrue(":)" in testerResult.output[1]) + self.assertTrue("prints exactly: foo".lower() in testerResult.output[1].lower()) + self.assertTrue(":)" in testerResult.output[2]) + self.assertTrue("sandbox.py and sandboxTest.py exist".lower() in testerResult.output[2].lower()) if __name__ == '__main__': - unittest.main() + unittest.main() From ecfb963fd445bfb6c071627ae104490cdc079019 Mon Sep 17 00:00:00 2001 From: jelleas Date: Sat, 27 Jan 2018 15:37:39 +0100 Subject: [PATCH 103/269] --dev fix --- checkpy/tester/tester.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/checkpy/tester/tester.py b/checkpy/tester/tester.py index 5e36cf6..4fc8702 100644 --- a/checkpy/tester/tester.py +++ b/checkpy/tester/tester.py @@ -36,7 +36,7 @@ def test(testName, module = "", debugMode = False): if len(testPaths) > 1: result.addOutput(printer.displayWarning("Found {} tests: {}, using: {}".format(len(testPaths), testPaths, testPaths[0]))) - + testFilePath = str(testPaths[0]) if testFilePath not in sys.path: @@ -60,10 +60,10 @@ def test(testName, module = "", debugMode = False): os.remove(path) else: testerResult = _runTests(testFileName.split(".")[0], path, debugMode = debugMode) - + testerResult.output = result.output + testerResult.output return testerResult - + def testModule(module, debugMode = False): testNames = discovery.getTestNames(module) @@ -145,10 +145,10 @@ def __init__(self, moduleName, fileName, debugMode, signalQueue, resultQueue): self.signalQueue = signalQueue self.resultQueue = resultQueue self.reservedNames = ["before", "after", "sandbox"] - + def run(self): if self.debugMode: - printer.DEBUG_MODE = True + printer.printer.DEBUG_MODE = True # overwrite argv so that it seems the file was run directly sys.argv = [self.fileName] @@ -160,7 +160,7 @@ def run(self): with Sandbox(path.Path(self.fileName)): module.sandbox() return self._runTestsFromModule(module) - + return self._runTestsFromModule(module) def _runTestsFromModule(self, module): From a1e3e441db2b7d82e7a0055af35faaab984a6d5c Mon Sep 17 00:00:00 2001 From: jelleas Date: Sat, 27 Jan 2018 16:02:30 +0100 Subject: [PATCH 104/269] 0.4 --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index d6c3448..bb1bad3 100644 --- a/setup.py +++ b/setup.py @@ -11,7 +11,7 @@ setup( name='checkPy', - version='0.3.22', + version='0.4.0', description='A simple python testing framework for educational purposes', long_description=long_description, From 1ad743c10d7052ab15700207989daf99d5fb4718 Mon Sep 17 00:00:00 2001 From: jelleas Date: Tue, 30 Jan 2018 15:45:27 +0100 Subject: [PATCH 105/269] checkpy -register --- .gitignore | 1 - checkpy/__main__.py | 5 ++ checkpy/database/__init__.py | 1 + checkpy/database/database.py | 101 ++++++++++++++++++++++++++++ checkpy/downloader/downloader.py | 112 +++++++++---------------------- checkpy/entities/path.py | 2 - checkpy/tester/discovery.py | 17 +++-- 7 files changed, 150 insertions(+), 89 deletions(-) create mode 100644 checkpy/database/__init__.py create mode 100644 checkpy/database/database.py diff --git a/.gitignore b/.gitignore index c8fb5a8..408cee6 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,4 @@ studentfiles -checkpy/storage/ checkpy/tests/ readme.md *.json diff --git a/checkpy/__main__.py b/checkpy/__main__.py index ee583f1..b25f8bc 100644 --- a/checkpy/__main__.py +++ b/checkpy/__main__.py @@ -19,6 +19,7 @@ def main(): parser.add_argument("-module", action="store", dest="module", help="provide a module name or path to run all tests from the module, or target a module for a specific test") parser.add_argument("-download", action="store", dest="githubLink", help="download tests from a Github repository and exit") + parser.add_argument("-register", action="store", dest="localLink", help="register a local folder that contains tests and exit") parser.add_argument("-update", action="store_true", help="update all downloaded tests and exit") parser.add_argument("-list", action="store_true", help="list all download locations and exit") parser.add_argument("-clean", action="store_true", help="remove all tests from the tests folder and exit") @@ -34,6 +35,10 @@ def main(): downloader.download(args.githubLink) return + if args.localLink: + downloader.register(args.localLink) + return + if args.update: downloader.update() return diff --git a/checkpy/database/__init__.py b/checkpy/database/__init__.py new file mode 100644 index 0000000..54e9ad7 --- /dev/null +++ b/checkpy/database/__init__.py @@ -0,0 +1 @@ +from checkpy.database.database import * diff --git a/checkpy/database/database.py b/checkpy/database/database.py new file mode 100644 index 0000000..7d7a2c8 --- /dev/null +++ b/checkpy/database/database.py @@ -0,0 +1,101 @@ +import tinydb +import os +import time +from checkpy.entities.path import Path, CHECKPYPATH + +_DBPATH = CHECKPYPATH + "database" + "db.json" + +def database(): + if not _DBPATH.exists(): + with open(str(_DBPATH), 'w') as f: + pass + return tinydb.TinyDB(str(_DBPATH)) + +def githubTable(): + return database().table("github") + +def localTable(): + return database().table("local") + +def clean(): + database().purge_tables() + +def forEachTestsPath(): + for path in forEachGithubPath(): + yield path + + for path in forEachLocalPath(): + yield path + +def forEachUserAndRepo(): + for username, repoName in ((entry["user"], entry["repo"]) for entry in githubTable().all()): + yield username, repoName + +def forEachGithubPath(): + for entry in githubTable().all(): + yield Path(entry["path"]) + +def forEachLocalPath(): + for entry in localTable().all(): + yield Path(entry["path"]) + +def isKnownGithub(username, repoName): + query = tinydb.Query() + return githubTable().contains((query.user == username) & (query.repo == repoName)) + +def addToGithubTable(username, repoName, releaseId, releaseTag): + if not isKnownGithub(username, repoName): + path = str(CHECKPYPATH + "tests" + repoName) + + githubTable().insert({ + "user" : username, + "repo" : repoName, + "path" : path, + "release" : releaseId, + "tag" : releaseTag, + "timestamp" : time.time() + }) + +def addToLocalTable(localPath): + query = tinydb.Query() + table = localTable() + + if not table.search(query.path == str(localPath)): + table.insert({ + "path" : str(localPath) + }) + +def updateGithubTable(username, repoName, releaseId, releaseTag): + query = tinydb.Query() + path = str(CHECKPYPATH + "tests" + repoName) + githubTable().update({ + "user" : username, + "repo" : repoName, + "path" : path, + "release" : releaseId, + "tag" : releaseTag, + "timestamp" : time.time() + }, query.user == username and query.repo == repoName) + +def timestampGithub(username, repoName): + query = tinydb.Query() + return githubTable().search(query.user == username and query.repo == repoName)[0]["timestamp"] + +def setTimestampGithub(username, repoName): + query = tinydb.Query() + githubTable().update(\ + { + "timestamp" : time.time() + }, query.user == username and query.repo == repoName) + +def githubPath(username, repoName): + query = tinydb.Query() + return Path(githubTable().search(query.user == username and query.repo == repoName)[0]["path"]) + +def releaseId(username, repoName): + query = tinydb.Query() + return githubTable().search(query.user == username and query.repo == repoName)[0]["release"] + +def releaseTag(username, repoName): + query = tinydb.Query() + return githubTable().search(query.user == username and query.repo == repoName)[0]["tag"] diff --git a/checkpy/downloader/downloader.py b/checkpy/downloader/downloader.py index 68db5b4..61cbe2b 100644 --- a/checkpy/downloader/downloader.py +++ b/checkpy/downloader/downloader.py @@ -2,9 +2,9 @@ import zipfile as zf import os import shutil -import tinydb import time -import checkpy.entities.path as checkpyPath +from checkpy.entities.path import Path +from checkpy import database from checkpy import caches from checkpy import printer from checkpy.entities import exception @@ -26,8 +26,17 @@ def download(githubLink): except exception.DownloadError as e: printer.displayError(str(e)) +def register(localLink): + path = Path(localLink) + + if not path.exists(): + printer.displayError("{} does not exist") + return + + database.addToLocalTable(path) + def update(): - for username, repoName in _forEachUserAndRepo(): + for username, repoName in database.forEachUserAndRepo(): try: _syncRelease(username, repoName) _download(username, repoName) @@ -35,23 +44,25 @@ def update(): printer.displayError(str(e)) def list(): - for username, repoName in _forEachUserAndRepo(): - printer.displayCustom("{} from {}".format(repoName, username)) + for username, repoName in database.forEachUserAndRepo(): + printer.displayCustom("Github: {} from {}".format(repoName, username)) + for path in database.forEachLocalPath(): + printer.displayCustom("Local: {}".format(path)) def clean(): - shutil.rmtree(str(checkpyPath.TESTSPATH), ignore_errors=True) - if checkpyPath.DBPATH.exists(): - os.remove(str(checkpyPath.DBPATH)) - printer.displayCustom("Removed all tests") + for path in database.forEachGithubPath(): + shutil.rmtree(str(path), ignore_errors=True) + database.clean() + printer.displayCustom("Removed all downloaded tests") return def updateSilently(): - for username, repoName in _forEachUserAndRepo(): + for username, repoName in database.forEachUserAndRepo(): # only attempt update if 300 sec have passed - if time.time() - _timestamp(username, repoName) < 300: + if time.time() - database.timestampGithub(username, repoName) < 300: continue - _setTimestamp(username, repoName) + database.setTimestampGithub(username, repoName) try: if _newReleaseAvailable(username, repoName): _download(username, repoName) @@ -60,13 +71,13 @@ def updateSilently(): def _newReleaseAvailable(githubUserName, githubRepoName): # unknown/new download - if not _isKnownDownloadLocation(githubUserName, githubRepoName): + if not database.isKnownGithub(githubUserName, githubRepoName): return True releaseJson = _getReleaseJson(githubUserName, githubRepoName) # new release id found - if releaseJson["id"] != _releaseId(githubUserName, githubRepoName): - _updateDownloadLocations(githubUserName, githubRepoName, releaseJson["id"], releaseJson["tag_name"]) + if releaseJson["id"] != database.releaseId(githubUserName, githubRepoName): + database.updateGithubTable(githubUserName, githubRepoName, releaseJson["id"], releaseJson["tag_name"]) return True # no new release found @@ -75,10 +86,10 @@ def _newReleaseAvailable(githubUserName, githubRepoName): def _syncRelease(githubUserName, githubRepoName): releaseJson = _getReleaseJson(githubUserName, githubRepoName) - if _isKnownDownloadLocation(githubUserName, githubRepoName): - _updateDownloadLocations(githubUserName, githubRepoName, releaseJson["id"], releaseJson["tag_name"]) + if database.isKnownGithub(githubUserName, githubRepoName): + database.updateGithubTable(githubUserName, githubRepoName, releaseJson["id"], releaseJson["tag_name"]) else: - _addToDownloadLocations(githubUserName, githubRepoName, releaseJson["id"], releaseJson["tag_name"]) + database.addToGithubTable(githubUserName, githubRepoName, releaseJson["id"], releaseJson["tag_name"]) # this performs one api call, beware of rate limit!!! # returns a dictionary representing the json returned by github @@ -109,7 +120,7 @@ def _getReleaseJson(githubUserName, githubRepoName): # use _syncRelease() to force an update in downloadLocations.json def _download(githubUserName, githubRepoName): githubLink = "https://github.com/{}/{}".format(githubUserName, githubRepoName) - zipLink = githubLink + "/archive/{}.zip".format(_releaseTag(githubUserName, githubRepoName)) + zipLink = githubLink + "/archive/{}.zip".format(database.releaseTag(githubUserName, githubRepoName)) try: r = requests.get(zipLink) @@ -129,7 +140,7 @@ def _download(githubUserName, githubRepoName): f = io.BytesIO(r.content) with zf.ZipFile(f) as z: - destPath = checkpyPath.TESTSPATH + githubRepoName + destPath = database.githubPath(githubUserName, githubRepoName) existingTests = set() for path, subdirs, files in destPath.walk(): @@ -137,7 +148,7 @@ def _download(githubUserName, githubRepoName): existingTests.add((path + f) - destPath) newTests = set() - for path in [checkpyPath.Path(name) for name in z.namelist()]: + for path in [Path(name) for name in z.namelist()]: if path.isPythonFile(): newTests.add(path.pathFromFolder("tests")) @@ -154,68 +165,11 @@ def _download(githubUserName, githubRepoName): printer.displayCustom("Finished downloading: {}".format(githubLink)) -def _downloadLocationsDatabase(): - if not checkpyPath.DBPATH.containingFolder().exists(): - os.makedirs(str(checkpyPath.DBPATH.containingFolder())) - if not checkpyPath.DBPATH.exists(): - with open(str(checkpyPath.DBPATH), 'w') as f: - pass - return tinydb.TinyDB(str(checkpyPath.DBPATH)) - -def _forEachUserAndRepo(): - for username, repoName in ((entry["user"], entry["repo"]) for entry in _downloadLocationsDatabase().all()): - yield username, repoName - -def _isKnownDownloadLocation(username, repoName): - query = tinydb.Query() - return _downloadLocationsDatabase().contains((query.user == username) & (query.repo == repoName)) - -def _addToDownloadLocations(username, repoName, releaseId, releaseTag): - if not _isKnownDownloadLocation(username, repoName): - _downloadLocationsDatabase().insert(\ - { - "user" : username, - "repo" : repoName, - "release" : releaseId, - "tag" : releaseTag, - "timestamp" : time.time() - }) - -def _updateDownloadLocations(username, repoName, releaseId, releaseTag): - query = tinydb.Query() - _downloadLocationsDatabase().update(\ - { - "user" : username, - "repo" : repoName, - "release" : releaseId, - "tag" : releaseTag, - "timestamp" : time.time() - }, query.user == username and query.repo == repoName) - -def _timestamp(username, repoName): - query = tinydb.Query() - return _downloadLocationsDatabase().search(query.user == username and query.repo == repoName)[0]["timestamp"] - -def _setTimestamp(username, repoName): - query = tinydb.Query() - _downloadLocationsDatabase().update(\ - { - "timestamp" : time.time() - }, query.user == username and query.repo == repoName) - -def _releaseId(username, repoName): - query = tinydb.Query() - return _downloadLocationsDatabase().search(query.user == username and query.repo == repoName)[0]["release"] - -def _releaseTag(username, repoName): - query = tinydb.Query() - return _downloadLocationsDatabase().search(query.user == username and query.repo == repoName)[0]["tag"] - def _extractTests(zipfile, destPath): if not destPath.exists(): os.makedirs(str(destPath)) - for path in [checkpyPath.Path(name) for name in zipfile.namelist()]: + for path in [Path(name) for name in zipfile.namelist()]: _extractTest(zipfile, path, destPath) def _extractTest(zipfile, path, destPath): diff --git a/checkpy/entities/path.py b/checkpy/entities/path.py index 096edb8..c95e4f9 100644 --- a/checkpy/entities/path.py +++ b/checkpy/entities/path.py @@ -134,5 +134,3 @@ def current(): userPath = Path(os.getcwd()) CHECKPYPATH = Path(os.path.abspath(os.path.dirname(__file__))[:-len("/entities")]) -TESTSPATH = CHECKPYPATH + "tests" -DBPATH = CHECKPYPATH + "storage" + "downloadLocations.json" diff --git a/checkpy/tester/discovery.py b/checkpy/tester/discovery.py index e84c3b1..be02064 100644 --- a/checkpy/tester/discovery.py +++ b/checkpy/tester/discovery.py @@ -1,6 +1,7 @@ import os import re -from checkpy.entities.path import Path, TESTSPATH +import checkpy.database as database +from checkpy.entities.path import Path def testExists(testName, module = ""): testFileName = testName.split(".")[0] + "Test.py" @@ -26,15 +27,17 @@ def getPath(path): return None def getTestNames(moduleName): - for (dirPath, subdirs, files) in TESTSPATH.walk(): - if Path(moduleName) in dirPath: - return [f.fileName[:-7] for f in files if f.fileName.endswith(".py") and not f.fileName.startswith("_")] + for testsPath in database.forEachTestsPath(): + for (dirPath, subdirs, files) in testsPath.walk(): + if Path(moduleName) in dirPath: + return [f.fileName[:-7] for f in files if f.fileName.endswith(".py") and not f.fileName.startswith("_")] def getTestPaths(testFileName, module = ""): testFilePaths = [] - for (dirPath, dirNames, fileNames) in TESTSPATH.walk(): - if Path(testFileName) in fileNames and (not module or Path(module) in dirPath): - testFilePaths.append(dirPath) + for testsPath in database.forEachTestsPath(): + for (dirPath, dirNames, fileNames) in testsPath.walk(): + if Path(testFileName) in fileNames and (not module or Path(module) in dirPath): + testFilePaths.append(dirPath) return testFilePaths def _backslashToForwardslash(text): From 1cd1740800c70aa5ff39e72b7c830bd3643b59a8 Mon Sep 17 00:00:00 2001 From: jelleas Date: Tue, 30 Jan 2018 15:53:37 +0100 Subject: [PATCH 106/269] -downloaded --- checkpy/downloader/downloader.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/checkpy/downloader/downloader.py b/checkpy/downloader/downloader.py index 61cbe2b..bf5c794 100644 --- a/checkpy/downloader/downloader.py +++ b/checkpy/downloader/downloader.py @@ -53,7 +53,7 @@ def clean(): for path in database.forEachGithubPath(): shutil.rmtree(str(path), ignore_errors=True) database.clean() - printer.displayCustom("Removed all downloaded tests") + printer.displayCustom("Removed all tests") return def updateSilently(): From 961b4c5dad153389cc0d774adfeca6a9f708e272 Mon Sep 17 00:00:00 2001 From: jelleas Date: Wed, 31 Jan 2018 11:51:46 +0100 Subject: [PATCH 107/269] get tests based on isTestCreator --- checkpy/tester/tester.py | 3 +-- checkpy/tests.py | 1 + 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/checkpy/tester/tester.py b/checkpy/tester/tester.py index 4fc8702..5f90710 100644 --- a/checkpy/tester/tester.py +++ b/checkpy/tester/tester.py @@ -176,8 +176,7 @@ def _runTestsFromModule(self, module): result.addOutput(printer.displayError("Something went wrong at setup:\n{}".format(e))) return - testCreators = [method for method in module.__dict__.values() if callable(method) and method.__name__ not in self.reservedNames] - + testCreators = [method for method in module.__dict__.values() if getattr(method, "isTestCreator", False)] result.nTests = len(testCreators) testResults = self._runTests(testCreators) diff --git a/checkpy/tests.py b/checkpy/tests.py index 060e09b..ef2157a 100644 --- a/checkpy/tests.py +++ b/checkpy/tests.py @@ -89,6 +89,7 @@ def exception(self): def test(priority): def testDecorator(testCreator): + testCreator.isTestCreator = True @caches.cache(testCreator) @wraps(testCreator) def testWrapper(fileName): From 3afd381942b5d56fc5cebc3ddc49a58f8b8ab608 Mon Sep 17 00:00:00 2001 From: jelleas Date: Wed, 31 Jan 2018 12:16:04 +0100 Subject: [PATCH 108/269] only files ending on test.py are considered tests --- checkpy/entities/path.py | 2 +- checkpy/tester/discovery.py | 4 ++-- checkpy/tester/tester.py | 1 - 3 files changed, 3 insertions(+), 4 deletions(-) diff --git a/checkpy/entities/path.py b/checkpy/entities/path.py index c95e4f9..fe55060 100644 --- a/checkpy/entities/path.py +++ b/checkpy/entities/path.py @@ -37,7 +37,7 @@ def exists(self): def walk(self): for path, subdirs, files in os.walk(str(self)): - yield Path(path), [Path(sd) for sd in subdirs], [Path(f) for f in files] + yield Path(path), subdirs, files def copyTo(self, destination): shutil.copyfile(str(self), str(destination)) diff --git a/checkpy/tester/discovery.py b/checkpy/tester/discovery.py index be02064..7bb3b3c 100644 --- a/checkpy/tester/discovery.py +++ b/checkpy/tester/discovery.py @@ -30,13 +30,13 @@ def getTestNames(moduleName): for testsPath in database.forEachTestsPath(): for (dirPath, subdirs, files) in testsPath.walk(): if Path(moduleName) in dirPath: - return [f.fileName[:-7] for f in files if f.fileName.endswith(".py") and not f.fileName.startswith("_")] + return [f[:-7] for f in files if f.lower().endswith("test.py")] def getTestPaths(testFileName, module = ""): testFilePaths = [] for testsPath in database.forEachTestsPath(): for (dirPath, dirNames, fileNames) in testsPath.walk(): - if Path(testFileName) in fileNames and (not module or Path(module) in dirPath): + if testFileName in fileNames and (not module or module in dirPath): testFilePaths.append(dirPath) return testFilePaths diff --git a/checkpy/tester/tester.py b/checkpy/tester/tester.py index 5f90710..c5d3757 100644 --- a/checkpy/tester/tester.py +++ b/checkpy/tester/tester.py @@ -144,7 +144,6 @@ def __init__(self, moduleName, fileName, debugMode, signalQueue, resultQueue): self.debugMode = debugMode self.signalQueue = signalQueue self.resultQueue = resultQueue - self.reservedNames = ["before", "after", "sandbox"] def run(self): if self.debugMode: From 15d20fb3dc2f1d65b9b6c7e6227d9c767e380c65 Mon Sep 17 00:00:00 2001 From: jelleas Date: Wed, 31 Jan 2018 12:33:24 +0100 Subject: [PATCH 109/269] fix: sandboxing with relative filepath --- checkpy/entities/path.py | 3 +++ checkpy/tester/discovery.py | 2 +- checkpy/tester/tester.py | 18 +++++++++--------- 3 files changed, 13 insertions(+), 10 deletions(-) diff --git a/checkpy/entities/path.py b/checkpy/entities/path.py index fe55060..45bdd1b 100644 --- a/checkpy/entities/path.py +++ b/checkpy/entities/path.py @@ -32,6 +32,9 @@ def containingFolder(self): def isPythonFile(self): return self.fileName.endswith(".py") + def absolutePath(self): + return Path(os.path.abspath(str(self))) + def exists(self): return os.path.exists(str(self)) diff --git a/checkpy/tester/discovery.py b/checkpy/tester/discovery.py index 7bb3b3c..121979a 100644 --- a/checkpy/tester/discovery.py +++ b/checkpy/tester/discovery.py @@ -30,7 +30,7 @@ def getTestNames(moduleName): for testsPath in database.forEachTestsPath(): for (dirPath, subdirs, files) in testsPath.walk(): if Path(moduleName) in dirPath: - return [f[:-7] for f in files if f.lower().endswith("test.py")] + return [f[:-len("test.py")] for f in files if f.lower().endswith("test.py")] def getTestPaths(testFileName, module = ""): testFilePaths = [] diff --git a/checkpy/tester/tester.py b/checkpy/tester/tester.py index c5d3757..0a53439 100644 --- a/checkpy/tester/tester.py +++ b/checkpy/tester/tester.py @@ -82,7 +82,7 @@ def _runTests(moduleName, fileName, debugMode = False): signalQueue = ctx.Queue() resultQueue = ctx.Queue() - tester = _Tester(moduleName, fileName, debugMode, signalQueue, resultQueue) + tester = _Tester(moduleName, path.Path(fileName).absolutePath(), debugMode, signalQueue, resultQueue) p = ctx.Process(target=tester.run, name="Tester") p.start() @@ -138,9 +138,9 @@ def __init__(self, isTiming = False, resetTimer = False, description = None, tim self.timeout = timeout class _Tester(object): - def __init__(self, moduleName, fileName, debugMode, signalQueue, resultQueue): + def __init__(self, moduleName, filePath, debugMode, signalQueue, resultQueue): self.moduleName = moduleName - self.fileName = fileName + self.filePath = filePath self.debugMode = debugMode self.signalQueue = signalQueue self.resultQueue = resultQueue @@ -150,13 +150,13 @@ def run(self): printer.printer.DEBUG_MODE = True # overwrite argv so that it seems the file was run directly - sys.argv = [self.fileName] + sys.argv = [self.filePath.fileName] module = importlib.import_module(self.moduleName) - module._fileName = self.fileName + module._fileName = self.filePath.fileName if hasattr(module, "sandbox"): - with Sandbox(path.Path(self.fileName)): + with Sandbox(self.filePath.absolutePath()): module.sandbox() return self._runTestsFromModule(module) @@ -166,7 +166,7 @@ def _runTestsFromModule(self, module): self._sendSignal(_Signal(isTiming = False)) result = TesterResult() - result.addOutput(printer.displayTestName(os.path.basename(self.fileName))) + result.addOutput(printer.displayTestName(self.filePath.fileName)) if hasattr(module, "before"): try: @@ -200,7 +200,7 @@ def _runTests(self, testCreators): cachedResults = {} # run tests in noncolliding execution order - for test in self._getTestsInExecutionOrder([tc(self.fileName) for tc in testCreators]): + for test in self._getTestsInExecutionOrder([tc(self.filePath.fileName) for tc in testCreators]): self._sendSignal(_Signal(isTiming = True, resetTimer = True, description = test.description(), timeout = test.timeout())) cachedResults[test] = test.run() self._sendSignal(_Signal(isTiming = False)) @@ -217,6 +217,6 @@ def _sendSignal(self, signal): def _getTestsInExecutionOrder(self, tests): testsInExecutionOrder = [] for i, test in enumerate(tests): - dependencies = self._getTestsInExecutionOrder([tc(self.fileName) for tc in test.dependencies()]) + [test] + dependencies = self._getTestsInExecutionOrder([tc(self.filePath.fileName) for tc in test.dependencies()]) + [test] testsInExecutionOrder.extend([t for t in dependencies if t not in testsInExecutionOrder]) return testsInExecutionOrder From 30ba607151bb38b92b08b29379476e4c299a3243 Mon Sep 17 00:00:00 2001 From: jelleas Date: Wed, 31 Jan 2018 14:49:17 +0100 Subject: [PATCH 110/269] docs --- checkpy/tester/tester.py | 2 + docs/Makefile | 20 +++++ docs/conf.py | 169 +++++++++++++++++++++++++++++++++++++++ docs/index.rst | 67 ++++++++++++++++ docs/make.bat | 36 +++++++++ 5 files changed, 294 insertions(+) create mode 100644 docs/Makefile create mode 100644 docs/conf.py create mode 100644 docs/index.rst create mode 100644 docs/make.bat diff --git a/checkpy/tester/tester.py b/checkpy/tester/tester.py index 0a53439..611912d 100644 --- a/checkpy/tester/tester.py +++ b/checkpy/tester/tester.py @@ -115,6 +115,8 @@ def _runTests(moduleName, fileName, debugMode = False): if not resultQueue.empty(): return resultQueue.get() + raise exception.CheckpyError(message = "An error occured while testing. The testing process exited unexpectedly.") + class TesterResult(object): def __init__(self): self.nTests = 0 diff --git a/docs/Makefile b/docs/Makefile new file mode 100644 index 0000000..5d774cf --- /dev/null +++ b/docs/Makefile @@ -0,0 +1,20 @@ +# Minimal makefile for Sphinx documentation +# + +# You can set these variables from the command line. +SPHINXOPTS = +SPHINXBUILD = sphinx-build +SPHINXPROJ = CheckPy +SOURCEDIR = . +BUILDDIR = _build + +# Put it first so that "make" without argument is like "make help". +help: + @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) + +.PHONY: help Makefile + +# Catch-all target: route all unknown targets to Sphinx using the new +# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). +%: Makefile + @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) \ No newline at end of file diff --git a/docs/conf.py b/docs/conf.py new file mode 100644 index 0000000..3ae3379 --- /dev/null +++ b/docs/conf.py @@ -0,0 +1,169 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# +# CheckPy documentation build configuration file, created by +# sphinx-quickstart on Wed Jan 31 14:12:52 2018. +# +# This file is execfile()d with the current directory set to its +# containing dir. +# +# Note that not all possible configuration values are present in this +# autogenerated file. +# +# All configuration values have a default; values that are commented out +# serve to show the default. + +# If extensions (or modules to document with autodoc) are in another directory, +# add these directories to sys.path here. If the directory is relative to the +# documentation root, use os.path.abspath to make it absolute, like shown here. +# +# import os +# import sys +# sys.path.insert(0, os.path.abspath('.')) + + +# -- General configuration ------------------------------------------------ + +# If your documentation needs a minimal Sphinx version, state it here. +# +# needs_sphinx = '1.0' + +# Add any Sphinx extension module names here, as strings. They can be +# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom +# ones. +extensions = [] + +# Add any paths that contain templates here, relative to this directory. +templates_path = ['_templates'] + +# The suffix(es) of source filenames. +# You can specify multiple suffix as a list of string: +# +# source_suffix = ['.rst', '.md'] +source_suffix = '.rst' + +# The master toctree document. +master_doc = 'index' + +# General information about the project. +project = 'CheckPy' +copyright = '2018, Jelle van Assema (JelleAs)' +author = 'Jelle van Assema (JelleAs)' + +# The version info for the project you're documenting, acts as replacement for +# |version| and |release|, also used in various other places throughout the +# built documents. +# +# The short X.Y version. +version = '0.4' +# The full version, including alpha/beta/rc tags. +release = '0.4' + +# The language for content autogenerated by Sphinx. Refer to documentation +# for a list of supported languages. +# +# This is also used if you do content translation via gettext catalogs. +# Usually you set "language" from the command line for these cases. +language = None + +# List of patterns, relative to source directory, that match files and +# directories to ignore when looking for source files. +# This patterns also effect to html_static_path and html_extra_path +exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store'] + +# The name of the Pygments (syntax highlighting) style to use. +pygments_style = 'sphinx' + +# If true, `todo` and `todoList` produce output, else they produce nothing. +todo_include_todos = False + + +# -- Options for HTML output ---------------------------------------------- + +# The theme to use for HTML and HTML Help pages. See the documentation for +# a list of builtin themes. +# +html_theme = 'alabaster' + +# Theme options are theme-specific and customize the look and feel of a theme +# further. For a list of options available for each theme, see the +# documentation. +# +# html_theme_options = {} + +# Add any paths that contain custom static files (such as style sheets) here, +# relative to this directory. They are copied after the builtin static files, +# so a file named "default.css" will overwrite the builtin "default.css". +html_static_path = ['_static'] + +# Custom sidebar templates, must be a dictionary that maps document names +# to template names. +# +# This is required for the alabaster theme +# refs: http://alabaster.readthedocs.io/en/latest/installation.html#sidebars +html_sidebars = { + '**': [ + 'relations.html', # needs 'show_related': True theme option to display + 'searchbox.html', + ] +} + + +# -- Options for HTMLHelp output ------------------------------------------ + +# Output file base name for HTML help builder. +htmlhelp_basename = 'CheckPydoc' + + +# -- Options for LaTeX output --------------------------------------------- + +latex_elements = { + # The paper size ('letterpaper' or 'a4paper'). + # + # 'papersize': 'letterpaper', + + # The font size ('10pt', '11pt' or '12pt'). + # + # 'pointsize': '10pt', + + # Additional stuff for the LaTeX preamble. + # + # 'preamble': '', + + # Latex figure (float) alignment + # + # 'figure_align': 'htbp', +} + +# Grouping the document tree into LaTeX files. List of tuples +# (source start file, target name, title, +# author, documentclass [howto, manual, or own class]). +latex_documents = [ + (master_doc, 'CheckPy.tex', 'CheckPy Documentation', + 'Jelle van Assema (JelleAs)', 'manual'), +] + + +# -- Options for manual page output --------------------------------------- + +# One entry per manual page. List of tuples +# (source start file, name, description, authors, manual section). +man_pages = [ + (master_doc, 'checkpy', 'CheckPy Documentation', + [author], 1) +] + + +# -- Options for Texinfo output ------------------------------------------- + +# Grouping the document tree into Texinfo files. List of tuples +# (source start file, target name, title, author, +# dir menu entry, description, category) +texinfo_documents = [ + (master_doc, 'CheckPy', 'CheckPy Documentation', + author, 'CheckPy', 'One line description of project.', + 'Miscellaneous'), +] + + + diff --git a/docs/index.rst b/docs/index.rst new file mode 100644 index 0000000..abe2ccf --- /dev/null +++ b/docs/index.rst @@ -0,0 +1,67 @@ +Welcome to CheckPy's documentation! +=================================== + +.. toctree:: + :maxdepth: 2 + :caption: Contents: + + + +Indices and tables +================== + +* :ref:`genindex` +* :ref:`modindex` +* :ref:`search` + + +CheckPy +======= + +A customizable education oriented tester for Python. +Developed for courses in the +`Minor Programming `__ at the `UvA `__. + +Installation +------------ + +:: + + pip install checkpy + +Usage +----- + +:: + + usage: checkpy [-h] [-module MODULE] [-download GITHUBLINK] + [-register LOCALLINK] [-update] [-list] [-clean] [--dev] + [file] + + checkPy: a python testing framework for education. You are running Python + version 3.6.2 and checkpy version 0.4.0. + + positional arguments: + file name of file to be tested + + optional arguments: + -h, --help show this help message and exit + -module MODULE provide a module name or path to run all tests from + the module, or target a module for a specific test + -download GITHUBLINK download tests from a Github repository and exit + -register LOCALLINK register a local folder that contains tests and exit + -update update all downloaded tests and exit + -list list all download locations and exit + -clean remove all tests from the tests folder and exit + --dev get extra information to support the development of + tests + + +Features +-------- + +- Customizable output, you choose what the users see. +- Support for blackbox and whitebox testing. +- The full scope of Python is available when designing tests. +- Automatic test distribution, CheckPy will keep its tests up to date by periodically checking for new tests. +- No infinite loops! CheckPy kills tests after a predefined timeout. diff --git a/docs/make.bat b/docs/make.bat new file mode 100644 index 0000000..da73edd --- /dev/null +++ b/docs/make.bat @@ -0,0 +1,36 @@ +@ECHO OFF + +pushd %~dp0 + +REM Command file for Sphinx documentation + +if "%SPHINXBUILD%" == "" ( + set SPHINXBUILD=sphinx-build +) +set SOURCEDIR=. +set BUILDDIR=_build +set SPHINXPROJ=CheckPy + +if "%1" == "" goto help + +%SPHINXBUILD% >NUL 2>NUL +if errorlevel 9009 ( + echo. + echo.The 'sphinx-build' command was not found. Make sure you have Sphinx + echo.installed, then set the SPHINXBUILD environment variable to point + echo.to the full path of the 'sphinx-build' executable. Alternatively you + echo.may add the Sphinx directory to PATH. + echo. + echo.If you don't have Sphinx installed, grab it from + echo.http://sphinx-doc.org/ + exit /b 1 +) + +%SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% +goto end + +:help +%SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% + +:end +popd From 18beebe0686ad450d211af28e2babf802e511036 Mon Sep 17 00:00:00 2001 From: jelleas Date: Wed, 31 Jan 2018 17:45:46 +0100 Subject: [PATCH 111/269] start docs --- docs/_static/fifteen.png | Bin 0 -> 153356 bytes docs/index.rst | 44 +++++++++++-------------- docs/intro.rst | 67 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 85 insertions(+), 26 deletions(-) create mode 100644 docs/_static/fifteen.png create mode 100644 docs/intro.rst diff --git a/docs/_static/fifteen.png b/docs/_static/fifteen.png new file mode 100644 index 0000000000000000000000000000000000000000..613b1479448be6eb1dafcd8f6d423f55ef675858 GIT binary patch literal 153356 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

Qpcy(^N7mnt9LLywbYci$NHlCb3$x9Ae`65 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 GfBk=KEK_X& literal 0 HcmV?d00001 diff --git a/docs/index.rst b/docs/index.rst index abe2ccf..0572a76 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -1,27 +1,18 @@ -Welcome to CheckPy's documentation! +Welcome to CheckPy! =================================== +An education oriented testing framework for Python. +Developed for courses in the +`Minor Programming `__ at the `University of Amsterdam `__. + +.. image:: _static/fifteen.png .. toctree:: :maxdepth: 2 :caption: Contents: + intro -Indices and tables -================== - -* :ref:`genindex` -* :ref:`modindex` -* :ref:`search` - - -CheckPy -======= - -A customizable education oriented tester for Python. -Developed for courses in the -`Minor Programming `__ at the `UvA `__. - Installation ------------ @@ -29,6 +20,17 @@ Installation pip install checkpy + +Features +-------- + + - Customizable output, you choose what the users see. + - Support for blackbox and whitebox testing. + - The full scope of Python is available when designing tests. + - Automatic test distribution, CheckPy will keep its tests up to date by periodically checking for new tests. + - No infinite loops! CheckPy kills tests after a predefined timeout. + + Usage ----- @@ -55,13 +57,3 @@ Usage -clean remove all tests from the tests folder and exit --dev get extra information to support the development of tests - - -Features --------- - -- Customizable output, you choose what the users see. -- Support for blackbox and whitebox testing. -- The full scope of Python is available when designing tests. -- Automatic test distribution, CheckPy will keep its tests up to date by periodically checking for new tests. -- No infinite loops! CheckPy kills tests after a predefined timeout. diff --git a/docs/intro.rst b/docs/intro.rst new file mode 100644 index 0000000..86518fc --- /dev/null +++ b/docs/intro.rst @@ -0,0 +1,67 @@ +Introduction to CheckPy +=================================== + +.. toctree:: + :maxdepth: 2 + :caption: Contents: + +Installation +------------------- + +:: + + pip install checkpy + + +Writing and running your first test +----------------------------------- + +First create a new directory to store your tests somewhere on your computer. +Then navigate to that directory, and create a new file called ``helloTest.py``. +CheckPy discovers tests for a particular source file (i.e. ``hello.py``) by looking +for a test file starting with a corresponding name (``hello``) and ending with ``test.py``. +So CheckPy uses ``helloTest.py`` to test ``hello.py``. + +Now open up ``helloTest.py`` and insert the following code: + +.. code-block:: python + + import checkpy.tests as t + import checkpy.lib as lib + import checkpy.assertlib as asserts + + @t.test(0) + def exactlyHelloWorld(test): + def testMethod(): + output = lib.outputOf( + test.fileName, + overwriteAttributes = [("__name__", "__main__")] + ) + return asserts.exact(output.strip(), "Hello, world!") + + test.test = testMethod + test.description = lambda : "prints exactly: Hello, world!" + +Next, create a file called ``hello.py`` somewhere on your computer. Insert the +following snippet of code in ``hello.py``: + +.. code-block:: python + + print("Hello, world!") + +Now there's only one thing left to do. We need to tell CheckPy where the tests are +located. You can do this by calling CheckPy with the `-register` flag and by providing an +absolute path to the directory ``helloTest.py`` is located in. Say ``helloTest.py`` +is located in ``\Users\foo\bar\tests\`` then call CheckPy like so: + +:: + + checkpy -register \Users\foo\bar\tests\ + +Alright, we're there. We got a test (``helloTest.py``), a Python file we want to test (``hello.py``), +and we've told CheckPy where to look for tests. Now navigate over to the +directory that contains ``hello.py`` and call CheckPy as follows: + +:: + + checkpy hello From 2dfec72f2dd9f0d72c3f43e9186909e0a152cbdb Mon Sep 17 00:00:00 2001 From: jelleas Date: Wed, 31 Jan 2018 17:48:59 +0100 Subject: [PATCH 112/269] docs in readme --- README.rst | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.rst b/README.rst index 86fd400..1f1ac22 100644 --- a/README.rst +++ b/README.rst @@ -6,6 +6,8 @@ used by students whom are taking courses in the `Minor Programming `__ at the `UvA `__. +`CheckPy docs `__ + Installation ------------ From 16fbc3c9937e5dfa6ef068a3fa2f784a09854937 Mon Sep 17 00:00:00 2001 From: jelleas Date: Wed, 7 Feb 2018 12:05:16 +0100 Subject: [PATCH 113/269] intro update --- docs/intro.rst | 58 ++++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 54 insertions(+), 4 deletions(-) diff --git a/docs/intro.rst b/docs/intro.rst index 86518fc..33311ba 100644 --- a/docs/intro.rst +++ b/docs/intro.rst @@ -33,10 +33,7 @@ Now open up ``helloTest.py`` and insert the following code: @t.test(0) def exactlyHelloWorld(test): def testMethod(): - output = lib.outputOf( - test.fileName, - overwriteAttributes = [("__name__", "__main__")] - ) + output = lib.outputOf(test.fileName) return asserts.exact(output.strip(), "Hello, world!") test.test = testMethod @@ -65,3 +62,56 @@ directory that contains ``hello.py`` and call CheckPy as follows: :: checkpy hello + + +How to write tests in CheckPy +----------------------------- + +Tests in CheckPy are instances of ``checkpy.tests.Test``. These ``Test`` instances have several +abstract methods that you can implement or rather, overwrite by binding a new method. +These methods is what CheckPy executes when running a test. For instance you have the +``description`` method which is called to produce a description for the test, the ``timeout`` +method which is called to determine the maximum alotted time for this test, and +ofcourse the ``test`` method which is called to actually perform the test. + +Lets start with the necessities. CheckPy requires you to overwrite two methods from every +``Test`` instance. These methods are ``test`` and ``description``. The ``description`` method +should produce the description, that can be just a string, for the user to see. In our +hello-world example we used this ``description`` method: + +.. code-block:: python + + test.description = lambda : "prints exactly: Hello, world!" + +Depending on whether the test fails or passes, the user sees this string in red or green +respectively. The other method we have to overwrite, the ``test`` method, should return +True of False depending on whether the tests passes or fails. You are free to implement +this method in any which way you want. CheckPy just offers some useful tools to make +your testing life easier. Again, looking back at our hello-world example, we used this +``test`` method: + +.. code-block:: python + + def testMethod(): + output = lib.outputOf(test.fileName) + return asserts.exact(output.strip(), "Hello, world!") + + test.test = testMethod + +So what's going on here? Python doesn't support multi statement lambda functions. This means that +if you want to use multiple statements we have to resort back to named functions, i.e. +``testMethod()``, and then bind this named function to the respective method of the ``Test`` +instance. You can put the above test method in a single statement lambda function, but +readability will suffer from it. Especially once we move on to some more complex test methods. + +Now there are just 2 lines of code in this ``testMethod``. First we take the output of +something called ``test.fileName``. ``test.fileName`` just refers to the name of the file +the user wants to test, CheckPy will automatically set this for you. ``lib.outputOf`` is +a function that gives you all the print-output of a python file. Thus what this line of code +does is simply run the file that the user wants to test, and then return the print output as +a string. + +The line ``return asserts.exact(output.strip(), "Hello, world!")`` is equivalent to +``return output.strip() == "Hello, world!"``. The ``checkpy.assertlib`` module, that is renamed +to ``asserts`` here, simply offers a collection of functions to perform assertions. These +functions do nothing more than return ``True`` or ``False``. From 36c7ec598737f6a0917117c875d0cb7a870e607a Mon Sep 17 00:00:00 2001 From: jelleas Date: Wed, 7 Feb 2018 15:27:08 +0100 Subject: [PATCH 114/269] is -> are --- docs/intro.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/intro.rst b/docs/intro.rst index 33311ba..e17a33a 100644 --- a/docs/intro.rst +++ b/docs/intro.rst @@ -69,7 +69,7 @@ How to write tests in CheckPy Tests in CheckPy are instances of ``checkpy.tests.Test``. These ``Test`` instances have several abstract methods that you can implement or rather, overwrite by binding a new method. -These methods is what CheckPy executes when running a test. For instance you have the +These methods are executed when CheckPy runs a test. For instance you have the ``description`` method which is called to produce a description for the test, the ``timeout`` method which is called to determine the maximum alotted time for this test, and ofcourse the ``test`` method which is called to actually perform the test. From 3f0d9dd3918169b5f343c5ce223e3e060937698d Mon Sep 17 00:00:00 2001 From: jelleas Date: Wed, 7 Feb 2018 15:28:49 +0100 Subject: [PATCH 115/269] we -> you --- docs/intro.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/intro.rst b/docs/intro.rst index e17a33a..fa0634e 100644 --- a/docs/intro.rst +++ b/docs/intro.rst @@ -99,7 +99,7 @@ your testing life easier. Again, looking back at our hello-world example, we use test.test = testMethod So what's going on here? Python doesn't support multi statement lambda functions. This means that -if you want to use multiple statements we have to resort back to named functions, i.e. +if you want to use multiple statements, you have to resort back to named functions, i.e. ``testMethod()``, and then bind this named function to the respective method of the ``Test`` instance. You can put the above test method in a single statement lambda function, but readability will suffer from it. Especially once we move on to some more complex test methods. From 207b81c3530bd57a439187c4c12e3352e4640a4f Mon Sep 17 00:00:00 2001 From: jelleas Date: Wed, 7 Feb 2018 16:20:29 +0100 Subject: [PATCH 116/269] docs - functions --- docs/intro.rst | 61 +++++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 56 insertions(+), 5 deletions(-) diff --git a/docs/intro.rst b/docs/intro.rst index fa0634e..1be9580 100644 --- a/docs/intro.rst +++ b/docs/intro.rst @@ -64,15 +64,31 @@ directory that contains ``hello.py`` and call CheckPy as follows: checkpy hello -How to write tests in CheckPy ------------------------------ +Writing simple tests in CheckPy +-------------------------------- Tests in CheckPy are instances of ``checkpy.tests.Test``. These ``Test`` instances have several abstract methods that you can implement or rather, overwrite by binding a new method. These methods are executed when CheckPy runs a test. For instance you have the ``description`` method which is called to produce a description for the test, the ``timeout`` method which is called to determine the maximum alotted time for this test, and -ofcourse the ``test`` method which is called to actually perform the test. +ofcourse the ``test`` method which is called to actually perform the test. This +``Test`` instance is automatically provided to you when you decorate a function with +the ``checkpy.tests.test`` decorator. In our hello-world example this looked something like: + +.. code-block:: python + + @t.test(0) + def exactlyHelloWorld(test): + +Here the ``t.test`` decorator (``t`` is short for ``checkpy.tests``) decorates +the function ``exactlyHelloWorld``. This causes CheckPy to treat ``exactlyHelloWorld`` +as a `test creator` function. That when called produces an instance of ``Test``. +The ``t.test`` decorator accepts an argument that is used to determine the order +in which the result of the test is shown to the screen (lowest first). The decorator +then passes an instance of ``Test`` to the decorated function (``exactlyHelloWorld``). +It is up to ``exactlyHelloWorld`` to overwrite some or all abstract methods of that one +instance of ``Test`` that it receives. Lets start with the necessities. CheckPy requires you to overwrite two methods from every ``Test`` instance. These methods are ``test`` and ``description``. The ``description`` method @@ -85,7 +101,7 @@ hello-world example we used this ``description`` method: Depending on whether the test fails or passes, the user sees this string in red or green respectively. The other method we have to overwrite, the ``test`` method, should return -True of False depending on whether the tests passes or fails. You are free to implement +``True`` or ``False`` depending on whether the tests passes or fails. You are free to implement this method in any which way you want. CheckPy just offers some useful tools to make your testing life easier. Again, looking back at our hello-world example, we used this ``test`` method: @@ -99,7 +115,7 @@ your testing life easier. Again, looking back at our hello-world example, we use test.test = testMethod So what's going on here? Python doesn't support multi statement lambda functions. This means that -if you want to use multiple statements, you have to resort back to named functions, i.e. +if you want to use multiple statements, you have to resort to named functions, i.e. ``testMethod()``, and then bind this named function to the respective method of the ``Test`` instance. You can put the above test method in a single statement lambda function, but readability will suffer from it. Especially once we move on to some more complex test methods. @@ -115,3 +131,38 @@ The line ``return asserts.exact(output.strip(), "Hello, world!")`` is equivalent ``return output.strip() == "Hello, world!"``. The ``checkpy.assertlib`` module, that is renamed to ``asserts`` here, simply offers a collection of functions to perform assertions. These functions do nothing more than return ``True`` or ``False``. + +That's all there is to it. You simply write a function and decorate it with the `test` decorator. +Overwrite a couple of methods of ``Test``, and you're good to go. + +Testing functions +----------------- + +Let's make life a little more exciting. CheckPy can do a lot more besides simply running a Python file +and looking at print output. Specifically CheckPy lets you import said Python file +as a module and do all sort of things with it and to it. Lets focus on Functions for now. + +For an assignment on (biological) virus simulations, we asked students to do the following: + +Write a function ``generateVirus(length)``. +This function should accept one argument ``length``, that is an integer representing the length of the virus. +The function should return a virus, that is a string of random nucleotides (``'A'``, ``'T'``, ``'G'`` or ``'C'``). + +This is just a small part of a bigger assignment that ultimately moves towards a simulation +of viruses in a patient. We can use CheckPy to test several aspects of this assignment. +For instance to test whether only the nucleotides ATGC occurred we wrote the following: + +.. code-block:: python + + @t.test(0) + def onlyATGC(test): + def testMethod(): + generateVirus = lib.getFunction("generateVirus", test.fileName) + pairs = "".join(generateVirus(10) for _ in range(1000)) + return asserts.containsOnly(pairs, "AGTC") + + test.test = testMethod + test.description = lambda : "generateVirus() produces viruses consisting only of A, T, G and C" + + +This test ... From 98e28e90d6a107f8e66e423fe3768763781fe315 Mon Sep 17 00:00:00 2001 From: jelleas Date: Thu, 8 Feb 2018 14:57:20 +0100 Subject: [PATCH 117/269] extra doc example --- docs/intro.rst | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/docs/intro.rst b/docs/intro.rst index 1be9580..63127d7 100644 --- a/docs/intro.rst +++ b/docs/intro.rst @@ -154,7 +154,7 @@ For instance to test whether only the nucleotides ATGC occurred we wrote the fol .. code-block:: python - @t.test(0) + @t.test(10) def onlyATGC(test): def testMethod(): generateVirus = lib.getFunction("generateVirus", test.fileName) @@ -164,5 +164,15 @@ For instance to test whether only the nucleotides ATGC occurred we wrote the fol test.test = testMethod test.description = lambda : "generateVirus() produces viruses consisting only of A, T, G and C" +To test whether the function actually exists and accepted just one argument, we wrote the following: + +.. code-block:: python + + @t.test(0) + def isDefined(test): + def testMethod(): + generateVirus = lib.getFunction("generateVirus", test.fileName) + return len(generateVirus.arguments) == 1 -This test ... + test.test = testMethod + test.description = lambda : "generateVirus is defined and accepts just one argument" From fa320a3045feb22710b31faa51710e1d28ac06d8 Mon Sep 17 00:00:00 2001 From: jelleas Date: Thu, 8 Feb 2018 15:43:46 +0100 Subject: [PATCH 118/269] arguments in doc --- docs/intro.rst | 58 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 58 insertions(+) diff --git a/docs/intro.rst b/docs/intro.rst index 63127d7..60ff330 100644 --- a/docs/intro.rst +++ b/docs/intro.rst @@ -176,3 +176,61 @@ To test whether the function actually exists and accepted just one argument, we test.test = testMethod test.description = lambda : "generateVirus is defined and accepts just one argument" + + +Testing programs with arguments +------------------------------- + +Taking a closer look at the ``checkpy.lib`` module we find three functions that +allow you to interact with dynamic components and results from the program we are +testing. All these functions (``outputOf``, ``getFunction`` and ``getModule``) take +in the same optional arguments that let you change the dynamic environment in which +the code is tested. Zooming in on ``outputOf``: + +.. code-block:: Python + + def outputOf( + fileName, + src = None, + argv = None, + stdinArgs = None, + ignoreExceptions = (), + overwriteAttributes = () + ): + """ + fileName is the file name you want the stdout output of + src can be used to ignore the source code of fileName, and instead use this string + argv is a collection of elements that are used to overwrite sys.argv + stdinArgs is a collection of arguments that are passed to stdin + ignoreExceptions is a collection of exceptions that should be ignored during execution + overwriteAttributes is a collection of tuples (attribute, value) that are overwritten + before trying to import the file + """ + +Lets see what we can do with this. +For an assignment we asked students to write a program that prints out how many +liters of water were used while showering. The program should prompt the user +for the number of minutes they shower, and then print out many liters of water +were used. We told them 1 minute of showering equaled 12 liters used. + +For this assignment we wrote the following test: + +.. code-block:: Python + + @t.test(10) + def oneLiter(test): + def testMethod(): + output = lib.outputOf( + test.fileName, + stdinArgs = [1], + overwriteAttributes = [("__name__", "__main__")] + ) + return asserts.contains(output, "12") + + test.test = testMethod + test.description = lambda : "1 minute equals 12 bottles." + +The above test runs the student's file, pushes the number 1 in stdin and sets the +attribute ``__name__`` to ``"__main__"``. It does not ignore any exceptions, +that means that CheckPy will fail the test if an exception is raised and kindly +tell the user what exception was raised. Argv is set to the default (just the program name). From 7bd47d0220b0af30278261b7eed4e5257a2d868a Mon Sep 17 00:00:00 2001 From: jelleas Date: Thu, 8 Feb 2018 16:00:08 +0100 Subject: [PATCH 119/269] style fixes --- docs/intro.rst | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/intro.rst b/docs/intro.rst index 60ff330..84e747f 100644 --- a/docs/intro.rst +++ b/docs/intro.rst @@ -162,7 +162,7 @@ For instance to test whether only the nucleotides ATGC occurred we wrote the fol return asserts.containsOnly(pairs, "AGTC") test.test = testMethod - test.description = lambda : "generateVirus() produces viruses consisting only of A, T, G and C" + test.description = lambda : "generateVirus produces viruses consisting only of A, T, G and C" To test whether the function actually exists and accepted just one argument, we wrote the following: @@ -227,8 +227,8 @@ For this assignment we wrote the following test: ) return asserts.contains(output, "12") - test.test = testMethod - test.description = lambda : "1 minute equals 12 bottles." + test.test = testMethod + test.description = lambda : "1 minute equals 12 bottles." The above test runs the student's file, pushes the number 1 in stdin and sets the attribute ``__name__`` to ``"__main__"``. It does not ignore any exceptions, From 0d49eb46730dbde316dc48e97697c673e827640b Mon Sep 17 00:00:00 2001 From: jelleas Date: Tue, 13 Feb 2018 15:07:38 +0100 Subject: [PATCH 120/269] docs update --- docs/intro.rst | 53 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 53 insertions(+) diff --git a/docs/intro.rst b/docs/intro.rst index 84e747f..c2b2f52 100644 --- a/docs/intro.rst +++ b/docs/intro.rst @@ -234,3 +234,56 @@ The above test runs the student's file, pushes the number 1 in stdin and sets th attribute ``__name__`` to ``"__main__"``. It does not ignore any exceptions, that means that CheckPy will fail the test if an exception is raised and kindly tell the user what exception was raised. Argv is set to the default (just the program name). + +Customizing output +------------------ + +An instance of ``Test`` has a couple of methods that you can use to show the user +exactly what you want the user to see. We have already seen the ``.description()`` +method that you can overwrite with a function that should produce the description +of the test. This description then turns green or red, with a happy or sad smiley +depending on whether the test fails or passes. +Besides the ``.description()`` method you also find the ``.success(info)`` and +``.fail(info)`` methods. These methods take in an argument called ``info`` and +should produce a message for the user to read when the test succeeds or fails +respectively. This message is printed directly under the description. +Take the following test: + +.. code-block:: Python + + @t.test(0) + def failExample1(test): + def testMethod(): + output = lib.outputOf(test.fileName) + line = lib.getLine(output, 0) + return asserts.numberOnLine(42, line) + + test.test = testMethod + test.description = lambda : "demonstrating the use of .fail()!" + test.fail = lambda info : "could not find 42 in the first line of the output" + +The above test looks for the number 42 on the first line of the output. If the test +fails it will print that it could not find 42 in the output. Okay, this is a little +boring, CheckPy just prints a static description if the test fails. So let's spice +things up. Take the following test: + +.. code-block:: Python + + @t.test(0) + def failExample2(test): + def testMethod(): + output = lib.outputOf(test.fileName) + line = lib.getLine(output, 0) + return asserts.numberOnLine(42, line), lib.getNumbersFromString(line) + + test.test = testMethod + test.description = lambda : "demonstrating the use of .fail()!" + test.fail = lambda info : "could not find 42 on the first line of output, only these numbers: {}".format(info) + +This test also looks for 42 on the first line of the output. If this test fails +however it will also print what numbers it did find on that one line of output. +Here's what's happening. The ``.test()`` method can return a second value besides +simply a boolean indicating whether the passed. This value is passed to the +``.fail(info)`` and ``.success(info)`` methods. So you can use this second return +value to customize what ``.fail(info)`` and ``.success(info)`` do. Here the +implementation of ``.fail(info)`` simply prints out whatever ``info`` it receives. From 7e53827ea5f2569f6478e8b756557f0dfe17a4d7 Mon Sep 17 00:00:00 2001 From: jelleas Date: Sat, 28 Apr 2018 15:23:47 +0200 Subject: [PATCH 121/269] silent mode, 0.4.1 --- checkpy/__init__.py | 8 ++++---- checkpy/__main__.py | 7 ++++--- checkpy/printer/printer.py | 25 +++++++++++++++++-------- checkpy/tester/tester.py | 27 +++++++++++++++------------ setup.py | 2 +- 5 files changed, 41 insertions(+), 28 deletions(-) diff --git a/checkpy/__init__.py b/checkpy/__init__.py index 8a30c17..c42a397 100644 --- a/checkpy/__init__.py +++ b/checkpy/__init__.py @@ -1,7 +1,7 @@ import os from .downloader import download, update -def testModule(moduleName): +def testModule(moduleName, debugMode = False, silentMode = False): """ Test all files from module """ @@ -10,7 +10,7 @@ def testModule(moduleName): from . import tester from . import downloader downloader.updateSilently() - results = tester.testModule(moduleName) + results = tester.testModule(moduleName, debugMode = debugMode, silentMode = silentMode) try: if __IPYTHON__: import matplotlib.pyplot @@ -19,7 +19,7 @@ def testModule(moduleName): pass return results -def test(fileName): +def test(fileName, debugMode = False, silentMode = False): """ Run tests for a single file """ @@ -28,7 +28,7 @@ def test(fileName): from . import tester from . import downloader downloader.updateSilently() - result = tester.test(fileName) + result = tester.test(fileName, debugMode = debugMode, silentMode = silentMode) try: if __IPYTHON__: import matplotlib.pyplot diff --git a/checkpy/__main__.py b/checkpy/__main__.py index b25f8bc..e067938 100644 --- a/checkpy/__main__.py +++ b/checkpy/__main__.py @@ -24,6 +24,7 @@ def main(): parser.add_argument("-list", action="store_true", help="list all download locations and exit") parser.add_argument("-clean", action="store_true", help="remove all tests from the tests folder and exit") parser.add_argument("--dev", action="store_true", help="get extra information to support the development of tests") + parser.add_argument("--silent", action="store_true", help="do not print test results to stdout") parser.add_argument("file", action="store", nargs="?", help="name of file to be tested") args = parser.parse_args() @@ -55,14 +56,14 @@ def main(): downloader.updateSilently() if args.module: - tester.test(args.file, module = args.module, debugMode = args.dev) + tester.test(args.file, module = args.module, debugMode = args.dev, silentMode = args.silent) else: - tester.test(args.file, debugMode = args.dev) + tester.test(args.file, debugMode = args.dev, silentMode = args.silent) return if args.module: downloader.updateSilently() - tester.testModule(args.module, debugMode = args.dev) + tester.testModule(args.module, debugMode = args.dev, silentMode = args.silent) return parser.print_help() diff --git a/checkpy/printer/printer.py b/checkpy/printer/printer.py index 4b7ce71..67afce8 100644 --- a/checkpy/printer/printer.py +++ b/checkpy/printer/printer.py @@ -4,6 +4,7 @@ colorama.init() DEBUG_MODE = False +SILENT_MODE = False class _Colors: PASS = '\033[92m' @@ -26,41 +27,49 @@ def display(testResult): if DEBUG_MODE and testResult.exception: msg += "\n {}".format(testResult.exception.stacktrace()) - print(msg) + if not SILENT_MODE: + print(msg) return msg def displayTestName(testName): msg = "{}Testing: {}{}".format(_Colors.NAME, testName, _Colors.ENDC) - print(msg) + if not SILENT_MODE: + print(msg) return msg def displayUpdate(fileName): msg = "{}Updated: {}{}".format(_Colors.WARNING, os.path.basename(fileName), _Colors.ENDC) - print(msg) + if not SILENT_MODE: + print(msg) return msg def displayRemoved(fileName): msg = "{}Removed: {}{}".format(_Colors.WARNING, os.path.basename(fileName), _Colors.ENDC) - print(msg) + if not SILENT_MODE: + print(msg) return msg def displayAdded(fileName): msg = "{}Added: {}{}".format(_Colors.WARNING, os.path.basename(fileName), _Colors.ENDC) - print(msg) + if not SILENT_MODE: + print(msg) return msg def displayCustom(message): - print(message) + if not SILENT_MODE: + print(message) return message def displayWarning(message): msg = "{}Warning: {}{}".format(_Colors.WARNING, message, _Colors.ENDC) - print(msg) + if not SILENT_MODE: + print(msg) return msg def displayError(message): msg = "{}{} {}{}".format(_Colors.WARNING, _Smileys.CONFUSED, message, _Colors.ENDC) - print(msg) + if not SILENT_MODE: + print(msg) return msg def _selectColorAndSmiley(testResult): diff --git a/checkpy/tester/tester.py b/checkpy/tester/tester.py index 611912d..eb6eb5a 100644 --- a/checkpy/tester/tester.py +++ b/checkpy/tester/tester.py @@ -12,7 +12,9 @@ import time import dill -def test(testName, module = "", debugMode = False): +def test(testName, module = "", debugMode = False, silentMode = False): + printer.printer.SILENT_MODE = silentMode + result = TesterResult() path = discovery.getPath(testName) @@ -55,26 +57,26 @@ def test(testName, module = "", debugMode = False): with open(path, "w") as f: f.write("".join([l for l in lines if "get_ipython" not in l])) - testerResult = _runTests(testFileName.split(".")[0], path, debugMode = debugMode) + testerResult = _runTests(testFileName.split(".")[0], path, debugMode = debugMode, silentMode = silentMode) + if path.endswith(".ipynb"): os.remove(path) - else: - testerResult = _runTests(testFileName.split(".")[0], path, debugMode = debugMode) - + testerResult.output = result.output + testerResult.output return testerResult -def testModule(module, debugMode = False): +def testModule(module, debugMode = False, silentMode = False): + printer.printer.SILENT_MODE = self.silentMode testNames = discovery.getTestNames(module) if not testNames: printer.displayError("no tests found in module: {}".format(module)) return - return [test(testName, module = module, debugMode = debugMode) for testName in testNames] + return [test(testName, module = module, debugMode = debugMode, silentMode = silentMode) for testName in testNames] -def _runTests(moduleName, fileName, debugMode = False): +def _runTests(moduleName, fileName, debugMode = False, silentMode = False): if sys.version_info[:2] >= (3,4): ctx = multiprocessing.get_context("spawn") else: @@ -82,7 +84,7 @@ def _runTests(moduleName, fileName, debugMode = False): signalQueue = ctx.Queue() resultQueue = ctx.Queue() - tester = _Tester(moduleName, path.Path(fileName).absolutePath(), debugMode, signalQueue, resultQueue) + tester = _Tester(moduleName, path.Path(fileName).absolutePath(), debugMode, silentMode, signalQueue, resultQueue) p = ctx.Process(target=tester.run, name="Tester") p.start() @@ -140,16 +142,17 @@ def __init__(self, isTiming = False, resetTimer = False, description = None, tim self.timeout = timeout class _Tester(object): - def __init__(self, moduleName, filePath, debugMode, signalQueue, resultQueue): + def __init__(self, moduleName, filePath, debugMode, silentMode, signalQueue, resultQueue): self.moduleName = moduleName self.filePath = filePath self.debugMode = debugMode + self.silentMode = silentMode self.signalQueue = signalQueue self.resultQueue = resultQueue def run(self): - if self.debugMode: - printer.printer.DEBUG_MODE = True + printer.printer.DEBUG_MODE = self.debugMode + printer.printer.SILENT_MODE = self.silentMode # overwrite argv so that it seems the file was run directly sys.argv = [self.filePath.fileName] diff --git a/setup.py b/setup.py index bb1bad3..5c3873c 100644 --- a/setup.py +++ b/setup.py @@ -11,7 +11,7 @@ setup( name='checkPy', - version='0.4.0', + version='0.4.1', description='A simple python testing framework for educational purposes', long_description=long_description, From 46aca5dd50d395c97bfbd4e6b3976d37b134347d Mon Sep 17 00:00:00 2001 From: jelleas Date: Mon, 3 Sep 2018 15:31:13 +0200 Subject: [PATCH 122/269] --json --- checkpy/__main__.py | 17 ++++++++++++++--- checkpy/tester/tester.py | 10 +++++++++- checkpy/tests.py | 6 ++++++ 3 files changed, 29 insertions(+), 4 deletions(-) diff --git a/checkpy/__main__.py b/checkpy/__main__.py index e067938..98b85f2 100644 --- a/checkpy/__main__.py +++ b/checkpy/__main__.py @@ -5,6 +5,7 @@ from checkpy import tester import shutil import time +import json import pkg_resources def main(): @@ -25,6 +26,7 @@ def main(): parser.add_argument("-clean", action="store_true", help="remove all tests from the tests folder and exit") parser.add_argument("--dev", action="store_true", help="get extra information to support the development of tests") parser.add_argument("--silent", action="store_true", help="do not print test results to stdout") + parser.add_argument("--json", action="store_true", help="return output as json, implies silent") parser.add_argument("file", action="store", nargs="?", help="name of file to be tested") args = parser.parse_args() @@ -52,18 +54,27 @@ def main(): downloader.clean() return + if args.json: + args.silent = True + if args.file: downloader.updateSilently() if args.module: - tester.test(args.file, module = args.module, debugMode = args.dev, silentMode = args.silent) + result = tester.test(args.file, module = args.module, debugMode = args.dev, silentMode = args.silent) else: - tester.test(args.file, debugMode = args.dev, silentMode = args.silent) + result = tester.test(args.file, debugMode = args.dev, silentMode = args.silent) + + if args.json: + print(json.dumps(result.asDict(), indent=4)) return if args.module: downloader.updateSilently() - tester.testModule(args.module, debugMode = args.dev, silentMode = args.silent) + results = tester.testModule(args.module, debugMode = args.dev, silentMode = args.silent) + + if args.json: + print(results) return parser.print_help() diff --git a/checkpy/tester/tester.py b/checkpy/tester/tester.py index eb6eb5a..939b180 100644 --- a/checkpy/tester/tester.py +++ b/checkpy/tester/tester.py @@ -61,7 +61,7 @@ def test(testName, module = "", debugMode = False, silentMode = False): if path.endswith(".ipynb"): os.remove(path) - + testerResult.output = result.output + testerResult.output return testerResult @@ -134,6 +134,14 @@ def addOutput(self, output): def addResult(self, testResult): self.testResults.append(testResult) + def asDict(self): + return {"nTests":self.nTests, + "nPassed":self.nPassedTests, + "nFailed":self.nFailedTests, + "nRun":self.nRunTests, + "output":self.output, + "results":[tr.asDict() for tr in self.testResults]} + class _Signal(object): def __init__(self, isTiming = False, resetTimer = False, description = None, timeout = None): self.isTiming = isTiming diff --git a/checkpy/tests.py b/checkpy/tests.py index ef2157a..927523b 100644 --- a/checkpy/tests.py +++ b/checkpy/tests.py @@ -87,6 +87,12 @@ def hasPassed(self): def exception(self): return self._exception + def asDict(self): + return {"passed":self.hasPassed, + "description":str(self.description), + "message":str(self.message), + "exception":str(self.exception)} + def test(priority): def testDecorator(testCreator): testCreator.isTestCreator = True From 6ad4f763a411498a15c37a29d72e058a45ae18ba Mon Sep 17 00:00:00 2001 From: jelleas Date: Mon, 3 Sep 2018 15:36:03 +0200 Subject: [PATCH 123/269] version++ --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 5c3873c..e369c96 100644 --- a/setup.py +++ b/setup.py @@ -11,7 +11,7 @@ setup( name='checkPy', - version='0.4.1', + version='0.4.2', description='A simple python testing framework for educational purposes', long_description=long_description, From 9955cb53927fb9153a1f1e6bc8be3ae3e45cd361 Mon Sep 17 00:00:00 2001 From: jelleas Date: Mon, 3 Sep 2018 16:05:53 +0200 Subject: [PATCH 124/269] --json for -m --- checkpy/__main__.py | 6 ++++-- checkpy/tester/tester.py | 14 ++++++++------ setup.py | 2 +- 3 files changed, 13 insertions(+), 9 deletions(-) diff --git a/checkpy/__main__.py b/checkpy/__main__.py index 98b85f2..f3793ec 100644 --- a/checkpy/__main__.py +++ b/checkpy/__main__.py @@ -73,8 +73,10 @@ def main(): downloader.updateSilently() results = tester.testModule(args.module, debugMode = args.dev, silentMode = args.silent) - if args.json: - print(results) + if args.json and results: + print(json.dumps([r.asDict() for r in results], indent=4)) + elif args.json and not results: + print([]) return parser.print_help() diff --git a/checkpy/tester/tester.py b/checkpy/tester/tester.py index 939b180..c332b21 100644 --- a/checkpy/tester/tester.py +++ b/checkpy/tester/tester.py @@ -15,7 +15,7 @@ def test(testName, module = "", debugMode = False, silentMode = False): printer.printer.SILENT_MODE = silentMode - result = TesterResult() + result = TesterResult(testName) path = discovery.getPath(testName) if not path: @@ -67,7 +67,7 @@ def test(testName, module = "", debugMode = False, silentMode = False): def testModule(module, debugMode = False, silentMode = False): - printer.printer.SILENT_MODE = self.silentMode + printer.printer.SILENT_MODE = silentMode testNames = discovery.getTestNames(module) if not testNames: @@ -101,7 +101,7 @@ def _runTests(moduleName, fileName, debugMode = False, silentMode = False): start = time.time() if isTiming and time.time() - start > timeout: - result = TesterResult() + result = TesterResult(path.Path(fileName).fileName) result.addOutput(printer.displayError("Timeout ({} seconds) reached during: {}".format(timeout, description))) p.terminate() p.join() @@ -120,7 +120,8 @@ def _runTests(moduleName, fileName, debugMode = False, silentMode = False): raise exception.CheckpyError(message = "An error occured while testing. The testing process exited unexpectedly.") class TesterResult(object): - def __init__(self): + def __init__(self, name): + self.name = name self.nTests = 0 self.nPassedTests = 0 self.nFailedTests = 0 @@ -135,7 +136,8 @@ def addResult(self, testResult): self.testResults.append(testResult) def asDict(self): - return {"nTests":self.nTests, + return {"name":self.name, + "nTests":self.nTests, "nPassed":self.nPassedTests, "nFailed":self.nFailedTests, "nRun":self.nRunTests, @@ -178,7 +180,7 @@ def run(self): def _runTestsFromModule(self, module): self._sendSignal(_Signal(isTiming = False)) - result = TesterResult() + result = TesterResult(self.filePath.fileName) result.addOutput(printer.displayTestName(self.filePath.fileName)) if hasattr(module, "before"): diff --git a/setup.py b/setup.py index e369c96..6889bda 100644 --- a/setup.py +++ b/setup.py @@ -11,7 +11,7 @@ setup( name='checkPy', - version='0.4.2', + version='0.4.3', description='A simple python testing framework for educational purposes', long_description=long_description, From 64ed440f3b081efa56cd34686382c06c88e9c951 Mon Sep 17 00:00:00 2001 From: jelleas Date: Wed, 26 Sep 2018 16:18:15 +0200 Subject: [PATCH 125/269] 0.4.4 --- checkpy/entities/function.py | 3 ++- setup.py | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/checkpy/entities/function.py b/checkpy/entities/function.py index 5612d0f..498ab99 100644 --- a/checkpy/entities/function.py +++ b/checkpy/entities/function.py @@ -1,5 +1,6 @@ import sys import contextlib +import inspect import checkpy.entities.exception as exception if sys.version_info >= (3,0): import io @@ -38,7 +39,7 @@ def name(self): @property def arguments(self): """gives the argument names of the function""" - return list(self._function.__code__.co_varnames) + return inspect.getfullargspec(self._function)[0] @property def printOutput(self): diff --git a/setup.py b/setup.py index 6889bda..931558e 100644 --- a/setup.py +++ b/setup.py @@ -11,7 +11,7 @@ setup( name='checkPy', - version='0.4.3', + version='0.4.4', description='A simple python testing framework for educational purposes', long_description=long_description, From 7cc793344ec4861a6a741a9510a369d48df2b9e0 Mon Sep 17 00:00:00 2001 From: jelleas Date: Mon, 1 Oct 2018 12:48:07 +0200 Subject: [PATCH 126/269] passed = a boolean --- checkpy/tests.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/checkpy/tests.py b/checkpy/tests.py index 927523b..49955c0 100644 --- a/checkpy/tests.py +++ b/checkpy/tests.py @@ -66,7 +66,7 @@ def timeout(): class TestResult(object): def __init__(self, hasPassed, description, message, exception = None): - self._hasPassed = hasPassed + self._hasPassed = bool(hasPassed) self._description = description self._message = message self._exception = exception From fb2c8f7b827e1f35a6fc6f5c2fcaa20cdbac4065 Mon Sep 17 00:00:00 2001 From: jelleas Date: Mon, 1 Oct 2018 12:48:40 +0200 Subject: [PATCH 127/269] 0.4.5 --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 931558e..3f9a0c5 100644 --- a/setup.py +++ b/setup.py @@ -11,7 +11,7 @@ setup( name='checkPy', - version='0.4.4', + version='0.4.5', description='A simple python testing framework for educational purposes', long_description=long_description, From 7898a3f1868e19e23382941b7ec858a9ccd4076e Mon Sep 17 00:00:00 2001 From: jelleas Date: Tue, 22 Jan 2019 13:38:28 +0100 Subject: [PATCH 128/269] try except finally, fix broken stdout --- checkpy/entities/function.py | 15 ++++++++++----- checkpy/lib/basic.py | 33 +++++++++++++++++++++++++-------- 2 files changed, 35 insertions(+), 13 deletions(-) diff --git a/checkpy/entities/function.py b/checkpy/entities/function.py index 498ab99..90d7998 100644 --- a/checkpy/entities/function.py +++ b/checkpy/entities/function.py @@ -13,12 +13,14 @@ def __init__(self, function): self._printOutput = "" def __call__(self, *args, **kwargs): + old = sys.stdout try: with self._captureStdout() as listener: outcome = self._function(*args, **kwargs) - self._printOutput = listener.content + self._printOutput = ""#listener.content return outcome except Exception as e: + sys.stdout = old argumentNames = self.arguments nArgs = len(args) + len(kwargs) @@ -59,10 +61,13 @@ def _captureStdout(self): outStreamListener.start() sys.stdout = outStreamListener.stream - yield outStreamListener - - sys.stdout = old - outStreamListener.stop() + try: + yield outStreamListener + except: + raise + finally: + sys.stdout = old + outStreamListener.stop() class _Stream(io.StringIO): def __init__(self, *args, **kwargs): diff --git a/checkpy/lib/basic.py b/checkpy/lib/basic.py index 5d4de98..d9cae80 100644 --- a/checkpy/lib/basic.py +++ b/checkpy/lib/basic.py @@ -59,6 +59,16 @@ def sourceOfDefinitions(fileName): insideDefinition = False return newSource +def documentFunction(func, documentation): + """Creates a function that shows documentation when its printed / shows up in an error.""" + class PrintableFunction: + def __call__(self, *args, **kwargs): + return func(*args, **kwargs) + + def __repr__(self): + return documentation + + return PrintableFunction() def getFunction(functionName, *args, **kwargs): return getattr(module(*args, **kwargs), functionName) @@ -239,8 +249,12 @@ def captureStdout(stdout=None): if stdout is None: stdout = StringIO.StringIO() sys.stdout = stdout - yield stdout - sys.stdout = old + try: + yield stdout + except: + raise + finally: + sys.stdout = old @contextlib.contextmanager def captureStdin(stdin=None): @@ -266,9 +280,12 @@ def input(prompt = None): stdin = StringIO.StringIO() sys.stdin = stdin - yield stdin - - sys.stdin = old - __builtins__["input"] = oldInput - if sys.version_info < (3,0): - __builtins__["raw_input"] = oldRawInput + try: + yield stdin + except: + raise + finally: + sys.stdin = old + __builtins__["input"] = oldInput + if sys.version_info < (3,0): + __builtins__["raw_input"] = oldRawInput From 2b03a85510a2dbe8f115281d6e3820107d235f40 Mon Sep 17 00:00:00 2001 From: jelleas Date: Tue, 22 Jan 2019 13:43:34 +0100 Subject: [PATCH 129/269] version bump --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 3f9a0c5..6bde633 100644 --- a/setup.py +++ b/setup.py @@ -11,7 +11,7 @@ setup( name='checkPy', - version='0.4.5', + version='0.4.6', description='A simple python testing framework for educational purposes', long_description=long_description, From e193e2b09152b0bba67e550471f405086adde23b Mon Sep 17 00:00:00 2001 From: jelleas Date: Thu, 16 Sep 2021 15:33:50 +0200 Subject: [PATCH 130/269] purge_tables -> drop_tables --- checkpy/database/database.py | 2 +- setup.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/checkpy/database/database.py b/checkpy/database/database.py index 7d7a2c8..2e1fd47 100644 --- a/checkpy/database/database.py +++ b/checkpy/database/database.py @@ -18,7 +18,7 @@ def localTable(): return database().table("local") def clean(): - database().purge_tables() + database().drop_tables() def forEachTestsPath(): for path in forEachGithubPath(): diff --git a/setup.py b/setup.py index 6bde633..2948a3f 100644 --- a/setup.py +++ b/setup.py @@ -11,7 +11,7 @@ setup( name='checkPy', - version='0.4.6', + version='0.4.7', description='A simple python testing framework for educational purposes', long_description=long_description, From 9dd88e6cf0d1c1469edb2b4f257c6647e0e9efc8 Mon Sep 17 00:00:00 2001 From: jelleas Date: Tue, 5 Oct 2021 10:48:01 +0200 Subject: [PATCH 131/269] file -> files, adds support for multiple checks in one run --- checkpy/__main__.py | 19 ++++++++++--------- checkpy/tester/tester.py | 3 --- setup.py | 2 +- 3 files changed, 11 insertions(+), 13 deletions(-) diff --git a/checkpy/__main__.py b/checkpy/__main__.py index f3793ec..07c92a4 100644 --- a/checkpy/__main__.py +++ b/checkpy/__main__.py @@ -3,8 +3,6 @@ import argparse from checkpy import downloader from checkpy import tester -import shutil -import time import json import pkg_resources @@ -27,7 +25,7 @@ def main(): parser.add_argument("--dev", action="store_true", help="get extra information to support the development of tests") parser.add_argument("--silent", action="store_true", help="do not print test results to stdout") parser.add_argument("--json", action="store_true", help="return output as json, implies silent") - parser.add_argument("file", action="store", nargs="?", help="name of file to be tested") + parser.add_argument("files", action="store", nargs="*", help="names of files to be tested") args = parser.parse_args() rootPath = os.sep.join(os.path.abspath(os.path.dirname(__file__)).split(os.sep)[:-1]) @@ -57,16 +55,19 @@ def main(): if args.json: args.silent = True - if args.file: + if args.files: downloader.updateSilently() - if args.module: - result = tester.test(args.file, module = args.module, debugMode = args.dev, silentMode = args.silent) - else: - result = tester.test(args.file, debugMode = args.dev, silentMode = args.silent) + results = [] + for f in args.files: + if args.module: + result = tester.test(f, module = args.module, debugMode = args.dev, silentMode = args.silent) + else: + result = tester.test(f, debugMode = args.dev, silentMode = args.silent) + results.append(result) if args.json: - print(json.dumps(result.asDict(), indent=4)) + print(json.dumps([r.asDict() for r in results], indent=4)) return if args.module: diff --git a/checkpy/tester/tester.py b/checkpy/tester/tester.py index c332b21..124bf75 100644 --- a/checkpy/tester/tester.py +++ b/checkpy/tester/tester.py @@ -1,5 +1,4 @@ from checkpy import printer -from checkpy import caches from checkpy.entities import exception, path from checkpy.tester import discovery from checkpy.tester.sandbox import Sandbox @@ -7,10 +6,8 @@ import subprocess import sys import importlib -import re import multiprocessing import time -import dill def test(testName, module = "", debugMode = False, silentMode = False): printer.printer.SILENT_MODE = silentMode diff --git a/setup.py b/setup.py index 2948a3f..bc85218 100644 --- a/setup.py +++ b/setup.py @@ -11,7 +11,7 @@ setup( name='checkPy', - version='0.4.7', + version='0.4.8', description='A simple python testing framework for educational purposes', long_description=long_description, From 5f5b41bc9863fdb564bc7d0f6dd132c3914ceca4 Mon Sep 17 00:00:00 2001 From: jelleas Date: Fri, 16 Sep 2022 15:21:36 +0200 Subject: [PATCH 132/269] --gh-auth option --- checkpy/__main__.py | 12 ++++++++++++ checkpy/downloader/downloader.py | 16 +++++++++++++++- 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/checkpy/__main__.py b/checkpy/__main__.py index 07c92a4..348ef3a 100644 --- a/checkpy/__main__.py +++ b/checkpy/__main__.py @@ -1,8 +1,10 @@ +from posixpath import split import sys import os import argparse from checkpy import downloader from checkpy import tester +from checkpy import printer import json import pkg_resources @@ -25,6 +27,7 @@ def main(): parser.add_argument("--dev", action="store_true", help="get extra information to support the development of tests") parser.add_argument("--silent", action="store_true", help="do not print test results to stdout") parser.add_argument("--json", action="store_true", help="return output as json, implies silent") + parser.add_argument("--gh-auth", action="store", help="username:personal_access_token for authentication with GitHub. Only used to increase the GitHub api's rate limit.") parser.add_argument("files", action="store", nargs="*", help="names of files to be tested") args = parser.parse_args() @@ -32,6 +35,15 @@ def main(): if rootPath not in sys.path: sys.path.append(rootPath) + if args.gh_auth: + split_auth = args.gh_auth.split(":") + + if len(split_auth) != 2: + printer.displayError("Invalid --gh-auth option. {} is not of the form username:personal_access_token. Note the :".format(args.gh_auth)) + return + + downloader.set_gh_auth(*split_auth) + if args.githubLink: downloader.download(args.githubLink) return diff --git a/checkpy/downloader/downloader.py b/checkpy/downloader/downloader.py index bf5c794..5f546d3 100644 --- a/checkpy/downloader/downloader.py +++ b/checkpy/downloader/downloader.py @@ -9,6 +9,14 @@ from checkpy import printer from checkpy.entities import exception +user = None +personal_access_token = None + +def set_gh_auth(username, pat): + global user, personal_access_token + user = username + personal_access_token = pat + def download(githubLink): if githubLink.endswith("/"): githubLink = githubLink[:-1] @@ -97,8 +105,14 @@ def _syncRelease(githubUserName, githubRepoName): def _getReleaseJson(githubUserName, githubRepoName): apiReleaseLink = "https://api.github.com/repos/{}/{}/releases/latest".format(githubUserName, githubRepoName) + global user + global personal_access_token try: - r = requests.get(apiReleaseLink) + if user and personal_access_token: + print(user, personal_access_token) + r = requests.get(apiReleaseLink, auth=(user, personal_access_token)) + else: + r = requests.get(apiReleaseLink) except requests.exceptions.ConnectionError as e: raise exception.DownloadError(message = "Oh no! It seems like there is no internet connection available?!") From 510b69c70139885c066d51b4bf1b7d6f99b84c6a Mon Sep 17 00:00:00 2001 From: jelleas Date: Fri, 16 Sep 2022 15:35:20 +0200 Subject: [PATCH 133/269] 0.4.9 --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index bc85218..34e8993 100644 --- a/setup.py +++ b/setup.py @@ -11,7 +11,7 @@ setup( name='checkPy', - version='0.4.8', + version='0.4.9', description='A simple python testing framework for educational purposes', long_description=long_description, From 241c8d4e32aca466e613dc8b434a49bdc38e5b8b Mon Sep 17 00:00:00 2001 From: Martijn Stegeman Date: Sun, 18 Sep 2022 17:45:41 +0200 Subject: [PATCH 134/269] capture stderr as well as stdout --- checkpy/entities/function.py | 7 +++++-- checkpy/lib/basic.py | 11 +++++++++-- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/checkpy/entities/function.py b/checkpy/entities/function.py index 90d7998..59999b7 100644 --- a/checkpy/entities/function.py +++ b/checkpy/entities/function.py @@ -56,17 +56,20 @@ def _captureStdout(self): returns a _StreamListener on said stream """ outStreamListener = _StreamListener(_outStream) - old = sys.stdout + old_stdout = sys.stdout + old_stderr = sys.stdout outStreamListener.start() sys.stdout = outStreamListener.stream + sys.stderr = sys.stdout try: yield outStreamListener except: raise finally: - sys.stdout = old + sys.stdout = old_stdout + sys.stderr = old_stderr outStreamListener.stop() class _Stream(io.StringIO): diff --git a/checkpy/lib/basic.py b/checkpy/lib/basic.py index d9cae80..63026bb 100644 --- a/checkpy/lib/basic.py +++ b/checkpy/lib/basic.py @@ -245,16 +245,23 @@ def removeComments(source): @contextlib.contextmanager def captureStdout(stdout=None): - old = sys.stdout + old_stdout = sys.stdout + old_stderr = sys.stderr + if stdout is None: stdout = StringIO.StringIO() + stderr = stdout + sys.stdout = stdout + sys.stderr = stdout + try: yield stdout except: raise finally: - sys.stdout = old + sys.stdout = old_stdout + sys.stderr = old_stderr @contextlib.contextmanager def captureStdin(stdin=None): From 9f8e90f01101cbcfc535e738d10950b46fe9128e Mon Sep 17 00:00:00 2001 From: Jelle van Assema Date: Tue, 20 Sep 2022 14:44:39 +0000 Subject: [PATCH 135/269] stderr => /dev/null when capturing stdout --- checkpy/entities/function.py | 15 ++++++++------- checkpy/lib/basic.py | 8 ++++---- 2 files changed, 12 insertions(+), 11 deletions(-) diff --git a/checkpy/entities/function.py b/checkpy/entities/function.py index 59999b7..5de5f0e 100644 --- a/checkpy/entities/function.py +++ b/checkpy/entities/function.py @@ -1,3 +1,4 @@ +import os import sys import contextlib import inspect @@ -17,7 +18,7 @@ def __call__(self, *args, **kwargs): try: with self._captureStdout() as listener: outcome = self._function(*args, **kwargs) - self._printOutput = ""#listener.content + self._printOutput = listener.content return outcome except Exception as e: sys.stdout = old @@ -57,19 +58,19 @@ def _captureStdout(self): """ outStreamListener = _StreamListener(_outStream) old_stdout = sys.stdout - old_stderr = sys.stdout - - outStreamListener.start() - sys.stdout = outStreamListener.stream - sys.stderr = sys.stdout + old_stderr = sys.stderr try: + outStreamListener.start() + sys.stdout = outStreamListener.stream + sys.stderr = open(os.devnull) yield outStreamListener except: raise finally: - sys.stdout = old_stdout + sys.stderr.close() sys.stderr = old_stderr + sys.stdout = old_stdout outStreamListener.stop() class _Stream(io.StringIO): diff --git a/checkpy/lib/basic.py b/checkpy/lib/basic.py index 63026bb..f798c60 100644 --- a/checkpy/lib/basic.py +++ b/checkpy/lib/basic.py @@ -1,5 +1,6 @@ import sys import re +import os try: # Python 2 import StringIO @@ -250,16 +251,15 @@ def captureStdout(stdout=None): if stdout is None: stdout = StringIO.StringIO() - stderr = stdout - - sys.stdout = stdout - sys.stderr = stdout try: + sys.stdout = stdout + sys.stderr = open(os.devnull) yield stdout except: raise finally: + sys.stderr.close() sys.stdout = old_stdout sys.stderr = old_stderr From 9709d16ff9de7d562407fda161ba15dcb4878385 Mon Sep 17 00:00:00 2001 From: jelleas Date: Tue, 20 Sep 2022 16:52:46 +0200 Subject: [PATCH 136/269] version bump --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 34e8993..2ebd190 100644 --- a/setup.py +++ b/setup.py @@ -11,7 +11,7 @@ setup( name='checkPy', - version='0.4.9', + version='0.4.10', description='A simple python testing framework for educational purposes', long_description=long_description, From be1dc5fde9487c3a1ba8f33c719cfebc77eb4e60 Mon Sep 17 00:00:00 2001 From: Martijn Stegeman Date: Mon, 3 Oct 2022 13:33:14 +0200 Subject: [PATCH 137/269] report wrong no of arguments without crashing --- checkpy/entities/function.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/checkpy/entities/function.py b/checkpy/entities/function.py index 5de5f0e..d974d7b 100644 --- a/checkpy/entities/function.py +++ b/checkpy/entities/function.py @@ -1,8 +1,11 @@ import os import sys +import re + import contextlib import inspect import checkpy.entities.exception as exception + if sys.version_info >= (3,0): import io else: @@ -21,6 +24,10 @@ def __call__(self, *args, **kwargs): self._printOutput = listener.content return outcome except Exception as e: + if isinstance(e,TypeError): + no_arguments = re.search(r"takes (\d+) positional arguments but (\d+) were given", e.__str__()) + if no_arguments: + raise exception.SourceException(exception = None, message = f"your function should take {no_arguments.group(1)} arguments but does not") sys.stdout = old argumentNames = self.arguments nArgs = len(args) + len(kwargs) From 924e82bcf05e17b9d7ac8b01b9ff1bc8f23c00ed Mon Sep 17 00:00:00 2001 From: Martijn Stegeman Date: Mon, 3 Oct 2022 13:37:31 +0200 Subject: [PATCH 138/269] neutral --- checkpy/entities/function.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/checkpy/entities/function.py b/checkpy/entities/function.py index d974d7b..b6c1404 100644 --- a/checkpy/entities/function.py +++ b/checkpy/entities/function.py @@ -27,7 +27,7 @@ def __call__(self, *args, **kwargs): if isinstance(e,TypeError): no_arguments = re.search(r"takes (\d+) positional arguments but (\d+) were given", e.__str__()) if no_arguments: - raise exception.SourceException(exception = None, message = f"your function should take {no_arguments.group(1)} arguments but does not") + raise exception.SourceException(exception = None, message = f"the function should take {no_arguments.group(1)} arguments but does not") sys.stdout = old argumentNames = self.arguments nArgs = len(args) + len(kwargs) From 54e209c5383c9b9cec58d769742c844cd6cd987d Mon Sep 17 00:00:00 2001 From: jelleas Date: Mon, 3 Oct 2022 14:55:51 +0200 Subject: [PATCH 139/269] version bump --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 2ebd190..c3e69f2 100644 --- a/setup.py +++ b/setup.py @@ -11,7 +11,7 @@ setup( name='checkPy', - version='0.4.10', + version='0.4.11', description='A simple python testing framework for educational purposes', long_description=long_description, From ee91301db9cd03e73f95cca048abe6cffa7ae106 Mon Sep 17 00:00:00 2001 From: Jelleas Date: Sat, 27 May 2023 15:55:49 +0200 Subject: [PATCH 140/269] test attributes dont have to be functions --- checkpy/tester/tester.py | 2 +- checkpy/tests.py | 9 +++++++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/checkpy/tester/tester.py b/checkpy/tester/tester.py index 124bf75..4ef7dbc 100644 --- a/checkpy/tester/tester.py +++ b/checkpy/tester/tester.py @@ -213,7 +213,7 @@ def _runTests(self, testCreators): # run tests in noncolliding execution order for test in self._getTestsInExecutionOrder([tc(self.filePath.fileName) for tc in testCreators]): - self._sendSignal(_Signal(isTiming = True, resetTimer = True, description = test.description(), timeout = test.timeout())) + self._sendSignal(_Signal(isTiming = True, resetTimer = True, description = "test.description()", timeout = test.timeout())) cachedResults[test] = test.run() self._sendSignal(_Signal(isTiming = False)) diff --git a/checkpy/tests.py b/checkpy/tests.py index 49955c0..1aa3227 100644 --- a/checkpy/tests.py +++ b/checkpy/tests.py @@ -94,6 +94,11 @@ def asDict(self): "exception":str(self.exception)} def test(priority): + def ensureCallable(test, attribute): + value = getattr(test, attribute) + if not callable(value): + setattr(test, attribute, lambda *args, **kwargs: value) + def testDecorator(testCreator): testCreator.isTestCreator = True @caches.cache(testCreator) @@ -101,6 +106,10 @@ def testDecorator(testCreator): def testWrapper(fileName): t = Test(fileName, priority) testCreator(t) + + for attr in ["description", "success", "fail", "exception", "dependencies", "timeout"]: + ensureCallable(t, attr) + return t return testWrapper return testDecorator From 4a5d02f8f07c75fc8932fb47a19ba98a18736498 Mon Sep 17 00:00:00 2001 From: Jelleas Date: Sat, 27 May 2023 15:56:49 +0200 Subject: [PATCH 141/269] typo --- checkpy/tester/tester.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/checkpy/tester/tester.py b/checkpy/tester/tester.py index 4ef7dbc..124bf75 100644 --- a/checkpy/tester/tester.py +++ b/checkpy/tester/tester.py @@ -213,7 +213,7 @@ def _runTests(self, testCreators): # run tests in noncolliding execution order for test in self._getTestsInExecutionOrder([tc(self.filePath.fileName) for tc in testCreators]): - self._sendSignal(_Signal(isTiming = True, resetTimer = True, description = "test.description()", timeout = test.timeout())) + self._sendSignal(_Signal(isTiming = True, resetTimer = True, description = test.description(), timeout = test.timeout())) cachedResults[test] = test.run() self._sendSignal(_Signal(isTiming = False)) From ee83bf8684651cc6b09d6d4e3a1fa3a4ae8c26a3 Mon Sep 17 00:00:00 2001 From: jelleas Date: Mon, 12 Jun 2023 15:53:53 +0200 Subject: [PATCH 142/269] allow for docstring Test descriptions --- checkpy/tests.py | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/checkpy/tests.py b/checkpy/tests.py index 1aa3227..26b43a2 100644 --- a/checkpy/tests.py +++ b/checkpy/tests.py @@ -3,7 +3,7 @@ from checkpy import caches from checkpy.entities import exception -class Test(object): +class Test: def __init__(self, fileName, priority): self._fileName = fileName self._priority = priority @@ -94,6 +94,19 @@ def asDict(self): "exception":str(self.exception)} def test(priority): + def ensureDescription(test, testCreator): + # If test description is set, nothing to do + if test.description != Test.description: + return + + # Otherwise, check if the test has a docstring and use that + if testCreator.__doc__ is None: + raise exception.TestError(f"{testCreator.__name__} has no description") + + # Use the docstring as description + test.description = testCreator.__doc__ + + def ensureCallable(test, attribute): value = getattr(test, attribute) if not callable(value): @@ -106,6 +119,8 @@ def testDecorator(testCreator): def testWrapper(fileName): t = Test(fileName, priority) testCreator(t) + + ensureDescription(t, testCreator) for attr in ["description", "success", "fail", "exception", "dependencies", "timeout"]: ensureCallable(t, attr) From ffa6149829f3ccaa657e9282e900d8f1b947a52b Mon Sep 17 00:00:00 2001 From: Jelleas Date: Thu, 22 Jun 2023 16:41:50 +0200 Subject: [PATCH 143/269] priority and dependencies on testCreator level --- checkpy/tester/tester.py | 28 ++++++++----- checkpy/tests.py | 89 ++++++++++++++++++++++++++-------------- 2 files changed, 75 insertions(+), 42 deletions(-) diff --git a/checkpy/tester/tester.py b/checkpy/tester/tester.py index 124bf75..a021a97 100644 --- a/checkpy/tester/tester.py +++ b/checkpy/tester/tester.py @@ -113,8 +113,8 @@ def _runTests(moduleName, fileName, debugMode = False, silentMode = False): if not resultQueue.empty(): return resultQueue.get() - - raise exception.CheckpyError(message = "An error occured while testing. The testing process exited unexpectedly.") + + raise exception.CheckpyError(message="An error occured while testing. The testing process exited unexpectedly.") class TesterResult(object): def __init__(self, name): @@ -212,11 +212,17 @@ def _runTests(self, testCreators): cachedResults = {} # run tests in noncolliding execution order - for test in self._getTestsInExecutionOrder([tc(self.filePath.fileName) for tc in testCreators]): - self._sendSignal(_Signal(isTiming = True, resetTimer = True, description = test.description(), timeout = test.timeout())) + for tc in self._getTestCreatorsInExecutionOrder(testCreators): + test = tc(self.filePath.fileName) + self._sendSignal(_Signal( + isTiming=True, + resetTimer=True, + description=test.description, + timeout=test.timeout() + )) cachedResults[test] = test.run() self._sendSignal(_Signal(isTiming = False)) - + # return test results in specified order return [cachedResults[test] for test in sorted(cachedResults.keys()) if cachedResults[test] != None] @@ -226,9 +232,9 @@ def _sendResult(self, result): def _sendSignal(self, signal): self.signalQueue.put(signal) - def _getTestsInExecutionOrder(self, tests): - testsInExecutionOrder = [] - for i, test in enumerate(tests): - dependencies = self._getTestsInExecutionOrder([tc(self.filePath.fileName) for tc in test.dependencies()]) + [test] - testsInExecutionOrder.extend([t for t in dependencies if t not in testsInExecutionOrder]) - return testsInExecutionOrder + def _getTestCreatorsInExecutionOrder(self, testCreators): + sortedTCs = [] + for tc in testCreators: + dependencies = self._getTestCreatorsInExecutionOrder(tc.dependencies) + [tc] + sortedTCs.extend([t for t in dependencies if t not in sortedTCs]) + return sortedTCs diff --git a/checkpy/tests.py b/checkpy/tests.py index 26b43a2..478d087 100644 --- a/checkpy/tests.py +++ b/checkpy/tests.py @@ -4,10 +4,20 @@ from checkpy.entities import exception class Test: - def __init__(self, fileName, priority): + def __init__(self, + fileName, + priority, + onDescriptionChange=lambda self: None, + onTimeoutChange=lambda self: None + ): self._fileName = fileName self._priority = priority + self._onDescriptionChange = onDescriptionChange + self._onTimeoutChange = onTimeoutChange + + self._description = "" + def __lt__(self, other): return self._priority < other._priority @@ -25,24 +35,20 @@ def run(self): else: hasPassed, info = result, "" except exception.CheckpyError as e: - return TestResult(False, self.description(), self.exception(e), exception = e) + return TestResult(False, self.description, self.exception(e), exception = e) except Exception as e: e = exception.TestError( exception = e, message = "while testing", stacktrace = traceback.format_exc()) - return TestResult(False, self.description(), self.exception(e), exception = e) + return TestResult(False, self.description, self.exception(e), exception = e) - return TestResult(hasPassed, self.description(), self.success(info) if hasPassed else self.fail(info)) + return TestResult(hasPassed, self.description, self.success(info) if hasPassed else self.fail(info)) @staticmethod def test(): raise NotImplementedError() - @staticmethod - def description(): - raise NotImplementedError() - @staticmethod def success(info): return "" @@ -58,6 +64,19 @@ def exception(exception): @staticmethod def dependencies(): return set() + + @property + def description(self): + return self._description + + @description.setter + def description(self, new_description): + if callable(new_description): + self._description = new_description() + else: + self._description = new_description + + self._onDescriptionChange(self) @staticmethod def timeout(): @@ -93,45 +112,51 @@ def asDict(self): "message":str(self.message), "exception":str(self.exception)} -def test(priority): - def ensureDescription(test, testCreator): - # If test description is set, nothing to do - if test.description != Test.description: - return - - # Otherwise, check if the test has a docstring and use that - if testCreator.__doc__ is None: - raise exception.TestError(f"{testCreator.__name__} has no description") - - # Use the docstring as description - test.description = testCreator.__doc__ +def test(priority=None, timeout=None): + def useDocStringDescription(test, testFunction): + if testFunction.__doc__ != None: + test.description = testFunction.__doc__ def ensureCallable(test, attribute): value = getattr(test, attribute) if not callable(value): setattr(test, attribute, lambda *args, **kwargs: value) - def testDecorator(testCreator): - testCreator.isTestCreator = True - @caches.cache(testCreator) - @wraps(testCreator) - def testWrapper(fileName): - t = Test(fileName, priority) - testCreator(t) + def testDecorator(testFunction): + testFunction.isTestCreator = True + testFunction.priority = priority + testFunction.dependencies = set() - ensureDescription(t, testCreator) + @caches.cache(testFunction) + @wraps(testFunction) + def testCreator(fileName): + t = Test(fileName, priority) - for attr in ["description", "success", "fail", "exception", "dependencies", "timeout"]: - ensureCallable(t, attr) + if timeout != None: + t.timeout = lambda: timeout + + useDocStringDescription(t, testFunction) + run = t.run + + def runMethod(): + testFunction(t) + for attr in ["success", "fail", "exception"]: + ensureCallable(t, attr) + return run() + + t.run = runMethod return t - return testWrapper + return testCreator + return testDecorator def failed(*precondTestCreators): def failedDecorator(testCreator): + testCreator.dependencies = testCreator.dependencies | set(precondTestCreators) + @wraps(testCreator) def testWrapper(fileName): test = testCreator(fileName) @@ -149,6 +174,8 @@ def runMethod(): def passed(*precondTestCreators): def passedDecorator(testCreator): + testCreator.dependencies = testCreator.dependencies | set(precondTestCreators) + @wraps(testCreator) def testWrapper(fileName): test = testCreator(fileName) From 2f86fb554d0f106ff681468639f5ba5f9ee8a72f Mon Sep 17 00:00:00 2001 From: jelleas Date: Fri, 23 Jun 2023 14:58:25 +0200 Subject: [PATCH 144/269] testCreator(test) instead of testCreator(fileName) --- checkpy/caches.py | 20 ++++++++++++++++++-- checkpy/tester/tester.py | 14 ++++++++++++-- checkpy/tests.py | 36 +++++++++++++++++------------------- 3 files changed, 47 insertions(+), 23 deletions(-) diff --git a/checkpy/caches.py b/checkpy/caches.py index 97df41f..8922e7f 100644 --- a/checkpy/caches.py +++ b/checkpy/caches.py @@ -3,13 +3,14 @@ _caches = [] - class _Cache(dict): """A dict() subclass that appends a self-reference to _caches""" def __init__(self, *args, **kwargs): super(_Cache, self).__init__(*args, **kwargs) _caches.append(self) +_testCache = _Cache() + def cache(*keys): """cache decorator @@ -37,7 +38,6 @@ def cachedFuncWrapper(*args, **kwargs): key = str(keys) else: key = str(args) + str(kwargs) + str(sys.argv) - if key not in localCache: localCache[key] = func(*args, **kwargs) return localCache[key] @@ -46,6 +46,22 @@ def cachedFuncWrapper(*args, **kwargs): return cacheWrapper +def cacheTestFunction(testFunction): + @wraps(testFunction) + def cachedTestFunction(*args, **kwargs): + key = testFunction.__name__ + if key not in _testCache: + _testCache[key] = testFunction(*args, **kwargs) + return _testCache[key] + return cachedTestFunction + + +def getCachedTest(testFunction): + key = testFunction.__name__ + return _testCache[key] + + def clearAllCaches(): for cache in _caches: cache.clear() + _testCache.clear() diff --git a/checkpy/tester/tester.py b/checkpy/tester/tester.py index a021a97..a5859fb 100644 --- a/checkpy/tester/tester.py +++ b/checkpy/tester/tester.py @@ -2,6 +2,8 @@ from checkpy.entities import exception, path from checkpy.tester import discovery from checkpy.tester.sandbox import Sandbox +from checkpy.tests import Test + import os import subprocess import sys @@ -212,8 +214,16 @@ def _runTests(self, testCreators): cachedResults = {} # run tests in noncolliding execution order - for tc in self._getTestCreatorsInExecutionOrder(testCreators): - test = tc(self.filePath.fileName) + for testCreator in self._getTestCreatorsInExecutionOrder(testCreators): + test = Test( + self.filePath.fileName, + testCreator.priority, + onDescriptionChange=lambda self: None, + onTimeoutChange=lambda self: None + ) + + testCreator(test) + self._sendSignal(_Signal( isTiming=True, resetTimer=True, diff --git a/checkpy/tests.py b/checkpy/tests.py index 478d087..38172cd 100644 --- a/checkpy/tests.py +++ b/checkpy/tests.py @@ -128,26 +128,24 @@ def testDecorator(testFunction): testFunction.priority = priority testFunction.dependencies = set() - @caches.cache(testFunction) + @caches.cacheTestFunction @wraps(testFunction) - def testCreator(fileName): - t = Test(fileName, priority) - + def testCreator(test): if timeout != None: - t.timeout = lambda: timeout + test.timeout = lambda: timeout - useDocStringDescription(t, testFunction) - run = t.run + useDocStringDescription(test, testFunction) + run = test.run def runMethod(): - testFunction(t) + testFunction(test) for attr in ["success", "fail", "exception"]: - ensureCallable(t, attr) + ensureCallable(test, attr) return run() - t.run = runMethod + test.run = runMethod - return t + return test return testCreator return testDecorator @@ -158,13 +156,13 @@ def failedDecorator(testCreator): testCreator.dependencies = testCreator.dependencies | set(precondTestCreators) @wraps(testCreator) - def testWrapper(fileName): - test = testCreator(fileName) + def testWrapper(test): + test = testCreator(test) dependencies = test.dependencies - test.dependencies = lambda : dependencies() | set(precondTestCreators) + test.dependencies = lambda: dependencies() | set(precondTestCreators) run = test.run def runMethod(): - testResults = [t(fileName).run() for t in precondTestCreators] + testResults = [caches.getCachedTest(t).run() for t in precondTestCreators] return run() if not any(t is None for t in testResults) and not any(t.hasPassed for t in testResults) else None test.run = runMethod return test @@ -177,13 +175,13 @@ def passedDecorator(testCreator): testCreator.dependencies = testCreator.dependencies | set(precondTestCreators) @wraps(testCreator) - def testWrapper(fileName): - test = testCreator(fileName) + def testWrapper(test): + test = testCreator(test) dependencies = test.dependencies - test.dependencies = lambda : dependencies() | set(precondTestCreators) + test.dependencies = lambda: dependencies() | set(precondTestCreators) run = test.run def runMethod(): - testResults = [t(fileName).run() for t in precondTestCreators] + testResults = [caches.getCachedTest(t).run() for t in precondTestCreators] return run() if not any(t is None for t in testResults) and all(t.hasPassed for t in testResults) else None test.run = runMethod return test From be5c8d701edcdb4b1fceebcdf75b8a8aa9b33a02 Mon Sep 17 00:00:00 2001 From: jelleas Date: Fri, 23 Jun 2023 16:20:40 +0200 Subject: [PATCH 145/269] testCreator => TestFunction --- checkpy/caches.py | 25 ++++----- checkpy/tester/tester.py | 6 +- checkpy/tests.py | 116 ++++++++++++++++++++------------------- 3 files changed, 74 insertions(+), 73 deletions(-) diff --git a/checkpy/caches.py b/checkpy/caches.py index 8922e7f..7df34af 100644 --- a/checkpy/caches.py +++ b/checkpy/caches.py @@ -46,19 +46,18 @@ def cachedFuncWrapper(*args, **kwargs): return cacheWrapper -def cacheTestFunction(testFunction): - @wraps(testFunction) - def cachedTestFunction(*args, **kwargs): - key = testFunction.__name__ - if key not in _testCache: - _testCache[key] = testFunction(*args, **kwargs) - return _testCache[key] - return cachedTestFunction - - -def getCachedTest(testFunction): - key = testFunction.__name__ - return _testCache[key] +def cacheTestResult(testFunction): + def wrapper(runFunction): + @wraps(runFunction) + def runFunctionWrapper(*args, **kwargs): + result = runFunction(*args, **kwargs) + _testCache[testFunction.__name__] = result + return result + return runFunctionWrapper + return wrapper + +def getCachedTestResult(testFunction): + return _testCache[testFunction.__name__] def clearAllCaches(): diff --git a/checkpy/tester/tester.py b/checkpy/tester/tester.py index a5859fb..15cf953 100644 --- a/checkpy/tester/tester.py +++ b/checkpy/tester/tester.py @@ -189,7 +189,7 @@ def _runTestsFromModule(self, module): result.addOutput(printer.displayError("Something went wrong at setup:\n{}".format(e))) return - testCreators = [method for method in module.__dict__.values() if getattr(method, "isTestCreator", False)] + testCreators = [method for method in module.__dict__.values() if getattr(method, "isTestFunction", False)] result.nTests = len(testCreators) testResults = self._runTests(testCreators) @@ -222,7 +222,7 @@ def _runTests(self, testCreators): onTimeoutChange=lambda self: None ) - testCreator(test) + run = testCreator(test) self._sendSignal(_Signal( isTiming=True, @@ -230,7 +230,7 @@ def _runTests(self, testCreators): description=test.description, timeout=test.timeout() )) - cachedResults[test] = test.run() + cachedResults[test] = run() self._sendSignal(_Signal(isTiming = False)) # return test results in specified order diff --git a/checkpy/tests.py b/checkpy/tests.py index 38172cd..4d64250 100644 --- a/checkpy/tests.py +++ b/checkpy/tests.py @@ -113,77 +113,79 @@ def asDict(self): "exception":str(self.exception)} -def test(priority=None, timeout=None): - def useDocStringDescription(test, testFunction): - if testFunction.__doc__ != None: - test.description = testFunction.__doc__ +class TestFunction: + def __init__(self, function, priority=None): + self._function = function + self.isTestFunction = True + self.priority = getattr(self._function, "priority", priority) + self.dependencies = getattr(self._function, "dependencies", set()) + self.__name__ = function.__name__ + + def __call__(self, test): + self._useDocStringDescription(test) + + @caches.cacheTestResult(self) + def runMethod(): + self._function(test) + for attr in ["success", "fail", "exception"]: + TestFunction._ensureCallable(test, attr) + return test.run() + + return runMethod + + def _useDocStringDescription(self, test): + if self._function.__doc__ != None: + test.description = self._function.__doc__ - def ensureCallable(test, attribute): + @staticmethod + def _ensureCallable(test, attribute): value = getattr(test, attribute) if not callable(value): setattr(test, attribute, lambda *args, **kwargs: value) - def testDecorator(testFunction): - testFunction.isTestCreator = True - testFunction.priority = priority - testFunction.dependencies = set() - - @caches.cacheTestFunction - @wraps(testFunction) - def testCreator(test): - if timeout != None: - test.timeout = lambda: timeout - - useDocStringDescription(test, testFunction) - run = test.run - - def runMethod(): - testFunction(test) - for attr in ["success", "fail", "exception"]: - ensureCallable(test, attr) - return run() - test.run = runMethod +class FailedTestFunction(TestFunction): + def __init__(self, function, preconditions, priority=None): + super().__init__(function=function, priority=priority) + self.preconditions = preconditions - return test - return testCreator + def __call__(self, test): + @caches.cacheTestResult(self) + def runMethod(): + if getattr(self._function, "isTestFunction", False): + run = self._function(test) + else: + run = test.run + testResults = [caches.getCachedTestResult(t) for t in self.preconditions] + if self._shouldRun(testResults): + return run() + return None + return runMethod + @staticmethod + def _shouldRun(testResults): + return not any(t is None for t in testResults) and not any(t.hasPassed for t in testResults) + + +class PassedTestFunction(FailedTestFunction): + @staticmethod + def _shouldRun(testResults): + return not any(t is None for t in testResults) and all(t.hasPassed for t in testResults) + + +def test(priority=None, timeout=None): + def testDecorator(testFunction): + return TestFunction(testFunction, priority) return testDecorator def failed(*precondTestCreators): - def failedDecorator(testCreator): - testCreator.dependencies = testCreator.dependencies | set(precondTestCreators) - - @wraps(testCreator) - def testWrapper(test): - test = testCreator(test) - dependencies = test.dependencies - test.dependencies = lambda: dependencies() | set(precondTestCreators) - run = test.run - def runMethod(): - testResults = [caches.getCachedTest(t).run() for t in precondTestCreators] - return run() if not any(t is None for t in testResults) and not any(t.hasPassed for t in testResults) else None - test.run = runMethod - return test - return testWrapper + def failedDecorator(testFunction): + return FailedTestFunction(testFunction, preconditions=precondTestCreators) return failedDecorator def passed(*precondTestCreators): - def passedDecorator(testCreator): - testCreator.dependencies = testCreator.dependencies | set(precondTestCreators) - - @wraps(testCreator) - def testWrapper(test): - test = testCreator(test) - dependencies = test.dependencies - test.dependencies = lambda: dependencies() | set(precondTestCreators) - run = test.run - def runMethod(): - testResults = [caches.getCachedTest(t).run() for t in precondTestCreators] - return run() if not any(t is None for t in testResults) and all(t.hasPassed for t in testResults) else None - test.run = runMethod - return test - return testWrapper + def passedDecorator(testFunction): + return PassedTestFunction(testFunction, preconditions=precondTestCreators) return passedDecorator From 0e9d3104edd35e4ce7d44bcc4e863a1b603a44d1 Mon Sep 17 00:00:00 2001 From: jelleas Date: Fri, 23 Jun 2023 16:58:02 +0200 Subject: [PATCH 146/269] priority is optional, test() is optional with failed/passed, failed + passed can be chained --- checkpy/caches.py | 1 + checkpy/tests.py | 35 +++++++++++++++++++++++++---------- 2 files changed, 26 insertions(+), 10 deletions(-) diff --git a/checkpy/caches.py b/checkpy/caches.py index 7df34af..2a164e8 100644 --- a/checkpy/caches.py +++ b/checkpy/caches.py @@ -56,6 +56,7 @@ def runFunctionWrapper(*args, **kwargs): return runFunctionWrapper return wrapper + def getCachedTestResult(testFunction): return _testCache[testFunction.__name__] diff --git a/checkpy/tests.py b/checkpy/tests.py index 4d64250..86ee743 100644 --- a/checkpy/tests.py +++ b/checkpy/tests.py @@ -114,10 +114,12 @@ def asDict(self): class TestFunction: + _previousPriority = -1 + def __init__(self, function, priority=None): self._function = function self.isTestFunction = True - self.priority = getattr(self._function, "priority", priority) + self.priority = self._getPriority(priority) self.dependencies = getattr(self._function, "dependencies", set()) self.__name__ = function.__name__ @@ -143,6 +145,19 @@ def _ensureCallable(test, attribute): if not callable(value): setattr(test, attribute, lambda *args, **kwargs: value) + def _getPriority(self, priority): + if priority: + TestFunction._previousPriority = priority + return priority + + inheritedPriority = getattr(self._function, "priority", None) + if inheritedPriority: + TestFunction._previousPriority = inheritedPriority + return inheritedPriority + + TestFunction._previousPriority += 1 + return TestFunction._previousPriority + class FailedTestFunction(TestFunction): def __init__(self, function, preconditions, priority=None): @@ -155,37 +170,37 @@ def runMethod(): if getattr(self._function, "isTestFunction", False): run = self._function(test) else: - run = test.run + run = TestFunction.__call__(self, test) testResults = [caches.getCachedTestResult(t) for t in self.preconditions] - if self._shouldRun(testResults): + if self.shouldRun(testResults): return run() return None return runMethod @staticmethod - def _shouldRun(testResults): + def shouldRun(testResults): return not any(t is None for t in testResults) and not any(t.hasPassed for t in testResults) class PassedTestFunction(FailedTestFunction): @staticmethod - def _shouldRun(testResults): + def shouldRun(testResults): return not any(t is None for t in testResults) and all(t.hasPassed for t in testResults) def test(priority=None, timeout=None): def testDecorator(testFunction): - return TestFunction(testFunction, priority) + return TestFunction(testFunction, priority=priority) return testDecorator -def failed(*precondTestCreators): +def failed(*preconditions, priority=None, timeout=None): def failedDecorator(testFunction): - return FailedTestFunction(testFunction, preconditions=precondTestCreators) + return FailedTestFunction(testFunction, preconditions, priority=priority) return failedDecorator -def passed(*precondTestCreators): +def passed(*preconditions, priority=None, timeout=None): def passedDecorator(testFunction): - return PassedTestFunction(testFunction, preconditions=precondTestCreators) + return PassedTestFunction(testFunction, preconditions, priority=priority) return passedDecorator From 0bdce9b101851f936427e031fa2d22b665521a56 Mon Sep 17 00:00:00 2001 From: jelleas Date: Mon, 26 Jun 2023 15:17:39 +0200 Subject: [PATCH 147/269] description & timeout changes passed to tester --- checkpy/tester/tester.py | 27 +++++++++++++++++++++------ checkpy/tests.py | 20 +++++++++++++++----- 2 files changed, 36 insertions(+), 11 deletions(-) diff --git a/checkpy/tester/tester.py b/checkpy/tester/tester.py index 15cf953..20cbd03 100644 --- a/checkpy/tester/tester.py +++ b/checkpy/tester/tester.py @@ -93,9 +93,12 @@ def _runTests(moduleName, fileName, debugMode = False, silentMode = False): while p.is_alive(): while not signalQueue.empty(): signal = signalQueue.get() - isTiming = signal.isTiming + description = signal.description - timeout = signal.timeout + if signal.isTiming != None: + isTiming = signal.isTiming + if signal.timeout != None: + timeout = signal.timeout if signal.resetTimer: start = time.time() @@ -144,7 +147,7 @@ def asDict(self): "results":[tr.asDict() for tr in self.testResults]} class _Signal(object): - def __init__(self, isTiming = False, resetTimer = False, description = None, timeout = None): + def __init__(self, isTiming=None, resetTimer=None, description=None, timeout=None): self.isTiming = isTiming self.resetTimer = resetTimer self.description = description @@ -213,13 +216,25 @@ def _runTestsFromModule(self, module): def _runTests(self, testCreators): cachedResults = {} + def handleDescriptionChange(test): + self._sendSignal(_Signal( + description=test.description + )) + + def handleTimeoutChange(test): + self._sendSignal(_Signal( + isTiming=True, + resetTimer=True, + timeout=test.timeout + )) + # run tests in noncolliding execution order for testCreator in self._getTestCreatorsInExecutionOrder(testCreators): test = Test( self.filePath.fileName, testCreator.priority, - onDescriptionChange=lambda self: None, - onTimeoutChange=lambda self: None + onDescriptionChange=handleDescriptionChange, + onTimeoutChange=handleTimeoutChange ) run = testCreator(test) @@ -228,7 +243,7 @@ def _runTests(self, testCreators): isTiming=True, resetTimer=True, description=test.description, - timeout=test.timeout() + timeout=test.timeout )) cachedResults[test] = run() self._sendSignal(_Signal(isTiming = False)) diff --git a/checkpy/tests.py b/checkpy/tests.py index 86ee743..a49d816 100644 --- a/checkpy/tests.py +++ b/checkpy/tests.py @@ -16,7 +16,8 @@ def __init__(self, self._onDescriptionChange = onDescriptionChange self._onTimeoutChange = onTimeoutChange - self._description = "" + self._description = "placeholder test description" + self._timeout = 10 def __lt__(self, other): return self._priority < other._priority @@ -78,10 +79,19 @@ def description(self, new_description): self._onDescriptionChange(self) - @staticmethod - def timeout(): - return 10 - + @property + def timeout(self): + return self._timeout + + @timeout.setter + def timeout(self, new_timeout): + if callable(new_timeout): + self._timeout = new_timeout() + else: + self._timeout = new_timeout + + self._onTimeoutChange(self) + class TestResult(object): def __init__(self, hasPassed, description, message, exception = None): From 41f0bb1d80b78a36d1c03c36ebdda1d216a7dc23 Mon Sep 17 00:00:00 2001 From: jelleas Date: Mon, 26 Jun 2023 15:19:36 +0200 Subject: [PATCH 148/269] don't reset description on timeout change --- checkpy/tester/tester.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/checkpy/tester/tester.py b/checkpy/tester/tester.py index 20cbd03..d83b4e3 100644 --- a/checkpy/tester/tester.py +++ b/checkpy/tester/tester.py @@ -94,7 +94,8 @@ def _runTests(moduleName, fileName, debugMode = False, silentMode = False): while not signalQueue.empty(): signal = signalQueue.get() - description = signal.description + if signal.description != None: + description = signal.description if signal.isTiming != None: isTiming = signal.isTiming if signal.timeout != None: From b6450d7fccdc7ab53cc7bd0530508aad0a64ce5e Mon Sep 17 00:00:00 2001 From: jelleas Date: Mon, 26 Jun 2023 15:41:33 +0200 Subject: [PATCH 149/269] set timeout via test/failed/passed --- checkpy/tester/tester.py | 1 + checkpy/tests.py | 72 +++++++++++++++++++++++++--------------- 2 files changed, 46 insertions(+), 27 deletions(-) diff --git a/checkpy/tester/tester.py b/checkpy/tester/tester.py index d83b4e3..f6b4d50 100644 --- a/checkpy/tester/tester.py +++ b/checkpy/tester/tester.py @@ -234,6 +234,7 @@ def handleTimeoutChange(test): test = Test( self.filePath.fileName, testCreator.priority, + timeout=testCreator.timeout, onDescriptionChange=handleDescriptionChange, onTimeoutChange=handleTimeoutChange ) diff --git a/checkpy/tests.py b/checkpy/tests.py index a49d816..2626ffd 100644 --- a/checkpy/tests.py +++ b/checkpy/tests.py @@ -1,12 +1,37 @@ import traceback -from functools import wraps + from checkpy import caches from checkpy.entities import exception + +__all__ = ["test", "failed", "passed"] + + +def test(priority=None, timeout=None): + def testDecorator(testFunction): + return TestFunction(testFunction, priority=priority, timeout=timeout) + return testDecorator + + +def failed(*preconditions, priority=None, timeout=None): + def failedDecorator(testFunction): + return FailedTestFunction(testFunction, preconditions, priority=priority, timeout=timeout) + return failedDecorator + + +def passed(*preconditions, priority=None, timeout=None): + def passedDecorator(testFunction): + return PassedTestFunction(testFunction, preconditions, priority=priority, timeout=timeout) + return passedDecorator + + class Test: + DEFAULT_TIMEOUT = 10 + def __init__(self, fileName, - priority, + priority, + timeout=None, onDescriptionChange=lambda self: None, onTimeoutChange=lambda self: None ): @@ -16,8 +41,8 @@ def __init__(self, self._onDescriptionChange = onDescriptionChange self._onTimeoutChange = onTimeoutChange - self._description = "placeholder test description" - self._timeout = 10 + self._description = "placeholder test description" + self._timeout = Test.DEFAULT_TIMEOUT if timeout is None else timeout def __lt__(self, other): return self._priority < other._priority @@ -94,7 +119,7 @@ def timeout(self, new_timeout): class TestResult(object): - def __init__(self, hasPassed, description, message, exception = None): + def __init__(self, hasPassed, description, message, exception=None): self._hasPassed = bool(hasPassed) self._description = description self._message = message @@ -126,11 +151,12 @@ def asDict(self): class TestFunction: _previousPriority = -1 - def __init__(self, function, priority=None): + def __init__(self, function, priority=None, timeout=None): self._function = function self.isTestFunction = True self.priority = self._getPriority(priority) self.dependencies = getattr(self._function, "dependencies", set()) + self.timeout = self._getTimeout(timeout) self.__name__ = function.__name__ def __call__(self, test): @@ -156,7 +182,7 @@ def _ensureCallable(test, attribute): setattr(test, attribute, lambda *args, **kwargs: value) def _getPriority(self, priority): - if priority: + if priority != None: TestFunction._previousPriority = priority return priority @@ -167,11 +193,21 @@ def _getPriority(self, priority): TestFunction._previousPriority += 1 return TestFunction._previousPriority + + def _getTimeout(self, timeout): + if timeout != None: + return timeout + + inheritedTimeout = getattr(self._function, "timeout", None) + if inheritedTimeout: + return inheritedTimeout + + return Test.DEFAULT_TIMEOUT class FailedTestFunction(TestFunction): - def __init__(self, function, preconditions, priority=None): - super().__init__(function=function, priority=priority) + def __init__(self, function, preconditions, priority=None, timeout=None): + super().__init__(function=function, priority=priority, timeout=timeout) self.preconditions = preconditions def __call__(self, test): @@ -196,21 +232,3 @@ class PassedTestFunction(FailedTestFunction): @staticmethod def shouldRun(testResults): return not any(t is None for t in testResults) and all(t.hasPassed for t in testResults) - - -def test(priority=None, timeout=None): - def testDecorator(testFunction): - return TestFunction(testFunction, priority=priority) - return testDecorator - - -def failed(*preconditions, priority=None, timeout=None): - def failedDecorator(testFunction): - return FailedTestFunction(testFunction, preconditions, priority=priority) - return failedDecorator - - -def passed(*preconditions, priority=None, timeout=None): - def passedDecorator(testFunction): - return PassedTestFunction(testFunction, preconditions, priority=priority) - return passedDecorator From 74c4a0353f51df4fdc03b2b34ee5ebb309dda498 Mon Sep 17 00:00:00 2001 From: jelleas Date: Mon, 26 Jun 2023 16:05:50 +0200 Subject: [PATCH 150/269] testFunctions optionally take test as arg --- checkpy/tests.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/checkpy/tests.py b/checkpy/tests.py index 2626ffd..50354bc 100644 --- a/checkpy/tests.py +++ b/checkpy/tests.py @@ -1,3 +1,4 @@ +import inspect import traceback from checkpy import caches @@ -164,7 +165,11 @@ def __call__(self, test): @caches.cacheTestResult(self) def runMethod(): - self._function(test) + if inspect.getfullargspec(self._function).args: + self._function(test) + else: + self._function() + for attr in ["success", "fail", "exception"]: TestFunction._ensureCallable(test, attr) return test.run() @@ -217,6 +222,7 @@ def runMethod(): run = self._function(test) else: run = TestFunction.__call__(self, test) + testResults = [caches.getCachedTestResult(t) for t in self.preconditions] if self.shouldRun(testResults): return run() From 36eb441e5f060cc21551a781a08b0a08eda9ff21 Mon Sep 17 00:00:00 2001 From: jelleas Date: Mon, 26 Jun 2023 16:18:05 +0200 Subject: [PATCH 151/269] use testFunction return value as test outcome --- checkpy/tests.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/checkpy/tests.py b/checkpy/tests.py index 50354bc..acccc05 100644 --- a/checkpy/tests.py +++ b/checkpy/tests.py @@ -166,9 +166,12 @@ def __call__(self, test): @caches.cacheTestResult(self) def runMethod(): if inspect.getfullargspec(self._function).args: - self._function(test) + result = self._function(test) else: - self._function() + result = self._function() + + if result != None: + test.test = lambda: result for attr in ["success", "fail", "exception"]: TestFunction._ensureCallable(test, attr) From 557fa7cc7bd0020a6a9cddaa48010b187ab2e3fd Mon Sep 17 00:00:00 2001 From: jelleas Date: Mon, 26 Jun 2023 16:58:14 +0200 Subject: [PATCH 152/269] filePath is optional --- checkpy/__init__.py | 3 +++ checkpy/lib/basic.py | 41 +++++++++++++++++++++++++++------------- checkpy/lib/static.py | 21 ++++++++++++++++---- checkpy/tester/tester.py | 4 ++++ 4 files changed, 52 insertions(+), 17 deletions(-) diff --git a/checkpy/__init__.py b/checkpy/__init__.py index c42a397..6b239cb 100644 --- a/checkpy/__init__.py +++ b/checkpy/__init__.py @@ -1,6 +1,9 @@ import os +import pathlib from .downloader import download, update +file: pathlib.Path = None + def testModule(moduleName, debugMode = False, silentMode = False): """ Test all files from module diff --git a/checkpy/lib/basic.py b/checkpy/lib/basic.py index f798c60..d0cc3d6 100644 --- a/checkpy/lib/basic.py +++ b/checkpy/lib/basic.py @@ -13,12 +13,16 @@ import tokenize import traceback import requests -from checkpy.entities import path -from checkpy.entities import exception -from checkpy.entities import function + +import checkpy +from checkpy.entities import path, exception, function from checkpy import caches -def require(fileName, source = None): + +def require(fileName=None, source=None): + if fileName is None: + fileName = checkpy.file.name + if source: download(fileName, source) return @@ -30,16 +34,24 @@ def require(fileName, source = None): filePath.copyTo(path.current() + fileName) -def fileExists(fileName): +def fileExists(fileName=None): + if fileName is None: + fileName = checkpy.file.name return path.Path(fileName).exists() -def source(fileName): +def source(fileName=None): + if fileName is None: + fileName = checkpy.file.name + source = "" with open(fileName) as f: source = f.read() return source -def sourceOfDefinitions(fileName): +def sourceOfDefinitions(fileName=None): + if fileName is None: + fileName = checkpy.file.name + newSource = "" with open(fileName) as f: @@ -84,12 +96,12 @@ def module(*args, **kwargs): @caches.cache() def moduleAndOutputOf( - fileName, - src = None, - argv = None, - stdinArgs = None, - ignoreExceptions = (), - overwriteAttributes = () + fileName=None, + src=None, + argv=None, + stdinArgs=None, + ignoreExceptions=(), + overwriteAttributes=() ): """ This function handles most of checkpy's under the hood functionality @@ -99,6 +111,9 @@ def moduleAndOutputOf( ignoredExceptions: a collection of Exceptions that will silently pass overwriteAttributes: a list of tuples [(attribute, value), ...] """ + if fileName is None: + fileName = checkpy.file.name + if src == None: src = source(fileName) diff --git a/checkpy/lib/static.py b/checkpy/lib/static.py index 2dd9ef2..aab5fc0 100644 --- a/checkpy/lib/static.py +++ b/checkpy/lib/static.py @@ -1,12 +1,19 @@ +import checkpy from checkpy import caches import redbaron -def source(fileName): +def source(fileName=None): + if fileName is None: + fileName = checkpy.file.name + with open(fileName) as f: return f.read() @caches.cache() -def fullSyntaxTree(fileName): +def fullSyntaxTree(fileName=None): + if fileName is None: + fileName = checkpy.file.name + return fstFromSource(source(fileName)) @caches.cache() @@ -14,13 +21,19 @@ def fstFromSource(source): return redbaron.RedBaron(source) @caches.cache() -def functionCode(functionName, fileName): +def functionCode(functionName, fileName=None): + if fileName is None: + fileName = checkpy.file.name + definitions = [d for d in fullSyntaxTree(fileName).find_all("def") if d.name == functionName] if definitions: return definitions[0] return None -def functionLOC(functionName, fileName): +def functionLOC(functionName, fileName=None): + if fileName is None: + fileName = checkpy.file.name + code = functionCode(functionName, fileName) ignoreNodes = [] ignoreNodeTypes = [redbaron.EndlNode, redbaron.StringNode] diff --git a/checkpy/tester/tester.py b/checkpy/tester/tester.py index f6b4d50..187a8a1 100644 --- a/checkpy/tester/tester.py +++ b/checkpy/tester/tester.py @@ -1,3 +1,4 @@ +import checkpy from checkpy import printer from checkpy.entities import exception, path from checkpy.tester import discovery @@ -5,6 +6,7 @@ from checkpy.tests import Test import os +import pathlib import subprocess import sys import importlib @@ -229,6 +231,8 @@ def handleTimeoutChange(test): timeout=test.timeout )) + checkpy.file = pathlib.Path(self.filePath.fileName) + # run tests in noncolliding execution order for testCreator in self._getTestCreatorsInExecutionOrder(testCreators): test = Test( From 981272daf56e61b55daaec7caec449eecbf19810 Mon Sep 17 00:00:00 2001 From: jelleas Date: Tue, 27 Jun 2023 17:47:22 +0200 Subject: [PATCH 153/269] filePath is not optional for lib.require and lib.fileExists --- checkpy/lib/basic.py | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/checkpy/lib/basic.py b/checkpy/lib/basic.py index d0cc3d6..370d2de 100644 --- a/checkpy/lib/basic.py +++ b/checkpy/lib/basic.py @@ -19,10 +19,7 @@ from checkpy import caches -def require(fileName=None, source=None): - if fileName is None: - fileName = checkpy.file.name - +def require(fileName, source=None): if source: download(fileName, source) return @@ -34,9 +31,7 @@ def require(fileName=None, source=None): filePath.copyTo(path.current() + fileName) -def fileExists(fileName=None): - if fileName is None: - fileName = checkpy.file.name +def fileExists(fileName): return path.Path(fileName).exists() def source(fileName=None): From 14dc1354a9cff77506d22191ccaf22194fd16135 Mon Sep 17 00:00:00 2001 From: jelleas Date: Tue, 27 Jun 2023 17:48:11 +0200 Subject: [PATCH 154/269] sandbox rework, no config yet --- checkpy/tester/sandbox.py | 51 ++++++++++++++++++++++++--------------- checkpy/tester/tester.py | 14 +++++------ 2 files changed, 38 insertions(+), 27 deletions(-) diff --git a/checkpy/tester/sandbox.py b/checkpy/tester/sandbox.py index 6b937f7..82b9064 100644 --- a/checkpy/tester/sandbox.py +++ b/checkpy/tester/sandbox.py @@ -1,25 +1,38 @@ +import contextlib +import glob import os import shutil -import uuid -import checkpy.entities.path as path +import tempfile +from pathlib import Path +from typing import Iterable, Union -class Sandbox(): - def __init__(self, filePath): - self.id = "sandbox_" + str(uuid.uuid4()) - self.path = path.Path(os.path.abspath(os.path.dirname(__file__))) + self.id - self._filePath = filePath - os.makedirs(str(self.path)) - def _clear(self): - if self.path.exists(): - shutil.rmtree(str(self.path)) +@contextlib.contextmanager +def sandbox(files:Iterable[Union[str, Path]]=None, name:Union[str, Path]=""): + with tempfile.TemporaryDirectory() as dir: + dir = Path(Path(dir) / name) + dir.mkdir(exist_ok=True) - def __enter__(self): - self._oldCWD = os.getcwd() - os.chdir(str(self.path)) - if self._filePath.exists(): - self._filePath.copyTo(self.path + self._filePath.fileName) + # If no files specified, take all files from cwd + if files is None: + cwd = Path.cwd() + paths = glob.glob(str(cwd / "**"), recursive=True) + files = [Path(p).relative_to(cwd) for p in paths if os.path.isfile(p)] - def __exit__(self, exc_type, exc_val, exc_tb): - os.chdir(str(self._oldCWD)) - self._clear() + for f in files: + dest = (dir / f).absolute() + dest.parent.mkdir(parents=True, exist_ok=True) + shutil.copy(f, dest) + + with cd(dir): + yield dir + + +@contextlib.contextmanager +def cd(dest:Union[str, Path]): + origin = Path.cwd() + try: + os.chdir(dest) + yield dest + finally: + os.chdir(origin) diff --git a/checkpy/tester/tester.py b/checkpy/tester/tester.py index 187a8a1..506f63a 100644 --- a/checkpy/tester/tester.py +++ b/checkpy/tester/tester.py @@ -2,7 +2,7 @@ from checkpy import printer from checkpy.entities import exception, path from checkpy.tester import discovery -from checkpy.tester.sandbox import Sandbox +from checkpy.tester.sandbox import sandbox from checkpy.tests import Test import os @@ -175,11 +175,6 @@ def run(self): module = importlib.import_module(self.moduleName) module._fileName = self.filePath.fileName - if hasattr(module, "sandbox"): - with Sandbox(self.filePath.absolutePath()): - module.sandbox() - return self._runTestsFromModule(module) - return self._runTestsFromModule(module) def _runTestsFromModule(self, module): @@ -251,8 +246,11 @@ def handleTimeoutChange(test): description=test.description, timeout=test.timeout )) - cachedResults[test] = run() - self._sendSignal(_Signal(isTiming = False)) + + with sandbox(): + cachedResults[test] = run() + + self._sendSignal(_Signal(isTiming=False)) # return test results in specified order return [cachedResults[test] for test in sorted(cachedResults.keys()) if cachedResults[test] != None] From 8d17f1e7f247942980e657293863dee7557c5ca7 Mon Sep 17 00:00:00 2001 From: jelleas Date: Wed, 28 Jun 2023 16:40:24 +0200 Subject: [PATCH 155/269] sandbox is optional, introducing only/include/exclude/require --- checkpy/entities/exception.py | 7 ++ checkpy/tester/sandbox.py | 205 ++++++++++++++++++++++++++++++++-- checkpy/tester/tester.py | 2 +- checkpy/tests.py | 24 ++-- 4 files changed, 214 insertions(+), 24 deletions(-) diff --git a/checkpy/entities/exception.py b/checkpy/entities/exception.py index 9dd107b..7ca18eb 100644 --- a/checkpy/entities/exception.py +++ b/checkpy/entities/exception.py @@ -38,3 +38,10 @@ class ExitError(CheckpyError): class PathError(CheckpyError): pass + +class TooManyFilesError(CheckpyError): + pass + +class MissingRequiredFiles(CheckpyError): + def __init__(self, missingFiles): + super().__init__(message=f"Missing the following required files: {', '.join(missingFiles)}") \ No newline at end of file diff --git a/checkpy/tester/sandbox.py b/checkpy/tester/sandbox.py index 82b9064..1b6cbba 100644 --- a/checkpy/tester/sandbox.py +++ b/checkpy/tester/sandbox.py @@ -4,35 +4,216 @@ import shutil import tempfile from pathlib import Path -from typing import Iterable, Union +from typing import Iterable, List, Set, Union + +from checkpy.entities.exception import TooManyFilesError, MissingRequiredFiles + + +__all__ = ["exclude", "include", "only", "require", "sandbox"] + + +DEFAULT_FILE_LIMIT = 10000 + + +class Config: + def __init__(self, onUpdate=lambda config: None): + self.includedFiles: Set[str] = set() + self.excludedFiles: Set[str] = set() + self.missingRequiredFiles: List[str] = [] + self.isSandboxed = False + self.root = Path.cwd() + self.onUpdate = onUpdate + + def _initSandbox(self): + if self.isSandboxed: + return + + self.includedFiles = _glob("*", root=self.root) + self.isSandboxed = True + + def exclude(self, *patterns: Iterable[Union[str, Path]]): + self._initSandbox() + + newExcluded: Set[str] = set() + + for pattern in patterns: + newExcluded |= _glob(pattern, root=self.root) + + self.includedFiles -= newExcluded + self.excludedFiles.update(newExcluded) + + self.onUpdate(self) + + def include(self, *patterns: Iterable[Union[str, Path]]): + self._initSandbox() + + newIncluded: Set[str] = set() + + for pattern in patterns: + newIncluded |= _glob(pattern, root=self.root) + + self.excludedFiles -= newIncluded + self.includedFiles.update(newIncluded) + + self.onUpdate(self) + + def only(self, *patterns: Iterable[Union[str, Path]]): + self._initSandbox() + + allFiles = self.includedFiles | self.excludedFiles + self.includedFiles = set.union(*[_glob(p, root=self.root) for p in patterns]) + self.excludedFiles = allFiles - self.includedFiles + + self.onUpdate(self) + + def require(self, *filePaths: Iterable[Union[str, Path]]): + self._initSandbox() + + with cd(self.root): + for fp in filePaths: + fp = str(fp) + if not Path(fp).exists(): + self.missingRequiredFiles.append(fp) + else: + try: + self.excludedFiles.remove(fp) + except KeyError: + pass + else: + self.includedFiles.add(fp) + + self.onUpdate(self) + + +config = Config() + +def exclude(*patterns: Iterable[Union[str, Path]]): + config.exclude(*patterns) + +def include(*patterns: Iterable[Union[str, Path]]): + config.include(*patterns) + +def only(*patterns: Iterable[Union[str, Path]]): + config.only(*patterns) + +def require(*filePaths: Iterable[Union[str, Path]]): + config.require(*filePaths) @contextlib.contextmanager -def sandbox(files:Iterable[Union[str, Path]]=None, name:Union[str, Path]=""): +def sandbox(name: Union[str, Path]=""): + if config.missingRequiredFiles: + raise MissingRequiredFiles(config.missingRequiredFiles) + + if not config.isSandboxed: + yield + return + with tempfile.TemporaryDirectory() as dir: dir = Path(Path(dir) / name) dir.mkdir(exist_ok=True) - # If no files specified, take all files from cwd - if files is None: - cwd = Path.cwd() - paths = glob.glob(str(cwd / "**"), recursive=True) - files = [Path(p).relative_to(cwd) for p in paths if os.path.isfile(p)] - - for f in files: + for f in config.includedFiles: dest = (dir / f).absolute() dest.parent.mkdir(parents=True, exist_ok=True) shutil.copy(f, dest) + + with cd(dir), sandboxConfig(): + yield + + +@contextlib.contextmanager +def conditionalSandbox(name: Union[str, Path]=""): + isSandboxed = False + tempDir = None + dir = None + + def sync(config): + for f in config.excludedFiles: + dest = (dir / f).absolute() + try: + os.remove(dest) + except FileNotFoundError: + pass + + for f in config.includedFiles: + dest = (dir / f).absolute() + dest.parent.mkdir(parents=True, exist_ok=True) + origin = (config.root / f).absolute() + shutil.copy(origin, dest) + + def onUpdate(config): + if config.missingRequiredFiles: + raise MissingRequiredFiles(config.missingRequiredFiles) + + nonlocal isSandboxed + if not isSandboxed: + isSandboxed = True + nonlocal tempDir + tempDir = tempfile.TemporaryDirectory() + nonlocal dir + dir = Path(Path(tempDir.name) / name) + dir.mkdir(exist_ok=True) + os.chdir(dir) - with cd(dir): - yield dir + sync(config) + + with sandboxConfig(onUpdate=onUpdate): + try: + yield + finally: + os.chdir(config.root) + if tempDir: + tempDir.cleanup() + + +@contextlib.contextmanager +def sandboxConfig(onUpdate=lambda config: None): + global config + oldConfig = config + try: + config = Config(onUpdate=onUpdate) + yield config + finally: + config = oldConfig @contextlib.contextmanager -def cd(dest:Union[str, Path]): +def cd(dest: Union[str, Path]): origin = Path.cwd() try: os.chdir(dest) yield dest finally: os.chdir(origin) + + +def _glob(pattern: Union[str, Path], root: Union[str, Path]=None, skip_dirs: bool=False, limit: int=DEFAULT_FILE_LIMIT) -> Set[str]: + with cd(root) if root else contextlib.nullcontext: + pattern = str(pattern) + + # Implicit recursive iff no / in pattern and starts with * + if "/" not in pattern and pattern.startswith("*"): + pattern = f"**/{pattern}" + + files = glob.iglob(pattern, recursive=True) + + all_files = set() + + def add_file(f): + fname = str(Path(f)) + all_files.add(fname) + if len(all_files) > limit: + raise TooManyFilesError(limit) + + # Expand dirs + for file in files: + if os.path.isdir(file) and not skip_dirs: + for f in _glob(f"{file}/**/*", skip_dirs=True): + if not os.path.isdir(f): + add_file(f) + else: + add_file(file) + + return all_files + diff --git a/checkpy/tester/tester.py b/checkpy/tester/tester.py index 506f63a..5b5d60a 100644 --- a/checkpy/tester/tester.py +++ b/checkpy/tester/tester.py @@ -249,7 +249,7 @@ def handleTimeoutChange(test): with sandbox(): cachedResults[test] = run() - + self._sendSignal(_Signal(isTiming=False)) # return test results in specified order diff --git a/checkpy/tests.py b/checkpy/tests.py index acccc05..6f18dae 100644 --- a/checkpy/tests.py +++ b/checkpy/tests.py @@ -3,6 +3,7 @@ from checkpy import caches from checkpy.entities import exception +from checkpy.tester.sandbox import conditionalSandbox __all__ = ["test", "failed", "passed"] @@ -165,17 +166,18 @@ def __call__(self, test): @caches.cacheTestResult(self) def runMethod(): - if inspect.getfullargspec(self._function).args: - result = self._function(test) - else: - result = self._function() - - if result != None: - test.test = lambda: result - - for attr in ["success", "fail", "exception"]: - TestFunction._ensureCallable(test, attr) - return test.run() + with conditionalSandbox(): + if inspect.getfullargspec(self._function).args: + result = self._function(test) + else: + result = self._function() + + if result != None: + test.test = lambda: result + + for attr in ["success", "fail", "exception"]: + TestFunction._ensureCallable(test, attr) + return test.run() return runMethod From 538fc5da88c4757d0c4e5ac61227072e320bed41 Mon Sep 17 00:00:00 2001 From: jelleas Date: Wed, 28 Jun 2023 17:06:52 +0200 Subject: [PATCH 156/269] conditionalSandbox works on diff included/excluded --- checkpy/tester/sandbox.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/checkpy/tester/sandbox.py b/checkpy/tester/sandbox.py index 1b6cbba..1a43a04 100644 --- a/checkpy/tester/sandbox.py +++ b/checkpy/tester/sandbox.py @@ -128,20 +128,27 @@ def conditionalSandbox(name: Union[str, Path]=""): tempDir = None dir = None + oldIncluded: Set[str] = set() + oldExcluded: Set[str] = set() + def sync(config): - for f in config.excludedFiles: + nonlocal oldIncluded, oldExcluded + for f in config.excludedFiles - oldExcluded: dest = (dir / f).absolute() try: os.remove(dest) except FileNotFoundError: pass - for f in config.includedFiles: + for f in config.includedFiles - oldExcluded: dest = (dir / f).absolute() dest.parent.mkdir(parents=True, exist_ok=True) origin = (config.root / f).absolute() shutil.copy(origin, dest) + oldIncluded = set(config.includedFiles) + oldExcluded = set(config.excludedFiles) + def onUpdate(config): if config.missingRequiredFiles: raise MissingRequiredFiles(config.missingRequiredFiles) From 588c0fe68728d89c2c2ad71c8b81b2707815cc6a Mon Sep 17 00:00:00 2001 From: jelleas Date: Mon, 3 Jul 2023 13:19:28 +0200 Subject: [PATCH 157/269] set checkpy.file before import module --- checkpy/tester/sandbox.py | 4 ++-- checkpy/tester/tester.py | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/checkpy/tester/sandbox.py b/checkpy/tester/sandbox.py index 1a43a04..a496e86 100644 --- a/checkpy/tester/sandbox.py +++ b/checkpy/tester/sandbox.py @@ -131,7 +131,7 @@ def conditionalSandbox(name: Union[str, Path]=""): oldIncluded: Set[str] = set() oldExcluded: Set[str] = set() - def sync(config): + def sync(config: Config): nonlocal oldIncluded, oldExcluded for f in config.excludedFiles - oldExcluded: dest = (dir / f).absolute() @@ -149,7 +149,7 @@ def sync(config): oldIncluded = set(config.includedFiles) oldExcluded = set(config.excludedFiles) - def onUpdate(config): + def onUpdate(config: Config): if config.missingRequiredFiles: raise MissingRequiredFiles(config.missingRequiredFiles) diff --git a/checkpy/tester/tester.py b/checkpy/tester/tester.py index 5b5d60a..96cc147 100644 --- a/checkpy/tester/tester.py +++ b/checkpy/tester/tester.py @@ -169,6 +169,8 @@ def run(self): printer.printer.DEBUG_MODE = self.debugMode printer.printer.SILENT_MODE = self.silentMode + checkpy.file = pathlib.Path(self.filePath.fileName) + # overwrite argv so that it seems the file was run directly sys.argv = [self.filePath.fileName] @@ -226,8 +228,6 @@ def handleTimeoutChange(test): timeout=test.timeout )) - checkpy.file = pathlib.Path(self.filePath.fileName) - # run tests in noncolliding execution order for testCreator in self._getTestCreatorsInExecutionOrder(testCreators): test = Test( From 366b2bb4ad9dd2d8000631d9fcdc06202522c38c Mon Sep 17 00:00:00 2001 From: jelleas Date: Mon, 3 Jul 2023 13:46:10 +0200 Subject: [PATCH 158/269] start on from checkpy import * --- checkpy/__init__.py | 51 +++++++++--------------------- checkpy/downloader/__init__.py | 2 +- checkpy/interactive.py | 38 ++++++++++++++++++++++ checkpy/lib/__init__.py | 1 + checkpy/{tester => lib}/sandbox.py | 0 checkpy/tester/__init__.py | 2 ++ checkpy/tester/discovery.py | 3 -- checkpy/tester/tester.py | 2 +- checkpy/tests.py | 2 +- 9 files changed, 59 insertions(+), 42 deletions(-) create mode 100644 checkpy/interactive.py rename checkpy/{tester => lib}/sandbox.py (100%) diff --git a/checkpy/__init__.py b/checkpy/__init__.py index 6b239cb..4f257c6 100644 --- a/checkpy/__init__.py +++ b/checkpy/__init__.py @@ -1,41 +1,20 @@ -import os import pathlib -from .downloader import download, update + +from checkpy.lib import outputOf, only, include, exclude, require +from checkpy.tests import test, failed, passed + +__all__ = [ + "test", + "failed", + "passed", + "outputOf", + "only", + "include", + "exclude", + "require", + "file" +] file: pathlib.Path = None -def testModule(moduleName, debugMode = False, silentMode = False): - """ - Test all files from module - """ - from . import caches - caches.clearAllCaches() - from . import tester - from . import downloader - downloader.updateSilently() - results = tester.testModule(moduleName, debugMode = debugMode, silentMode = silentMode) - try: - if __IPYTHON__: - import matplotlib.pyplot - matplotlib.pyplot.close("all") - except: - pass - return results -def test(fileName, debugMode = False, silentMode = False): - """ - Run tests for a single file - """ - from . import caches - caches.clearAllCaches() - from . import tester - from . import downloader - downloader.updateSilently() - result = tester.test(fileName, debugMode = debugMode, silentMode = silentMode) - try: - if __IPYTHON__: - import matplotlib.pyplot - matplotlib.pyplot.close("all") - except: - pass - return result diff --git a/checkpy/downloader/__init__.py b/checkpy/downloader/__init__.py index 3f4b639..5b65241 100644 --- a/checkpy/downloader/__init__.py +++ b/checkpy/downloader/__init__.py @@ -1 +1 @@ -from checkpy.downloader.downloader import * +from .downloader import * diff --git a/checkpy/interactive.py b/checkpy/interactive.py new file mode 100644 index 0000000..93be34f --- /dev/null +++ b/checkpy/interactive.py @@ -0,0 +1,38 @@ +import os +from checkpy.downloader import download, update + +def testModule(moduleName, debugMode = False, silentMode = False): + """ + Test all files from module + """ + from . import caches + caches.clearAllCaches() + from . import tester + from . import downloader + downloader.updateSilently() + results = tester.testModule(moduleName, debugMode = debugMode, silentMode = silentMode) + try: + if __IPYTHON__: + import matplotlib.pyplot + matplotlib.pyplot.close("all") + except: + pass + return results + +def test(fileName, debugMode = False, silentMode = False): + """ + Run tests for a single file + """ + from . import caches + caches.clearAllCaches() + from . import tester + from . import downloader + downloader.updateSilently() + result = tester.test(fileName, debugMode = debugMode, silentMode = silentMode) + try: + if __IPYTHON__: + import matplotlib.pyplot + matplotlib.pyplot.close("all") + except: + pass + return result \ No newline at end of file diff --git a/checkpy/lib/__init__.py b/checkpy/lib/__init__.py index 98e0fc2..3e29eda 100644 --- a/checkpy/lib/__init__.py +++ b/checkpy/lib/__init__.py @@ -1 +1,2 @@ from checkpy.lib.basic import * +from checkpy.lib.sandbox import * \ No newline at end of file diff --git a/checkpy/tester/sandbox.py b/checkpy/lib/sandbox.py similarity index 100% rename from checkpy/tester/sandbox.py rename to checkpy/lib/sandbox.py diff --git a/checkpy/tester/__init__.py b/checkpy/tester/__init__.py index 4dd0f14..00f6107 100644 --- a/checkpy/tester/__init__.py +++ b/checkpy/tester/__init__.py @@ -1 +1,3 @@ from checkpy.tester.tester import * + +__all__ = ["test", "testModule", "only", "include", "exclude", "require"] \ No newline at end of file diff --git a/checkpy/tester/discovery.py b/checkpy/tester/discovery.py index 121979a..170a1a8 100644 --- a/checkpy/tester/discovery.py +++ b/checkpy/tester/discovery.py @@ -39,6 +39,3 @@ def getTestPaths(testFileName, module = ""): if testFileName in fileNames and (not module or module in dirPath): testFilePaths.append(dirPath) return testFilePaths - -def _backslashToForwardslash(text): - return re.sub("\\\\", "/", text) diff --git a/checkpy/tester/tester.py b/checkpy/tester/tester.py index 96cc147..1dd9100 100644 --- a/checkpy/tester/tester.py +++ b/checkpy/tester/tester.py @@ -2,7 +2,7 @@ from checkpy import printer from checkpy.entities import exception, path from checkpy.tester import discovery -from checkpy.tester.sandbox import sandbox +from checkpy.lib.sandbox import sandbox from checkpy.tests import Test import os diff --git a/checkpy/tests.py b/checkpy/tests.py index 6f18dae..261a0ec 100644 --- a/checkpy/tests.py +++ b/checkpy/tests.py @@ -3,7 +3,7 @@ from checkpy import caches from checkpy.entities import exception -from checkpy.tester.sandbox import conditionalSandbox +from checkpy.lib.sandbox import conditionalSandbox __all__ = ["test", "failed", "passed"] From 92212c4fbd39a748366ee86a50ad6e3648cf199a Mon Sep 17 00:00:00 2001 From: jelleas Date: Mon, 3 Jul 2023 16:14:42 +0200 Subject: [PATCH 159/269] type hints in test.py, introduce hide=True --- checkpy/tests.py | 151 ++++++++++++++++++++++++++++++++++------------- 1 file changed, 109 insertions(+), 42 deletions(-) diff --git a/checkpy/tests.py b/checkpy/tests.py index 261a0ec..921cc8f 100644 --- a/checkpy/tests.py +++ b/checkpy/tests.py @@ -1,6 +1,8 @@ import inspect import traceback +from typing import Dict, List, Set, Tuple, Union, Callable, Iterable, Optional + from checkpy import caches from checkpy.entities import exception from checkpy.lib.sandbox import conditionalSandbox @@ -9,31 +11,45 @@ __all__ = ["test", "failed", "passed"] -def test(priority=None, timeout=None): - def testDecorator(testFunction): +def test( + priority: Optional[int]=None, + timeout: Optional[int]=None + ) -> Callable[[Callable], "TestFunction"]: + def testDecorator(testFunction: Callable) -> TestFunction: return TestFunction(testFunction, priority=priority, timeout=timeout) return testDecorator -def failed(*preconditions, priority=None, timeout=None): - def failedDecorator(testFunction): - return FailedTestFunction(testFunction, preconditions, priority=priority, timeout=timeout) +def failed( + *preconditions: List["TestFunction"], + priority: Optional[int]=None, + timeout: Optional[int]=None, + hide: bool=True + ) -> Callable[[Callable], "FailedTestFunction"]: + def failedDecorator(testFunction: Callable) -> FailedTestFunction: + return FailedTestFunction(testFunction, preconditions, priority=priority, timeout=timeout, hide=hide) return failedDecorator -def passed(*preconditions, priority=None, timeout=None): - def passedDecorator(testFunction): - return PassedTestFunction(testFunction, preconditions, priority=priority, timeout=timeout) +def passed( + *preconditions: List["TestFunction"], + priority: Optional[int]=None, + timeout: Optional[int]=None, + hide: bool=True + ) -> Callable[[Callable], "PassedTestFunction"]: + def passedDecorator(testFunction: Callable) -> PassedTestFunction: + return PassedTestFunction(testFunction, preconditions, priority=priority, timeout=timeout, hide=hide) return passedDecorator class Test: DEFAULT_TIMEOUT = 10 + PLACEHOLDER_DESCRIPTION = "placeholder test description" def __init__(self, - fileName, - priority, - timeout=None, + fileName: str, + priority: int, + timeout: int=None, onDescriptionChange=lambda self: None, onTimeoutChange=lambda self: None ): @@ -43,18 +59,18 @@ def __init__(self, self._onDescriptionChange = onDescriptionChange self._onTimeoutChange = onTimeoutChange - self._description = "placeholder test description" + self._description = Test.PLACEHOLDER_DESCRIPTION self._timeout = Test.DEFAULT_TIMEOUT if timeout is None else timeout def __lt__(self, other): return self._priority < other._priority @property - def fileName(self): + def fileName(self) -> str: return self._fileName @caches.cache() - def run(self): + def run(self) -> Union["TestResult", None]: try: result = self.test() @@ -69,32 +85,32 @@ def run(self): exception = e, message = "while testing", stacktrace = traceback.format_exc()) - return TestResult(False, self.description, self.exception(e), exception = e) + return TestResult(False, self.description, self.exception(e), exception=e) return TestResult(hasPassed, self.description, self.success(info) if hasPassed else self.fail(info)) @staticmethod - def test(): + def test() -> Union[bool, Tuple[bool, str]]: raise NotImplementedError() @staticmethod - def success(info): + def success(info: str) -> str: return "" @staticmethod - def fail(info): + def fail(info: str) -> str: return info @staticmethod - def exception(exception): + def exception(exception: Exception) -> Exception: return exception @staticmethod - def dependencies(): + def dependencies() -> Set["TestFunction"]: return set() @property - def description(self): + def description(self) -> str: return self._description @description.setter @@ -107,7 +123,7 @@ def description(self, new_description): self._onDescriptionChange(self) @property - def timeout(self): + def timeout(self) -> int: return self._timeout @timeout.setter @@ -121,7 +137,7 @@ def timeout(self, new_timeout): class TestResult(object): - def __init__(self, hasPassed, description, message, exception=None): + def __init__(self, hasPassed: Union[bool, None], description: str, message: str, exception: Exception=None): self._hasPassed = bool(hasPassed) self._description = description self._message = message @@ -143,17 +159,24 @@ def hasPassed(self): def exception(self): return self._exception - def asDict(self): - return {"passed":self.hasPassed, - "description":str(self.description), - "message":str(self.message), - "exception":str(self.exception)} + def asDict(self) -> Dict[str, Union[bool, None, str]]: + return { + "passed": self.hasPassed, + "description": str(self.description), + "message": str(self.message), + "exception": str(self.exception) + } class TestFunction: _previousPriority = -1 - def __init__(self, function, priority=None, timeout=None): + def __init__( + self, + function: Callable, + priority: Optional[int]=None, + timeout: Optional[int]=None + ): self._function = function self.isTestFunction = True self.priority = self._getPriority(priority) @@ -161,13 +184,15 @@ def __init__(self, function, priority=None, timeout=None): self.timeout = self._getTimeout(timeout) self.__name__ = function.__name__ - def __call__(self, test): - self._useDocStringDescription(test) + def __call__(self, test: Test) -> Callable[[], Union["TestResult", None]]: + self.useDocStringDescription(test) @caches.cacheTestResult(self) def runMethod(): with conditionalSandbox(): - if inspect.getfullargspec(self._function).args: + if getattr(self._function, "isTestFunction", False): + result = self._function(test)() + elif inspect.getfullargspec(self._function).args: result = self._function(test) else: result = self._function() @@ -181,17 +206,20 @@ def runMethod(): return runMethod - def _useDocStringDescription(self, test): + def useDocStringDescription(self, test: Test) -> None: + if getattr(self._function, "isTestFunction", False): + self._function.useDocStringDescription(test) + if self._function.__doc__ != None: test.description = self._function.__doc__ @staticmethod - def _ensureCallable(test, attribute): + def _ensureCallable(test: Test, attribute: str) -> None: value = getattr(test, attribute) if not callable(value): setattr(test, attribute, lambda *args, **kwargs: value) - def _getPriority(self, priority): + def _getPriority(self, priority: Optional[int]) -> int: if priority != None: TestFunction._previousPriority = priority return priority @@ -204,7 +232,7 @@ def _getPriority(self, priority): TestFunction._previousPriority += 1 return TestFunction._previousPriority - def _getTimeout(self, timeout): + def _getTimeout(self, timeout: Optional[int]) -> int: if timeout != None: return timeout @@ -216,30 +244,69 @@ def _getTimeout(self, timeout): class FailedTestFunction(TestFunction): - def __init__(self, function, preconditions, priority=None, timeout=None): + HIDE_MESSAGE = "can't check until another check fails" + + def __init__( + self, + function: Callable, + preconditions: Iterable[TestFunction], + priority: Optional[int]=None, + timeout: Optional[int]=None, + hide: Optional[bool]=None + ): super().__init__(function=function, priority=priority, timeout=timeout) self.preconditions = preconditions + self.shouldHide = self._getHide(hide) + + def __call__(self, test: Test) -> Callable[[], Union["TestResult", None]]: + self.useDocStringDescription(test) - def __call__(self, test): @caches.cacheTestResult(self) def runMethod(): if getattr(self._function, "isTestFunction", False): run = self._function(test) else: run = TestFunction.__call__(self, test) - + + self.requireDocstringIfNotHidden(test) + testResults = [caches.getCachedTestResult(t) for t in self.preconditions] if self.shouldRun(testResults): return run() - return None + + if self.shouldHide: + return None + + return TestResult( + None, + test.description, + self.HIDE_MESSAGE + ) return runMethod + def requireDocstringIfNotHidden(self, test: Test) -> None: + if not self.shouldHide and test.description == Test.PLACEHOLDER_DESCRIPTION: + raise exception.TestError(f"Test {self.__name__} requires a docstring description if hide=False") + @staticmethod - def shouldRun(testResults): + def shouldRun(testResults: Iterable[TestResult]) -> bool: return not any(t is None for t in testResults) and not any(t.hasPassed for t in testResults) + def _getHide(self, hide: Optional[bool]) -> bool: + if hide != None: + return hide + + inheritedHide = getattr(self._function, "hide", None) + if inheritedHide: + return inheritedHide + + return True + + class PassedTestFunction(FailedTestFunction): + HIDE_MESSAGE = "can't check until another check passes" + @staticmethod - def shouldRun(testResults): + def shouldRun(testResults: Iterable[TestResult]) -> bool: return not any(t is None for t in testResults) and all(t.hasPassed for t in testResults) From 479f6152e90572e3283d4af67fa0d86bd31a743a Mon Sep 17 00:00:00 2001 From: jelleas Date: Mon, 3 Jul 2023 16:20:27 +0200 Subject: [PATCH 160/269] neutral color and straight face on skipped check --- checkpy/printer/printer.py | 3 +++ checkpy/tests.py | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/checkpy/printer/printer.py b/checkpy/printer/printer.py index 67afce8..d90b954 100644 --- a/checkpy/printer/printer.py +++ b/checkpy/printer/printer.py @@ -17,6 +17,7 @@ class _Smileys: HAPPY = ":)" SAD = ":(" CONFUSED = ":S" + NEUTRAL = ":|" def display(testResult): color, smiley = _selectColorAndSmiley(testResult) @@ -77,4 +78,6 @@ def _selectColorAndSmiley(testResult): return _Colors.PASS, _Smileys.HAPPY if type(testResult.message) is exception.SourceException: return _Colors.WARNING, _Smileys.CONFUSED + if testResult.hasPassed is None: + return _Colors.WARNING, _Smileys.NEUTRAL return _Colors.FAIL, _Smileys.SAD diff --git a/checkpy/tests.py b/checkpy/tests.py index 921cc8f..f226ac5 100644 --- a/checkpy/tests.py +++ b/checkpy/tests.py @@ -138,7 +138,7 @@ def timeout(self, new_timeout): class TestResult(object): def __init__(self, hasPassed: Union[bool, None], description: str, message: str, exception: Exception=None): - self._hasPassed = bool(hasPassed) + self._hasPassed = hasPassed self._description = description self._message = message self._exception = exception From d7faeb38c51f8fd566b9d35ee5566329428f382f Mon Sep 17 00:00:00 2001 From: Jelleas Date: Tue, 4 Jul 2023 17:15:28 +0200 Subject: [PATCH 161/269] use pytest asserts? --- checkpy/tester/tester.py | 51 +++++++++++++++++++++++++++++++++++++--- 1 file changed, 48 insertions(+), 3 deletions(-) diff --git a/checkpy/tester/tester.py b/checkpy/tester/tester.py index 1dd9100..bcb242f 100644 --- a/checkpy/tester/tester.py +++ b/checkpy/tester/tester.py @@ -156,6 +156,49 @@ def __init__(self, isTiming=None, resetTimer=None, description=None, timeout=Non self.description = description self.timeout = timeout + +import contextlib +import sys +import _pytest.assertion +from _pytest.config import get_config +import _pytest.assertion.util + + +@contextlib.contextmanager +def rewriteAssertionsContext(): + + + config = get_config() + # config._parser.addini("python_files", ["helloTest.py"], "args") + # config.inicfg = {"python_files": ["helloTest.py"]} + config._inicache["python_files"] = ["*Test.py"] + #config._inicache["enable_assertion_pass_hook"] = True + #config.option.verbose = 4 + config.parse([]) + _pytest.assertion.install_importhook(config) + #print('=====!', config.getini("enable_assertion_pass_hook")) + _pytest.assertion.register_assert_rewrite("helloTest.py") + + + def func(op, left, right): + print("====", type(left), op, type(right)) + return "foo!" + + # def func2(op, left, right): + # print("============", type(left), op, type(right)) + # _pytest.assertion.util._assertion_pass = func2 + + try: + # next(a) + _pytest.assertion.util._reprcompare = func + # + yield + finally: + _pytest.assertion.util._reprcompare = None + # sys.meta_path.remove(hook) + pass + + class _Tester(object): def __init__(self, moduleName, filePath, debugMode, silentMode, signalQueue, resultQueue): self.moduleName = moduleName @@ -174,10 +217,12 @@ def run(self): # overwrite argv so that it seems the file was run directly sys.argv = [self.filePath.fileName] - module = importlib.import_module(self.moduleName) - module._fileName = self.filePath.fileName + with rewriteAssertionsContext(): + + module = importlib.import_module(self.moduleName) + module._fileName = self.filePath.fileName - return self._runTestsFromModule(module) + return self._runTestsFromModule(module) def _runTestsFromModule(self, module): self._sendSignal(_Signal(isTiming = False)) From b2c5e646055db9461b9a1161419c6883a79c6c32 Mon Sep 17 00:00:00 2001 From: Jelleas Date: Tue, 4 Jul 2023 22:20:20 +0200 Subject: [PATCH 162/269] switch to dessert, move run() to TestFunction --- checkpy/lib/sandbox.py | 2 +- checkpy/printer/printer.py | 4 +-- checkpy/tester/tester.py | 52 ++++++--------------------- checkpy/tests.py | 73 ++++++++++++++++++++------------------ 4 files changed, 52 insertions(+), 79 deletions(-) diff --git a/checkpy/lib/sandbox.py b/checkpy/lib/sandbox.py index a496e86..bc66f8c 100644 --- a/checkpy/lib/sandbox.py +++ b/checkpy/lib/sandbox.py @@ -196,7 +196,7 @@ def cd(dest: Union[str, Path]): def _glob(pattern: Union[str, Path], root: Union[str, Path]=None, skip_dirs: bool=False, limit: int=DEFAULT_FILE_LIMIT) -> Set[str]: - with cd(root) if root else contextlib.nullcontext: + with cd(root) if root else contextlib.nullcontext(): pattern = str(pattern) # Implicit recursive iff no / in pattern and starts with * diff --git a/checkpy/printer/printer.py b/checkpy/printer/printer.py index d90b954..82112a0 100644 --- a/checkpy/printer/printer.py +++ b/checkpy/printer/printer.py @@ -1,5 +1,6 @@ from checkpy.entities import exception import os +import traceback import colorama colorama.init() @@ -26,8 +27,7 @@ def display(testResult): msg += "\n - {}".format(testResult.message) if DEBUG_MODE and testResult.exception: - msg += "\n {}".format(testResult.exception.stacktrace()) - + msg += "\n{}".format("".join(traceback.format_tb(testResult.exception.__traceback__))) if not SILENT_MODE: print(msg) return msg diff --git a/checkpy/tester/tester.py b/checkpy/tester/tester.py index bcb242f..75e31bf 100644 --- a/checkpy/tester/tester.py +++ b/checkpy/tester/tester.py @@ -156,47 +156,8 @@ def __init__(self, isTiming=None, resetTimer=None, description=None, timeout=Non self.description = description self.timeout = timeout - -import contextlib +import dessert import sys -import _pytest.assertion -from _pytest.config import get_config -import _pytest.assertion.util - - -@contextlib.contextmanager -def rewriteAssertionsContext(): - - - config = get_config() - # config._parser.addini("python_files", ["helloTest.py"], "args") - # config.inicfg = {"python_files": ["helloTest.py"]} - config._inicache["python_files"] = ["*Test.py"] - #config._inicache["enable_assertion_pass_hook"] = True - #config.option.verbose = 4 - config.parse([]) - _pytest.assertion.install_importhook(config) - #print('=====!', config.getini("enable_assertion_pass_hook")) - _pytest.assertion.register_assert_rewrite("helloTest.py") - - - def func(op, left, right): - print("====", type(left), op, type(right)) - return "foo!" - - # def func2(op, left, right): - # print("============", type(left), op, type(right)) - # _pytest.assertion.util._assertion_pass = func2 - - try: - # next(a) - _pytest.assertion.util._reprcompare = func - # - yield - finally: - _pytest.assertion.util._reprcompare = None - # sys.meta_path.remove(hook) - pass class _Tester(object): @@ -217,7 +178,16 @@ def run(self): # overwrite argv so that it seems the file was run directly sys.argv = [self.filePath.fileName] - with rewriteAssertionsContext(): + # have pytest (dessert) rewrite the asserts in the AST + with dessert.rewrite_assertions_context(): + + # callback that gets called when an assert fails + def func(op, left, right): + if op == "==": + return f"expected \"{right}\" but found \"{left}\"" + + # TODO: should be a cleaner way to inject "pytest_assertrepr_compare" + dessert.util._reprcompare = func module = importlib.import_module(self.moduleName) module._fileName = self.filePath.fileName diff --git a/checkpy/tests.py b/checkpy/tests.py index f226ac5..5d79d00 100644 --- a/checkpy/tests.py +++ b/checkpy/tests.py @@ -68,27 +68,7 @@ def __lt__(self, other): @property def fileName(self) -> str: return self._fileName - - @caches.cache() - def run(self) -> Union["TestResult", None]: - try: - result = self.test() - - if type(result) == tuple: - hasPassed, info = result - else: - hasPassed, info = result, "" - except exception.CheckpyError as e: - return TestResult(False, self.description, self.exception(e), exception = e) - except Exception as e: - e = exception.TestError( - exception = e, - message = "while testing", - stacktrace = traceback.format_exc()) - return TestResult(False, self.description, self.exception(e), exception=e) - - return TestResult(hasPassed, self.description, self.success(info) if hasPassed else self.fail(info)) - + @staticmethod def test() -> Union[bool, Tuple[bool, str]]: raise NotImplementedError() @@ -134,7 +114,7 @@ def timeout(self, new_timeout): self._timeout = new_timeout self._onTimeoutChange(self) - + class TestResult(object): def __init__(self, hasPassed: Union[bool, None], description: str, message: str, exception: Exception=None): @@ -190,19 +170,42 @@ def __call__(self, test: Test) -> Callable[[], Union["TestResult", None]]: @caches.cacheTestResult(self) def runMethod(): with conditionalSandbox(): - if getattr(self._function, "isTestFunction", False): - result = self._function(test)() - elif inspect.getfullargspec(self._function).args: - result = self._function(test) - else: - result = self._function() - - if result != None: - test.test = lambda: result - - for attr in ["success", "fail", "exception"]: - TestFunction._ensureCallable(test, attr) - return test.run() + try: + if getattr(self._function, "isTestFunction", False): + result = self._function(test)() + elif inspect.getfullargspec(self._function).args: + result = self._function(test) + else: + result = self._function() + + for attr in ["success", "fail", "exception"]: + TestFunction._ensureCallable(test, attr) + + if result is None: + if test.test != Test.test: + result = test.test() + else: + result = True + + if type(result) == tuple: + hasPassed, info = result + else: + hasPassed, info = result, "" + except AssertionError as e: + last = traceback.extract_tb(e.__traceback__)[-1] + # print(last, dir(last), last.line, last.lineno) + + return TestResult(False, test.description, test.exception(e), exception=e) + except exception.CheckpyError as e: + return TestResult(False, test.description, test.exception(e), exception=e) + except Exception as e: + e = exception.TestError( + exception = e, + message = "while testing", + stacktrace = traceback.format_exc()) + return TestResult(False, test.description, test.exception(e), exception=e) + + return TestResult(hasPassed, test.description, test.success(info) if hasPassed else test.fail(info)) return runMethod From 6725729a039ddc3d9369d99805ad5448c63b4515 Mon Sep 17 00:00:00 2001 From: Jelleas Date: Wed, 5 Jul 2023 12:11:13 +0200 Subject: [PATCH 163/269] use pytest's assertrepr_compare as default, introduced lib.explanation.py --- checkpy/entities/exception.py | 2 -- checkpy/lib/explanation.py | 34 ++++++++++++++++++++++++++++++++++ checkpy/printer/printer.py | 7 ++++++- checkpy/tester/tester.py | 13 ++++--------- 4 files changed, 44 insertions(+), 12 deletions(-) create mode 100644 checkpy/lib/explanation.py diff --git a/checkpy/entities/exception.py b/checkpy/entities/exception.py index 7ca18eb..1c96d1d 100644 --- a/checkpy/entities/exception.py +++ b/checkpy/entities/exception.py @@ -1,5 +1,3 @@ -import traceback - class CheckpyError(Exception): def __init__(self, exception = None, message = "", output = "", stacktrace = ""): self._exception = exception diff --git a/checkpy/lib/explanation.py b/checkpy/lib/explanation.py new file mode 100644 index 0000000..030fe02 --- /dev/null +++ b/checkpy/lib/explanation.py @@ -0,0 +1,34 @@ +from typing import Callable, List, Optional + +from dessert.util import assertrepr_compare + +__all__ = ["addExplainer"] + + +_explainers: List[Callable[[str, str, str], Optional[str]]] = [] + + +def addExplainer(explainer: Callable[[str, str, str], Optional[str]]) -> None: + _explainers.append(explainer) + + +def explainCompare(op: str, left: str, right: str) -> Optional[str]: + for explainer in _explainers: + rep = explainer(op, left, right) + if rep: + return rep + + # Fall back on pytest (dessert) explanations + rep = assertrepr_compare(MockConfig(), op, left, right) + if rep: + # On how to introduce newlines see: + # https://github.com/vmalloc/dessert/blob/97616513a9ea600d50d53e9499044b51aeaf037a/dessert/util.py#L32 + return "\n~".join(rep) + + return rep + + +class MockConfig: + """This config is only used for config.getoption('verbose')""" + def getoption(*args, **kwargs): + return 0 diff --git a/checkpy/printer/printer.py b/checkpy/printer/printer.py index 82112a0..2c835f0 100644 --- a/checkpy/printer/printer.py +++ b/checkpy/printer/printer.py @@ -27,7 +27,12 @@ def display(testResult): msg += "\n - {}".format(testResult.message) if DEBUG_MODE and testResult.exception: - msg += "\n{}".format("".join(traceback.format_tb(testResult.exception.__traceback__))) + exc = testResult.exception + if hasattr(exc, "stacktrace"): + stack = str(exc.stacktrace()) + else: + stack = "".join(traceback.format_tb(testResult.exception.__traceback__)) + msg += "\n" + stack if not SILENT_MODE: print(msg) return msg diff --git a/checkpy/tester/tester.py b/checkpy/tester/tester.py index 75e31bf..a430d6b 100644 --- a/checkpy/tester/tester.py +++ b/checkpy/tester/tester.py @@ -3,8 +3,11 @@ from checkpy.entities import exception, path from checkpy.tester import discovery from checkpy.lib.sandbox import sandbox +from checkpy.lib.explanation import explainCompare from checkpy.tests import Test +import dessert + import os import pathlib import subprocess @@ -156,9 +159,6 @@ def __init__(self, isTiming=None, resetTimer=None, description=None, timeout=Non self.description = description self.timeout = timeout -import dessert -import sys - class _Tester(object): def __init__(self, moduleName, filePath, debugMode, silentMode, signalQueue, resultQueue): @@ -181,13 +181,8 @@ def run(self): # have pytest (dessert) rewrite the asserts in the AST with dessert.rewrite_assertions_context(): - # callback that gets called when an assert fails - def func(op, left, right): - if op == "==": - return f"expected \"{right}\" but found \"{left}\"" - # TODO: should be a cleaner way to inject "pytest_assertrepr_compare" - dessert.util._reprcompare = func + dessert.util._reprcompare = explainCompare module = importlib.import_module(self.moduleName) module._fileName = self.filePath.fileName From 8baaba69e8f1c81d52ae5b357428a6acb089b131 Mon Sep 17 00:00:00 2001 From: Jelleas Date: Wed, 5 Jul 2023 16:13:08 +0200 Subject: [PATCH 164/269] simplifyAssertionMessage --- checkpy/lib/explanation.py | 77 +++++++++++++++++++++++++++++++++++++- checkpy/tests.py | 5 ++- 2 files changed, 80 insertions(+), 2 deletions(-) diff --git a/checkpy/lib/explanation.py b/checkpy/lib/explanation.py index 030fe02..e5587af 100644 --- a/checkpy/lib/explanation.py +++ b/checkpy/lib/explanation.py @@ -1,7 +1,11 @@ -from typing import Callable, List, Optional +import re + +from typing import Callable, List, Optional, Union from dessert.util import assertrepr_compare +import checkpy + __all__ = ["addExplainer"] @@ -28,6 +32,77 @@ def explainCompare(op: str, left: str, right: str) -> Optional[str]: return rep +def simplifyAssertionMessage(assertion: Union[str, AssertionError]) -> str: + message = str(assertion) + # return message + + # Find any substitution lines of the form where ... = ... from pytest + whereRegex = re.compile(r"\n[\s]*\+(\s*)(where|and)[\s]*(.*)") + whereLines = whereRegex.findall(message) + + # If there are none, nothing to do + if not whereLines: + return message + + # Find the line containing assert ..., this is what will be substituted + match = re.compile(r".*assert .*").search(message) + assertLine = match.group(0) + + # Always include any lines before the assert line (a custom message) + result = message[:match.start()] + + substitutionRegex = re.compile(r"(.*) = (.*)") + oldIndent = 0 + oldSub = "" + skipping = False + + # For each where line, apply the substitution on the first match + for indent, _, substitution in whereLines: + newIndent = len(indent) + + # If the previous step was skipped, and the next step is more indented, keep skipping + if skipping and newIndent > oldIndent: + continue + + # If the new indentation is smaller, there is a new substitution, cut off the old part + # This prevents previous substitutions from interfering with new substitutions + # For instance (2 == 1) + where 2 = foo(1) => (foo(1) == 1) where 1 = ... + if newIndent <= oldIndent: + end = re.search(re.escape(oldSub), assertLine).end() + result += assertLine[:end] + assertLine = assertLine[end:] + oldSub = "" + + # Otherwise, no longer skipping + oldIndent = newIndent + skipping = False + + # Find the left (the original) and the right (the substitute) + match = substitutionRegex.match(substitution) + left, right = match.group(1), match.group(2) + + # If the right contains any checkpy function, skip + if any(elem + "(" in right for elem in checkpy.__all__): + skipping = True + continue + + # Substitute the first match in assertLine + assertLine = re.sub( + re.escape(left), + right, + assertLine, + count=1, + flags=re.S + ) + + oldSub = right + + # Ensure all newlines are escaped + assertLine = assertLine.replace("\n", "\\n") + + return result + assertLine + + class MockConfig: """This config is only used for config.getoption('verbose')""" def getoption(*args, **kwargs): diff --git a/checkpy/tests.py b/checkpy/tests.py index 5d79d00..06cba30 100644 --- a/checkpy/tests.py +++ b/checkpy/tests.py @@ -6,6 +6,7 @@ from checkpy import caches from checkpy.entities import exception from checkpy.lib.sandbox import conditionalSandbox +from checkpy.lib.explanation import simplifyAssertionMessage __all__ = ["test", "failed", "passed"] @@ -195,7 +196,9 @@ def runMethod(): last = traceback.extract_tb(e.__traceback__)[-1] # print(last, dir(last), last.line, last.lineno) - return TestResult(False, test.description, test.exception(e), exception=e) + msg = simplifyAssertionMessage(str(e)) + + return TestResult(False, test.description, test.exception(msg), exception=e) except exception.CheckpyError as e: return TestResult(False, test.description, test.exception(e), exception=e) except Exception as e: From dcae391ecc12971f3cbc06ca1255dbc630e71697 Mon Sep 17 00:00:00 2001 From: Jelleas Date: Mon, 17 Jul 2023 12:39:12 +0200 Subject: [PATCH 165/269] rm dead (py2) code --- checkpy/__init__.py | 2 -- checkpy/lib/basic.py | 19 ++----------------- checkpy/tester/discovery.py | 1 - 3 files changed, 2 insertions(+), 20 deletions(-) diff --git a/checkpy/__init__.py b/checkpy/__init__.py index 4f257c6..579931e 100644 --- a/checkpy/__init__.py +++ b/checkpy/__init__.py @@ -16,5 +16,3 @@ ] file: pathlib.Path = None - - diff --git a/checkpy/lib/basic.py b/checkpy/lib/basic.py index 370d2de..3f4fce6 100644 --- a/checkpy/lib/basic.py +++ b/checkpy/lib/basic.py @@ -1,14 +1,8 @@ import sys import re import os -try: - # Python 2 - import StringIO -except: - # Python 3 - import io as StringIO +import io as StringIO import contextlib -import importlib import imp import tokenize import traceback @@ -137,10 +131,7 @@ def moduleAndOutputOf( setattr(mod, attr, value) # execute code in mod - if sys.version_info >= (3,0): - exec(src, mod.__dict__) - else: - exec(src) in mod.__dict__ + exec(src, mod.__dict__) # add resulting module to sys sys.modules[moduleName] = mod @@ -288,10 +279,6 @@ def input(prompt = None): oldInput = input __builtins__["input"] = newInput(oldInput) - if sys.version_info < (3,0): - oldRawInput = raw_input - __builtins__["raw_input"] = newInput(oldRawInput) - old = sys.stdin if stdin is None: stdin = StringIO.StringIO() @@ -304,5 +291,3 @@ def input(prompt = None): finally: sys.stdin = old __builtins__["input"] = oldInput - if sys.version_info < (3,0): - __builtins__["raw_input"] = oldRawInput diff --git a/checkpy/tester/discovery.py b/checkpy/tester/discovery.py index 170a1a8..abec7e0 100644 --- a/checkpy/tester/discovery.py +++ b/checkpy/tester/discovery.py @@ -1,5 +1,4 @@ import os -import re import checkpy.database as database from checkpy.entities.path import Path From d48ba87288a62311c809bdfd372f1ebd205da75b Mon Sep 17 00:00:00 2001 From: Jelleas Date: Mon, 17 Jul 2023 13:06:06 +0200 Subject: [PATCH 166/269] always conditionalSandbox --- checkpy/lib/sandbox.py | 27 ++++++++++++++------------- checkpy/tester/tester.py | 12 ++++++------ 2 files changed, 20 insertions(+), 19 deletions(-) diff --git a/checkpy/lib/sandbox.py b/checkpy/lib/sandbox.py index bc66f8c..6e00741 100644 --- a/checkpy/lib/sandbox.py +++ b/checkpy/lib/sandbox.py @@ -9,12 +9,25 @@ from checkpy.entities.exception import TooManyFilesError, MissingRequiredFiles -__all__ = ["exclude", "include", "only", "require", "sandbox"] +__all__ = ["exclude", "include", "only", "require"] DEFAULT_FILE_LIMIT = 10000 +def exclude(*patterns: Iterable[Union[str, Path]]): + config.exclude(*patterns) + +def include(*patterns: Iterable[Union[str, Path]]): + config.include(*patterns) + +def only(*patterns: Iterable[Union[str, Path]]): + config.only(*patterns) + +def require(*filePaths: Iterable[Union[str, Path]]): + config.require(*filePaths) + + class Config: def __init__(self, onUpdate=lambda config: None): self.includedFiles: Set[str] = set() @@ -87,18 +100,6 @@ def require(self, *filePaths: Iterable[Union[str, Path]]): config = Config() -def exclude(*patterns: Iterable[Union[str, Path]]): - config.exclude(*patterns) - -def include(*patterns: Iterable[Union[str, Path]]): - config.include(*patterns) - -def only(*patterns: Iterable[Union[str, Path]]): - config.only(*patterns) - -def require(*filePaths: Iterable[Union[str, Path]]): - config.require(*filePaths) - @contextlib.contextmanager def sandbox(name: Union[str, Path]=""): diff --git a/checkpy/tester/tester.py b/checkpy/tester/tester.py index a430d6b..bbecc95 100644 --- a/checkpy/tester/tester.py +++ b/checkpy/tester/tester.py @@ -2,7 +2,7 @@ from checkpy import printer from checkpy.entities import exception, path from checkpy.tester import discovery -from checkpy.lib.sandbox import sandbox +from checkpy.lib.sandbox import conditionalSandbox from checkpy.lib.explanation import explainCompare from checkpy.tests import Test @@ -184,10 +184,11 @@ def run(self): # TODO: should be a cleaner way to inject "pytest_assertrepr_compare" dessert.util._reprcompare = explainCompare - module = importlib.import_module(self.moduleName) - module._fileName = self.filePath.fileName + with conditionalSandbox(): + module = importlib.import_module(self.moduleName) + module._fileName = self.filePath.fileName - return self._runTestsFromModule(module) + return self._runTestsFromModule(module) def _runTestsFromModule(self, module): self._sendSignal(_Signal(isTiming = False)) @@ -257,8 +258,7 @@ def handleTimeoutChange(test): timeout=test.timeout )) - with sandbox(): - cachedResults[test] = run() + cachedResults[test] = run() self._sendSignal(_Signal(isTiming=False)) From 9a49522be91c280ed0bf7fc740fd89412c104b74 Mon Sep 17 00:00:00 2001 From: Jelleas Date: Mon, 17 Jul 2023 14:18:30 +0200 Subject: [PATCH 167/269] tester.getTest() + tester cleanup --- checkpy/tester/__init__.py | 2 +- checkpy/tester/tester.py | 162 +++++++++++++++++++++++-------------- 2 files changed, 101 insertions(+), 63 deletions(-) diff --git a/checkpy/tester/__init__.py b/checkpy/tester/__init__.py index 00f6107..50f35ae 100644 --- a/checkpy/tester/__init__.py +++ b/checkpy/tester/__init__.py @@ -1,3 +1,3 @@ from checkpy.tester.tester import * -__all__ = ["test", "testModule", "only", "include", "exclude", "require"] \ No newline at end of file +__all__ = ["test", "testModule", "getTest", "only", "include", "exclude", "require"] \ No newline at end of file diff --git a/checkpy/tester/tester.py b/checkpy/tester/tester.py index bbecc95..3aadd03 100644 --- a/checkpy/tester/tester.py +++ b/checkpy/tester/tester.py @@ -1,10 +1,13 @@ import checkpy from checkpy import printer -from checkpy.entities import exception, path +from checkpy.entities import exception from checkpy.tester import discovery from checkpy.lib.sandbox import conditionalSandbox from checkpy.lib.explanation import explainCompare -from checkpy.tests import Test +from checkpy.tests import Test, TestResult, TestFunction + +from types import ModuleType +from typing import Dict, Iterable, List, Optional, Union import dessert @@ -14,9 +17,21 @@ import sys import importlib import multiprocessing +from multiprocessing.queues import Queue import time -def test(testName, module = "", debugMode = False, silentMode = False): + +__all__ = ["getTest", "test", "testModule", "TesterResult"] + + +_activeTest: Optional[Test] = None + + +def getTest() -> Optional[Test]: + return _activeTest + + +def test(testName: str, module="", debugMode=False, silentMode=False) -> "TesterResult": printer.printer.SILENT_MODE = silentMode result = TesterResult(testName) @@ -70,25 +85,23 @@ def test(testName, module = "", debugMode = False, silentMode = False): return testerResult -def testModule(module, debugMode = False, silentMode = False): +def testModule(module: ModuleType, debugMode=False, silentMode=False) -> Optional[List["TesterResult"]]: printer.printer.SILENT_MODE = silentMode testNames = discovery.getTestNames(module) if not testNames: printer.displayError("no tests found in module: {}".format(module)) - return + return None return [test(testName, module = module, debugMode = debugMode, silentMode = silentMode) for testName in testNames] -def _runTests(moduleName, fileName, debugMode = False, silentMode = False): - if sys.version_info[:2] >= (3,4): - ctx = multiprocessing.get_context("spawn") - else: - ctx = multiprocessing - signalQueue = ctx.Queue() - resultQueue = ctx.Queue() - tester = _Tester(moduleName, path.Path(fileName).absolutePath(), debugMode, silentMode, signalQueue, resultQueue) +def _runTests(moduleName: str, fileName: str, debugMode=False, silentMode=False) -> "TesterResult": + ctx = multiprocessing.get_context("spawn") + + signalQueue: "Queue[_Signal]" = ctx.Queue() + resultQueue: "Queue[TesterResult]" = ctx.Queue() + tester = _Tester(moduleName, pathlib.Path(fileName), debugMode, silentMode, signalQueue, resultQueue) p = ctx.Process(target=tester.run, name="Tester") p.start() @@ -99,17 +112,17 @@ def _runTests(moduleName, fileName, debugMode = False, silentMode = False): while not signalQueue.empty(): signal = signalQueue.get() - if signal.description != None: + if signal.description is not None: description = signal.description - if signal.isTiming != None: + if signal.isTiming is not None: isTiming = signal.isTiming - if signal.timeout != None: + if signal.timeout is not None: timeout = signal.timeout if signal.resetTimer: start = time.time() if isTiming and time.time() - start > timeout: - result = TesterResult(path.Path(fileName).fileName) + result = TesterResult(pathlib.Path(fileName).name) result.addOutput(printer.displayError("Timeout ({} seconds) reached during: {}".format(timeout, description))) p.terminate() p.join() @@ -127,33 +140,43 @@ def _runTests(moduleName, fileName, debugMode = False, silentMode = False): raise exception.CheckpyError(message="An error occured while testing. The testing process exited unexpectedly.") + class TesterResult(object): - def __init__(self, name): + def __init__(self, name: str): self.name = name self.nTests = 0 self.nPassedTests = 0 self.nFailedTests = 0 self.nRunTests = 0 - self.output = [] - self.testResults = [] + self.output: List[str] = [] + self.testResults: List[TestResult] = [] - def addOutput(self, output): + def addOutput(self, output: str): self.output.append(output) - def addResult(self, testResult): + def addResult(self, testResult: TestResult): self.testResults.append(testResult) - def asDict(self): - return {"name":self.name, - "nTests":self.nTests, - "nPassed":self.nPassedTests, - "nFailed":self.nFailedTests, - "nRun":self.nRunTests, - "output":self.output, - "results":[tr.asDict() for tr in self.testResults]} + def asDict(self) -> Dict[str, Union[str, int, List]]: + return { + "name": self.name, + "nTests": self.nTests, + "nPassed": self.nPassedTests, + "nFailed": self.nFailedTests, + "nRun": self.nRunTests, + "output": self.output, + "results": [tr.asDict() for tr in self.testResults] + } + class _Signal(object): - def __init__(self, isTiming=None, resetTimer=None, description=None, timeout=None): + def __init__( + self, + isTiming: Optional[bool]=None, + resetTimer: Optional[bool]=None, + description: Optional[str]=None, + timeout: Optional[int]=None + ): self.isTiming = isTiming self.resetTimer = resetTimer self.description = description @@ -161,9 +184,17 @@ def __init__(self, isTiming=None, resetTimer=None, description=None, timeout=Non class _Tester(object): - def __init__(self, moduleName, filePath, debugMode, silentMode, signalQueue, resultQueue): + def __init__( + self, + moduleName: str, + filePath: pathlib.Path, + debugMode: bool, + silentMode: bool, + signalQueue: "Queue[_Signal]", + resultQueue: "Queue[TesterResult]" + ): self.moduleName = moduleName - self.filePath = filePath + self.filePath = filePath.absolute() self.debugMode = debugMode self.silentMode = silentMode self.signalQueue = signalQueue @@ -173,10 +204,10 @@ def run(self): printer.printer.DEBUG_MODE = self.debugMode printer.printer.SILENT_MODE = self.silentMode - checkpy.file = pathlib.Path(self.filePath.fileName) + checkpy.file = self.filePath # overwrite argv so that it seems the file was run directly - sys.argv = [self.filePath.fileName] + sys.argv = [self.filePath.name] # have pytest (dessert) rewrite the asserts in the AST with dessert.rewrite_assertions_context(): @@ -186,15 +217,15 @@ def run(self): with conditionalSandbox(): module = importlib.import_module(self.moduleName) - module._fileName = self.filePath.fileName + module._fileName = self.filePath.name - return self._runTestsFromModule(module) + self._runTestsFromModule(module) - def _runTestsFromModule(self, module): + def _runTestsFromModule(self, module: ModuleType): self._sendSignal(_Signal(isTiming = False)) - result = TesterResult(self.filePath.fileName) - result.addOutput(printer.displayTestName(self.filePath.fileName)) + result = TesterResult(self.filePath.name) + result.addOutput(printer.displayTestName(self.filePath.name)) if hasattr(module, "before"): try: @@ -203,10 +234,10 @@ def _runTestsFromModule(self, module): result.addOutput(printer.displayError("Something went wrong at setup:\n{}".format(e))) return - testCreators = [method for method in module.__dict__.values() if getattr(method, "isTestFunction", False)] - result.nTests = len(testCreators) + testFunctions = [method for method in module.__dict__.values() if getattr(method, "isTestFunction", False)] + result.nTests = len(testFunctions) - testResults = self._runTests(testCreators) + testResults = self._runTests(testFunctions) result.nRunTests = len(testResults) result.nPassedTests = len([tr for tr in testResults if tr.hasPassed]) @@ -224,32 +255,36 @@ def _runTestsFromModule(self, module): self._sendResult(result) - def _runTests(self, testCreators): - cachedResults = {} + def _runTests(self, testFunctions: Iterable[TestFunction]) -> List[TestResult]: + cachedResults: Dict[Test, Optional[TestResult]] = {} - def handleDescriptionChange(test): + def handleDescriptionChange(test: Test): self._sendSignal(_Signal( description=test.description )) - def handleTimeoutChange(test): + def handleTimeoutChange(test: Test): self._sendSignal(_Signal( isTiming=True, resetTimer=True, timeout=test.timeout )) + global _activeTest + # run tests in noncolliding execution order - for testCreator in self._getTestCreatorsInExecutionOrder(testCreators): + for testFunction in self._getTestFunctionsInExecutionOrder(testFunctions): test = Test( - self.filePath.fileName, - testCreator.priority, - timeout=testCreator.timeout, + self.filePath.name, + testFunction.priority, + timeout=testFunction.timeout, onDescriptionChange=handleDescriptionChange, onTimeoutChange=handleTimeoutChange ) - run = testCreator(test) + _activeTest = test + + run = testFunction(test) self._sendSignal(_Signal( isTiming=True, @@ -260,20 +295,23 @@ def handleTimeoutChange(test): cachedResults[test] = run() + _activeTest = None + self._sendSignal(_Signal(isTiming=False)) # return test results in specified order - return [cachedResults[test] for test in sorted(cachedResults.keys()) if cachedResults[test] != None] - - def _sendResult(self, result): + sortedResults = [cachedResults[test] for test in sorted(cachedResults)] + return [result for result in sortedResults if result is not None] + + def _sendResult(self, result: TesterResult): self.resultQueue.put(result) - def _sendSignal(self, signal): + def _sendSignal(self, signal: _Signal): self.signalQueue.put(signal) - def _getTestCreatorsInExecutionOrder(self, testCreators): - sortedTCs = [] - for tc in testCreators: - dependencies = self._getTestCreatorsInExecutionOrder(tc.dependencies) + [tc] - sortedTCs.extend([t for t in dependencies if t not in sortedTCs]) - return sortedTCs + def _getTestFunctionsInExecutionOrder(self, testFunctions: Iterable[TestFunction]) -> List[TestFunction]: + sortedTFs: List[TestFunction] = [] + for tf in testFunctions: + dependencies = self._getTestFunctionsInExecutionOrder(tf.dependencies) + [tf] + sortedTFs.extend([t for t in dependencies if t not in sortedTFs]) + return sortedTFs From a9487d7fdcb53960529d515a879ee14d181a4f83 Mon Sep 17 00:00:00 2001 From: Jelleas Date: Mon, 17 Jul 2023 14:19:35 +0200 Subject: [PATCH 168/269] tester.getTest -> tester.getActiveTest --- checkpy/tester/__init__.py | 2 +- checkpy/tester/tester.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/checkpy/tester/__init__.py b/checkpy/tester/__init__.py index 50f35ae..2e8fc28 100644 --- a/checkpy/tester/__init__.py +++ b/checkpy/tester/__init__.py @@ -1,3 +1,3 @@ from checkpy.tester.tester import * -__all__ = ["test", "testModule", "getTest", "only", "include", "exclude", "require"] \ No newline at end of file +__all__ = ["test", "testModule", "getActiveTest", "only", "include", "exclude", "require"] \ No newline at end of file diff --git a/checkpy/tester/tester.py b/checkpy/tester/tester.py index 3aadd03..ad42076 100644 --- a/checkpy/tester/tester.py +++ b/checkpy/tester/tester.py @@ -21,13 +21,13 @@ import time -__all__ = ["getTest", "test", "testModule", "TesterResult"] +__all__ = ["getActiveTest", "test", "testModule", "TesterResult"] _activeTest: Optional[Test] = None -def getTest() -> Optional[Test]: +def getActiveTest() -> Optional[Test]: return _activeTest From 5008ab0972d75cc92b391c5d060086ba60ed393b Mon Sep 17 00:00:00 2001 From: Jelleas Date: Mon, 17 Jul 2023 15:46:00 +0200 Subject: [PATCH 169/269] rm _ensureCallable, instead overwrite __setattr__ --- checkpy/tests.py | 28 +++++++++++++--------------- 1 file changed, 13 insertions(+), 15 deletions(-) diff --git a/checkpy/tests.py b/checkpy/tests.py index 06cba30..aa78fc3 100644 --- a/checkpy/tests.py +++ b/checkpy/tests.py @@ -1,7 +1,7 @@ import inspect import traceback -from typing import Dict, List, Set, Tuple, Union, Callable, Iterable, Optional +from typing import Any, Dict, List, Set, Tuple, Union, Callable, Iterable, Optional from checkpy import caches from checkpy.entities import exception @@ -95,11 +95,11 @@ def description(self) -> str: return self._description @description.setter - def description(self, new_description): - if callable(new_description): - self._description = new_description() + def description(self, newDescription: Union[str, Callable[[], str]]): + if callable(newDescription): + self._description = newDescription() else: - self._description = new_description + self._description = newDescription self._onDescriptionChange(self) @@ -108,7 +108,7 @@ def timeout(self) -> int: return self._timeout @timeout.setter - def timeout(self, new_timeout): + def timeout(self, new_timeout: Union[int, Callable[[], int]]): if callable(new_timeout): self._timeout = new_timeout() else: @@ -116,6 +116,13 @@ def timeout(self, new_timeout): self._onTimeoutChange(self) + def __setattr__(self, __name: str, __value: Any) -> None: + value = __value + if __name in ["fail", "success", "exception"]: + if not callable(__value): + value = lambda *args, **kwargs: __value + super().__setattr__(__name, value) + class TestResult(object): def __init__(self, hasPassed: Union[bool, None], description: str, message: str, exception: Exception=None): @@ -179,9 +186,6 @@ def runMethod(): else: result = self._function() - for attr in ["success", "fail", "exception"]: - TestFunction._ensureCallable(test, attr) - if result is None: if test.test != Test.test: result = test.test() @@ -219,12 +223,6 @@ def useDocStringDescription(self, test: Test) -> None: if self._function.__doc__ != None: test.description = self._function.__doc__ - @staticmethod - def _ensureCallable(test: Test, attribute: str) -> None: - value = getattr(test, attribute) - if not callable(value): - setattr(test, attribute, lambda *args, **kwargs: value) - def _getPriority(self, priority: Optional[int]) -> int: if priority != None: TestFunction._previousPriority = priority From e8ec4345982eff17d9aa9618c8fa1264a17a00f5 Mon Sep 17 00:00:00 2001 From: Jelleas Date: Mon, 17 Jul 2023 17:11:40 +0200 Subject: [PATCH 170/269] consistent indents in explanations --- checkpy/lib/explanation.py | 2 -- checkpy/printer/printer.py | 4 +++- checkpy/tests.py | 10 +++++++--- 3 files changed, 10 insertions(+), 6 deletions(-) diff --git a/checkpy/lib/explanation.py b/checkpy/lib/explanation.py index e5587af..d3f15bd 100644 --- a/checkpy/lib/explanation.py +++ b/checkpy/lib/explanation.py @@ -34,7 +34,6 @@ def explainCompare(op: str, left: str, right: str) -> Optional[str]: def simplifyAssertionMessage(assertion: Union[str, AssertionError]) -> str: message = str(assertion) - # return message # Find any substitution lines of the form where ... = ... from pytest whereRegex = re.compile(r"\n[\s]*\+(\s*)(where|and)[\s]*(.*)") @@ -99,7 +98,6 @@ def simplifyAssertionMessage(assertion: Union[str, AssertionError]) -> str: # Ensure all newlines are escaped assertLine = assertLine.replace("\n", "\\n") - return result + assertLine diff --git a/checkpy/printer/printer.py b/checkpy/printer/printer.py index 2c835f0..5fea015 100644 --- a/checkpy/printer/printer.py +++ b/checkpy/printer/printer.py @@ -1,6 +1,8 @@ from checkpy.entities import exception + import os import traceback + import colorama colorama.init() @@ -24,7 +26,7 @@ def display(testResult): color, smiley = _selectColorAndSmiley(testResult) msg = "{}{} {}{}".format(color, smiley, testResult.description, _Colors.ENDC) if testResult.message: - msg += "\n - {}".format(testResult.message) + msg += "\n " + "\n ".join(testResult.message.split("\n")) if DEBUG_MODE and testResult.exception: exc = testResult.exception diff --git a/checkpy/tests.py b/checkpy/tests.py index aa78fc3..3d1ce14 100644 --- a/checkpy/tests.py +++ b/checkpy/tests.py @@ -197,12 +197,16 @@ def runMethod(): else: hasPassed, info = result, "" except AssertionError as e: - last = traceback.extract_tb(e.__traceback__)[-1] + # last = traceback.extract_tb(e.__traceback__)[-1] # print(last, dir(last), last.line, last.lineno) - msg = simplifyAssertionMessage(str(e)) + assertMsg = simplifyAssertionMessage(str(e)) + failMsg = test.fail("") + if failMsg and not failMsg.endswith("\n"): + failMsg += "\n" + msg = failMsg + assertMsg - return TestResult(False, test.description, test.exception(msg), exception=e) + return TestResult(False, test.description, msg) except exception.CheckpyError as e: return TestResult(False, test.description, test.exception(e), exception=e) except Exception as e: From c8709acfac8fea3f0e626274c3d5f021eb420161 Mon Sep 17 00:00:00 2001 From: Jelleas Date: Mon, 17 Jul 2023 17:17:28 +0200 Subject: [PATCH 171/269] add dessert to setup.py --- setup.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/setup.py b/setup.py index c3e69f2..4c7ac5a 100644 --- a/setup.py +++ b/setup.py @@ -44,7 +44,14 @@ include_package_data=True, - install_requires=["requests", "tinydb", "dill", "colorama", "redbaron"], + install_requires=[ + "requests", + "tinydb", + "dill", + "colorama", + "redbaron", + "dessert" + ], extras_require={ 'dev': [], From 21eaa2da6a691d5b6460ed90968bfb99162bb7bd Mon Sep 17 00:00:00 2001 From: jelleas Date: Tue, 18 Jul 2023 14:46:33 +0200 Subject: [PATCH 172/269] fix dangling open file in database.py --- checkpy/database/database.py | 100 +++++++++++++++++++++-------------- 1 file changed, 60 insertions(+), 40 deletions(-) diff --git a/checkpy/database/database.py b/checkpy/database/database.py index 2e1fd47..41b3ba8 100644 --- a/checkpy/database/database.py +++ b/checkpy/database/database.py @@ -1,21 +1,31 @@ import tinydb import os import time +import contextlib from checkpy.entities.path import Path, CHECKPYPATH _DBPATH = CHECKPYPATH + "database" + "db.json" +@contextlib.contextmanager def database(): if not _DBPATH.exists(): with open(str(_DBPATH), 'w') as f: pass - return tinydb.TinyDB(str(_DBPATH)) + try: + db = tinydb.TinyDB(str(_DBPATH)) + yield db + finally: + db.close() +@contextlib.contextmanager def githubTable(): - return database().table("github") + with database() as db: + yield db.table("github") +@contextlib.contextmanager def localTable(): - return database().table("local") + with database() as db: + yield db.table("local") def clean(): database().drop_tables() @@ -28,74 +38,84 @@ def forEachTestsPath(): yield path def forEachUserAndRepo(): - for username, repoName in ((entry["user"], entry["repo"]) for entry in githubTable().all()): - yield username, repoName + with githubTable() as table: + for username, repoName in [(entry["user"], entry["repo"]) for entry in table.all()]: + yield username, repoName def forEachGithubPath(): - for entry in githubTable().all(): - yield Path(entry["path"]) + with githubTable() as table: + for entry in table.all(): + yield Path(entry["path"]) def forEachLocalPath(): - for entry in localTable().all(): - yield Path(entry["path"]) + with localTable() as table: + for entry in table.all(): + yield Path(entry["path"]) def isKnownGithub(username, repoName): query = tinydb.Query() - return githubTable().contains((query.user == username) & (query.repo == repoName)) + with githubTable() as table: + return table.contains((query.user == username) & (query.repo == repoName)) def addToGithubTable(username, repoName, releaseId, releaseTag): if not isKnownGithub(username, repoName): path = str(CHECKPYPATH + "tests" + repoName) - githubTable().insert({ - "user" : username, - "repo" : repoName, - "path" : path, - "release" : releaseId, - "tag" : releaseTag, - "timestamp" : time.time() - }) + with githubTable() as table: + table.insert({ + "user" : username, + "repo" : repoName, + "path" : path, + "release" : releaseId, + "tag" : releaseTag, + "timestamp" : time.time() + }) def addToLocalTable(localPath): query = tinydb.Query() - table = localTable() - - if not table.search(query.path == str(localPath)): - table.insert({ - "path" : str(localPath) - }) + with localTable() as table: + if not table.search(query.path == str(localPath)): + table.insert({ + "path" : str(localPath) + }) def updateGithubTable(username, repoName, releaseId, releaseTag): query = tinydb.Query() path = str(CHECKPYPATH + "tests" + repoName) - githubTable().update({ - "user" : username, - "repo" : repoName, - "path" : path, - "release" : releaseId, - "tag" : releaseTag, - "timestamp" : time.time() - }, query.user == username and query.repo == repoName) + with githubTable() as table: + table.update({ + "user" : username, + "repo" : repoName, + "path" : path, + "release" : releaseId, + "tag" : releaseTag, + "timestamp" : time.time() + }, query.user == username and query.repo == repoName) def timestampGithub(username, repoName): query = tinydb.Query() - return githubTable().search(query.user == username and query.repo == repoName)[0]["timestamp"] + with githubTable() as table: + return table.search(query.user == username and query.repo == repoName)[0]["timestamp"] def setTimestampGithub(username, repoName): query = tinydb.Query() - githubTable().update(\ - { - "timestamp" : time.time() - }, query.user == username and query.repo == repoName) + with githubTable() as table: + table.update( + {"timestamp" : time.time()}, + query.user == username and query.repo == repoName + ) def githubPath(username, repoName): query = tinydb.Query() - return Path(githubTable().search(query.user == username and query.repo == repoName)[0]["path"]) + with githubTable() as table: + return Path(table.search(query.user == username and query.repo == repoName)[0]["path"]) def releaseId(username, repoName): query = tinydb.Query() - return githubTable().search(query.user == username and query.repo == repoName)[0]["release"] + with githubTable() as table: + return table.search(query.user == username and query.repo == repoName)[0]["release"] def releaseTag(username, repoName): query = tinydb.Query() - return githubTable().search(query.user == username and query.repo == repoName)[0]["tag"] + with githubTable() as table: + return table.search(query.user == username and query.repo == repoName)[0]["tag"] From 334641763c1aec58d247cffdeb0df7ae6ca4529e Mon Sep 17 00:00:00 2001 From: jelleas Date: Tue, 18 Jul 2023 17:10:05 +0200 Subject: [PATCH 173/269] reorganized checkpy.lib, added deprecationWarnings --- checkpy/assertlib/basic.py | 7 + checkpy/database/database.py | 3 +- checkpy/entities/function.py | 7 +- checkpy/lib/__init__.py | 57 +++- checkpy/lib/basic.py | 361 ++++++++++++---------- checkpy/lib/monkeypatch.py | 29 ++ checkpy/lib/static.py | 95 ++++-- checkpy/tester/tester.py | 4 + tests/integrationtests/downloader_test.py | 5 +- 9 files changed, 360 insertions(+), 208 deletions(-) create mode 100644 checkpy/lib/monkeypatch.py diff --git a/checkpy/assertlib/basic.py b/checkpy/assertlib/basic.py index c13ba7a..d89f341 100644 --- a/checkpy/assertlib/basic.py +++ b/checkpy/assertlib/basic.py @@ -1,6 +1,13 @@ from checkpy import lib import re import os +import warnings + +warnings.warn( + """checkpy.assertlib is deprecated. Use `assert` statements instead.""", + DeprecationWarning, + stacklevel=2 +) def exact(actual, expected): return actual == expected diff --git a/checkpy/database/database.py b/checkpy/database/database.py index 41b3ba8..49f4bf1 100644 --- a/checkpy/database/database.py +++ b/checkpy/database/database.py @@ -28,7 +28,8 @@ def localTable(): yield db.table("local") def clean(): - database().drop_tables() + with database() as db: + db.drop_tables() def forEachTestsPath(): for path in forEachGithubPath(): diff --git a/checkpy/entities/function.py b/checkpy/entities/function.py index b6c1404..0a2e17e 100644 --- a/checkpy/entities/function.py +++ b/checkpy/entities/function.py @@ -1,15 +1,12 @@ import os import sys import re - import contextlib import inspect +import io + import checkpy.entities.exception as exception -if sys.version_info >= (3,0): - import io -else: - import StringIO as io class Function(object): def __init__(self, function): diff --git a/checkpy/lib/__init__.py b/checkpy/lib/__init__.py index 3e29eda..e72b6d7 100644 --- a/checkpy/lib/__init__.py +++ b/checkpy/lib/__init__.py @@ -1,2 +1,57 @@ from checkpy.lib.basic import * -from checkpy.lib.sandbox import * \ No newline at end of file +from checkpy.lib.sandbox import * +from checkpy.lib.static import getSource +from checkpy.lib.static import getSourceOfDefinitions +from checkpy.lib.static import removeComments +from checkpy.lib.monkeypatch import documentFunction +from checkpy.lib.monkeypatch import neutralizeFunction + +# backward-compatible imports (v2 -> v1) +from checkpy.lib.basic import removeWhiteSpace +from checkpy.lib.basic import getPositiveIntegersFromString +from checkpy.lib.basic import getNumbersFromString +from checkpy.lib.basic import getLine +from checkpy.lib.basic import fileExists +from checkpy.lib.basic import download +from checkpy.lib.basic import require + + +# backward-compatible renames (v2 -> v1) +def source(fileName) -> str: + import warnings + warnings.warn( + """source() is deprecated. + Use getSource() instead.""", + DeprecationWarning, stacklevel=2 + ) + return getSource(fileName) + + +def sourceOfDefinitions(fileName) -> str: + import warnings + warnings.warn( + """sourceOfDefinitions() is deprecated. + Use getSourceOfDefinitions() instead.""", + DeprecationWarning, stacklevel=2 + ) + return getSourceOfDefinitions(fileName) + + +def module(*args, **kwargs): + import warnings + warnings.warn( + """module() is deprecated. + Use getModule() instead.""", + DeprecationWarning, stacklevel=2 + ) + return getModule(*args, **kwargs) + + +def moduleAndOutputOf(*args, **kwargs): + import warnings + warnings.warn( + """moduleAndOutputOf() is deprecated.q + Use getModuleAndOutputOf() instead.""", + DeprecationWarning, stacklevel=2 + ) + return getModuleAndOutputOf(*args, **kwargs) \ No newline at end of file diff --git a/checkpy/lib/basic.py b/checkpy/lib/basic.py index 3f4fce6..07cbddb 100644 --- a/checkpy/lib/basic.py +++ b/checkpy/lib/basic.py @@ -1,97 +1,102 @@ +import io import sys import re import os -import io as StringIO import contextlib import imp -import tokenize import traceback import requests +from pathlib import Path +from types import ModuleType +from typing import Any, Iterable, List, Optional, Tuple, TextIO, Union +from warnings import warn + import checkpy from checkpy.entities import path, exception, function from checkpy import caches - - -def require(fileName, source=None): - if source: - download(fileName, source) - return - - filePath = path.userPath + fileName - - if not fileExists(str(filePath)): - raise exception.CheckpyError("Required file {} does not exist".format(fileName)) - - filePath.copyTo(path.current() + fileName) - -def fileExists(fileName): - return path.Path(fileName).exists() - -def source(fileName=None): - if fileName is None: - fileName = checkpy.file.name - - source = "" - with open(fileName) as f: - source = f.read() - return source - -def sourceOfDefinitions(fileName=None): - if fileName is None: - fileName = checkpy.file.name - - newSource = "" - - with open(fileName) as f: - insideDefinition = False - for line in removeComments(f.read()).split("\n"): - line += "\n" - if not line.strip(): - continue - - if (line.startswith(" ") or line.startswith("\t")) and insideDefinition: - newSource += line - elif line.startswith("def ") or line.startswith("class "): - newSource += line - insideDefinition = True - elif line.startswith("import ") or line.startswith("from "): - newSource += line - else: - insideDefinition = False - return newSource - -def documentFunction(func, documentation): - """Creates a function that shows documentation when its printed / shows up in an error.""" - class PrintableFunction: - def __call__(self, *args, **kwargs): - return func(*args, **kwargs) - - def __repr__(self): - return documentation - - return PrintableFunction() - -def getFunction(functionName, *args, **kwargs): - return getattr(module(*args, **kwargs), functionName) - -def outputOf(*args, **kwargs): - _, output = moduleAndOutputOf(*args, **kwargs) +from checkpy.lib.static import getSource + + +__all__ = [ + "getFunction", + "getModule", + "outputOf", + "getModuleAndOutputOf", + "captureStdin", + "captureStdout" +] + + +def getFunction( + functionName: str, + fileName: Optional[Union[str, Path]]=None, + src: Optional[str]=None, + argv: Optional[List[str]]=None, + stdinArgs: Optional[List[str]]=None, + ignoreExceptions: Iterable[Exception]=(), + overwriteAttributes: Iterable[Tuple[str, Any]]=() +) -> function.Function: + """Run the file then get the function with functionName""" + return getattr(getModule( + fileName=fileName, + src=src, + argv=argv, + stdinArgs=stdinArgs, + ignoreExceptions=ignoreExceptions, + overwriteAttributes=overwriteAttributes + ), functionName) + + +def outputOf( + fileName: Optional[Union[str, Path]]=None, + src: Optional[str]=None, + argv: Optional[List[str]]=None, + stdinArgs: Optional[List[str]]=None, + ignoreExceptions: Iterable[Exception]=(), + overwriteAttributes: Iterable[Tuple[str, Any]]=() +) -> str: + """Get the output after running the file.""" + _, output = getModuleAndOutputOf( + fileName=fileName, + src=src, + argv=argv, + stdinArgs=stdinArgs, + ignoreExceptions=ignoreExceptions, + overwriteAttributes=overwriteAttributes + ) return output -def module(*args, **kwargs): - mod, _ = moduleAndOutputOf(*args, **kwargs) + +def getModule( + fileName: Optional[Union[str, Path]]=None, + src: Optional[str]=None, + argv: Optional[List[str]]=None, + stdinArgs: Optional[List[str]]=None, + ignoreExceptions: Iterable[Exception]=(), + overwriteAttributes: Iterable[Tuple[str, Any]]=() +) -> ModuleType: + """Get the python Module after running the file.""" + mod, _ = getModuleAndOutputOf( + fileName=fileName, + src=src, + argv=argv, + stdinArgs=stdinArgs, + ignoreExceptions=ignoreExceptions, + overwriteAttributes=overwriteAttributes + ) return mod + @caches.cache() -def moduleAndOutputOf( - fileName=None, - src=None, - argv=None, - stdinArgs=None, - ignoreExceptions=(), - overwriteAttributes=() - ): +def getModuleAndOutputOf( + fileName: Optional[Union[str, Path]]=None, + src: Optional[str]=None, + argv: Optional[List[str]]=None, + stdinArgs: Optional[List[str]]=None, + ignoreExceptions: Iterable[Exception]=(), + overwriteAttributes: Iterable[Tuple[str, Any]]=() + ) -> Tuple[ModuleType, str]: """ This function handles most of checkpy's under the hood functionality fileName: the name of the file to run @@ -103,8 +108,8 @@ def moduleAndOutputOf( if fileName is None: fileName = checkpy.file.name - if src == None: - src = source(fileName) + if src is None: + src = getSource(fileName) mod = None output = "" @@ -121,21 +126,20 @@ def moduleAndOutputOf( if argv: sys.argv, argv = argv, sys.argv - moduleName = fileName.split(".")[0] + moduleName = str(fileName).split(".")[0] - try: - mod = imp.new_module(moduleName) - - # overwrite attributes - for attr, value in overwriteAttributes: - setattr(mod, attr, value) + mod = imp.new_module(moduleName) + # overwrite attributes + for attr, value in overwriteAttributes: + setattr(mod, attr, value) + try: # execute code in mod exec(src, mod.__dict__) # add resulting module to sys sys.modules[moduleName] = mod - except tuple(ignoreExceptions) as e: + except tuple(ignoreExceptions) as e: # type: ignore pass except exception.CheckpyError as e: excep = e @@ -166,92 +170,14 @@ def moduleAndOutputOf( return mod, output -def neutralizeFunction(function): - def dummy(*args, **kwargs): - pass - setattr(function, "__code__", dummy.__code__) - -def neutralizeFunctionFromImport(mod, functionName, importedModuleName): - for attr in [getattr(mod, name) for name in dir(mod)]: - if getattr(attr, "__name__", None) == importedModuleName: - if hasattr(attr, functionName): - neutralizeFunction(getattr(attr, functionName)) - if getattr(attr, "__name__", None) == functionName and getattr(attr, "__module__", None) == importedModuleName: - if hasattr(mod, functionName): - neutralizeFunction(getattr(mod, functionName)) - -def download(fileName, source): - try: - r = requests.get(source) - except requests.exceptions.ConnectionError as e: - raise exception.DownloadError(message = "Oh no! It seems like there is no internet connection available?!") - - if not r.ok: - raise exception.DownloadError(message = "Failed to download {} because: {}".format(source, r.reason)) - - with open(str(fileName), "wb+") as target: - target.write(r.content) - -def removeWhiteSpace(s): - return re.sub(r"\s+", "", s, flags=re.UNICODE) - -def getPositiveIntegersFromString(s): - return [int(i) for i in re.findall(r"\d+", s)] - -def getNumbersFromString(s): - return [eval(n) for n in re.findall(r"[-+]?\d*\.\d+|[-+]?\d+", s)] - -def getLine(text, lineNumber): - lines = text.split("\n") - try: - return lines[lineNumber] - except IndexError: - raise IndexError("Expected to have atleast {} lines in:\n{}".format(lineNumber + 1, text)) - -# inspiration from http://stackoverflow.com/questions/1769332/script-to-remove-python-comments-docstrings -def removeComments(source): - io_obj = StringIO.StringIO(source) - out = "" - prev_toktype = tokenize.INDENT - last_lineno = -1 - last_col = 0 - indentation = "\t" - for token_type, token_string, (start_line, start_col), (end_line, end_col), ltext in tokenize.generate_tokens(io_obj.readline): - if start_line > last_lineno: - last_col = 0 - - # figure out type of indentation used - if token_type == tokenize.INDENT: - indentation = "\t" if "\t" in token_string else " " - - # write indentation - if start_col > last_col and last_col == 0: - out += indentation * (start_col - last_col) - # write other whitespace - elif start_col > last_col: - out += " " * (start_col - last_col) - - # ignore comments - if token_type == tokenize.COMMENT: - pass - # put all docstrings on a single line - elif token_type == tokenize.STRING: - out += re.sub("\n", " ", token_string) - else: - out += token_string - - prev_toktype = token_type - last_col = end_col - last_lineno = end_line - return out @contextlib.contextmanager -def captureStdout(stdout=None): +def captureStdout(stdout: Optional[TextIO]=None): old_stdout = sys.stdout old_stderr = sys.stderr if stdout is None: - stdout = StringIO.StringIO() + stdout = io.StringIO() try: sys.stdout = stdout @@ -264,8 +190,9 @@ def captureStdout(stdout=None): sys.stdout = old_stdout sys.stderr = old_stderr + @contextlib.contextmanager -def captureStdin(stdin=None): +def captureStdin(stdin: Optional[TextIO]=None): def newInput(oldInput): def input(prompt = None): try: @@ -281,7 +208,7 @@ def input(prompt = None): __builtins__["input"] = newInput(oldInput) old = sys.stdin if stdin is None: - stdin = StringIO.StringIO() + stdin = io.StringIO() sys.stdin = stdin try: @@ -291,3 +218,97 @@ def input(prompt = None): finally: sys.stdin = old __builtins__["input"] = oldInput + + +def removeWhiteSpace(s): + warn("""checkpy.lib.removeWhiteSpace() is deprecated. Instead use: + import re + re.sub(r"\s+", "", text) + """, DeprecationWarning, stacklevel=2) + return re.sub(r"\s+", "", s, flags=re.UNICODE) + + +def getPositiveIntegersFromString(s): + warn("""checkpy.lib.getPositiveIntegersFromString() is deprecated. Instead use: + import re + [int(i) for i in re.findall(r"\d+", text)] + """, DeprecationWarning, stacklevel=2) + return [int(i) for i in re.findall(r"\d+", s)] + + +def getNumbersFromString(s): + warn("""checkpy.lib.getNumbersFromString() is deprecated. Instead use: + import re + re.findall(r"[-+]?\d*\.\d+|[-+]?\d+", text) + + OR + + numbers = [] + for item in text.split(): + try: + numbers.append(float(item)) + except ValueError: + pass + """, DeprecationWarning, stacklevel=2) + return [eval(n) for n in re.findall(r"[-+]?\d*\.\d+|[-+]?\d+", s)] + + +def getLine(text, lineNumber): + warn("""checkpy.lib.getLine() is deprecated. Instead try: + lines = text.split("\n") + assert len(lines) >= lineNumber + line = lines[lineNumber] + """, DeprecationWarning, stacklevel=2) + lines = text.split("\n") + try: + return lines[lineNumber] + except IndexError: + raise IndexError("Expected to have atleast {} lines in:\n{}".format(lineNumber + 1, text)) + + +def fileExists(fileName): + warn("""checkpy.lib.fileExists() is deprecated. Use pathlib.Path instead: + from pathlib import Path + Path(filename).exists() + """, DeprecationWarning, stacklevel=2) + return path.Path(fileName).exists() + + +def download(fileName, source): + warn("""checkpy.lib.download() is deprecated. Use requests to download files: + import requests + url = 'http://google.com/favicon.ico' + r = requests.get(url, allow_redirects=True) + with open('google.ico', 'wb') as f: + f.write(r.content) + """, DeprecationWarning, stacklevel=2) + try: + r = requests.get(source) + except requests.exceptions.ConnectionError as e: + raise exception.DownloadError(message = "Oh no! It seems like there is no internet connection available?!") + + if not r.ok: + raise exception.DownloadError(message = "Failed to download {} because: {}".format(source, r.reason)) + + with open(str(fileName), "wb+") as target: + target.write(r.content) + + +def require(fileName, source=None): + warn("""checkpy.lib.require() is deprecated. Use requests to download files: + import requests + url = 'http://google.com/favicon.ico' + r = requests.get(url, allow_redirects=True) + with open('google.ico', 'wb') as f: + f.write(r.content) + """, DeprecationWarning, stacklevel=2) + if source: + download(fileName, source) + return + + filePath = path.userPath + fileName + + if not fileExists(str(filePath)): + raise exception.CheckpyError("Required file {} does not exist".format(fileName)) + + filePath.copyTo(path.current() + fileName) \ No newline at end of file diff --git a/checkpy/lib/monkeypatch.py b/checkpy/lib/monkeypatch.py new file mode 100644 index 0000000..40c12f9 --- /dev/null +++ b/checkpy/lib/monkeypatch.py @@ -0,0 +1,29 @@ +from typing import Callable + +from checkpy.entities.function import Function + +__all__ = ["documentFunction", "neutralizeFunction"] + + +def documentFunction(func: Callable, documentation: str) -> "PrintableFunction": + """Creates a function that shows documentation when its printed / shows up in an error.""" + return PrintableFunction(func, documentation) + + +def neutralizeFunction(function: Callable): + """ + Patches the function to do nothing (no op). + Useful for unblocking blocking functions like time.sleep() or plt.slow() + """ + def dummy(*args, **kwargs): + pass + setattr(function, "__code__", dummy.__code__) + + +class PrintableFunction(Function): + def __init__(self, function, docs): + super().__init__(self, function) + self._docs = docs + + def __repr__(self): + return self._docs diff --git a/checkpy/lib/static.py b/checkpy/lib/static.py index aab5fc0..696807d 100644 --- a/checkpy/lib/static.py +++ b/checkpy/lib/static.py @@ -1,47 +1,84 @@ +import io +import re +import tokenize + +from pathlib import Path +from typing import Optional, Union + import checkpy -from checkpy import caches -import redbaron -def source(fileName=None): + +__all__ = ["getSource", "getSourceOfDefinitions", "removeComments"] + + +def getSource(fileName: Optional[Union[str, Path]]=None) -> str: + """Get the contents of the file.""" if fileName is None: fileName = checkpy.file.name with open(fileName) as f: return f.read() -@caches.cache() -def fullSyntaxTree(fileName=None): + +def getSourceOfDefinitions(fileName: Optional[Union[str, Path]]=None) -> str: + """Get just the source code inside definitions (def / class).""" if fileName is None: fileName = checkpy.file.name - return fstFromSource(source(fileName)) + newSource = "" -@caches.cache() -def fstFromSource(source): - return redbaron.RedBaron(source) + with open(fileName) as f: + insideDefinition = False + for line in removeComments(f.read()).split("\n"): + line += "\n" + if not line.strip(): + continue -@caches.cache() -def functionCode(functionName, fileName=None): - if fileName is None: - fileName = checkpy.file.name + if (line.startswith(" ") or line.startswith("\t")) and insideDefinition: + newSource += line + elif line.startswith("def ") or line.startswith("class "): + newSource += line + insideDefinition = True + elif line.startswith("import ") or line.startswith("from "): + newSource += line + else: + insideDefinition = False + return newSource - definitions = [d for d in fullSyntaxTree(fileName).find_all("def") if d.name == functionName] - if definitions: - return definitions[0] - return None -def functionLOC(functionName, fileName=None): - if fileName is None: - fileName = checkpy.file.name +# inspiration from http://stackoverflow.com/questions/1769332/script-to-remove-python-comments-docstrings +def removeComments(source: str) -> str: + io_obj = io.StringIO(source) + out = "" + prev_toktype = tokenize.INDENT + last_lineno = -1 + last_col = 0 + indentation = "\t" + for token_type, token_string, (start_line, start_col), (end_line, end_col), ltext in tokenize.generate_tokens(io_obj.readline): + if start_line > last_lineno: + last_col = 0 + + # figure out type of indentation used + if token_type == tokenize.INDENT: + indentation = "\t" if "\t" in token_string else " " - code = functionCode(functionName, fileName) - ignoreNodes = [] - ignoreNodeTypes = [redbaron.EndlNode, redbaron.StringNode] - for node in code.value: - if any(isinstance(node, t) for t in ignoreNodeTypes): - ignoreNodes.append(node) + # write indentation + if start_col > last_col and last_col == 0: + out += indentation * (start_col - last_col) + # write other whitespace + elif start_col > last_col: + out += " " * (start_col - last_col) - for ignoreNode in ignoreNodes: - code.value.remove(ignoreNode) + # ignore comments + if token_type == tokenize.COMMENT: + pass + # put all docstrings on a single line + elif token_type == tokenize.STRING: + out += re.sub("\n", " ", token_string) + else: + out += token_string - return len(code.value) + 1 \ No newline at end of file + prev_toktype = token_type + last_col = end_col + last_lineno = end_line + return out diff --git a/checkpy/tester/tester.py b/checkpy/tester/tester.py index ad42076..90b6615 100644 --- a/checkpy/tester/tester.py +++ b/checkpy/tester/tester.py @@ -204,6 +204,10 @@ def run(self): printer.printer.DEBUG_MODE = self.debugMode printer.printer.SILENT_MODE = self.silentMode + if self.debugMode: + import warnings + warnings.simplefilter('always', DeprecationWarning) + checkpy.file = self.filePath # overwrite argv so that it seems the file was run directly diff --git a/tests/integrationtests/downloader_test.py b/tests/integrationtests/downloader_test.py index 73c83b9..4e8bc84 100644 --- a/tests/integrationtests/downloader_test.py +++ b/tests/integrationtests/downloader_test.py @@ -9,6 +9,7 @@ # Python 3 import io as StringIO import checkpy +import checkpy.interactive import checkpy.downloader as downloader import checkpy.caches as caches import checkpy.entities.exception as exception @@ -53,13 +54,13 @@ def tearDown(self): def test_spelledOutLink(self): downloader.download("https://github.com/jelleas/tests") - testerResult = checkpy.test(self.fileName) + testerResult = checkpy.interactive.test(self.fileName) self.assertTrue(len(testerResult.testResults) == 1) self.assertTrue(testerResult.testResults[0].hasPassed) def test_incompleteLink(self): downloader.download("jelleas/tests") - testerResult = checkpy.test(self.fileName) + testerResult = checkpy.interactive.test(self.fileName) self.assertTrue(len(testerResult.testResults) == 1) self.assertTrue(testerResult.testResults[0].hasPassed) From 48c00ed83e538a85f5af07b183f20c7ebb71ca60 Mon Sep 17 00:00:00 2001 From: jelleas Date: Tue, 18 Jul 2023 17:23:19 +0200 Subject: [PATCH 174/269] add getFunction, getModule, static, monkeypatch to __init__.py --- checkpy/__init__.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/checkpy/__init__.py b/checkpy/__init__.py index 579931e..004a2a6 100644 --- a/checkpy/__init__.py +++ b/checkpy/__init__.py @@ -1,13 +1,20 @@ import pathlib -from checkpy.lib import outputOf, only, include, exclude, require from checkpy.tests import test, failed, passed +from checkpy.lib.basic import outputOf, getModule, getFunction +from checkpy.lib.sandbox import only, include, exclude, require +from checkpy.lib import static +from checkpy.lib import monkeypatch __all__ = [ - "test", + "test", "failed", "passed", "outputOf", + "getModule", + "getFunction", + "static", + "monkeypatch", "only", "include", "exclude", From 86473677b73c28a0c126e27c70ab36db30a56b35 Mon Sep 17 00:00:00 2001 From: jelleas Date: Tue, 18 Jul 2023 17:50:52 +0200 Subject: [PATCH 175/269] cleanup namespace polution --- checkpy/__init__.py | 6 +++--- checkpy/lib/monkeypatch.py | 13 +++++++------ checkpy/lib/static.py | 23 +++++++++++------------ 3 files changed, 21 insertions(+), 21 deletions(-) diff --git a/checkpy/__init__.py b/checkpy/__init__.py index 004a2a6..00248a8 100644 --- a/checkpy/__init__.py +++ b/checkpy/__init__.py @@ -1,11 +1,11 @@ -import pathlib - from checkpy.tests import test, failed, passed from checkpy.lib.basic import outputOf, getModule, getFunction from checkpy.lib.sandbox import only, include, exclude, require from checkpy.lib import static from checkpy.lib import monkeypatch +import pathlib as _pathlib + __all__ = [ "test", "failed", @@ -22,4 +22,4 @@ "file" ] -file: pathlib.Path = None +file: _pathlib.Path = None diff --git a/checkpy/lib/monkeypatch.py b/checkpy/lib/monkeypatch.py index 40c12f9..f13a8e6 100644 --- a/checkpy/lib/monkeypatch.py +++ b/checkpy/lib/monkeypatch.py @@ -1,16 +1,17 @@ -from typing import Callable +from typing import Callable as _Callable + +from checkpy.entities.function import _Function -from checkpy.entities.function import Function __all__ = ["documentFunction", "neutralizeFunction"] -def documentFunction(func: Callable, documentation: str) -> "PrintableFunction": +def documentFunction(func: _Callable, documentation: str) -> "_PrintableFunction": """Creates a function that shows documentation when its printed / shows up in an error.""" - return PrintableFunction(func, documentation) + return _PrintableFunction(func, documentation) -def neutralizeFunction(function: Callable): +def neutralizeFunction(function: _Callable): """ Patches the function to do nothing (no op). Useful for unblocking blocking functions like time.sleep() or plt.slow() @@ -20,7 +21,7 @@ def dummy(*args, **kwargs): setattr(function, "__code__", dummy.__code__) -class PrintableFunction(Function): +class _PrintableFunction(_Function): def __init__(self, function, docs): super().__init__(self, function) self._docs = docs diff --git a/checkpy/lib/static.py b/checkpy/lib/static.py index 696807d..8e7fb76 100644 --- a/checkpy/lib/static.py +++ b/checkpy/lib/static.py @@ -1,29 +1,30 @@ -import io -import re -import tokenize +import io as _io +import re as _re +import tokenize as _tokenize -from pathlib import Path -from typing import Optional, Union +from pathlib import Path as _Path +from typing import Optional as _Optional +from typing import Union as _Union -import checkpy +import checkpy as _checkpy __all__ = ["getSource", "getSourceOfDefinitions", "removeComments"] -def getSource(fileName: Optional[Union[str, Path]]=None) -> str: +def getSource(fileName: _Optional[_Union[str, _Path]]=None) -> str: """Get the contents of the file.""" if fileName is None: - fileName = checkpy.file.name + fileName = _checkpy.file.name with open(fileName) as f: return f.read() -def getSourceOfDefinitions(fileName: Optional[Union[str, Path]]=None) -> str: +def getSourceOfDefinitions(fileName: _Optional[_Union[str, _Path]]=None) -> str: """Get just the source code inside definitions (def / class).""" if fileName is None: - fileName = checkpy.file.name + fileName = _checkpy.file.name newSource = "" @@ -50,7 +51,6 @@ def getSourceOfDefinitions(fileName: Optional[Union[str, Path]]=None) -> str: def removeComments(source: str) -> str: io_obj = io.StringIO(source) out = "" - prev_toktype = tokenize.INDENT last_lineno = -1 last_col = 0 indentation = "\t" @@ -78,7 +78,6 @@ def removeComments(source: str) -> str: else: out += token_string - prev_toktype = token_type last_col = end_col last_lineno = end_line return out From ba7f0af6dfaf61616326f2c1a35ede59f654d329 Mon Sep 17 00:00:00 2001 From: jelleas Date: Wed, 19 Jul 2023 16:05:22 +0200 Subject: [PATCH 176/269] always ignore warnings, +patchMatplotlib, static.getFunctionCalls --- checkpy/__main__.py | 4 +++ checkpy/lib/explanation.py | 23 +++++++++++-- checkpy/lib/monkeypatch.py | 36 ++++++++++++++++++-- checkpy/lib/static.py | 70 +++++++++++++++++++++++++++++++++++--- checkpy/tester/tester.py | 3 +- checkpy/tests.py | 4 +-- 6 files changed, 128 insertions(+), 12 deletions(-) diff --git a/checkpy/__main__.py b/checkpy/__main__.py index 348ef3a..26ee21b 100644 --- a/checkpy/__main__.py +++ b/checkpy/__main__.py @@ -7,8 +7,12 @@ from checkpy import printer import json import pkg_resources +import warnings + def main(): + warnings.filterwarnings("ignore") + parser = argparse.ArgumentParser( description = """ diff --git a/checkpy/lib/explanation.py b/checkpy/lib/explanation.py index d3f15bd..a55bc92 100644 --- a/checkpy/lib/explanation.py +++ b/checkpy/lib/explanation.py @@ -1,5 +1,6 @@ import re +from types import ModuleType from typing import Callable, List, Optional, Union from dessert.util import assertrepr_compare @@ -80,8 +81,9 @@ def simplifyAssertionMessage(assertion: Union[str, AssertionError]) -> str: match = substitutionRegex.match(substitution) left, right = match.group(1), match.group(2) - # If the right contains any checkpy function, skip - if any(elem + "(" in right for elem in checkpy.__all__): + # If the right contains any checkpy function or module, skip + print(left) + if _shouldSkip(right): skipping = True continue @@ -101,6 +103,23 @@ def simplifyAssertionMessage(assertion: Union[str, AssertionError]) -> str: return result + assertLine +def _shouldSkip(content): + modules = [checkpy] + for elem in checkpy.__all__: + attr = getattr(checkpy, elem) + if isinstance(attr, ModuleType): + modules.append(attr) + + skippedFunctionNames = [] + for module in modules: + for elem in module.__all__: + attr = getattr(module, elem) + if callable(attr): + skippedFunctionNames.append(elem) + + return any(elem in content for elem in skippedFunctionNames) + + class MockConfig: """This config is only used for config.getoption('verbose')""" def getoption(*args, **kwargs): diff --git a/checkpy/lib/monkeypatch.py b/checkpy/lib/monkeypatch.py index f13a8e6..41f4e59 100644 --- a/checkpy/lib/monkeypatch.py +++ b/checkpy/lib/monkeypatch.py @@ -1,9 +1,14 @@ from typing import Callable as _Callable -from checkpy.entities.function import _Function +from checkpy.entities.function import Function as _Function -__all__ = ["documentFunction", "neutralizeFunction"] +__all__ = [ + "documentFunction", + "neutralizeFunction", + "patchMatplotlib", + "patchNumpy" +] def documentFunction(func: _Callable, documentation: str) -> "_PrintableFunction": @@ -21,6 +26,33 @@ def dummy(*args, **kwargs): setattr(function, "__code__", dummy.__code__) +def patchMatplotlib(): + """ + Patches matplotlib's blocking functions to do nothing. + Make sure this is called before any matplotlib.pyplot import. + """ + try: + import matplotlib + matplotlib.use("Agg") + import matplotlib.pyplot as plt + plt.switch_backend("Agg") + neutralizeFunction(plt.pause) + neutralizeFunction(plt.show) + except ImportError: + pass + + +def patchNumpy(): + """ + Always have numpy raise floating-point errors as an error, no warnings. + """ + try: + import numpy + numpy.seterr('raise') + except ImportError: + pass + + class _PrintableFunction(_Function): def __init__(self, function, docs): super().__init__(self, function) diff --git a/checkpy/lib/static.py b/checkpy/lib/static.py index 8e7fb76..eabb987 100644 --- a/checkpy/lib/static.py +++ b/checkpy/lib/static.py @@ -5,11 +5,18 @@ from pathlib import Path as _Path from typing import Optional as _Optional from typing import Union as _Union +from typing import List as _List import checkpy as _checkpy -__all__ = ["getSource", "getSourceOfDefinitions", "removeComments"] +__all__ = [ + "getSource", + "getSourceOfDefinitions", + "removeComments", + "getFunctionCalls", + "getFunctionDefinitions" +] def getSource(fileName: _Optional[_Union[str, _Path]]=None) -> str: @@ -49,6 +56,7 @@ def getSourceOfDefinitions(fileName: _Optional[_Union[str, _Path]]=None) -> str: # inspiration from http://stackoverflow.com/questions/1769332/script-to-remove-python-comments-docstrings def removeComments(source: str) -> str: + """Remove comments from a string containing Python source code.""" io_obj = io.StringIO(source) out = "" last_lineno = -1 @@ -59,7 +67,7 @@ def removeComments(source: str) -> str: last_col = 0 # figure out type of indentation used - if token_type == tokenize.INDENT: + if token_type == _tokenize.INDENT: indentation = "\t" if "\t" in token_string else " " # write indentation @@ -70,14 +78,66 @@ def removeComments(source: str) -> str: out += " " * (start_col - last_col) # ignore comments - if token_type == tokenize.COMMENT: + if token_type == _tokenize.COMMENT: pass # put all docstrings on a single line - elif token_type == tokenize.STRING: - out += re.sub("\n", " ", token_string) + elif token_type == _tokenize.STRING: + out += _re.sub("\n", " ", token_string) else: out += token_string last_col = end_col last_lineno = end_line return out + + +def getFunctionCalls(source: _Optional[str]=None) -> _List[str]: + """Get all Function calls from source.""" + import ast + + class CallVisitor(ast.NodeVisitor): + def __init__(self): + self.parts = [] + + def visit_Attribute(self, node): + super().generic_visit(node) + self.parts.append(node.attr) + + def visit_Name(self, node): + self.parts.append(node.id) + + @property + def call(self): + return ".".join(self.parts) + "()" + + class FunctionsVisitor(ast.NodeVisitor): + def __init__(self): + self.functionCalls = [] + + def visit_Call(self, node): + callVisitor = CallVisitor() + callVisitor.visit(node.func) + super().generic_visit(node) + self.functionCalls.append(callVisitor.call) + + if source is None: + source = getSource() + + tree = ast.parse(source) + visitor = FunctionsVisitor() + visitor.visit(tree) + return visitor.functionCalls + +def getFunctionDefinitions( + *functionNames: str, + source: _Optional[str]=None + ) -> _List[str]: + """Get all Function definitions from source.""" + def isFunctionDefIn(functionName, src): + regex = _re.compile(".*def[ \\t]+{}[ \\t]*\(.*?\).*".format(functionName), _re.DOTALL) + return regex.match(src) + + if source is None: + source = getSource() + source = removeComments(source) + return all(isFunctionDefIn(fName, source) for fName in functionNames) \ No newline at end of file diff --git a/checkpy/tester/tester.py b/checkpy/tester/tester.py index 90b6615..c8a1b2c 100644 --- a/checkpy/tester/tester.py +++ b/checkpy/tester/tester.py @@ -19,6 +19,7 @@ import multiprocessing from multiprocessing.queues import Queue import time +import warnings __all__ = ["getActiveTest", "test", "testModule", "TesterResult"] @@ -204,8 +205,8 @@ def run(self): printer.printer.DEBUG_MODE = self.debugMode printer.printer.SILENT_MODE = self.silentMode + warnings.filterwarnings("ignore") if self.debugMode: - import warnings warnings.simplefilter('always', DeprecationWarning) checkpy.file = self.filePath diff --git a/checkpy/tests.py b/checkpy/tests.py index 3d1ce14..c34dea3 100644 --- a/checkpy/tests.py +++ b/checkpy/tests.py @@ -208,13 +208,13 @@ def runMethod(): return TestResult(False, test.description, msg) except exception.CheckpyError as e: - return TestResult(False, test.description, test.exception(e), exception=e) + return TestResult(False, test.description, str(test.exception(e)), exception=e) except Exception as e: e = exception.TestError( exception = e, message = "while testing", stacktrace = traceback.format_exc()) - return TestResult(False, test.description, test.exception(e), exception=e) + return TestResult(False, test.description, str(test.exception(e)), exception=e) return TestResult(hasPassed, test.description, test.success(info) if hasPassed else test.fail(info)) From 1c2f6e93e40af14900f1fc1e96fed9ef2a8b29c0 Mon Sep 17 00:00:00 2001 From: jelleas Date: Wed, 19 Jul 2023 17:11:55 +0200 Subject: [PATCH 177/269] static.getFunctionDefinitions --- checkpy/lib/explanation.py | 1 - checkpy/lib/static.py | 42 +++++++++++++++++++++----------------- 2 files changed, 23 insertions(+), 20 deletions(-) diff --git a/checkpy/lib/explanation.py b/checkpy/lib/explanation.py index a55bc92..01f3b18 100644 --- a/checkpy/lib/explanation.py +++ b/checkpy/lib/explanation.py @@ -82,7 +82,6 @@ def simplifyAssertionMessage(assertion: Union[str, AssertionError]) -> str: left, right = match.group(1), match.group(2) # If the right contains any checkpy function or module, skip - print(left) if _shouldSkip(right): skipping = True continue diff --git a/checkpy/lib/static.py b/checkpy/lib/static.py index eabb987..7ed86d5 100644 --- a/checkpy/lib/static.py +++ b/checkpy/lib/static.py @@ -1,3 +1,4 @@ +import ast as _ast import io as _io import re as _re import tokenize as _tokenize @@ -57,12 +58,12 @@ def getSourceOfDefinitions(fileName: _Optional[_Union[str, _Path]]=None) -> str: # inspiration from http://stackoverflow.com/questions/1769332/script-to-remove-python-comments-docstrings def removeComments(source: str) -> str: """Remove comments from a string containing Python source code.""" - io_obj = io.StringIO(source) + io_obj = _io.StringIO(source) out = "" last_lineno = -1 last_col = 0 indentation = "\t" - for token_type, token_string, (start_line, start_col), (end_line, end_col), ltext in tokenize.generate_tokens(io_obj.readline): + for token_type, token_string, (start_line, start_col), (end_line, end_col), ltext in _tokenize.generate_tokens(io_obj.readline): if start_line > last_lineno: last_col = 0 @@ -92,10 +93,8 @@ def removeComments(source: str) -> str: def getFunctionCalls(source: _Optional[str]=None) -> _List[str]: - """Get all Function calls from source.""" - import ast - - class CallVisitor(ast.NodeVisitor): + """Get all names of function called in source.""" + class CallVisitor(_ast.NodeVisitor): def __init__(self): self.parts = [] @@ -108,9 +107,9 @@ def visit_Name(self, node): @property def call(self): - return ".".join(self.parts) + "()" + return ".".join(self.parts) - class FunctionsVisitor(ast.NodeVisitor): + class FunctionsVisitor(_ast.NodeVisitor): def __init__(self): self.functionCalls = [] @@ -123,21 +122,26 @@ def visit_Call(self, node): if source is None: source = getSource() - tree = ast.parse(source) + tree = _ast.parse(source) visitor = FunctionsVisitor() visitor.visit(tree) return visitor.functionCalls -def getFunctionDefinitions( - *functionNames: str, - source: _Optional[str]=None - ) -> _List[str]: - """Get all Function definitions from source.""" - def isFunctionDefIn(functionName, src): - regex = _re.compile(".*def[ \\t]+{}[ \\t]*\(.*?\).*".format(functionName), _re.DOTALL) - return regex.match(src) + +def getFunctionDefinitions(source: _Optional[str]=None) -> _List[str]: + """Get all names of Function definitions from source.""" + class FunctionsVisitor(_ast.NodeVisitor): + def __init__(self): + self.functionNames = [] + + def visit_FunctionDef(self, node: _ast.FunctionDef): + self.functionNames.append(node.name) + super().generic_visit(node) if source is None: source = getSource() - source = removeComments(source) - return all(isFunctionDefIn(fName, source) for fName in functionNames) \ No newline at end of file + + tree = _ast.parse(source) + visitor = FunctionsVisitor() + visitor.visit(tree) + return visitor.functionNames From bb587c76baeb1e8e350c678ee9058ca8e24b6a3f Mon Sep 17 00:00:00 2001 From: Jelleas Date: Thu, 20 Jul 2023 12:46:03 +0200 Subject: [PATCH 178/269] add __repr__ to Function --- checkpy/entities/function.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/checkpy/entities/function.py b/checkpy/entities/function.py index 0a2e17e..e91fb7e 100644 --- a/checkpy/entities/function.py +++ b/checkpy/entities/function.py @@ -53,6 +53,9 @@ def printOutput(self): """stateful function that returns the print (stdout) output of the latest function call as a string""" return self._printOutput + def __repr__(self): + return self._function.__name__ + @contextlib.contextmanager def _captureStdout(self): """ From 31b7fa8e9af7a774df7b429c5ec6c9ce7da5f735 Mon Sep 17 00:00:00 2001 From: Jelleas Date: Thu, 20 Jul 2023 13:15:27 +0200 Subject: [PATCH 179/269] filter out: Use -v to get the full diff --- checkpy/lib/explanation.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/checkpy/lib/explanation.py b/checkpy/lib/explanation.py index 01f3b18..68e5dd7 100644 --- a/checkpy/lib/explanation.py +++ b/checkpy/lib/explanation.py @@ -36,6 +36,11 @@ def explainCompare(op: str, left: str, right: str) -> Optional[str]: def simplifyAssertionMessage(assertion: Union[str, AssertionError]) -> str: message = str(assertion) + # Filter out pytest's "Use -v to get the full diff" message + lines = message.split("\n") + lines = [line for line in lines if "Use -v to get the full diff" not in line] + message = "\n".join(lines) + # Find any substitution lines of the form where ... = ... from pytest whereRegex = re.compile(r"\n[\s]*\+(\s*)(where|and)[\s]*(.*)") whereLines = whereRegex.findall(message) From c0cb75a82449e001b1469b3b7dd8f17efbcb4c4d Mon Sep 17 00:00:00 2001 From: Jelleas Date: Thu, 20 Jul 2023 13:31:31 +0200 Subject: [PATCH 180/269] fix IndexError on mismatched args in func def / call --- checkpy/entities/function.py | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/checkpy/entities/function.py b/checkpy/entities/function.py index e91fb7e..4735d15 100644 --- a/checkpy/entities/function.py +++ b/checkpy/entities/function.py @@ -31,11 +31,14 @@ def __call__(self, *args, **kwargs): message = "while trying to execute {}()".format(self.name) if nArgs > 0: - argsRepr = ", ".join("{}={}".format(argumentNames[i], args[i]) for i in range(len(args))) - kwargsRepr = ", ".join("{}={}".format(kwargName, kwargs[kwargName]) for kwargName in argumentNames[len(args):nArgs]) - representation = ", ".join(s for s in [argsRepr, kwargsRepr] if s) - message = "while trying to execute {}({})".format(self.name, representation) - + if len(argumentNames) == len(args): + argsRepr = ", ".join("{}={}".format(argumentNames[i], args[i]) for i in range(len(args))) + kwargsRepr = ", ".join("{}={}".format(kwargName, kwargs[kwargName]) for kwargName in argumentNames[len(args):nArgs]) + representation = ", ".join(s for s in [argsRepr, kwargsRepr] if s) + message = "while trying to execute {}({})".format(self.name, representation) + else: + argsRepr = ','.join(str(arg) for arg in args) + message = f"while trying to exectute {self.name}({argsRepr})" raise exception.SourceException(exception = e, message = message) @property From c2d38d21d0a6a1808e9de910087e6e191032526c Mon Sep 17 00:00:00 2001 From: Jelleas Date: Thu, 20 Jul 2023 14:03:19 +0200 Subject: [PATCH 181/269] tweak sub regex --- checkpy/lib/explanation.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/checkpy/lib/explanation.py b/checkpy/lib/explanation.py index 68e5dd7..f6b4f74 100644 --- a/checkpy/lib/explanation.py +++ b/checkpy/lib/explanation.py @@ -93,8 +93,8 @@ def simplifyAssertionMessage(assertion: Union[str, AssertionError]) -> str: # Substitute the first match in assertLine assertLine = re.sub( - re.escape(left), - right, + r"( )" + re.escape(left) + r"( |$|\()", + r"\1" + right + r"\2", assertLine, count=1, flags=re.S From ba1953d413b54527a4e8e69fe4e00d740997c331 Mon Sep 17 00:00:00 2001 From: Jelleas Date: Thu, 20 Jul 2023 14:08:07 +0200 Subject: [PATCH 182/269] fix monkeypatch.documentFunction --- checkpy/lib/monkeypatch.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/checkpy/lib/monkeypatch.py b/checkpy/lib/monkeypatch.py index 41f4e59..7e7b7a3 100644 --- a/checkpy/lib/monkeypatch.py +++ b/checkpy/lib/monkeypatch.py @@ -55,7 +55,7 @@ def patchNumpy(): class _PrintableFunction(_Function): def __init__(self, function, docs): - super().__init__(self, function) + super().__init__(function) self._docs = docs def __repr__(self): From 90c96177ee0701e571b86a2c98def442a2ca759b Mon Sep 17 00:00:00 2001 From: Jelleas Date: Thu, 20 Jul 2023 16:57:30 +0200 Subject: [PATCH 183/269] introduce pytest.approx --- checkpy/__init__.py | 4 +++- checkpy/assertlib/basic.py | 4 ++-- checkpy/lib/basic.py | 4 ++-- checkpy/lib/explanation.py | 12 +++++++++--- setup.py | 2 +- 5 files changed, 17 insertions(+), 9 deletions(-) diff --git a/checkpy/__init__.py b/checkpy/__init__.py index 00248a8..accd8a2 100644 --- a/checkpy/__init__.py +++ b/checkpy/__init__.py @@ -3,6 +3,7 @@ from checkpy.lib.sandbox import only, include, exclude, require from checkpy.lib import static from checkpy.lib import monkeypatch +from pytest import approx import pathlib as _pathlib @@ -19,7 +20,8 @@ "include", "exclude", "require", - "file" + "file", + "approx" ] file: _pathlib.Path = None diff --git a/checkpy/assertlib/basic.py b/checkpy/assertlib/basic.py index d89f341..21053f1 100644 --- a/checkpy/assertlib/basic.py +++ b/checkpy/assertlib/basic.py @@ -44,10 +44,10 @@ def numberOnLine(number, line, deviation = 0): def fileContainsFunctionCalls(fileName, *functionNames): source = lib.removeComments(lib.source(fileName)) - fCallInSrc = lambda fName, src : re.match(re.compile(".*{}[ \\t]*\(.*?\).*".format(fName), re.DOTALL), src) + fCallInSrc = lambda fName, src : re.match(re.compile(r".*{}[ \t]*(.*?).*".format(fName), re.DOTALL), src) return all(fCallInSrc(fName, source) for fName in functionNames) def fileContainsFunctionDefinitions(fileName, *functionNames): source = lib.removeComments(lib.source(fileName)) - fDefInSrc = lambda fName, src : re.match(re.compile(".*def[ \\t]+{}[ \\t]*\(.*?\).*".format(fName), re.DOTALL), src) + fDefInSrc = lambda fName, src : re.match(re.compile(r".*def[ \t]+{}[ \t]*(.*?).*".format(fName), re.DOTALL), src) return all(fDefInSrc(fName, source) for fName in functionNames) diff --git a/checkpy/lib/basic.py b/checkpy/lib/basic.py index 07cbddb..c594042 100644 --- a/checkpy/lib/basic.py +++ b/checkpy/lib/basic.py @@ -255,8 +255,8 @@ def getNumbersFromString(s): def getLine(text, lineNumber): warn("""checkpy.lib.getLine() is deprecated. Instead try: - lines = text.split("\n") - assert len(lines) >= lineNumber + lines = text.split("\\n") + assert len(lines) >= lineNumber + 1 line = lines[lineNumber] """, DeprecationWarning, stacklevel=2) lines = text.split("\n") diff --git a/checkpy/lib/explanation.py b/checkpy/lib/explanation.py index f6b4f74..ed6f8df 100644 --- a/checkpy/lib/explanation.py +++ b/checkpy/lib/explanation.py @@ -92,15 +92,21 @@ def simplifyAssertionMessage(assertion: Union[str, AssertionError]) -> str: continue # Substitute the first match in assertLine + oldAssertLine = assertLine assertLine = re.sub( - r"( )" + re.escape(left) + r"( |$|\()", + r"([^\w])" + re.escape(left) + r"([^\w\.])", r"\1" + right + r"\2", assertLine, count=1, flags=re.S ) - oldSub = right + # If substitution succeeds, keep track of the sub + if oldAssertLine != assertLine: + oldSub = right + # Else substitution failed, start skipping. + else: + skipping = True # Ensure all newlines are escaped assertLine = assertLine.replace("\n", "\\n") @@ -120,7 +126,7 @@ def _shouldSkip(content): attr = getattr(module, elem) if callable(attr): skippedFunctionNames.append(elem) - + return any(elem in content for elem in skippedFunctionNames) diff --git a/setup.py b/setup.py index 4c7ac5a..8478088 100644 --- a/setup.py +++ b/setup.py @@ -49,7 +49,7 @@ "tinydb", "dill", "colorama", - "redbaron", + "pytest", "dessert" ], From 63cb30c59e5686c36522df8507dbe04f5bf822a8 Mon Sep 17 00:00:00 2001 From: Jelleas Date: Thu, 20 Jul 2023 17:42:56 +0200 Subject: [PATCH 184/269] fix wrong number of args exception --- checkpy/entities/function.py | 217 ++++++++++++++++++----------------- 1 file changed, 110 insertions(+), 107 deletions(-) diff --git a/checkpy/entities/function.py b/checkpy/entities/function.py index 4735d15..afff102 100644 --- a/checkpy/entities/function.py +++ b/checkpy/entities/function.py @@ -9,126 +9,129 @@ class Function(object): - def __init__(self, function): - self._function = function - self._printOutput = "" - - def __call__(self, *args, **kwargs): - old = sys.stdout - try: - with self._captureStdout() as listener: - outcome = self._function(*args, **kwargs) - self._printOutput = listener.content - return outcome - except Exception as e: - if isinstance(e,TypeError): - no_arguments = re.search(r"takes (\d+) positional arguments but (\d+) were given", e.__str__()) - if no_arguments: - raise exception.SourceException(exception = None, message = f"the function should take {no_arguments.group(1)} arguments but does not") - sys.stdout = old - argumentNames = self.arguments - nArgs = len(args) + len(kwargs) - - message = "while trying to execute {}()".format(self.name) - if nArgs > 0: - if len(argumentNames) == len(args): - argsRepr = ", ".join("{}={}".format(argumentNames[i], args[i]) for i in range(len(args))) - kwargsRepr = ", ".join("{}={}".format(kwargName, kwargs[kwargName]) for kwargName in argumentNames[len(args):nArgs]) - representation = ", ".join(s for s in [argsRepr, kwargsRepr] if s) - message = "while trying to execute {}({})".format(self.name, representation) - else: - argsRepr = ','.join(str(arg) for arg in args) - message = f"while trying to exectute {self.name}({argsRepr})" - raise exception.SourceException(exception = e, message = message) - - @property - def name(self): - """gives the name of the function""" - return self._function.__name__ - - @property - def arguments(self): - """gives the argument names of the function""" - return inspect.getfullargspec(self._function)[0] - - @property - def printOutput(self): - """stateful function that returns the print (stdout) output of the latest function call as a string""" - return self._printOutput - - def __repr__(self): - return self._function.__name__ - - @contextlib.contextmanager - def _captureStdout(self): - """ - capture sys.stdout in _outStream - (a _Stream that is an instance of StringIO extended with the Observer pattern) - returns a _StreamListener on said stream - """ - outStreamListener = _StreamListener(_outStream) - old_stdout = sys.stdout - old_stderr = sys.stderr - - try: - outStreamListener.start() - sys.stdout = outStreamListener.stream - sys.stderr = open(os.devnull) - yield outStreamListener - except: - raise - finally: - sys.stderr.close() - sys.stderr = old_stderr - sys.stdout = old_stdout - outStreamListener.stop() + def __init__(self, function): + self._function = function + self._printOutput = "" + + def __call__(self, *args, **kwargs): + old = sys.stdout + try: + with self._captureStdout() as listener: + outcome = self._function(*args, **kwargs) + self._printOutput = listener.content + return outcome + except Exception as e: + if isinstance(e,TypeError): + no_arguments = re.search(r"(\w+\(\)) takes (\d+) positional arguments but (\d+) were given", str(e)) + if no_arguments: + raise exception.SourceException( + exception=None, + message=f"{no_arguments.group(1)} should take {no_arguments.group(3)} arguments but takes {no_arguments.group(2)} instead" + ) + sys.stdout = old + argumentNames = self.arguments + nArgs = len(args) + len(kwargs) + + message = "while trying to execute {}()".format(self.name) + if nArgs > 0: + if len(argumentNames) == len(args): + argsRepr = ", ".join("{}={}".format(argumentNames[i], args[i]) for i in range(len(args))) + kwargsRepr = ", ".join("{}={}".format(kwargName, kwargs[kwargName]) for kwargName in argumentNames[len(args):nArgs]) + representation = ", ".join(s for s in [argsRepr, kwargsRepr] if s) + message = "while trying to execute {}({})".format(self.name, representation) + else: + argsRepr = ','.join(str(arg) for arg in args) + message = f"while trying to exectute {self.name}({argsRepr})" + raise exception.SourceException(exception = e, message = message) + + @property + def name(self): + """gives the name of the function""" + return self._function.__name__ + + @property + def arguments(self): + """gives the argument names of the function""" + return inspect.getfullargspec(self._function)[0] + + @property + def printOutput(self): + """stateful function that returns the print (stdout) output of the latest function call as a string""" + return self._printOutput + + def __repr__(self): + return self._function.__name__ + + @contextlib.contextmanager + def _captureStdout(self): + """ + capture sys.stdout in _outStream + (a _Stream that is an instance of StringIO extended with the Observer pattern) + returns a _StreamListener on said stream + """ + outStreamListener = _StreamListener(_outStream) + old_stdout = sys.stdout + old_stderr = sys.stderr + + try: + outStreamListener.start() + sys.stdout = outStreamListener.stream + sys.stderr = open(os.devnull) + yield outStreamListener + except: + raise + finally: + sys.stderr.close() + sys.stderr = old_stderr + sys.stdout = old_stdout + outStreamListener.stop() class _Stream(io.StringIO): - def __init__(self, *args, **kwargs): - io.StringIO.__init__(self, *args, **kwargs) - self._listeners = [] + def __init__(self, *args, **kwargs): + io.StringIO.__init__(self, *args, **kwargs) + self._listeners = [] - def register(self, listener): - self._listeners.append(listener) + def register(self, listener): + self._listeners.append(listener) - def unregister(self, listener): - self._listeners.remove(listener) + def unregister(self, listener): + self._listeners.remove(listener) - def write(self, text): - """Overwrites StringIO.write to update all listeners""" - io.StringIO.write(self, text) - self._onUpdate(text) + def write(self, text): + """Overwrites StringIO.write to update all listeners""" + io.StringIO.write(self, text) + self._onUpdate(text) - def writelines(self, sequence): - """Overwrites StringIO.writelines to update all listeners""" - io.StringIO.writelines(self, sequence) - for item in sequence: - self._onUpdate(item) + def writelines(self, sequence): + """Overwrites StringIO.writelines to update all listeners""" + io.StringIO.writelines(self, sequence) + for item in sequence: + self._onUpdate(item) - def _onUpdate(self, content): - for listener in self._listeners: - listener.update(content) + def _onUpdate(self, content): + for listener in self._listeners: + listener.update(content) class _StreamListener(object): - def __init__(self, stream): - self._stream = stream - self._content = "" + def __init__(self, stream): + self._stream = stream + self._content = "" - def start(self): - self.stream.register(self) + def start(self): + self.stream.register(self) - def stop(self): - self.stream.unregister(self) + def stop(self): + self.stream.unregister(self) - def update(self, content): - self._content += content + def update(self, content): + self._content += content - @property - def content(self): - return self._content + @property + def content(self): + return self._content - @property - def stream(self): - return self._stream + @property + def stream(self): + return self._stream _outStream = _Stream() From ffe98fbec9b05661b4cf2e73dfbd53c7a5cecf37 Mon Sep 17 00:00:00 2001 From: jelleas Date: Fri, 21 Jul 2023 13:32:09 +0200 Subject: [PATCH 185/269] static.getNumbersFrom --- checkpy/lib/__init__.py | 3 +++ checkpy/lib/basic.py | 12 +----------- checkpy/lib/static.py | 21 +++++++++++++++++++++ 3 files changed, 25 insertions(+), 11 deletions(-) diff --git a/checkpy/lib/__init__.py b/checkpy/lib/__init__.py index e72b6d7..cdd162e 100644 --- a/checkpy/lib/__init__.py +++ b/checkpy/lib/__init__.py @@ -3,6 +3,9 @@ from checkpy.lib.static import getSource from checkpy.lib.static import getSourceOfDefinitions from checkpy.lib.static import removeComments +from checkpy.lib.static import getFunctionDefinitions +from checkpy.lib.static import getFunctionCalls +from checkpy.lib.static import getNumbersFrom from checkpy.lib.monkeypatch import documentFunction from checkpy.lib.monkeypatch import neutralizeFunction diff --git a/checkpy/lib/basic.py b/checkpy/lib/basic.py index c594042..54f38c8 100644 --- a/checkpy/lib/basic.py +++ b/checkpy/lib/basic.py @@ -238,17 +238,7 @@ def getPositiveIntegersFromString(s): def getNumbersFromString(s): warn("""checkpy.lib.getNumbersFromString() is deprecated. Instead use: - import re - re.findall(r"[-+]?\d*\.\d+|[-+]?\d+", text) - - OR - - numbers = [] - for item in text.split(): - try: - numbers.append(float(item)) - except ValueError: - pass + lib.static.getNumbersFrom(s) """, DeprecationWarning, stacklevel=2) return [eval(n) for n in re.findall(r"[-+]?\d*\.\d+|[-+]?\d+", s)] diff --git a/checkpy/lib/static.py b/checkpy/lib/static.py index 7ed86d5..0a1c3d3 100644 --- a/checkpy/lib/static.py +++ b/checkpy/lib/static.py @@ -55,6 +55,27 @@ def getSourceOfDefinitions(fileName: _Optional[_Union[str, _Path]]=None) -> str: return newSource +def getNumbersFrom(text: str) -> _List[_Union[int, float]]: + """ + Get all Python parseable numbers from a string. + Numbers are assumed to be seperated by whitespace from other text. + whitespace = \s = [\\r\\n\\t\\f\\v ] + """ + numbers: _List[_Union[int, float]] = [] + + numbers = [] + for elem in _re.split(r"\s", text): + try: + if "." in elem: + numbers.append(float(elem)) + else: + numbers.append(int(elem)) + except ValueError: + pass + + return numbers + + # inspiration from http://stackoverflow.com/questions/1769332/script-to-remove-python-comments-docstrings def removeComments(source: str) -> str: """Remove comments from a string containing Python source code.""" From 4023a0b64648e1ca47a1ab67b8356865be873ae6 Mon Sep 17 00:00:00 2001 From: jelleas Date: Fri, 21 Jul 2023 13:32:34 +0200 Subject: [PATCH 186/269] rm dead code --- checkpy/lib/static.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/checkpy/lib/static.py b/checkpy/lib/static.py index 0a1c3d3..dd1457c 100644 --- a/checkpy/lib/static.py +++ b/checkpy/lib/static.py @@ -62,8 +62,6 @@ def getNumbersFrom(text: str) -> _List[_Union[int, float]]: whitespace = \s = [\\r\\n\\t\\f\\v ] """ numbers: _List[_Union[int, float]] = [] - - numbers = [] for elem in _re.split(r"\s", text): try: if "." in elem: From 37855d782154965df9acc76a9e9f28352edcd1ac Mon Sep 17 00:00:00 2001 From: jelleas Date: Fri, 21 Jul 2023 13:34:06 +0200 Subject: [PATCH 187/269] rm python2 from setup.py --- setup.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/setup.py b/setup.py index 8478088..856ffc9 100644 --- a/setup.py +++ b/setup.py @@ -32,10 +32,8 @@ 'License :: OSI Approved :: MIT License', - 'Programming Language :: Python :: 2', - 'Programming Language :: Python :: 2.7', 'Programming Language :: Python :: 3', - 'Programming Language :: Python :: 3.6', + 'Programming Language :: Python :: 3.7', ], keywords='new unexperienced programmers automatic testing minor programming', From 0aa1e21d214d3606d4ed1816eb6421e860d0f35b Mon Sep 17 00:00:00 2001 From: jelleas Date: Fri, 21 Jul 2023 19:02:02 +0200 Subject: [PATCH 188/269] Type --- checkpy/__init__.py | 2 ++ checkpy/__main__.py | 1 - checkpy/entities/function.py | 7 ++++++- checkpy/entities/type.py | 37 ++++++++++++++++++++++++++++++++++++ 4 files changed, 45 insertions(+), 2 deletions(-) create mode 100644 checkpy/entities/type.py diff --git a/checkpy/__init__.py b/checkpy/__init__.py index accd8a2..4b99ed0 100644 --- a/checkpy/__init__.py +++ b/checkpy/__init__.py @@ -3,6 +3,7 @@ from checkpy.lib.sandbox import only, include, exclude, require from checkpy.lib import static from checkpy.lib import monkeypatch +from checkpy.entities.type import Type from pytest import approx import pathlib as _pathlib @@ -14,6 +15,7 @@ "outputOf", "getModule", "getFunction", + "Type", "static", "monkeypatch", "only", diff --git a/checkpy/__main__.py b/checkpy/__main__.py index 26ee21b..bb5f78b 100644 --- a/checkpy/__main__.py +++ b/checkpy/__main__.py @@ -1,4 +1,3 @@ -from posixpath import split import sys import os import argparse diff --git a/checkpy/entities/function.py b/checkpy/entities/function.py index afff102..a6ef811 100644 --- a/checkpy/entities/function.py +++ b/checkpy/entities/function.py @@ -51,9 +51,14 @@ def name(self): @property def arguments(self): - """gives the argument names of the function""" + """gives the parameter names of the function""" return inspect.getfullargspec(self._function)[0] + @property + def parameters(self): + """gives the parameter names of the function""" + return self.arguments + @property def printOutput(self): """stateful function that returns the print (stdout) output of the latest function call as a string""" diff --git a/checkpy/entities/type.py b/checkpy/entities/type.py new file mode 100644 index 0000000..58519ad --- /dev/null +++ b/checkpy/entities/type.py @@ -0,0 +1,37 @@ +import typeguard + +class Type: + """ + An equals and not equals comparible type annotation. + + assert [1, 2.0, None] == Type(List[Union[int, float, None]]) + assert [1, 2, 3] == Type(Iterable[int]) + assert {1: "foo"} != Type(Dict[int, str]) + assert (1, "foo", 3) != Type(Tuple[int, str, int]) + + This is built on top of typeguard.check_type, see docs @ + https://typeguard.readthedocs.io/en/stable/api.html#typeguard.check_type + """ + def __init__(self, type_: type): + self._type = type_ + + def __eq__(self, __value: object) -> bool: + isEq = True + def callback(err: typeguard.TypeCheckError, memo: typeguard.TypeCheckMemo): + nonlocal isEq + isEq = False + typeguard.check_type(__value, self._type, typecheck_fail_callback=callback) + return isEq + + def __repr__(self) -> str: + return (str(self._type) + .replace("typing.", "") + .replace("", "int") + .replace("", "float") + .replace("", "bool") + .replace("", "str") + .replace("", "list") + .replace("", "tuple") + .replace("", "dict") + .replace("", "set") + ) From 6e044454a2cb83aa5be11b5257f63f125eb0968a Mon Sep 17 00:00:00 2001 From: jelleas Date: Fri, 21 Jul 2023 19:03:02 +0200 Subject: [PATCH 189/269] add typeguard to setup.py --- setup.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 856ffc9..5eade98 100644 --- a/setup.py +++ b/setup.py @@ -48,7 +48,8 @@ "dill", "colorama", "pytest", - "dessert" + "dessert", + "typeguard" ], extras_require={ From 61b62b6e55978b713d2d9a02385f9be6af910f38 Mon Sep 17 00:00:00 2001 From: jelleas Date: Tue, 25 Jul 2023 18:12:45 +0200 Subject: [PATCH 190/269] experimental declarative builder + better Type assertion messages --- checkpy/lib/builder.py | 221 +++++++++++++++++++++++++++++++++++++ checkpy/lib/explanation.py | 11 +- 2 files changed, 230 insertions(+), 2 deletions(-) create mode 100644 checkpy/lib/builder.py diff --git a/checkpy/lib/builder.py b/checkpy/lib/builder.py new file mode 100644 index 0000000..80580a4 --- /dev/null +++ b/checkpy/lib/builder.py @@ -0,0 +1,221 @@ +import checkpy.tests +import checkpy.entities.function +import checkpy.entities.exception +import checkpy +from copy import deepcopy +from uuid import uuid4 +from typing import Any, Callable, Dict, Iterable, Optional, List, Tuple, Union + + +class FunctionBuilder: + def __init__(self, functionName: str): + self._state: FunctionState = FunctionState(functionName) + self._blocks: List[Callable[["FunctionState"], None]] = [] + + self.name(functionName) + + def name(self, functionName: str) -> "FunctionBuilder": + def testName(state: FunctionState): + state.name = functionName + state.description = f"defines the function {functionName}()" + assert functionName in checkpy.static.getFunctionDefinitions(),\ + f'no function found with name {functionName}()' + + self._blocks.append(testName) + return self + + def params(self, *params: str) -> "FunctionBuilder": + def testParams(state: FunctionState): + state.params = params + state.description = f"defines the function as {state.name}({', '.join(params)})" + + real = state.function.parameters + expected = state.params + + assert len(real) == len(expected),\ + f"expected {len(expected)} arguments. Your function {state.name}() has"\ + f" {len(real)} arguments" + + assert real == expected,\ + f"parameters should exactly match the requested function definition" + + self._blocks.append(testParams) + return self + + def returnType(self, type_: type) -> "FunctionBuilder": + def testType(state: FunctionState): + state.returnType = type_ + + if state.wasCalled: + state.description = f"{state.getFunctionCallRepr()} returns a value of type {state.returnType}" + returned = state.returned + assert returned == checkpy.Type(type_) + + self._blocks.append(testType) + return self + + def returned(self, expected: Any) -> "FunctionBuilder": + def testReturned(state: FunctionState): + actual = state.returned + state.description = f"{state.getFunctionCallRepr()} should return {expected}" + assert actual == expected + + self._blocks.append(testReturned) + return self + + def call(self, *args: Any, **kwargs: Any) -> "FunctionBuilder": + def testCall(state: FunctionState): + state.args = args + state.kwargs = kwargs + state.description = f"calling function {state.getFunctionCallRepr()}" + state.returned = state.function(*args, **kwargs) + + state.description = f"{state.getFunctionCallRepr()} returns a value of type {state.returnType}" + type_ = state.returnType + returned = state.returned + assert returned == checkpy.Type(type_) + + self._blocks.append(testCall) + return self + + def timeout(self, time: int) -> "FunctionBuilder": + def setTimeout(state: FunctionState): + state.timeout = time + + self._blocks.append(setTimeout) + return self + + def description(self, description: str) -> "FunctionBuilder": + def setDecription(state: FunctionState): + state.description = description + + self._blocks.append(setDecription) + return self + + def do(self, function: Callable[["FunctionState"], None]) -> "FunctionBuilder": + self._blocks.append(function) + return self + + def build(self) -> checkpy.tests.TestFunction: + def testFunction(): + self.log: List[FunctionState] = [] + + for block in self._blocks: + self.log.append(deepcopy(self._state)) + block(self._state) + + testFunction.__name__ = f"builder_function_test_{self._state.name}()_{uuid4()}" + testFunction.__doc__ = self._state.description + return checkpy.tests.test()(testFunction) + + +class FunctionState: + def __init__(self, functionName: str): + self._description: str = f"defines the function {functionName}()" + self._name: str = functionName + self._params: Optional[List[str]] = None + self._wasCalled: bool = False + self._returned: Any = None + self._returnType: type = Any + self._args: List[Any] = [] + self._kwargs: Dict[str, Any] = {} + self._timeout: int = 10 + self._descriptionFormatter: Callable[[str, FunctionState], str] =\ + lambda descr, state: f"testing {state.name}() >> {descr}" + + @property + def name(self) -> str: + return self._name + + @name.setter + def name(self, newName: str): + self._name = str(newName) + + @property + def params(self) -> Tuple[str]: + if self._params is None: + raise checkpy.entities.exception.CheckpyError( + f"params are not set for function builder test {self._name}()" + ) + return self._params + + @params.setter + def params(self, parameters: Iterable[str]): + self._params = list(parameters) + + @property + def function(self) -> checkpy.entities.function.Function: + return checkpy.getFunction(self.name) + + @property + def wasCalled(self) -> bool: + return self._wasCalled + + @property + def returned(self) -> Any: + if not self.wasCalled: + raise checkpy.entities.exception.CheckpyError( + f"function was never called for function builder test {self._name}" + ) + return self._returned + + @returned.setter + def returned(self, newReturned): + self._wasCalled = True + self._returned = newReturned + + @property + def args(self) -> List[Any]: + return self._args + + @args.setter + def args(self, newArgs: Iterable[Any]): + self._args = newArgs + + @property + def kwargs(self) -> Dict[str, Any]: + return self._kwargs + + @kwargs.setter + def kwargs(self, newKwargs: Dict[str, Any]): + self._kwargs = newKwargs + + @property + def returnType(self) -> type: + return self._returnType + + @returnType.setter + def returnType(self, newReturnType: type): + self._returnType = newReturnType + + @property + def timeout(self) -> int: + return self._timeout + + @timeout.setter + def timeout(self, newTimeout): + self._timeout = newTimeout + checkpy.tester.getActiveTest().timeout = self.timeout + + @property + def description(self) -> str: + return self._descriptionFormatter(self._description, self) + + @description.setter + def description(self, newDescription): + self._description = newDescription + checkpy.tester.getActiveTest().description = self.description + + def getFunctionCallRepr(self): + argsRepr = ", ".join(str(arg) for arg in self.args) + kwargsRepr = ", ".join(f"{k}={v}" for k, v in self.kwargs.items()) + repr = ', '.join([a for a in (argsRepr, kwargsRepr) if a]) + return f"{self.name}({repr})" + + def setDescriptionFormatter(self, formatter: Callable[[str, "FunctionState"], str]): + self._descriptionFormatter = formatter + checkpy.tester.getActiveTest().description = self.description + + +def function(functionName: str) -> FunctionBuilder: + return FunctionBuilder(functionName) \ No newline at end of file diff --git a/checkpy/lib/explanation.py b/checkpy/lib/explanation.py index ed6f8df..93bdca5 100644 --- a/checkpy/lib/explanation.py +++ b/checkpy/lib/explanation.py @@ -1,7 +1,7 @@ import re from types import ModuleType -from typing import Callable, List, Optional, Union +from typing import Any, Callable, List, Optional, Union from dessert.util import assertrepr_compare @@ -17,12 +17,19 @@ def addExplainer(explainer: Callable[[str, str, str], Optional[str]]) -> None: _explainers.append(explainer) -def explainCompare(op: str, left: str, right: str) -> Optional[str]: +def explainCompare(op: str, left: Any, right: Any) -> Optional[str]: for explainer in _explainers: rep = explainer(op, left, right) if rep: return rep + # Custom Type messages + if isinstance(right, checkpy.Type): + return f"{left} is not of type {right}" + + if isinstance(left, checkpy.Type): + return f"{right} is not of type {left}" + # Fall back on pytest (dessert) explanations rep = assertrepr_compare(MockConfig(), op, left, right) if rep: From b052bea28880a017aac8b52210dd84361a5f0614 Mon Sep 17 00:00:00 2001 From: Jelleas Date: Wed, 26 Jul 2023 15:45:10 +0200 Subject: [PATCH 191/269] function.stdout.stdoutRegex --- checkpy/lib/builder.py | 64 ++++++++++++++++++++++++++++++++++++------ 1 file changed, 56 insertions(+), 8 deletions(-) diff --git a/checkpy/lib/builder.py b/checkpy/lib/builder.py index 80580a4..7faca41 100644 --- a/checkpy/lib/builder.py +++ b/checkpy/lib/builder.py @@ -1,10 +1,20 @@ +import re + +from copy import deepcopy +from uuid import uuid4 +from typing import Any, Callable, Dict, Iterable, Optional, List, Tuple, Union + import checkpy.tests import checkpy.entities.function import checkpy.entities.exception import checkpy -from copy import deepcopy -from uuid import uuid4 -from typing import Any, Callable, Dict, Iterable, Optional, List, Tuple, Union + + +__all__ = ["function", "FunctionBuilder", "FunctionState"] + + +def function(functionName: str) -> "FunctionBuilder": + return FunctionBuilder(functionName) class FunctionBuilder: @@ -54,7 +64,7 @@ def testType(state: FunctionState): self._blocks.append(testType) return self - def returned(self, expected: Any) -> "FunctionBuilder": + def returns(self, expected: Any) -> "FunctionBuilder": def testReturned(state: FunctionState): actual = state.returned state.description = f"{state.getFunctionCallRepr()} should return {expected}" @@ -62,6 +72,48 @@ def testReturned(state: FunctionState): self._blocks.append(testReturned) return self + + def stdout(self, expected: Any) -> "FunctionBuilder": + def testStdout(state: FunctionState): + actual = state.function.printOutput + expectedStr = str(expected) + + descrStr = expectedStr.replace("\n", "\\n") + if len(descrStr) > 40: + descrStr = descrStr[:20] + " ... " + descrStr[-20:] + state.description = f"{state.getFunctionCallRepr()} should print {descrStr}" + + assert actual == expectedStr + + self._blocks.append(testStdout) + return self + + def stdoutRegex(self, regex: Union[str, re.Pattern], readable: Optional[str]=None) -> "FunctionBuilder": + def testStdoutRegex(state: FunctionState): + nonlocal regex + if isinstance(regex, str): + regex = re.compile(regex) + + if readable: + state.description = f"{state.getFunctionCallRepr()} should print {readable}" + else: + state.description = f"{state.getFunctionCallRepr()} should print output matching regular expression: {regex}" + + actual = state.function.printOutput + + match = regex.match(actual) + if not match: + if readable: + raise AssertionError(f"The printed output does not match the expected output. This is expected:\n" + f"{readable}\n" + f"This is what {state.getFunctionCallRepr()} printed:\n" + f"{actual}") + raise AssertionError(f"The printed output does not match regular expression: {regex}.\n" + f"This is what {state.getFunctionCallRepr()} printed:\n" + f"{actual}") + + self._blocks.append(testStdoutRegex) + return self def call(self, *args: Any, **kwargs: Any) -> "FunctionBuilder": def testCall(state: FunctionState): @@ -215,7 +267,3 @@ def getFunctionCallRepr(self): def setDescriptionFormatter(self, formatter: Callable[[str, "FunctionState"], str]): self._descriptionFormatter = formatter checkpy.tester.getActiveTest().description = self.description - - -def function(functionName: str) -> FunctionBuilder: - return FunctionBuilder(functionName) \ No newline at end of file From 71516bd3f3216dd02a99fd264311224427ea7d9c Mon Sep 17 00:00:00 2001 From: Jelleas Date: Wed, 26 Jul 2023 16:36:26 +0200 Subject: [PATCH 192/269] FunctionBuilder docs --- checkpy/lib/builder.py | 104 +++++++++++++++++++++++++++++++++++++++-- 1 file changed, 100 insertions(+), 4 deletions(-) diff --git a/checkpy/lib/builder.py b/checkpy/lib/builder.py index 7faca41..4c153fb 100644 --- a/checkpy/lib/builder.py +++ b/checkpy/lib/builder.py @@ -14,17 +14,63 @@ def function(functionName: str) -> "FunctionBuilder": + """ + A declarative approach to writing checks through method chaining. For example: + + testSquare = ( + function("square") # assert function square() is defined + .params("x") # assert that square() accepts one parameter called x + .returnType("int") # assert that the function always returns an integer + .call(2) # call the function with argument 2 + .returns(4) # assert that the function returns 4 + .call(3) # now call the function with argument 3 + .returns(9) # assert that the function returns 9 + .build() # done, build the test + ) + + # Builders can be reused as long as build() is not called. For example: + + squareBuilder = ( + function("square") + .params("x") + .returnType("int") + ) + + testSquare2 = squareBuilder.call(2).returns(4).build() # do remember to call build()! + testSquare3 = squareBuilder.call(3).returns(9).build() # do remember to call build()! + + # Tests created by this approach can depend and be depended on by other tests like normal: + + testSquare4 = passed(testSquare2, testSquare3)( + squareBuilder.call(4).returns(16).build() # testSquare4 will only run if both testSquare2 and 3 pass + ) + + @passed(testSquare2) + def testSquareError(): # testSquareError will only run if testSquare2 passes. + \"\"\"square("foo") raises a ValueError\"\"\" + try: + square("foo") + except ValueError: + return + return False + """ return FunctionBuilder(functionName) class FunctionBuilder: def __init__(self, functionName: str): + """ + A Builder of Function tests through method chaining. + Each method adds a part of a test on a stack. + Upon `.build()` a checkpy test is created that executes each entry in the stack. + """ self._state: FunctionState = FunctionState(functionName) self._blocks: List[Callable[["FunctionState"], None]] = [] self.name(functionName) def name(self, functionName: str) -> "FunctionBuilder": + """Assert that a function with functionName is defined.""" def testName(state: FunctionState): state.name = functionName state.description = f"defines the function {functionName}()" @@ -35,6 +81,7 @@ def testName(state: FunctionState): return self def params(self, *params: str) -> "FunctionBuilder": + """Assert that the function accepts exactly these parameters.""" def testParams(state: FunctionState): state.params = params state.description = f"defines the function as {state.name}({', '.join(params)})" @@ -53,6 +100,12 @@ def testParams(state: FunctionState): return self def returnType(self, type_: type) -> "FunctionBuilder": + """ + Assert that the function always returns values of type_. + Note that type_ can be any typehint. For instance: + + function("square").returnType(Optional[int]) # assert that square returns an int or None + """ def testType(state: FunctionState): state.returnType = type_ @@ -65,6 +118,7 @@ def testType(state: FunctionState): return self def returns(self, expected: Any) -> "FunctionBuilder": + """Assert that the last call returns expected.""" def testReturned(state: FunctionState): actual = state.returned state.description = f"{state.getFunctionCallRepr()} should return {expected}" @@ -73,22 +127,26 @@ def testReturned(state: FunctionState): self._blocks.append(testReturned) return self - def stdout(self, expected: Any) -> "FunctionBuilder": + def stdout(self, expected: str) -> "FunctionBuilder": + """Assert that the last call printed expected.""" def testStdout(state: FunctionState): actual = state.function.printOutput - expectedStr = str(expected) - descrStr = expectedStr.replace("\n", "\\n") + descrStr = expected.replace("\n", "\\n") if len(descrStr) > 40: descrStr = descrStr[:20] + " ... " + descrStr[-20:] state.description = f"{state.getFunctionCallRepr()} should print {descrStr}" - assert actual == expectedStr + assert actual == expected self._blocks.append(testStdout) return self def stdoutRegex(self, regex: Union[str, re.Pattern], readable: Optional[str]=None) -> "FunctionBuilder": + """ + Assert that the last call printed output matching regex. + If readable is passed, show that instead of the regex in the test's output. + """ def testStdoutRegex(state: FunctionState): nonlocal regex if isinstance(regex, str): @@ -116,6 +174,7 @@ def testStdoutRegex(state: FunctionState): return self def call(self, *args: Any, **kwargs: Any) -> "FunctionBuilder": + """Call the function with args and kwargs.""" def testCall(state: FunctionState): state.args = args state.kwargs = kwargs @@ -131,6 +190,7 @@ def testCall(state: FunctionState): return self def timeout(self, time: int) -> "FunctionBuilder": + """Reset the timeout on the check to time.""" def setTimeout(state: FunctionState): state.timeout = time @@ -138,17 +198,42 @@ def setTimeout(state: FunctionState): return self def description(self, description: str) -> "FunctionBuilder": + """ + Fixate the test's description on description. + The test's description will not change after a call to this method, + and can only change by calling this method again. + """ def setDecription(state: FunctionState): + state.setDescriptionFormatter(lambda descr, state: descr) + state.isDescriptionMutable = True state.description = description + state.isDescriptionMutable = False self._blocks.append(setDecription) return self def do(self, function: Callable[["FunctionState"], None]) -> "FunctionBuilder": + """ + Put function on the internal stack and call it after all previous calls have resolved. + .do serves as an entry point for extensibility. Allowing you, the test writer, to insert + specific and custom asserts, hints, and the like. For example: + + def checkDataFileIsUnchanged(state: "FunctionState"): + with open("data.txt") as f: + assert f.read() == "42\\n", "make sure not to change the file data.txt" + + test = function("process_data").call("data.txt").do(checkDataFileIsUnchanged).build() + """ self._blocks.append(function) return self def build(self) -> checkpy.tests.TestFunction: + """ + Build the actual test (checkpy.tests.TestFunction). This should always be the last call. + Be sure to store the result in a global, to allow checkpy to discover the test. For instance: + + testSquare = (function("square").call(3).returns(9).build()) + """ def testFunction(): self.log: List[FunctionState] = [] @@ -172,6 +257,7 @@ def __init__(self, functionName: str): self._args: List[Any] = [] self._kwargs: Dict[str, Any] = {} self._timeout: int = 10 + self._isDescriptionMutable: bool = True self._descriptionFormatter: Callable[[str, FunctionState], str] =\ lambda descr, state: f"testing {state.name}() >> {descr}" @@ -255,9 +341,19 @@ def description(self) -> str: @description.setter def description(self, newDescription): + if not self.isDescriptionMutable: + return self._description = newDescription checkpy.tester.getActiveTest().description = self.description + @property + def isDescriptionMutable(self): + return self._isDescriptionMutable + + @isDescriptionMutable.setter + def isDescriptionMutable(self, newIsDescriptionMutable: bool): + self._isDescriptionMutable = newIsDescriptionMutable + def getFunctionCallRepr(self): argsRepr = ", ".join(str(arg) for arg in self.args) kwargsRepr = ", ".join(f"{k}={v}" for k, v in self.kwargs.items()) From 82859efed39e2c3b8cbdb888604b0a8ae4d02e94 Mon Sep 17 00:00:00 2001 From: Jelleas Date: Wed, 26 Jul 2023 16:38:48 +0200 Subject: [PATCH 193/269] FunctionBuilder docs --- checkpy/lib/builder.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/checkpy/lib/builder.py b/checkpy/lib/builder.py index 4c153fb..8c3faa4 100644 --- a/checkpy/lib/builder.py +++ b/checkpy/lib/builder.py @@ -17,6 +17,7 @@ def function(functionName: str) -> "FunctionBuilder": """ A declarative approach to writing checks through method chaining. For example: + ``` testSquare = ( function("square") # assert function square() is defined .params("x") # assert that square() accepts one parameter called x @@ -53,6 +54,7 @@ def testSquareError(): # testSquareError will only run if testSquare2 passes. except ValueError: return return False + ``` """ return FunctionBuilder(functionName) @@ -104,7 +106,7 @@ def returnType(self, type_: type) -> "FunctionBuilder": Assert that the function always returns values of type_. Note that type_ can be any typehint. For instance: - function("square").returnType(Optional[int]) # assert that square returns an int or None + `function("square").returnType(Optional[int]) # assert that square returns an int or None` """ def testType(state: FunctionState): state.returnType = type_ @@ -218,11 +220,13 @@ def do(self, function: Callable[["FunctionState"], None]) -> "FunctionBuilder": .do serves as an entry point for extensibility. Allowing you, the test writer, to insert specific and custom asserts, hints, and the like. For example: + ``` def checkDataFileIsUnchanged(state: "FunctionState"): with open("data.txt") as f: assert f.read() == "42\\n", "make sure not to change the file data.txt" test = function("process_data").call("data.txt").do(checkDataFileIsUnchanged).build() + ``` """ self._blocks.append(function) return self @@ -232,7 +236,7 @@ def build(self) -> checkpy.tests.TestFunction: Build the actual test (checkpy.tests.TestFunction). This should always be the last call. Be sure to store the result in a global, to allow checkpy to discover the test. For instance: - testSquare = (function("square").call(3).returns(9).build()) + `testSquare = (function("square").call(3).returns(9).build())` """ def testFunction(): self.log: List[FunctionState] = [] From 08a6488d04f6a40d80b0e597d906105f91e13d5b Mon Sep 17 00:00:00 2001 From: Jelleas Date: Wed, 26 Jul 2023 16:56:37 +0200 Subject: [PATCH 194/269] FunctionState docs --- checkpy/lib/builder.py | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/checkpy/lib/builder.py b/checkpy/lib/builder.py index 8c3faa4..6572d7a 100644 --- a/checkpy/lib/builder.py +++ b/checkpy/lib/builder.py @@ -251,6 +251,10 @@ def testFunction(): class FunctionState: + """ + The state of the current test. + This object serves as the "single source of truth" for each method in `FunctionBuilder`. + """ def __init__(self, functionName: str): self._description: str = f"defines the function {functionName}()" self._name: str = functionName @@ -267,6 +271,7 @@ def __init__(self, functionName: str): @property def name(self) -> str: + """The name of the function to be tested.""" return self._name @name.setter @@ -275,6 +280,7 @@ def name(self, newName: str): @property def params(self) -> Tuple[str]: + """The exact parameter names and order that the function accepts.""" if self._params is None: raise checkpy.entities.exception.CheckpyError( f"params are not set for function builder test {self._name}()" @@ -287,14 +293,17 @@ def params(self, parameters: Iterable[str]): @property def function(self) -> checkpy.entities.function.Function: + """The executable function.""" return checkpy.getFunction(self.name) @property def wasCalled(self) -> bool: + """Has the function been called yet?""" return self._wasCalled @property def returned(self) -> Any: + """What the last function call returned.""" if not self.wasCalled: raise checkpy.entities.exception.CheckpyError( f"function was never called for function builder test {self._name}" @@ -308,6 +317,7 @@ def returned(self, newReturned): @property def args(self) -> List[Any]: + """The args that were given to the last function call (excluding keyword args)""" return self._args @args.setter @@ -316,6 +326,7 @@ def args(self, newArgs: Iterable[Any]): @property def kwargs(self) -> Dict[str, Any]: + """The keyword args that were given to the last function call (excluding normal args)""" return self._kwargs @kwargs.setter @@ -324,6 +335,10 @@ def kwargs(self, newKwargs: Dict[str, Any]): @property def returnType(self) -> type: + """ + The typehint of what the function should return according to the test. + This is not the typehint of the function itself! + """ return self._returnType @returnType.setter @@ -332,6 +347,10 @@ def returnType(self, newReturnType: type): @property def timeout(self) -> int: + """ + The timeout of the test in seconds. + This is not the time left, just the total time available for this test. + """ return self._timeout @timeout.setter @@ -341,6 +360,7 @@ def timeout(self, newTimeout): @property def description(self) -> str: + """The description of the test, what is ultimately shown on the screen.""" return self._descriptionFormatter(self._description, self) @description.setter @@ -352,6 +372,7 @@ def description(self, newDescription): @property def isDescriptionMutable(self): + """Can the description be changed (mutated)?""" return self._isDescriptionMutable @isDescriptionMutable.setter @@ -359,11 +380,23 @@ def isDescriptionMutable(self, newIsDescriptionMutable: bool): self._isDescriptionMutable = newIsDescriptionMutable def getFunctionCallRepr(self): + """ + Helper method to get a formatted string of the function call. + For instance: foo(2, bar=3) + Note this method can only be called after a call to the tested function. + Do be sure to check state.wasCalled! + """ argsRepr = ", ".join(str(arg) for arg in self.args) kwargsRepr = ", ".join(f"{k}={v}" for k, v in self.kwargs.items()) repr = ', '.join([a for a in (argsRepr, kwargsRepr) if a]) return f"{self.name}({repr})" def setDescriptionFormatter(self, formatter: Callable[[str, "FunctionState"], str]): + """ + The test's description is formatted by a function accepting the new description and the state. + This method allows you to overwrite that function, for instance: + + `state.setDescriptionFormatter(lambda descr, state: f"Testing your function {state.name}: {descr}")` + """ self._descriptionFormatter = formatter checkpy.tester.getActiveTest().description = self.description From dc335a0f3d1de22803c35e713a1e09362c02d8ef Mon Sep 17 00:00:00 2001 From: Jelleas Date: Wed, 26 Jul 2023 17:04:19 +0200 Subject: [PATCH 195/269] builder types --- checkpy/lib/builder.py | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/checkpy/lib/builder.py b/checkpy/lib/builder.py index 6572d7a..ece62d2 100644 --- a/checkpy/lib/builder.py +++ b/checkpy/lib/builder.py @@ -5,12 +5,13 @@ from typing import Any, Callable, Dict, Iterable, Optional, List, Tuple, Union import checkpy.tests +import checkpy.tester import checkpy.entities.function import checkpy.entities.exception import checkpy -__all__ = ["function", "FunctionBuilder", "FunctionState"] +__all__ = ["function"] def function(functionName: str) -> "FunctionBuilder": @@ -85,7 +86,7 @@ def testName(state: FunctionState): def params(self, *params: str) -> "FunctionBuilder": """Assert that the function accepts exactly these parameters.""" def testParams(state: FunctionState): - state.params = params + state.params = list(params) state.description = f"defines the function as {state.name}({', '.join(params)})" real = state.function.parameters @@ -178,7 +179,7 @@ def testStdoutRegex(state: FunctionState): def call(self, *args: Any, **kwargs: Any) -> "FunctionBuilder": """Call the function with args and kwargs.""" def testCall(state: FunctionState): - state.args = args + state.args = list(args) state.kwargs = kwargs state.description = f"calling function {state.getFunctionCallRepr()}" state.returned = state.function(*args, **kwargs) @@ -261,7 +262,7 @@ def __init__(self, functionName: str): self._params: Optional[List[str]] = None self._wasCalled: bool = False self._returned: Any = None - self._returnType: type = Any + self._returnType: Any = Any self._args: List[Any] = [] self._kwargs: Dict[str, Any] = {} self._timeout: int = 10 @@ -279,7 +280,7 @@ def name(self, newName: str): self._name = str(newName) @property - def params(self) -> Tuple[str]: + def params(self) -> List[str]: """The exact parameter names and order that the function accepts.""" if self._params is None: raise checkpy.entities.exception.CheckpyError( @@ -311,7 +312,7 @@ def returned(self) -> Any: return self._returned @returned.setter - def returned(self, newReturned): + def returned(self, newReturned: Any): self._wasCalled = True self._returned = newReturned @@ -322,7 +323,7 @@ def args(self) -> List[Any]: @args.setter def args(self, newArgs: Iterable[Any]): - self._args = newArgs + self._args = list(newArgs) @property def kwargs(self) -> Dict[str, Any]: @@ -354,7 +355,7 @@ def timeout(self) -> int: return self._timeout @timeout.setter - def timeout(self, newTimeout): + def timeout(self, newTimeout: int): self._timeout = newTimeout checkpy.tester.getActiveTest().timeout = self.timeout @@ -364,7 +365,7 @@ def description(self) -> str: return self._descriptionFormatter(self._description, self) @description.setter - def description(self, newDescription): + def description(self, newDescription: str): if not self.isDescriptionMutable: return self._description = newDescription From 6c6618e1de296e8d88fe069299d5cb92100159da Mon Sep 17 00:00:00 2001 From: Jelleas Date: Wed, 26 Jul 2023 17:18:51 +0200 Subject: [PATCH 196/269] import builder in __init__.py --- checkpy/__init__.py | 6 ++++++ checkpy/lib/builder.py | 16 ++++++++++++++++ 2 files changed, 22 insertions(+) diff --git a/checkpy/__init__.py b/checkpy/__init__.py index 4b99ed0..fbf075b 100644 --- a/checkpy/__init__.py +++ b/checkpy/__init__.py @@ -1,3 +1,8 @@ +import dessert as _dessert + +with _dessert.rewrite_assertions_context(): + from checkpy.lib import builder + from checkpy.tests import test, failed, passed from checkpy.lib.basic import outputOf, getModule, getFunction from checkpy.lib.sandbox import only, include, exclude, require @@ -18,6 +23,7 @@ "Type", "static", "monkeypatch", + "builder", "only", "include", "exclude", diff --git a/checkpy/lib/builder.py b/checkpy/lib/builder.py index ece62d2..24ea2ca 100644 --- a/checkpy/lib/builder.py +++ b/checkpy/lib/builder.py @@ -1,3 +1,19 @@ +""" +A declarative approach to writing checks through method chaining. For example: + +``` +testSquare = (builder + .function("square") # assert function square() is defined + .params("x") # assert that square() accepts one parameter called x + .returnType("int") # assert that the function always returns an integer + .call(2) # call the function with argument 2 + .returns(4) # assert that the function returns 4 + .call(3) # now call the function with argument 3 + .returns(9) # assert that the function returns 9 + .build() # done, build the test +) +""" + import re from copy import deepcopy From ecc1c057adfe4b6efaa52abfc40b6b597b138d74 Mon Sep 17 00:00:00 2001 From: Jelleas Date: Wed, 26 Jul 2023 18:15:22 +0200 Subject: [PATCH 197/269] builder.function('square', fileName='foo.py') --- checkpy/lib/basic.py | 17 ++++++++++------ checkpy/lib/builder.py | 46 ++++++++++++++++++++++++++++-------------- 2 files changed, 42 insertions(+), 21 deletions(-) diff --git a/checkpy/lib/basic.py b/checkpy/lib/basic.py index 54f38c8..4f2a5fc 100644 --- a/checkpy/lib/basic.py +++ b/checkpy/lib/basic.py @@ -99,11 +99,13 @@ def getModuleAndOutputOf( ) -> Tuple[ModuleType, str]: """ This function handles most of checkpy's under the hood functionality - fileName: the name of the file to run - source: the source code to be run - stdinArgs: optional arguments passed to stdin - ignoredExceptions: a collection of Exceptions that will silently pass - overwriteAttributes: a list of tuples [(attribute, value), ...] + + fileName (optional): the name of the file to run + src (optional): the source code to run + argv (optional): set sys.argv to argv before importing, + stdinArgs (optional): arguments passed to stdin + ignoreExceptions (optional): exceptions that will silently pass while importing + overwriteAttributes (optional): attributes to overwrite in the imported module """ if fileName is None: fileName = checkpy.file.name @@ -126,7 +128,10 @@ def getModuleAndOutputOf( if argv: sys.argv, argv = argv, sys.argv - moduleName = str(fileName).split(".")[0] + if any(name == "__name__" and attr == "__main__" for name, attr in overwriteAttributes): + moduleName = "__main__" + else: + moduleName = str(fileName).split(".")[0] mod = imp.new_module(moduleName) # overwrite attributes diff --git a/checkpy/lib/builder.py b/checkpy/lib/builder.py index 24ea2ca..8c2454c 100644 --- a/checkpy/lib/builder.py +++ b/checkpy/lib/builder.py @@ -4,21 +4,22 @@ ``` testSquare = (builder .function("square") # assert function square() is defined - .params("x") # assert that square() accepts one parameter called x - .returnType("int") # assert that the function always returns an integer - .call(2) # call the function with argument 2 - .returns(4) # assert that the function returns 4 - .call(3) # now call the function with argument 3 - .returns(9) # assert that the function returns 9 - .build() # done, build the test + .params("x") # assert that square() accepts one parameter called x + .returnType("int") # assert that the function always returns an integer + .call(2) # call the function with argument 2 + .returns(4) # assert that the function returns 4 + .call(3) # now call the function with argument 3 + .returns(9) # assert that the function returns 9 + .build() # done, build the test ) """ import re from copy import deepcopy -from uuid import uuid4 +from pathlib import Path from typing import Any, Callable, Dict, Iterable, Optional, List, Tuple, Union +from uuid import uuid4 import checkpy.tests import checkpy.tester @@ -30,9 +31,11 @@ __all__ = ["function"] -def function(functionName: str) -> "FunctionBuilder": +def function(functionName: str, fileName: Optional[str]=None) -> "FunctionBuilder": """ - A declarative approach to writing checks through method chaining. For example: + A declarative approach to writing checks through method chaining. + + For example: ``` testSquare = ( @@ -73,17 +76,17 @@ def testSquareError(): # testSquareError will only run if testSquare2 passes. return False ``` """ - return FunctionBuilder(functionName) + return FunctionBuilder(functionName, fileName=fileName) class FunctionBuilder: - def __init__(self, functionName: str): + def __init__(self, functionName: str, fileName: Optional[str]=None): """ A Builder of Function tests through method chaining. Each method adds a part of a test on a stack. Upon `.build()` a checkpy test is created that executes each entry in the stack. """ - self._state: FunctionState = FunctionState(functionName) + self._state: FunctionState = FunctionState(functionName, fileName=fileName) self._blocks: List[Callable[["FunctionState"], None]] = [] self.name(functionName) @@ -272,9 +275,10 @@ class FunctionState: The state of the current test. This object serves as the "single source of truth" for each method in `FunctionBuilder`. """ - def __init__(self, functionName: str): + def __init__(self, functionName: str, fileName: Optional[str]=None): self._description: str = f"defines the function {functionName}()" self._name: str = functionName + self._fileName: Optional[str] = fileName self._params: Optional[List[str]] = None self._wasCalled: bool = False self._returned: Any = None @@ -295,6 +299,18 @@ def name(self) -> str: def name(self, newName: str): self._name = str(newName) + @property + def fileName(self) -> Optional[str]: + """ + The name of the Python file to run and import. + If this is not set (`None`), the default file (`checkpy.file.name`) is used. + """ + return self._fileName + + @fileName.setter + def fileName(self, newFileName: Optional[str]): + self._fileName = newFileName + @property def params(self) -> List[str]: """The exact parameter names and order that the function accepts.""" @@ -311,7 +327,7 @@ def params(self, parameters: Iterable[str]): @property def function(self) -> checkpy.entities.function.Function: """The executable function.""" - return checkpy.getFunction(self.name) + return checkpy.getFunction(self.name, fileName=self.fileName) @property def wasCalled(self) -> bool: From 87d8d44b34b74cfd8581ced2a658833c12e43502 Mon Sep 17 00:00:00 2001 From: Jelleas Date: Wed, 26 Jul 2023 18:21:22 +0200 Subject: [PATCH 198/269] builder.function.returnType does no assertions --- checkpy/lib/builder.py | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/checkpy/lib/builder.py b/checkpy/lib/builder.py index 8c2454c..f6bf824 100644 --- a/checkpy/lib/builder.py +++ b/checkpy/lib/builder.py @@ -17,8 +17,7 @@ import re from copy import deepcopy -from pathlib import Path -from typing import Any, Callable, Dict, Iterable, Optional, List, Tuple, Union +from typing import Any, Callable, Dict, Iterable, Optional, List, Union from uuid import uuid4 import checkpy.tests @@ -126,16 +125,11 @@ def returnType(self, type_: type) -> "FunctionBuilder": Assert that the function always returns values of type_. Note that type_ can be any typehint. For instance: - `function("square").returnType(Optional[int]) # assert that square returns an int or None` + `function("square").returnType(Optional[int]).call(2) # assert that square returns an int or None` """ def testType(state: FunctionState): state.returnType = type_ - if state.wasCalled: - state.description = f"{state.getFunctionCallRepr()} returns a value of type {state.returnType}" - returned = state.returned - assert returned == checkpy.Type(type_) - self._blocks.append(testType) return self From e74d73f53e64c2f1b43c05d7b164c67f6af40fa2 Mon Sep 17 00:00:00 2001 From: Jelleas Date: Wed, 26 Jul 2023 18:24:46 +0200 Subject: [PATCH 199/269] builder.function.returnType phrasing --- checkpy/lib/builder.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/checkpy/lib/builder.py b/checkpy/lib/builder.py index f6bf824..e103507 100644 --- a/checkpy/lib/builder.py +++ b/checkpy/lib/builder.py @@ -122,7 +122,7 @@ def testParams(state: FunctionState): def returnType(self, type_: type) -> "FunctionBuilder": """ - Assert that the function always returns values of type_. + From now on, assert that the function always returns values of type_ when called. Note that type_ can be any typehint. For instance: `function("square").returnType(Optional[int]).call(2) # assert that square returns an int or None` From 21b9f4762826739ea2dc1c24f0775340be235628 Mon Sep 17 00:00:00 2001 From: Jelleas Date: Wed, 26 Jul 2023 18:33:14 +0200 Subject: [PATCH 200/269] rm deprecated import imp --- checkpy/lib/basic.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/checkpy/lib/basic.py b/checkpy/lib/basic.py index 4f2a5fc..634f058 100644 --- a/checkpy/lib/basic.py +++ b/checkpy/lib/basic.py @@ -3,7 +3,6 @@ import re import os import contextlib -import imp import traceback import requests @@ -133,7 +132,7 @@ def getModuleAndOutputOf( else: moduleName = str(fileName).split(".")[0] - mod = imp.new_module(moduleName) + mod = ModuleType(moduleName) # overwrite attributes for attr, value in overwriteAttributes: setattr(mod, attr, value) From dd7275e7ebd418083e6936444ae6add1ec4ee19f Mon Sep 17 00:00:00 2001 From: Jelleas Date: Wed, 26 Jul 2023 20:52:35 +0200 Subject: [PATCH 201/269] FunctionBuilder => function, more do --- checkpy/lib/builder.py | 64 ++++++++++++++++-------------------------- 1 file changed, 24 insertions(+), 40 deletions(-) diff --git a/checkpy/lib/builder.py b/checkpy/lib/builder.py index e103507..70c73cb 100644 --- a/checkpy/lib/builder.py +++ b/checkpy/lib/builder.py @@ -30,9 +30,11 @@ __all__ = ["function"] -def function(functionName: str, fileName: Optional[str]=None) -> "FunctionBuilder": +class function: """ A declarative approach to writing checks through method chaining. + Each method adds a part of a test on a stack. + Upon `.build()` a checkpy test is created that executes each entry in the stack. For example: @@ -75,22 +77,13 @@ def testSquareError(): # testSquareError will only run if testSquare2 passes. return False ``` """ - return FunctionBuilder(functionName, fileName=fileName) - - -class FunctionBuilder: - def __init__(self, functionName: str, fileName: Optional[str]=None): - """ - A Builder of Function tests through method chaining. - Each method adds a part of a test on a stack. - Upon `.build()` a checkpy test is created that executes each entry in the stack. - """ + def __init__(self, functionName: str, fileName: Optional[str]=None): self._state: FunctionState = FunctionState(functionName, fileName=fileName) self._blocks: List[Callable[["FunctionState"], None]] = [] self.name(functionName) - def name(self, functionName: str) -> "FunctionBuilder": + def name(self, functionName: str) -> "function": """Assert that a function with functionName is defined.""" def testName(state: FunctionState): state.name = functionName @@ -98,10 +91,9 @@ def testName(state: FunctionState): assert functionName in checkpy.static.getFunctionDefinitions(),\ f'no function found with name {functionName}()' - self._blocks.append(testName) - return self + return self.do(testName) - def params(self, *params: str) -> "FunctionBuilder": + def params(self, *params: str) -> "function": """Assert that the function accepts exactly these parameters.""" def testParams(state: FunctionState): state.params = list(params) @@ -117,10 +109,9 @@ def testParams(state: FunctionState): assert real == expected,\ f"parameters should exactly match the requested function definition" - self._blocks.append(testParams) - return self + return self.do(testParams) - def returnType(self, type_: type) -> "FunctionBuilder": + def returnType(self, type_: type) -> "function": """ From now on, assert that the function always returns values of type_ when called. Note that type_ can be any typehint. For instance: @@ -130,20 +121,18 @@ def returnType(self, type_: type) -> "FunctionBuilder": def testType(state: FunctionState): state.returnType = type_ - self._blocks.append(testType) - return self + return self.do(testType) - def returns(self, expected: Any) -> "FunctionBuilder": + def returns(self, expected: Any) -> "function": """Assert that the last call returns expected.""" def testReturned(state: FunctionState): actual = state.returned state.description = f"{state.getFunctionCallRepr()} should return {expected}" assert actual == expected - self._blocks.append(testReturned) - return self + return self.do(testReturned) - def stdout(self, expected: str) -> "FunctionBuilder": + def stdout(self, expected: str) -> "function": """Assert that the last call printed expected.""" def testStdout(state: FunctionState): actual = state.function.printOutput @@ -155,10 +144,9 @@ def testStdout(state: FunctionState): assert actual == expected - self._blocks.append(testStdout) - return self + return self.do(testStdout) - def stdoutRegex(self, regex: Union[str, re.Pattern], readable: Optional[str]=None) -> "FunctionBuilder": + def stdoutRegex(self, regex: Union[str, re.Pattern], readable: Optional[str]=None) -> "function": """ Assert that the last call printed output matching regex. If readable is passed, show that instead of the regex in the test's output. @@ -186,10 +174,9 @@ def testStdoutRegex(state: FunctionState): f"This is what {state.getFunctionCallRepr()} printed:\n" f"{actual}") - self._blocks.append(testStdoutRegex) - return self + return self.do(testStdoutRegex) - def call(self, *args: Any, **kwargs: Any) -> "FunctionBuilder": + def call(self, *args: Any, **kwargs: Any) -> "function": """Call the function with args and kwargs.""" def testCall(state: FunctionState): state.args = list(args) @@ -202,18 +189,16 @@ def testCall(state: FunctionState): returned = state.returned assert returned == checkpy.Type(type_) - self._blocks.append(testCall) - return self + return self.do(testCall) - def timeout(self, time: int) -> "FunctionBuilder": + def timeout(self, time: int) -> "function": """Reset the timeout on the check to time.""" def setTimeout(state: FunctionState): state.timeout = time - self._blocks.append(setTimeout) - return self + return self.do(setTimeout) - def description(self, description: str) -> "FunctionBuilder": + def description(self, description: str) -> "function": """ Fixate the test's description on description. The test's description will not change after a call to this method, @@ -225,10 +210,9 @@ def setDecription(state: FunctionState): state.description = description state.isDescriptionMutable = False - self._blocks.append(setDecription) - return self + return self.do(setDecription) - def do(self, function: Callable[["FunctionState"], None]) -> "FunctionBuilder": + def do(self, function: Callable[["FunctionState"], None]) -> "function": """ Put function on the internal stack and call it after all previous calls have resolved. .do serves as an entry point for extensibility. Allowing you, the test writer, to insert @@ -267,7 +251,7 @@ def testFunction(): class FunctionState: """ The state of the current test. - This object serves as the "single source of truth" for each method in `FunctionBuilder`. + This object serves as the "single source of truth" for each method in `function`. """ def __init__(self, functionName: str, fileName: Optional[str]=None): self._description: str = f"defines the function {functionName}()" From d81f6ead0e4b9c8ea78a71f78106127df3f751ea Mon Sep 17 00:00:00 2001 From: Jelleas Date: Wed, 26 Jul 2023 21:29:13 +0200 Subject: [PATCH 202/269] introducedstatic.getAstNodes + static.getgetSourceOfDefinitions uses ast --- checkpy/lib/static.py | 289 ++++++++++++++++++++++++------------------ 1 file changed, 165 insertions(+), 124 deletions(-) diff --git a/checkpy/lib/static.py b/checkpy/lib/static.py index dd1457c..52a26f8 100644 --- a/checkpy/lib/static.py +++ b/checkpy/lib/static.py @@ -9,158 +9,199 @@ from typing import List as _List import checkpy as _checkpy +import checkpy.entities.exception as _exception __all__ = [ - "getSource", - "getSourceOfDefinitions", - "removeComments", - "getFunctionCalls", - "getFunctionDefinitions" + "getSource", + "getSourceOfDefinitions", + "removeComments", + "getFunctionCalls", + "getFunctionDefinitions" ] def getSource(fileName: _Optional[_Union[str, _Path]]=None) -> str: - """Get the contents of the file.""" - if fileName is None: - fileName = _checkpy.file.name + """Get the contents of the file.""" + if fileName is None: + fileName = _checkpy.file.name - with open(fileName) as f: - return f.read() + with open(fileName) as f: + return f.read() def getSourceOfDefinitions(fileName: _Optional[_Union[str, _Path]]=None) -> str: - """Get just the source code inside definitions (def / class).""" - if fileName is None: - fileName = _checkpy.file.name - - newSource = "" - - with open(fileName) as f: - insideDefinition = False - for line in removeComments(f.read()).split("\n"): - line += "\n" - if not line.strip(): - continue - - if (line.startswith(" ") or line.startswith("\t")) and insideDefinition: - newSource += line - elif line.startswith("def ") or line.startswith("class "): - newSource += line - insideDefinition = True - elif line.startswith("import ") or line.startswith("from "): - newSource += line - else: - insideDefinition = False - return newSource + """Get just the source code inside definitions (def / class).""" + if fileName is None: + fileName = _checkpy.file.name + + source = getSource(fileName) + + class Visitor(_ast.NodeVisitor): + def __init__(self): + self.lineNumbers = set() + + def visit_ClassDef(self, node: _ast.ClassDef): + self.lineNumbers |= set(range(node.lineno - 1, node.end_lineno)) + super().generic_visit(node) + + def visit_FunctionDef(self, node: _ast.FunctionDef): + self.lineNumbers |= set(range(node.lineno - 1, node.end_lineno)) + super().generic_visit(node) + + tree = _ast.parse(source) + visitor = Visitor() + visitor.visit(tree) + + lines = source.split("\n") + return "\n".join(lines[n] for n in sorted(visitor.lineNumbers)) def getNumbersFrom(text: str) -> _List[_Union[int, float]]: - """ - Get all Python parseable numbers from a string. - Numbers are assumed to be seperated by whitespace from other text. - whitespace = \s = [\\r\\n\\t\\f\\v ] - """ - numbers: _List[_Union[int, float]] = [] - for elem in _re.split(r"\s", text): - try: - if "." in elem: - numbers.append(float(elem)) - else: - numbers.append(int(elem)) - except ValueError: - pass - - return numbers + """ + Get all Python parseable numbers from a string. + Numbers are assumed to be seperated by whitespace from other text. + whitespace = \s = [\\r\\n\\t\\f\\v ] + """ + numbers: _List[_Union[int, float]] = [] + for elem in _re.split(r"\s", text): + try: + if "." in elem: + numbers.append(float(elem)) + else: + numbers.append(int(elem)) + except ValueError: + pass + + return numbers # inspiration from http://stackoverflow.com/questions/1769332/script-to-remove-python-comments-docstrings def removeComments(source: str) -> str: - """Remove comments from a string containing Python source code.""" - io_obj = _io.StringIO(source) - out = "" - last_lineno = -1 - last_col = 0 - indentation = "\t" - for token_type, token_string, (start_line, start_col), (end_line, end_col), ltext in _tokenize.generate_tokens(io_obj.readline): - if start_line > last_lineno: - last_col = 0 - - # figure out type of indentation used - if token_type == _tokenize.INDENT: - indentation = "\t" if "\t" in token_string else " " - - # write indentation - if start_col > last_col and last_col == 0: - out += indentation * (start_col - last_col) - # write other whitespace - elif start_col > last_col: - out += " " * (start_col - last_col) - - # ignore comments - if token_type == _tokenize.COMMENT: - pass - # put all docstrings on a single line - elif token_type == _tokenize.STRING: - out += _re.sub("\n", " ", token_string) - else: - out += token_string - - last_col = end_col - last_lineno = end_line - return out + """Remove comments from a string containing Python source code.""" + io_obj = _io.StringIO(source) + out = "" + last_lineno = -1 + last_col = 0 + indentation = "\t" + for token_type, token_string, (start_line, start_col), (end_line, end_col), ltext in _tokenize.generate_tokens(io_obj.readline): + if start_line > last_lineno: + last_col = 0 + + # figure out type of indentation used + if token_type == _tokenize.INDENT: + indentation = "\t" if "\t" in token_string else " " + + # write indentation + if start_col > last_col and last_col == 0: + out += indentation * (start_col - last_col) + # write other whitespace + elif start_col > last_col: + out += " " * (start_col - last_col) + + # ignore comments + if token_type == _tokenize.COMMENT: + pass + # put all docstrings on a single line + elif token_type == _tokenize.STRING: + out += _re.sub("\n", " ", token_string) + else: + out += token_string + + last_col = end_col + last_lineno = end_line + return out def getFunctionCalls(source: _Optional[str]=None) -> _List[str]: - """Get all names of function called in source.""" - class CallVisitor(_ast.NodeVisitor): - def __init__(self): - self.parts = [] + """Get all names of function called in source.""" + class CallVisitor(_ast.NodeVisitor): + def __init__(self): + self.parts = [] - def visit_Attribute(self, node): - super().generic_visit(node) - self.parts.append(node.attr) + def visit_Attribute(self, node): + super().generic_visit(node) + self.parts.append(node.attr) - def visit_Name(self, node): - self.parts.append(node.id) + def visit_Name(self, node): + self.parts.append(node.id) - @property - def call(self): - return ".".join(self.parts) + @property + def call(self): + return ".".join(self.parts) - class FunctionsVisitor(_ast.NodeVisitor): - def __init__(self): - self.functionCalls = [] + class FunctionsVisitor(_ast.NodeVisitor): + def __init__(self): + self.functionCalls = [] - def visit_Call(self, node): - callVisitor = CallVisitor() - callVisitor.visit(node.func) - super().generic_visit(node) - self.functionCalls.append(callVisitor.call) + def visit_Call(self, node): + callVisitor = CallVisitor() + callVisitor.visit(node.func) + super().generic_visit(node) + self.functionCalls.append(callVisitor.call) - if source is None: - source = getSource() + if source is None: + source = getSource() - tree = _ast.parse(source) - visitor = FunctionsVisitor() - visitor.visit(tree) - return visitor.functionCalls + tree = _ast.parse(source) + visitor = FunctionsVisitor() + visitor.visit(tree) + return visitor.functionCalls def getFunctionDefinitions(source: _Optional[str]=None) -> _List[str]: - """Get all names of Function definitions from source.""" - class FunctionsVisitor(_ast.NodeVisitor): - def __init__(self): - self.functionNames = [] - - def visit_FunctionDef(self, node: _ast.FunctionDef): - self.functionNames.append(node.name) - super().generic_visit(node) - - if source is None: - source = getSource() - - tree = _ast.parse(source) - visitor = FunctionsVisitor() - visitor.visit(tree) - return visitor.functionNames + """Get all names of Function definitions from source.""" + class FunctionsVisitor(_ast.NodeVisitor): + def __init__(self): + self.functionNames = [] + + def visit_FunctionDef(self, node: _ast.FunctionDef): + self.functionNames.append(node.name) + super().generic_visit(node) + + if source is None: + source = getSource() + + tree = _ast.parse(source) + visitor = FunctionsVisitor() + visitor.visit(tree) + return visitor.functionNames + + +def getAstNodes(*types: type, source: _Optional[str]=None) -> _List[_ast.AST]: + """ + Given a list of ast.AST types find all nodes with those types. + Every node found will have a `.lineno` attribute to get its line number. + Some examples: + + ``` + getAstNodes(ast.For) # Will find all for-loops in the source + getAstNodes(ast.Mult, ast.Add) # Will find all uses of multiplication (*) and addition (+) + ``` + """ + for type_ in types: + if type_.__module__ != _ast.__name__: + raise _exception.CheckpyError(f"{type_} passed to getAstNodes() is not of type ast.AST") + + nodes: _List[_ast.AST] = [] + + class Visitor(_ast.NodeVisitor): + def __init__(self): + self.lineNumber = 0 + + def generic_visit(self, node: _ast.AST): + if hasattr(node, "lineno"): + self.lineNumber = node.lineno + + if any(isinstance(node, type_) for type_ in types): + node.lineno = self.lineNumber + nodes.append(node) + + super().generic_visit(node) + + if source is None: + source = getSource() + + tree = _ast.parse(source) + Visitor().visit(tree) + return nodes \ No newline at end of file From 5851a6efc4ba3faa2c854f338910434156b412bc Mon Sep 17 00:00:00 2001 From: Jelleas Date: Wed, 26 Jul 2023 21:31:07 +0200 Subject: [PATCH 203/269] fix static __all__ --- checkpy/lib/static.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/checkpy/lib/static.py b/checkpy/lib/static.py index 52a26f8..6676631 100644 --- a/checkpy/lib/static.py +++ b/checkpy/lib/static.py @@ -16,8 +16,10 @@ "getSource", "getSourceOfDefinitions", "removeComments", + "getNumbersFrom", "getFunctionCalls", - "getFunctionDefinitions" + "getFunctionDefinitions", + "getAstNodes" ] From 8468c2df7be7afa9aec243264d5e56c1dacb246f Mon Sep 17 00:00:00 2001 From: Jelleas Date: Wed, 26 Jul 2023 21:39:24 +0200 Subject: [PATCH 204/269] stdout() accepts Any --- checkpy/lib/builder.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/checkpy/lib/builder.py b/checkpy/lib/builder.py index 70c73cb..954df31 100644 --- a/checkpy/lib/builder.py +++ b/checkpy/lib/builder.py @@ -27,7 +27,7 @@ import checkpy -__all__ = ["function"] +__all__ = ["function", "FunctionState"] class function: @@ -126,22 +126,22 @@ def testType(state: FunctionState): def returns(self, expected: Any) -> "function": """Assert that the last call returns expected.""" def testReturned(state: FunctionState): - actual = state.returned state.description = f"{state.getFunctionCallRepr()} should return {expected}" - assert actual == expected + assert state.returned == expected return self.do(testReturned) - def stdout(self, expected: str) -> "function": + def stdout(self, expected: Any) -> "function": """Assert that the last call printed expected.""" def testStdout(state: FunctionState): - actual = state.function.printOutput - + nonlocal expected + expected = str(expected) descrStr = expected.replace("\n", "\\n") if len(descrStr) > 40: descrStr = descrStr[:20] + " ... " + descrStr[-20:] state.description = f"{state.getFunctionCallRepr()} should print {descrStr}" - + + actual = state.function.printOutput assert actual == expected return self.do(testStdout) From 92ec83fdc368ca765ce4ef90f356073c5bcd4953 Mon Sep 17 00:00:00 2001 From: Jelleas Date: Wed, 26 Jul 2023 21:42:52 +0200 Subject: [PATCH 205/269] getAstNodes phrasing --- checkpy/lib/static.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/checkpy/lib/static.py b/checkpy/lib/static.py index 6676631..7f819bf 100644 --- a/checkpy/lib/static.py +++ b/checkpy/lib/static.py @@ -172,7 +172,7 @@ def visit_FunctionDef(self, node: _ast.FunctionDef): def getAstNodes(*types: type, source: _Optional[str]=None) -> _List[_ast.AST]: """ - Given a list of ast.AST types find all nodes with those types. + Given ast.AST types find all nodes with those types. Every node found will have a `.lineno` attribute to get its line number. Some examples: From 2cc7c2a8beb8be7242ab97f87d0cbf316ef6c162 Mon Sep 17 00:00:00 2001 From: Jelleas Date: Wed, 26 Jul 2023 22:11:41 +0200 Subject: [PATCH 206/269] return Self, not 'function' --- checkpy/lib/builder.py | 26 +++++++++++++++----------- 1 file changed, 15 insertions(+), 11 deletions(-) diff --git a/checkpy/lib/builder.py b/checkpy/lib/builder.py index 954df31..def029f 100644 --- a/checkpy/lib/builder.py +++ b/checkpy/lib/builder.py @@ -18,6 +18,7 @@ from copy import deepcopy from typing import Any, Callable, Dict, Iterable, Optional, List, Union +from typing_extensions import Self from uuid import uuid4 import checkpy.tests @@ -83,17 +84,20 @@ def __init__(self, functionName: str, fileName: Optional[str]=None): self.name(functionName) - def name(self, functionName: str) -> "function": + def name(self, functionName: str) -> Self: """Assert that a function with functionName is defined.""" def testName(state: FunctionState): state.name = functionName state.description = f"defines the function {functionName}()" - assert functionName in checkpy.static.getFunctionDefinitions(),\ + + source = checkpy.static.getSource(state.fileName) + funcDefs = checkpy.static.getFunctionDefinitions(source) + assert functionName in funcDefs,\ f'no function found with name {functionName}()' return self.do(testName) - def params(self, *params: str) -> "function": + def params(self, *params: str) -> Self: """Assert that the function accepts exactly these parameters.""" def testParams(state: FunctionState): state.params = list(params) @@ -111,7 +115,7 @@ def testParams(state: FunctionState): return self.do(testParams) - def returnType(self, type_: type) -> "function": + def returnType(self, type_: type) -> Self: """ From now on, assert that the function always returns values of type_ when called. Note that type_ can be any typehint. For instance: @@ -123,7 +127,7 @@ def testType(state: FunctionState): return self.do(testType) - def returns(self, expected: Any) -> "function": + def returns(self, expected: Any) -> Self: """Assert that the last call returns expected.""" def testReturned(state: FunctionState): state.description = f"{state.getFunctionCallRepr()} should return {expected}" @@ -131,7 +135,7 @@ def testReturned(state: FunctionState): return self.do(testReturned) - def stdout(self, expected: Any) -> "function": + def stdout(self, expected: Any) -> Self: """Assert that the last call printed expected.""" def testStdout(state: FunctionState): nonlocal expected @@ -146,7 +150,7 @@ def testStdout(state: FunctionState): return self.do(testStdout) - def stdoutRegex(self, regex: Union[str, re.Pattern], readable: Optional[str]=None) -> "function": + def stdoutRegex(self, regex: Union[str, re.Pattern], readable: Optional[str]=None) -> Self: """ Assert that the last call printed output matching regex. If readable is passed, show that instead of the regex in the test's output. @@ -176,7 +180,7 @@ def testStdoutRegex(state: FunctionState): return self.do(testStdoutRegex) - def call(self, *args: Any, **kwargs: Any) -> "function": + def call(self, *args: Any, **kwargs: Any) -> Self: """Call the function with args and kwargs.""" def testCall(state: FunctionState): state.args = list(args) @@ -191,14 +195,14 @@ def testCall(state: FunctionState): return self.do(testCall) - def timeout(self, time: int) -> "function": + def timeout(self, time: int) -> Self: """Reset the timeout on the check to time.""" def setTimeout(state: FunctionState): state.timeout = time return self.do(setTimeout) - def description(self, description: str) -> "function": + def description(self, description: str) -> Self: """ Fixate the test's description on description. The test's description will not change after a call to this method, @@ -212,7 +216,7 @@ def setDecription(state: FunctionState): return self.do(setDecription) - def do(self, function: Callable[["FunctionState"], None]) -> "function": + def do(self, function: Callable[["FunctionState"], None]) -> Self: """ Put function on the internal stack and call it after all previous calls have resolved. .do serves as an entry point for extensibility. Allowing you, the test writer, to insert From e6388ed34f0a79db9120edbe9bf4f310973ebd9a Mon Sep 17 00:00:00 2001 From: Jelleas Date: Wed, 26 Jul 2023 23:24:26 +0200 Subject: [PATCH 207/269] AbstractSyntaxTree --- checkpy/__init__.py | 2 ++ checkpy/entities/abstractsyntaxtree.py | 32 ++++++++++++++++++++++++++ checkpy/lib/explanation.py | 17 ++++++++++++-- checkpy/lib/static.py | 2 +- 4 files changed, 50 insertions(+), 3 deletions(-) create mode 100644 checkpy/entities/abstractsyntaxtree.py diff --git a/checkpy/__init__.py b/checkpy/__init__.py index fbf075b..42114dd 100644 --- a/checkpy/__init__.py +++ b/checkpy/__init__.py @@ -9,6 +9,7 @@ from checkpy.lib import static from checkpy.lib import monkeypatch from checkpy.entities.type import Type +from checkpy.entities.abstractsyntaxtree import AbstractSyntaxTree from pytest import approx import pathlib as _pathlib @@ -21,6 +22,7 @@ "getModule", "getFunction", "Type", + "AbstractSyntaxTree", "static", "monkeypatch", "builder", diff --git a/checkpy/entities/abstractsyntaxtree.py b/checkpy/entities/abstractsyntaxtree.py new file mode 100644 index 0000000..b6e7906 --- /dev/null +++ b/checkpy/entities/abstractsyntaxtree.py @@ -0,0 +1,32 @@ +import checkpy +import checkpy.entities.exception +import ast +from typing import Optional, List + +__all__ = ["AbstractSyntaxTree"] + +class AbstractSyntaxTree: + """ + An 'in' and 'not in' comparible AbstractSyntaxTree for any ast.Node (any type of ast.AST). + For instance: + + ``` + ast.For in AbstractSyntaxTree() # check if a for-loop is present + ast.Mult in AbstractSyntaxTree() # check if multiplication is used + ``` + """ + def __init__(self, fileName: Optional[str]=None): + # Keep track of any nodes found from last search for a pretty assertion message + self.foundNodes: List[ast.AST] = [] + + # Similarly hold on to the source code + self.source: str = checkpy.static.getSource(fileName=fileName) + + def __contains__(self, item: type) -> bool: + if item.__module__ != ast.__name__: + raise checkpy.entities.exception.CheckpyError( + f"{item} is not of type {ast.AST}. Can only search for {ast.AST} types in AbstractSyntaxTree." + ) + + self.foundNodes = checkpy.static.getAstNodes(item, source=self.source) + return bool(self.foundNodes) diff --git a/checkpy/lib/explanation.py b/checkpy/lib/explanation.py index 93bdca5..98c9af1 100644 --- a/checkpy/lib/explanation.py +++ b/checkpy/lib/explanation.py @@ -25,10 +25,23 @@ def explainCompare(op: str, left: Any, right: Any) -> Optional[str]: # Custom Type messages if isinstance(right, checkpy.Type): - return f"{left} is not of type {right}" + return f"{left} is of type {right}" if isinstance(left, checkpy.Type): - return f"{right} is not of type {left}" + return f"{right} is of type {left}" + + # Custom AbstractSyntaxTree messages + if isinstance(right, checkpy.AbstractSyntaxTree): + if op == "in": + return f"'{left.__name__}' is used in the source code" + + prefix = f"'{left.__name__}' is not used in the source code\n~" + allLines = right.source.split("\n") + lineNoWidth = len(str(max(n.lineno for n in right.foundNodes))) + lines = [] + for node in right.foundNodes: + lines.append(f"On line {str(node.lineno).rjust(lineNoWidth)}: {allLines[node.lineno - 1]}") + return prefix + "\n~".join(lines) # Fall back on pytest (dessert) explanations rep = assertrepr_compare(MockConfig(), op, left, right) diff --git a/checkpy/lib/static.py b/checkpy/lib/static.py index 7f819bf..fc22bcd 100644 --- a/checkpy/lib/static.py +++ b/checkpy/lib/static.py @@ -206,4 +206,4 @@ def generic_visit(self, node: _ast.AST): tree = _ast.parse(source) Visitor().visit(tree) - return nodes \ No newline at end of file + return nodes From ada1af196d48f4cf341d408c31b043426745e053 Mon Sep 17 00:00:00 2001 From: Jelleas Date: Wed, 26 Jul 2023 23:26:04 +0200 Subject: [PATCH 208/269] AbstractSyntaxTree phrasing --- checkpy/entities/abstractsyntaxtree.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/checkpy/entities/abstractsyntaxtree.py b/checkpy/entities/abstractsyntaxtree.py index b6e7906..fae8820 100644 --- a/checkpy/entities/abstractsyntaxtree.py +++ b/checkpy/entities/abstractsyntaxtree.py @@ -11,8 +11,8 @@ class AbstractSyntaxTree: For instance: ``` - ast.For in AbstractSyntaxTree() # check if a for-loop is present - ast.Mult in AbstractSyntaxTree() # check if multiplication is used + assert ast.For in AbstractSyntaxTree() # assert that a for-loop is present + assert ast.Mult in AbstractSyntaxTree() # assert that multiplication is present ``` """ def __init__(self, fileName: Optional[str]=None): From 0dd36239e36b48a6ba484c42d995057204651da0 Mon Sep 17 00:00:00 2001 From: Jelleas Date: Thu, 27 Jul 2023 14:03:24 +0200 Subject: [PATCH 209/269] builder.function.do deepcopies always --- checkpy/__init__.py | 4 +-- checkpy/entities/abstractsyntaxtree.py | 32 ------------------ checkpy/lib/builder.py | 45 ++++++++++++++++++-------- checkpy/lib/explanation.py | 6 ++-- checkpy/lib/static.py | 30 ++++++++++++++++- checkpy/{entities => lib}/type.py | 0 6 files changed, 64 insertions(+), 53 deletions(-) delete mode 100644 checkpy/entities/abstractsyntaxtree.py rename checkpy/{entities => lib}/type.py (100%) diff --git a/checkpy/__init__.py b/checkpy/__init__.py index 42114dd..fa4de98 100644 --- a/checkpy/__init__.py +++ b/checkpy/__init__.py @@ -8,8 +8,7 @@ from checkpy.lib.sandbox import only, include, exclude, require from checkpy.lib import static from checkpy.lib import monkeypatch -from checkpy.entities.type import Type -from checkpy.entities.abstractsyntaxtree import AbstractSyntaxTree +from checkpy.lib.type import Type from pytest import approx import pathlib as _pathlib @@ -22,7 +21,6 @@ "getModule", "getFunction", "Type", - "AbstractSyntaxTree", "static", "monkeypatch", "builder", diff --git a/checkpy/entities/abstractsyntaxtree.py b/checkpy/entities/abstractsyntaxtree.py deleted file mode 100644 index fae8820..0000000 --- a/checkpy/entities/abstractsyntaxtree.py +++ /dev/null @@ -1,32 +0,0 @@ -import checkpy -import checkpy.entities.exception -import ast -from typing import Optional, List - -__all__ = ["AbstractSyntaxTree"] - -class AbstractSyntaxTree: - """ - An 'in' and 'not in' comparible AbstractSyntaxTree for any ast.Node (any type of ast.AST). - For instance: - - ``` - assert ast.For in AbstractSyntaxTree() # assert that a for-loop is present - assert ast.Mult in AbstractSyntaxTree() # assert that multiplication is present - ``` - """ - def __init__(self, fileName: Optional[str]=None): - # Keep track of any nodes found from last search for a pretty assertion message - self.foundNodes: List[ast.AST] = [] - - # Similarly hold on to the source code - self.source: str = checkpy.static.getSource(fileName=fileName) - - def __contains__(self, item: type) -> bool: - if item.__module__ != ast.__name__: - raise checkpy.entities.exception.CheckpyError( - f"{item} is not of type {ast.AST}. Can only search for {ast.AST} types in AbstractSyntaxTree." - ) - - self.foundNodes = checkpy.static.getAstNodes(item, source=self.source) - return bool(self.foundNodes) diff --git a/checkpy/lib/builder.py b/checkpy/lib/builder.py index def029f..fde74d8 100644 --- a/checkpy/lib/builder.py +++ b/checkpy/lib/builder.py @@ -79,10 +79,10 @@ def testSquareError(): # testSquareError will only run if testSquare2 passes. ``` """ def __init__(self, functionName: str, fileName: Optional[str]=None): - self._state: FunctionState = FunctionState(functionName, fileName=fileName) + self._initialState: FunctionState = FunctionState(functionName, fileName=fileName) self._blocks: List[Callable[["FunctionState"], None]] = [] - - self.name(functionName) + self._description: Optional[str] = None + self._blocks = self.name(functionName)._blocks def name(self, functionName: str) -> Self: """Assert that a function with functionName is defined.""" @@ -94,6 +94,8 @@ def testName(state: FunctionState): funcDefs = checkpy.static.getFunctionDefinitions(source) assert functionName in funcDefs,\ f'no function found with name {functionName}()' + + state.description = "" return self.do(testName) @@ -107,11 +109,13 @@ def testParams(state: FunctionState): expected = state.params assert len(real) == len(expected),\ - f"expected {len(expected)} arguments. Your function {state.name}() has"\ - f" {len(real)} arguments" + f"expected {len(expected)} parameters, your function {state.name}() takes"\ + f" {len(real)} parameters" assert real == expected,\ f"parameters should exactly match the requested function definition" + + state.description = "" return self.do(testParams) @@ -132,6 +136,7 @@ def returns(self, expected: Any) -> Self: def testReturned(state: FunctionState): state.description = f"{state.getFunctionCallRepr()} should return {expected}" assert state.returned == expected + state.description = "" return self.do(testReturned) @@ -147,6 +152,7 @@ def testStdout(state: FunctionState): actual = state.function.printOutput assert actual == expected + state.description = "" return self.do(testStdout) @@ -177,6 +183,7 @@ def testStdoutRegex(state: FunctionState): raise AssertionError(f"The printed output does not match regular expression: {regex}.\n" f"This is what {state.getFunctionCallRepr()} printed:\n" f"{actual}") + state.description = "" return self.do(testStdoutRegex) @@ -188,10 +195,11 @@ def testCall(state: FunctionState): state.description = f"calling function {state.getFunctionCallRepr()}" state.returned = state.function(*args, **kwargs) - state.description = f"{state.getFunctionCallRepr()} returns a value of type {state.returnType}" + state.description = f"{state.getFunctionCallRepr()} returns a value of type {checkpy.Type(state.returnType)}" type_ = state.returnType returned = state.returned assert returned == checkpy.Type(type_) + state.description = "" return self.do(testCall) @@ -204,7 +212,7 @@ def setTimeout(state: FunctionState): def description(self, description: str) -> Self: """ - Fixate the test's description on description. + Fixate the test's description on this description. The test's description will not change after a call to this method, and can only change by calling this method again. """ @@ -214,7 +222,12 @@ def setDecription(state: FunctionState): state.description = description state.isDescriptionMutable = False - return self.do(setDecription) + self = self.do(setDecription) + + if self._description is None: + self._description = description + + return self def do(self, function: Callable[["FunctionState"], None]) -> Self: """ @@ -230,6 +243,7 @@ def checkDataFileIsUnchanged(state: "FunctionState"): test = function("process_data").call("data.txt").do(checkDataFileIsUnchanged).build() ``` """ + self = deepcopy(self) self._blocks.append(function) return self @@ -240,15 +254,18 @@ def build(self) -> checkpy.tests.TestFunction: `testSquare = (function("square").call(3).returns(9).build())` """ + blocks = list(self._blocks) + state = deepcopy(self._initialState) + def testFunction(): self.log: List[FunctionState] = [] - for block in self._blocks: - self.log.append(deepcopy(self._state)) - block(self._state) + for block in blocks: + self.log.append(deepcopy(state)) + block(state) - testFunction.__name__ = f"builder_function_test_{self._state.name}()_{uuid4()}" - testFunction.__doc__ = self._state.description + testFunction.__name__ = f"builder_function_test_{state.name}()_{uuid4()}" + testFunction.__doc__ = self._description if self._description is not None else state.description return checkpy.tests.test()(testFunction) @@ -270,7 +287,7 @@ def __init__(self, functionName: str, fileName: Optional[str]=None): self._timeout: int = 10 self._isDescriptionMutable: bool = True self._descriptionFormatter: Callable[[str, FunctionState], str] =\ - lambda descr, state: f"testing {state.name}() >> {descr}" + lambda descr, state: f"testing {state.name}()" + (f" >> {descr}" if descr else "") @property def name(self) -> str: diff --git a/checkpy/lib/explanation.py b/checkpy/lib/explanation.py index 98c9af1..3f2fea8 100644 --- a/checkpy/lib/explanation.py +++ b/checkpy/lib/explanation.py @@ -10,10 +10,10 @@ __all__ = ["addExplainer"] -_explainers: List[Callable[[str, str, str], Optional[str]]] = [] +_explainers: List[Callable[[str, Any, Any], Optional[str]]] = [] -def addExplainer(explainer: Callable[[str, str, str], Optional[str]]) -> None: +def addExplainer(explainer: Callable[[str, Any, Any], Optional[str]]) -> None: _explainers.append(explainer) @@ -31,7 +31,7 @@ def explainCompare(op: str, left: Any, right: Any) -> Optional[str]: return f"{right} is of type {left}" # Custom AbstractSyntaxTree messages - if isinstance(right, checkpy.AbstractSyntaxTree): + if isinstance(right, checkpy.static.AbstractSyntaxTree): if op == "in": return f"'{left.__name__}' is used in the source code" diff --git a/checkpy/lib/static.py b/checkpy/lib/static.py index fc22bcd..cf6d61f 100644 --- a/checkpy/lib/static.py +++ b/checkpy/lib/static.py @@ -19,7 +19,8 @@ "getNumbersFrom", "getFunctionCalls", "getFunctionDefinitions", - "getAstNodes" + "getAstNodes", + "AbstractSyntaxTree" ] @@ -207,3 +208,30 @@ def generic_visit(self, node: _ast.AST): tree = _ast.parse(source) Visitor().visit(tree) return nodes + + +class AbstractSyntaxTree: + """ + An 'in' and 'not in' comparible AbstractSyntaxTree for any ast.Node (any type of ast.AST). + For instance: + + ``` + assert ast.For in AbstractSyntaxTree() # assert that a for-loop is present + assert ast.Mult in AbstractSyntaxTree() # assert that multiplication is present + ``` + """ + def __init__(self, fileName: _Optional[str]=None): + # Keep track of any nodes found from last search for a pretty assertion message + self.foundNodes: _List[_ast.AST] = [] + + # Similarly hold on to the source code + self.source: str = getSource(fileName=fileName) + + def __contains__(self, item: type) -> bool: + if item.__module__ != _ast.__name__: + raise _checkpy.entities.exception.CheckpyError( + f"{item} is not of type {_ast.AST}. Can only search for {_ast.AST} types in AbstractSyntaxTree." + ) + + self.foundNodes = getAstNodes(item, source=self.source) + return bool(self.foundNodes) \ No newline at end of file diff --git a/checkpy/entities/type.py b/checkpy/lib/type.py similarity index 100% rename from checkpy/entities/type.py rename to checkpy/lib/type.py From 79a4799235e7f44ca33dd929009e580aa5790749 Mon Sep 17 00:00:00 2001 From: Jelleas Date: Thu, 27 Jul 2023 14:14:00 +0200 Subject: [PATCH 210/269] builder.function, put .description block first if only name block present --- checkpy/lib/builder.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/checkpy/lib/builder.py b/checkpy/lib/builder.py index fde74d8..970dd01 100644 --- a/checkpy/lib/builder.py +++ b/checkpy/lib/builder.py @@ -224,6 +224,10 @@ def setDecription(state: FunctionState): self = self.do(setDecription) + # If the description block is the only block (after the mandatory name block), put it first + if len(self._blocks) == 2: + self._blocks.reverse() + if self._description is None: self._description = description From 411583775ed8de69c7f5968ab5a655bb283e2e3c Mon Sep 17 00:00:00 2001 From: Jelleas Date: Thu, 27 Jul 2023 16:04:37 +0200 Subject: [PATCH 211/269] builder.function end with a celebratory message --- checkpy/lib/builder.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/checkpy/lib/builder.py b/checkpy/lib/builder.py index 970dd01..9ff1cce 100644 --- a/checkpy/lib/builder.py +++ b/checkpy/lib/builder.py @@ -262,12 +262,14 @@ def build(self) -> checkpy.tests.TestFunction: state = deepcopy(self._initialState) def testFunction(): - self.log: List[FunctionState] = [] - for block in blocks: - self.log.append(deepcopy(state)) block(state) + if state.wasCalled: + state.description = f"{state.getFunctionCallRepr()} works as expected" + else: + state.description = f"{state.name} is correctly defined" + testFunction.__name__ = f"builder_function_test_{state.name}()_{uuid4()}" testFunction.__doc__ = self._description if self._description is not None else state.description return checkpy.tests.test()(testFunction) From 68b4bcf5bcdaef2688808c9a14dd4d565ac86860 Mon Sep 17 00:00:00 2001 From: Jelleas Date: Thu, 27 Jul 2023 16:35:18 +0200 Subject: [PATCH 212/269] phrasings --- checkpy/lib/builder.py | 8 ++++---- checkpy/lib/explanation.py | 13 +++++++------ 2 files changed, 11 insertions(+), 10 deletions(-) diff --git a/checkpy/lib/builder.py b/checkpy/lib/builder.py index 9ff1cce..9f21bc0 100644 --- a/checkpy/lib/builder.py +++ b/checkpy/lib/builder.py @@ -109,8 +109,8 @@ def testParams(state: FunctionState): expected = state.params assert len(real) == len(expected),\ - f"expected {len(expected)} parameters, your function {state.name}() takes"\ - f" {len(real)} parameters" + f"expected {len(expected)} parameter(s), your function {state.name}() takes"\ + f" {len(real)} parameter(s)" assert real == expected,\ f"parameters should exactly match the requested function definition" @@ -135,7 +135,7 @@ def returns(self, expected: Any) -> Self: """Assert that the last call returns expected.""" def testReturned(state: FunctionState): state.description = f"{state.getFunctionCallRepr()} should return {expected}" - assert state.returned == expected + assert state.returned == expected, f"{state.getFunctionCallRepr()} returned: {state.returned}" state.description = "" return self.do(testReturned) @@ -198,7 +198,7 @@ def testCall(state: FunctionState): state.description = f"{state.getFunctionCallRepr()} returns a value of type {checkpy.Type(state.returnType)}" type_ = state.returnType returned = state.returned - assert returned == checkpy.Type(type_) + assert returned == checkpy.Type(type_), f"{state.getFunctionCallRepr()} returned: {returned}" state.description = "" return self.do(testCall) diff --git a/checkpy/lib/explanation.py b/checkpy/lib/explanation.py index 3f2fea8..5203c9a 100644 --- a/checkpy/lib/explanation.py +++ b/checkpy/lib/explanation.py @@ -23,14 +23,15 @@ def explainCompare(op: str, left: Any, right: Any) -> Optional[str]: if rep: return rep - # Custom Type messages - if isinstance(right, checkpy.Type): + # Custom Type message + if isinstance(left, checkpy.Type) or isinstance(right, checkpy.Type): + if isinstance(left, checkpy.Type): + left, right = right, left + if isinstance(left, str): + left = f'"{left}"' return f"{left} is of type {right}" - if isinstance(left, checkpy.Type): - return f"{right} is of type {left}" - - # Custom AbstractSyntaxTree messages + # Custom AbstractSyntaxTree message if isinstance(right, checkpy.static.AbstractSyntaxTree): if op == "in": return f"'{left.__name__}' is used in the source code" From 6dda3a18bef89c1db96109e5f8497d00ef122c38 Mon Sep 17 00:00:00 2001 From: Jelleas Date: Thu, 27 Jul 2023 19:51:53 +0200 Subject: [PATCH 213/269] entities/path.py is deprecated --- checkpy/__init__.py | 9 ++- checkpy/database/database.py | 57 ++++++++-------- checkpy/downloader/downloader.py | 112 +++++++++++++++---------------- checkpy/entities/path.py | 7 ++ checkpy/lib/basic.py | 14 ++-- checkpy/lib/static.py | 10 ++- checkpy/tester/discovery.py | 27 ++++---- tests/unittests/lib_test.py | 8 +-- 8 files changed, 131 insertions(+), 113 deletions(-) diff --git a/checkpy/__init__.py b/checkpy/__init__.py index fa4de98..40525ab 100644 --- a/checkpy/__init__.py +++ b/checkpy/__init__.py @@ -1,3 +1,11 @@ +import pathlib as _pathlib + +# Path to the directory checkpy was called from +USERPATH: _pathlib.Path = _pathlib.Path.cwd() + +# Path to the directory of checkpy +CHECKPYPATH: _pathlib.Path = _pathlib.Path(__file__).parent + import dessert as _dessert with _dessert.rewrite_assertions_context(): @@ -11,7 +19,6 @@ from checkpy.lib.type import Type from pytest import approx -import pathlib as _pathlib __all__ = [ "test", diff --git a/checkpy/database/database.py b/checkpy/database/database.py index 49f4bf1..7932f63 100644 --- a/checkpy/database/database.py +++ b/checkpy/database/database.py @@ -1,16 +1,16 @@ import tinydb -import os +from tinydb.table import Table import time import contextlib -from checkpy.entities.path import Path, CHECKPYPATH +import checkpy +import pathlib +from typing import Generator, Iterable, Tuple -_DBPATH = CHECKPYPATH + "database" + "db.json" +_DBPATH = checkpy.CHECKPYPATH / "database" / "db.json" @contextlib.contextmanager -def database(): - if not _DBPATH.exists(): - with open(str(_DBPATH), 'w') as f: - pass +def database() -> Generator[tinydb.TinyDB, None, None]: + _DBPATH.touch() try: db = tinydb.TinyDB(str(_DBPATH)) yield db @@ -18,12 +18,12 @@ def database(): db.close() @contextlib.contextmanager -def githubTable(): +def githubTable()-> Generator[Table, None, None]: with database() as db: yield db.table("github") @contextlib.contextmanager -def localTable(): +def localTable() -> Generator[Table, None, None]: with database() as db: yield db.table("local") @@ -31,36 +31,35 @@ def clean(): with database() as db: db.drop_tables() -def forEachTestsPath(): +def forEachTestsPath() -> Iterable[pathlib.Path]: for path in forEachGithubPath(): yield path for path in forEachLocalPath(): yield path -def forEachUserAndRepo(): +def forEachUserAndRepo() -> Iterable[Tuple[str, str]]: with githubTable() as table: - for username, repoName in [(entry["user"], entry["repo"]) for entry in table.all()]: - yield username, repoName + return [(entry["user"], entry["repo"]) for entry in table.all()] -def forEachGithubPath(): +def forEachGithubPath() -> Iterable[pathlib.Path]: with githubTable() as table: for entry in table.all(): - yield Path(entry["path"]) + yield pathlib.Path(entry["path"]) -def forEachLocalPath(): +def forEachLocalPath() -> Iterable[pathlib.Path]: with localTable() as table: for entry in table.all(): - yield Path(entry["path"]) + yield pathlib.Path(entry["path"]) -def isKnownGithub(username, repoName): +def isKnownGithub(username: str, repoName: str) -> bool: query = tinydb.Query() with githubTable() as table: return table.contains((query.user == username) & (query.repo == repoName)) -def addToGithubTable(username, repoName, releaseId, releaseTag): +def addToGithubTable(username: str, repoName: str, releaseId: str, releaseTag: str): if not isKnownGithub(username, repoName): - path = str(CHECKPYPATH + "tests" + repoName) + path = str(checkpy.CHECKPYPATH / "tests" / repoName) with githubTable() as table: table.insert({ @@ -72,7 +71,7 @@ def addToGithubTable(username, repoName, releaseId, releaseTag): "timestamp" : time.time() }) -def addToLocalTable(localPath): +def addToLocalTable(localPath: pathlib.Path): query = tinydb.Query() with localTable() as table: if not table.search(query.path == str(localPath)): @@ -80,9 +79,9 @@ def addToLocalTable(localPath): "path" : str(localPath) }) -def updateGithubTable(username, repoName, releaseId, releaseTag): +def updateGithubTable(username: str, repoName: str, releaseId: str, releaseTag: str): query = tinydb.Query() - path = str(CHECKPYPATH + "tests" + repoName) + path = str(checkpy.CHECKPYPATH / "tests" / repoName) with githubTable() as table: table.update({ "user" : username, @@ -93,12 +92,12 @@ def updateGithubTable(username, repoName, releaseId, releaseTag): "timestamp" : time.time() }, query.user == username and query.repo == repoName) -def timestampGithub(username, repoName): +def timestampGithub(username: str, repoName: str) -> float: query = tinydb.Query() with githubTable() as table: return table.search(query.user == username and query.repo == repoName)[0]["timestamp"] -def setTimestampGithub(username, repoName): +def setTimestampGithub(username: str, repoName: str): query = tinydb.Query() with githubTable() as table: table.update( @@ -106,17 +105,17 @@ def setTimestampGithub(username, repoName): query.user == username and query.repo == repoName ) -def githubPath(username, repoName): +def githubPath(username: str, repoName: str) -> pathlib.Path: query = tinydb.Query() with githubTable() as table: - return Path(table.search(query.user == username and query.repo == repoName)[0]["path"]) + return pathlib.Path(table.search(query.user == username and query.repo == repoName)[0]["path"]) -def releaseId(username, repoName): +def releaseId(username: str, repoName: str) -> str: query = tinydb.Query() with githubTable() as table: return table.search(query.user == username and query.repo == repoName)[0]["release"] -def releaseTag(username, repoName): +def releaseTag(username: str, repoName: str) -> str: query = tinydb.Query() with githubTable() as table: return table.search(query.user == username and query.repo == repoName)[0]["tag"] diff --git a/checkpy/downloader/downloader.py b/checkpy/downloader/downloader.py index 5f546d3..9484422 100644 --- a/checkpy/downloader/downloader.py +++ b/checkpy/downloader/downloader.py @@ -1,28 +1,31 @@ import requests import zipfile as zf import os +import io +import pathlib import shutil import time -from checkpy.entities.path import Path + +from typing import Dict, Optional, Set, Union + from checkpy import database -from checkpy import caches from checkpy import printer from checkpy.entities import exception -user = None -personal_access_token = None +user: Optional[str] = None +personal_access_token: Optional[str] = None -def set_gh_auth(username, pat): +def set_gh_auth(username: str, pat: str): global user, personal_access_token user = username personal_access_token = pat -def download(githubLink): +def download(githubLink: str): if githubLink.endswith("/"): githubLink = githubLink[:-1] if "/" not in githubLink: - printer.displayError("{} is not a valid download location".format(githubLink)) + printer.displayError(f"{githubLink} is not a valid download location") return username = githubLink.split("/")[-2].lower() @@ -34,8 +37,8 @@ def download(githubLink): except exception.DownloadError as e: printer.displayError(str(e)) -def register(localLink): - path = Path(localLink) +def register(localLink: Union[str, pathlib.Path]): + path = pathlib.Path(localLink) if not path.exists(): printer.displayError("{} does not exist") @@ -53,9 +56,9 @@ def update(): def list(): for username, repoName in database.forEachUserAndRepo(): - printer.displayCustom("Github: {} from {}".format(repoName, username)) + printer.displayCustom(f"Github: {repoName} from {username}") for path in database.forEachLocalPath(): - printer.displayCustom("Local: {}".format(path)) + printer.displayCustom(f"Local: {path}") def clean(): for path in database.forEachGithubPath(): @@ -77,7 +80,7 @@ def updateSilently(): except exception.DownloadError as e: pass -def _newReleaseAvailable(githubUserName, githubRepoName): +def _newReleaseAvailable(githubUserName: str, githubRepoName: str) -> bool: # unknown/new download if not database.isKnownGithub(githubUserName, githubRepoName): return True @@ -91,7 +94,7 @@ def _newReleaseAvailable(githubUserName, githubRepoName): # no new release found return False -def _syncRelease(githubUserName, githubRepoName): +def _syncRelease(githubUserName: str, githubRepoName: str): releaseJson = _getReleaseJson(githubUserName, githubRepoName) if database.isKnownGithub(githubUserName, githubRepoName): @@ -99,42 +102,44 @@ def _syncRelease(githubUserName, githubRepoName): else: database.addToGithubTable(githubUserName, githubRepoName, releaseJson["id"], releaseJson["tag_name"]) -# this performs one api call, beware of rate limit!!! -# returns a dictionary representing the json returned by github -# incase of an error, raises an exception.DownloadError -def _getReleaseJson(githubUserName, githubRepoName): - apiReleaseLink = "https://api.github.com/repos/{}/{}/releases/latest".format(githubUserName, githubRepoName) + +def _getReleaseJson(githubUserName: str, githubRepoName: str) -> Dict: + """ + This performs one api call, beware of rate limit!!! + Returns a dictionary representing the json returned by github + In case of an error, raises an exception.DownloadError + """ + apiReleaseLink = f"https://api.github.com/repos/{githubUserName}/{githubRepoName}/releases/latest" global user global personal_access_token try: if user and personal_access_token: - print(user, personal_access_token) r = requests.get(apiReleaseLink, auth=(user, personal_access_token)) else: r = requests.get(apiReleaseLink) except requests.exceptions.ConnectionError as e: - raise exception.DownloadError(message = "Oh no! It seems like there is no internet connection available?!") + raise exception.DownloadError(message="Oh no! It seems like there is no internet connection available?!") # exceeded rate limit, if r.status_code == 403: - raise exception.DownloadError(message = "Tried finding new releases from {}/{} but exceeded the rate limit, try again within an hour!".format(githubUserName, githubRepoName)) + raise exception.DownloadError(message=f"Tried finding new releases from {githubUserName}/{githubRepoName} but exceeded the rate limit, try again within an hour!") # no releases found or page not found if r.status_code == 404: - raise exception.DownloadError(message = "Failed to check for new tests from {}/{} because: no releases found (404)".format(githubUserName, githubRepoName)) + raise exception.DownloadError(message=f"Failed to check for new tests from {githubUserName}/{githubRepoName} because: no releases found (404)") # random error if not r.ok: - raise exception.DownloadError(message = "Failed to sync releases from {}/{} because: {}".format(githubUserName, githubRepoName, r.reason)) + raise exception.DownloadError(message=f"Failed to sync releases from {githubUserName}/{githubRepoName} because: {r.reason}") return r.json() # download tests for githubUserName and githubRepoName from what is known in downloadlocations.json # use _syncRelease() to force an update in downloadLocations.json -def _download(githubUserName, githubRepoName): - githubLink = "https://github.com/{}/{}".format(githubUserName, githubRepoName) - zipLink = githubLink + "/archive/{}.zip".format(database.releaseTag(githubUserName, githubRepoName)) +def _download(githubUserName: str, githubRepoName: str): + githubLink = f"https://github.com/{githubUserName}/{githubRepoName}" + zipLink = githubLink + f"/archive/{database.releaseTag(githubUserName, githubRepoName)}.zip" try: r = requests.get(zipLink) @@ -142,65 +147,58 @@ def _download(githubUserName, githubRepoName): raise exception.DownloadError(message = "Oh no! It seems like there is no internet connection available?!") if not r.ok: - raise exception.DownloadError(message = "Failed to download {} because: {}".format(githubLink, r.reason)) + raise exception.DownloadError(message = f"Failed to download {githubLink} because: {r.reason}") - try: - # Python 2 - import StringIO - f = StringIO.StringIO(r.content) - except ImportError: - # Python 3 - import io - f = io.BytesIO(r.content) + f = io.BytesIO(r.content) with zf.ZipFile(f) as z: destPath = database.githubPath(githubUserName, githubRepoName) - existingTests = set() - for path, subdirs, files in destPath.walk(): + existingTests: Set[pathlib.Path] = set() + for path, subdirs, files in os.walk(destPath): for f in files: - existingTests.add((path + f) - destPath) + existingTests.add((pathlib.Path(path) / f).relative_to(destPath)) - newTests = set() - for path in [Path(name) for name in z.namelist()]: - if path.isPythonFile(): - newTests.add(path.pathFromFolder("tests")) + newTests: Set[pathlib.Path] = set() + for name in z.namelist(): + if name.endswith(".py"): + newTests.add(pathlib.Path(pathlib.Path(name).as_posix().split("tests/")[1])) - for filePath in [fp for fp in existingTests - newTests if fp.isPythonFile()]: + for filePath in [fp for fp in existingTests - newTests if fp]: printer.displayRemoved(str(filePath)) - for filePath in [fp for fp in newTests - existingTests if fp.isPythonFile()]: + for filePath in [fp for fp in newTests - existingTests if fp.suffix == ".py"]: printer.displayAdded(str(filePath)) for filePath in existingTests - newTests: - os.remove(str(destPath + filePath)) + (destPath / filePath).unlink() # remove file _extractTests(z, destPath) - printer.displayCustom("Finished downloading: {}".format(githubLink)) + printer.displayCustom(f"Finished downloading: {githubLink}") -def _extractTests(zipfile, destPath): +def _extractTests(zipfile: zf.ZipFile, destPath: pathlib.Path): if not destPath.exists(): os.makedirs(str(destPath)) - for path in [Path(name) for name in zipfile.namelist()]: + for path in [pathlib.Path(name) for name in zipfile.namelist()]: _extractTest(zipfile, path, destPath) -def _extractTest(zipfile, path, destPath): - if "tests" not in path: +def _extractTest(zipfile: zf.ZipFile, path: pathlib.Path, destPath: pathlib.Path): + if not "tests/" in path.as_posix(): return - subfolderPath = path.pathFromFolder("tests") - filePath = destPath + subfolderPath + subfolderPath = pathlib.Path(path.as_posix().split("tests/")[1]) + filePath = destPath / subfolderPath - if path.isPythonFile(): + if path.suffix == ".py": _extractFile(zipfile, path, filePath) - elif subfolderPath and not os.path.exists(str(filePath)): + elif subfolderPath and not filePath.exists(): os.makedirs(str(filePath)) -def _extractFile(zipfile, path, filePath): - zipPathString = str(path).replace("\\", "/") - if os.path.isfile(str(filePath)): +def _extractFile(zipfile: zf.ZipFile, path: pathlib.Path, filePath: pathlib.Path): + zipPathString = path.as_posix() + if filePath.is_file(): with zipfile.open(zipPathString) as new, open(str(filePath), "r") as existing: # read file, decode, strip trailing whitespace, remove carrier return newText = ''.join(new.read().decode('utf-8').strip().splitlines()) diff --git a/checkpy/entities/path.py b/checkpy/entities/path.py index 45bdd1b..d72e51a 100644 --- a/checkpy/entities/path.py +++ b/checkpy/entities/path.py @@ -2,6 +2,13 @@ import sys import shutil import checkpy.entities.exception as exception +import warnings + +warnings.warn( + """checkpy.entities.path is deprecated. Use pathlib.Path instead.""", + DeprecationWarning, + stacklevel=2 +) class Path(object): def __init__(self, path): diff --git a/checkpy/lib/basic.py b/checkpy/lib/basic.py index 634f058..ca51836 100644 --- a/checkpy/lib/basic.py +++ b/checkpy/lib/basic.py @@ -1,10 +1,12 @@ +import contextlib import io -import sys -import re import os -import contextlib -import traceback +import pathlib +import re import requests +import shutil +import sys +import traceback from pathlib import Path from types import ModuleType @@ -300,9 +302,9 @@ def require(fileName, source=None): download(fileName, source) return - filePath = path.userPath + fileName + filePath = checkpy.USERPATH / fileName if not fileExists(str(filePath)): raise exception.CheckpyError("Required file {} does not exist".format(fileName)) - filePath.copyTo(path.current() + fileName) \ No newline at end of file + shutil.copyfile(filePath, pathlib.Path.cwd() / fileName) \ No newline at end of file diff --git a/checkpy/lib/static.py b/checkpy/lib/static.py index cf6d61f..639aa61 100644 --- a/checkpy/lib/static.py +++ b/checkpy/lib/static.py @@ -34,7 +34,7 @@ def getSource(fileName: _Optional[_Union[str, _Path]]=None) -> str: def getSourceOfDefinitions(fileName: _Optional[_Union[str, _Path]]=None) -> str: - """Get just the source code inside definitions (def / class).""" + """Get just the source code inside definitions (def / class) and any imports.""" if fileName is None: fileName = _checkpy.file.name @@ -52,6 +52,14 @@ def visit_FunctionDef(self, node: _ast.FunctionDef): self.lineNumbers |= set(range(node.lineno - 1, node.end_lineno)) super().generic_visit(node) + def visit_Import(self, node: _ast.Import): + self.lineNumbers.add(node.lineno - 1) + super().generic_visit(node) + + def visit_ImportFrom(self, node: _ast.ImportFrom): + self.lineNumbers.add(node.lineno - 1) + super().generic_visit(node) + tree = _ast.parse(source) visitor = Visitor() visitor.visit(tree) diff --git a/checkpy/tester/discovery.py b/checkpy/tester/discovery.py index abec7e0..30c290e 100644 --- a/checkpy/tester/discovery.py +++ b/checkpy/tester/discovery.py @@ -1,13 +1,14 @@ import os import checkpy.database as database -from checkpy.entities.path import Path +import pathlib +from typing import Optional, List, Union -def testExists(testName, module = ""): +def testExists(testName: str, module: str="") -> bool: testFileName = testName.split(".")[0] + "Test.py" - testPaths = getTestPaths(testFileName, module = module) + testPaths = getTestPaths(testFileName, module=module) return len(testPaths) > 0 -def getPath(path): +def getPath(path: Union[str, pathlib.Path]) -> Optional[pathlib.Path]: filePath = os.path.dirname(path) if not filePath: filePath = os.path.dirname(os.path.abspath(path)) @@ -15,26 +16,26 @@ def getPath(path): fileName = os.path.basename(path) if "." in fileName: - path = Path(os.path.join(filePath, fileName)) + path = pathlib.Path(os.path.join(filePath, fileName)) return path if path.exists() else None for extension in [".py", ".ipynb"]: - path = Path(os.path.join(filePath, fileName + extension)) + path = pathlib.Path(os.path.join(filePath, fileName + extension)) if path.exists(): return path return None -def getTestNames(moduleName): +def getTestNames(moduleName: str) -> Optional[List[str]]: for testsPath in database.forEachTestsPath(): - for (dirPath, subdirs, files) in testsPath.walk(): - if Path(moduleName) in dirPath: + for (dirPath, subdirs, files) in os.walk(testsPath): + if moduleName in dirPath: return [f[:-len("test.py")] for f in files if f.lower().endswith("test.py")] -def getTestPaths(testFileName, module = ""): - testFilePaths = [] +def getTestPaths(testFileName: str, module: str="") -> List[pathlib.Path]: + testFilePaths: List[pathlib.Path] = [] for testsPath in database.forEachTestsPath(): - for (dirPath, dirNames, fileNames) in testsPath.walk(): + for (dirPath, dirNames, fileNames) in os.walk(testsPath): if testFileName in fileNames and (not module or module in dirPath): - testFilePaths.append(dirPath) + testFilePaths.append(pathlib.Path(dirPath)) return testFilePaths diff --git a/tests/unittests/lib_test.py b/tests/unittests/lib_test.py index 3ebaeab..1c0b924 100644 --- a/tests/unittests/lib_test.py +++ b/tests/unittests/lib_test.py @@ -49,8 +49,6 @@ def test_fileDownload(self): os.remove(fileName) def test_fileLocalCopy(self): - import checkpy.entities.path as path - print(path.userPath) os.chdir(self.dirname) lib.require(self.fileName) self.assertTrue(os.path.isfile(self.fileName)) @@ -93,8 +91,7 @@ def main(): """ expectedOutcome = \ """def main(): - pass -""" + pass""" self.write(source) self.assertEqual(lib.sourceOfDefinitions(self.fileName), expectedOutcome) @@ -119,8 +116,7 @@ def test_multilineString(self): def test_import(self): source = \ """import os -from os import path -""" +from os import path""" self.write(source) self.assertEqual(lib.sourceOfDefinitions(self.fileName), source) From ff961c515215fe1179229d0f8a7e01c2e2c50b3b Mon Sep 17 00:00:00 2001 From: Jelleas Date: Fri, 28 Jul 2023 14:23:20 +0200 Subject: [PATCH 214/269] typehint all the things --- checkpy/__init__.py | 3 +- checkpy/__main__.py | 15 +- checkpy/downloader/downloader.py | 4 +- checkpy/entities/exception.py | 17 +- checkpy/entities/function.py | 47 ++-- checkpy/interactive.py | 19 +- checkpy/lib/__init__.py | 1 - checkpy/lib/basic.py | 466 ++++++++++++++++--------------- checkpy/lib/builder.py | 28 +- checkpy/lib/explanation.py | 20 +- checkpy/lib/monkeypatch.py | 2 +- checkpy/lib/sandbox.py | 92 ++++-- checkpy/lib/static.py | 23 +- checkpy/printer/printer.py | 24 +- checkpy/tester/discovery.py | 1 + checkpy/tester/tester.py | 12 +- checkpy/tests.py | 35 ++- 17 files changed, 465 insertions(+), 344 deletions(-) diff --git a/checkpy/__init__.py b/checkpy/__init__.py index 40525ab..8fd4b7a 100644 --- a/checkpy/__init__.py +++ b/checkpy/__init__.py @@ -1,4 +1,5 @@ import pathlib as _pathlib +import typing as _typing # Path to the directory checkpy was called from USERPATH: _pathlib.Path = _pathlib.Path.cwd() @@ -39,4 +40,4 @@ "approx" ] -file: _pathlib.Path = None +file: _typing.Optional[_pathlib.Path] = None diff --git a/checkpy/__main__.py b/checkpy/__main__.py index bb5f78b..041b278 100644 --- a/checkpy/__main__.py +++ b/checkpy/__main__.py @@ -76,9 +76,9 @@ def main(): results = [] for f in args.files: if args.module: - result = tester.test(f, module = args.module, debugMode = args.dev, silentMode = args.silent) + result = tester.test(f, module=args.module, debugMode=args.dev, silentMode=args.silent) else: - result = tester.test(f, debugMode = args.dev, silentMode = args.silent) + result = tester.test(f, debugMode=args.dev, silentMode=args.silent) results.append(result) if args.json: @@ -87,12 +87,13 @@ def main(): if args.module: downloader.updateSilently() - results = tester.testModule(args.module, debugMode = args.dev, silentMode = args.silent) + moduleResults = tester.testModule(args.module, debugMode=args.dev, silentMode=args.silent) - if args.json and results: - print(json.dumps([r.asDict() for r in results], indent=4)) - elif args.json and not results: - print([]) + if args.json: + if moduleResults is None: + print("[]") + else: + print(json.dumps([r.asDict() for r in moduleResults], indent=4)) return parser.print_help() diff --git a/checkpy/downloader/downloader.py b/checkpy/downloader/downloader.py index 9484422..ee178e0 100644 --- a/checkpy/downloader/downloader.py +++ b/checkpy/downloader/downloader.py @@ -156,8 +156,8 @@ def _download(githubUserName: str, githubRepoName: str): existingTests: Set[pathlib.Path] = set() for path, subdirs, files in os.walk(destPath): - for f in files: - existingTests.add((pathlib.Path(path) / f).relative_to(destPath)) + for fil in files: + existingTests.add((pathlib.Path(path) / fil).relative_to(destPath)) newTests: Set[pathlib.Path] = set() for name in z.namelist(): diff --git a/checkpy/entities/exception.py b/checkpy/entities/exception.py index 1c96d1d..6a24ca0 100644 --- a/checkpy/entities/exception.py +++ b/checkpy/entities/exception.py @@ -1,14 +1,23 @@ +import typing as _typing + class CheckpyError(Exception): - def __init__(self, exception = None, message = "", output = "", stacktrace = ""): + def __init__( + self, + exception: + _typing.Optional[Exception]=None, + message: str="", + output: str="", + stacktrace: str="" + ): self._exception = exception self._message = message self._output = output self._stacktrace = stacktrace - def output(self): + def output(self) -> str: return self._output - def stacktrace(self): + def stacktrace(self) -> str: return self._stacktrace def __str__(self): @@ -41,5 +50,5 @@ class TooManyFilesError(CheckpyError): pass class MissingRequiredFiles(CheckpyError): - def __init__(self, missingFiles): + def __init__(self, missingFiles: _typing.List[str]): super().__init__(message=f"Missing the following required files: {', '.join(missingFiles)}") \ No newline at end of file diff --git a/checkpy/entities/function.py b/checkpy/entities/function.py index a6ef811..0d81426 100644 --- a/checkpy/entities/function.py +++ b/checkpy/entities/function.py @@ -4,16 +4,17 @@ import contextlib import inspect import io +import typing import checkpy.entities.exception as exception class Function(object): - def __init__(self, function): + def __init__(self, function: typing.Callable): self._function = function self._printOutput = "" - def __call__(self, *args, **kwargs): + def __call__(self, *args, **kwargs) -> typing.Any: old = sys.stdout try: with self._captureStdout() as listener: @@ -45,22 +46,22 @@ def __call__(self, *args, **kwargs): raise exception.SourceException(exception = e, message = message) @property - def name(self): + def name(self) -> str: """gives the name of the function""" return self._function.__name__ @property - def arguments(self): + def arguments(self) -> typing.List[str]: """gives the parameter names of the function""" - return inspect.getfullargspec(self._function)[0] + return self.parameters @property - def parameters(self): + def parameters(self) -> typing.List[str]: """gives the parameter names of the function""" - return self.arguments + return inspect.getfullargspec(self._function)[0] @property - def printOutput(self): + def printOutput(self) -> str: """stateful function that returns the print (stdout) output of the latest function call as a string""" return self._printOutput @@ -68,7 +69,7 @@ def __repr__(self): return self._function.__name__ @contextlib.contextmanager - def _captureStdout(self): + def _captureStdout(self) -> typing.Generator["_StreamListener", None, None]: """ capture sys.stdout in _outStream (a _Stream that is an instance of StringIO extended with the Observer pattern) @@ -94,31 +95,31 @@ def _captureStdout(self): class _Stream(io.StringIO): def __init__(self, *args, **kwargs): io.StringIO.__init__(self, *args, **kwargs) - self._listeners = [] + self._listeners: typing.List["_StreamListener"] = [] - def register(self, listener): + def register(self, listener: "_StreamListener"): self._listeners.append(listener) - def unregister(self, listener): + def unregister(self, listener: "_StreamListener"): self._listeners.remove(listener) - def write(self, text): + def write(self, text: str): """Overwrites StringIO.write to update all listeners""" io.StringIO.write(self, text) self._onUpdate(text) - def writelines(self, sequence): + def writelines(self, lines: typing.Iterable): """Overwrites StringIO.writelines to update all listeners""" - io.StringIO.writelines(self, sequence) - for item in sequence: - self._onUpdate(item) + io.StringIO.writelines(self, lines) + for line in lines: + self._onUpdate(line) - def _onUpdate(self, content): + def _onUpdate(self, content: str): for listener in self._listeners: listener.update(content) -class _StreamListener(object): - def __init__(self, stream): +class _StreamListener: + def __init__(self, stream: _Stream): self._stream = stream self._content = "" @@ -128,15 +129,15 @@ def start(self): def stop(self): self.stream.unregister(self) - def update(self, content): + def update(self, content: str): self._content += content @property - def content(self): + def content(self) -> str: return self._content @property - def stream(self): + def stream(self) -> _Stream: return self._stream _outStream = _Stream() diff --git a/checkpy/interactive.py b/checkpy/interactive.py index 93be34f..1372908 100644 --- a/checkpy/interactive.py +++ b/checkpy/interactive.py @@ -1,4 +1,3 @@ -import os from checkpy.downloader import download, update def testModule(moduleName, debugMode = False, silentMode = False): @@ -12,9 +11,12 @@ def testModule(moduleName, debugMode = False, silentMode = False): downloader.updateSilently() results = tester.testModule(moduleName, debugMode = debugMode, silentMode = silentMode) try: - if __IPYTHON__: - import matplotlib.pyplot - matplotlib.pyplot.close("all") + if __IPYTHON__: # type: ignore [name-defined] + try: + import matplotlib.pyplot + matplotlib.pyplot.close("all") + except: + pass except: pass return results @@ -30,9 +32,12 @@ def test(fileName, debugMode = False, silentMode = False): downloader.updateSilently() result = tester.test(fileName, debugMode = debugMode, silentMode = silentMode) try: - if __IPYTHON__: - import matplotlib.pyplot - matplotlib.pyplot.close("all") + if __IPYTHON__: # type: ignore [name-defined] + try: + import matplotlib.pyplot + matplotlib.pyplot.close("all") + except: + pass except: pass return result \ No newline at end of file diff --git a/checkpy/lib/__init__.py b/checkpy/lib/__init__.py index cdd162e..38e01e5 100644 --- a/checkpy/lib/__init__.py +++ b/checkpy/lib/__init__.py @@ -1,5 +1,4 @@ from checkpy.lib.basic import * -from checkpy.lib.sandbox import * from checkpy.lib.static import getSource from checkpy.lib.static import getSourceOfDefinitions from checkpy.lib.static import removeComments diff --git a/checkpy/lib/basic.py b/checkpy/lib/basic.py index ca51836..22e6050 100644 --- a/checkpy/lib/basic.py +++ b/checkpy/lib/basic.py @@ -20,86 +20,86 @@ __all__ = [ - "getFunction", - "getModule", - "outputOf", - "getModuleAndOutputOf", - "captureStdin", - "captureStdout" + "getFunction", + "getModule", + "outputOf", + "getModuleAndOutputOf", + "captureStdin", + "captureStdout" ] def getFunction( - functionName: str, - fileName: Optional[Union[str, Path]]=None, - src: Optional[str]=None, - argv: Optional[List[str]]=None, - stdinArgs: Optional[List[str]]=None, - ignoreExceptions: Iterable[Exception]=(), - overwriteAttributes: Iterable[Tuple[str, Any]]=() + functionName: str, + fileName: Optional[Union[str, Path]]=None, + src: Optional[str]=None, + argv: Optional[List[str]]=None, + stdinArgs: Optional[List[str]]=None, + ignoreExceptions: Iterable[Exception]=(), + overwriteAttributes: Iterable[Tuple[str, Any]]=() ) -> function.Function: - """Run the file then get the function with functionName""" - return getattr(getModule( - fileName=fileName, - src=src, - argv=argv, - stdinArgs=stdinArgs, - ignoreExceptions=ignoreExceptions, - overwriteAttributes=overwriteAttributes - ), functionName) + """Run the file then get the function with functionName""" + return getattr(getModule( + fileName=fileName, + src=src, + argv=argv, + stdinArgs=stdinArgs, + ignoreExceptions=ignoreExceptions, + overwriteAttributes=overwriteAttributes + ), functionName) def outputOf( - fileName: Optional[Union[str, Path]]=None, - src: Optional[str]=None, - argv: Optional[List[str]]=None, - stdinArgs: Optional[List[str]]=None, - ignoreExceptions: Iterable[Exception]=(), - overwriteAttributes: Iterable[Tuple[str, Any]]=() + fileName: Optional[Union[str, Path]]=None, + src: Optional[str]=None, + argv: Optional[List[str]]=None, + stdinArgs: Optional[List[str]]=None, + ignoreExceptions: Iterable[Exception]=(), + overwriteAttributes: Iterable[Tuple[str, Any]]=() ) -> str: - """Get the output after running the file.""" - _, output = getModuleAndOutputOf( - fileName=fileName, - src=src, - argv=argv, - stdinArgs=stdinArgs, - ignoreExceptions=ignoreExceptions, - overwriteAttributes=overwriteAttributes - ) - return output + """Get the output after running the file.""" + _, output = getModuleAndOutputOf( + fileName=fileName, + src=src, + argv=argv, + stdinArgs=stdinArgs, + ignoreExceptions=ignoreExceptions, + overwriteAttributes=overwriteAttributes + ) + return output def getModule( - fileName: Optional[Union[str, Path]]=None, - src: Optional[str]=None, - argv: Optional[List[str]]=None, - stdinArgs: Optional[List[str]]=None, - ignoreExceptions: Iterable[Exception]=(), - overwriteAttributes: Iterable[Tuple[str, Any]]=() + fileName: Optional[Union[str, Path]]=None, + src: Optional[str]=None, + argv: Optional[List[str]]=None, + stdinArgs: Optional[List[str]]=None, + ignoreExceptions: Iterable[Exception]=(), + overwriteAttributes: Iterable[Tuple[str, Any]]=() ) -> ModuleType: - """Get the python Module after running the file.""" - mod, _ = getModuleAndOutputOf( - fileName=fileName, - src=src, - argv=argv, - stdinArgs=stdinArgs, - ignoreExceptions=ignoreExceptions, - overwriteAttributes=overwriteAttributes - ) - return mod + """Get the python Module after running the file.""" + mod, _ = getModuleAndOutputOf( + fileName=fileName, + src=src, + argv=argv, + stdinArgs=stdinArgs, + ignoreExceptions=ignoreExceptions, + overwriteAttributes=overwriteAttributes + ) + return mod @caches.cache() def getModuleAndOutputOf( - fileName: Optional[Union[str, Path]]=None, - src: Optional[str]=None, - argv: Optional[List[str]]=None, - stdinArgs: Optional[List[str]]=None, - ignoreExceptions: Iterable[Exception]=(), - overwriteAttributes: Iterable[Tuple[str, Any]]=() - ) -> Tuple[ModuleType, str]: - """ - This function handles most of checkpy's under the hood functionality + fileName: Optional[Union[str, Path]]=None, + src: Optional[str]=None, + argv: Optional[List[str]]=None, + stdinArgs: Optional[List[str]]=None, + ignoreExceptions: Iterable[Exception]=(), + overwriteAttributes: Iterable[Tuple[str, Any]]=() + ) -> Tuple[ModuleType, str]: + """ + This function handles most of checkpy's under the hood functionality fileName (optional): the name of the file to run src (optional): the source code to run @@ -107,204 +107,208 @@ def getModuleAndOutputOf( stdinArgs (optional): arguments passed to stdin ignoreExceptions (optional): exceptions that will silently pass while importing overwriteAttributes (optional): attributes to overwrite in the imported module - """ - if fileName is None: - fileName = checkpy.file.name - - if src is None: - src = getSource(fileName) - - mod = None - output = "" - excep = None - - with captureStdout() as stdout, captureStdin() as stdin: - # fill stdin with args - if stdinArgs: - for arg in stdinArgs: - stdin.write(str(arg) + "\n") - stdin.seek(0) - - # if argv given, overwrite sys.argv - if argv: - sys.argv, argv = argv, sys.argv - - if any(name == "__name__" and attr == "__main__" for name, attr in overwriteAttributes): - moduleName = "__main__" - else: - moduleName = str(fileName).split(".")[0] - - mod = ModuleType(moduleName) - # overwrite attributes - for attr, value in overwriteAttributes: - setattr(mod, attr, value) - - try: - # execute code in mod - exec(src, mod.__dict__) - - # add resulting module to sys - sys.modules[moduleName] = mod - except tuple(ignoreExceptions) as e: # type: ignore - pass - except exception.CheckpyError as e: - excep = e - except Exception as e: - excep = exception.SourceException( - exception = e, - message = "while trying to import the code", - output = stdout.getvalue(), - stacktrace = traceback.format_exc()) - except SystemExit as e: - excep = exception.ExitError( - message = "exit({}) while trying to import the code".format(int(e.args[0])), - output = stdout.getvalue(), - stacktrace = traceback.format_exc()) - - # wrap every function in mod with Function - for name, func in [(name, f) for name, f in mod.__dict__.items() if callable(f)]: - if func.__module__ == moduleName: - setattr(mod, name, function.Function(func)) - - # reset sys.argv - if argv: - sys.argv = argv - - output = stdout.getvalue() - if excep: - raise excep - - return mod, output + """ + if fileName is None: + if checkpy.file is None: + raise checkpy.entities.exception.CheckpyError( + message=f"Cannot call getSourceOfDefinitions() without passing fileName as argument if not test is running." + ) + fileName = checkpy.file.name + + if src is None: + src = getSource(fileName) + + mod = None + output = "" + excep = None + + with captureStdout() as stdout, captureStdin() as stdin: + # fill stdin with args + if stdinArgs: + for arg in stdinArgs: + stdin.write(str(arg) + "\n") + stdin.seek(0) + + # if argv given, overwrite sys.argv + if argv: + sys.argv, argv = argv, sys.argv + + if any(name == "__name__" and attr == "__main__" for name, attr in overwriteAttributes): + moduleName = "__main__" + else: + moduleName = str(fileName).split(".")[0] + + mod = ModuleType(moduleName) + # overwrite attributes + for attr, value in overwriteAttributes: + setattr(mod, attr, value) + + try: + # execute code in mod + exec(src, mod.__dict__) + + # add resulting module to sys + sys.modules[moduleName] = mod + except tuple(ignoreExceptions) as e: # type: ignore + pass + except exception.CheckpyError as e: + excep = e + except Exception as e: + excep = exception.SourceException( + exception = e, + message = "while trying to import the code", + output = stdout.getvalue(), + stacktrace = traceback.format_exc()) + except SystemExit as e: + excep = exception.ExitError( + message = "exit({}) while trying to import the code".format(int(e.args[0])), + output = stdout.getvalue(), + stacktrace = traceback.format_exc()) + + # wrap every function in mod with Function + for name, func in [(name, f) for name, f in mod.__dict__.items() if callable(f)]: + if func.__module__ == moduleName: + setattr(mod, name, function.Function(func)) + + # reset sys.argv + if argv: + sys.argv = argv + + output = stdout.getvalue() + if excep: + raise excep + + return mod, output @contextlib.contextmanager def captureStdout(stdout: Optional[TextIO]=None): - old_stdout = sys.stdout - old_stderr = sys.stderr + old_stdout = sys.stdout + old_stderr = sys.stderr - if stdout is None: - stdout = io.StringIO() + if stdout is None: + stdout = io.StringIO() - try: - sys.stdout = stdout - sys.stderr = open(os.devnull) - yield stdout - except: - raise - finally: - sys.stderr.close() - sys.stdout = old_stdout - sys.stderr = old_stderr + try: + sys.stdout = stdout + sys.stderr = open(os.devnull) + yield stdout + except: + raise + finally: + sys.stderr.close() + sys.stdout = old_stdout + sys.stderr = old_stderr @contextlib.contextmanager def captureStdin(stdin: Optional[TextIO]=None): - def newInput(oldInput): - def input(prompt = None): - try: - return oldInput() - except EOFError as e: - e = exception.InputError( - message = "You requested too much user input", - stacktrace = traceback.format_exc()) - raise e - return input - - oldInput = input - __builtins__["input"] = newInput(oldInput) - old = sys.stdin - if stdin is None: - stdin = io.StringIO() - sys.stdin = stdin - - try: - yield stdin - except: - raise - finally: - sys.stdin = old - __builtins__["input"] = oldInput + def newInput(oldInput): + def input(prompt=None): + try: + return oldInput() + except EOFError: + e = exception.InputError( + message="You requested too much user input", + stacktrace=traceback.format_exc()) + raise e + return input + + oldInput = input + __builtins__["input"] = newInput(oldInput) + old = sys.stdin + if stdin is None: + stdin = io.StringIO() + sys.stdin = stdin + + try: + yield stdin + except: + raise + finally: + sys.stdin = old + __builtins__["input"] = oldInput def removeWhiteSpace(s): - warn("""checkpy.lib.removeWhiteSpace() is deprecated. Instead use: - import re - re.sub(r"\s+", "", text) - """, DeprecationWarning, stacklevel=2) - return re.sub(r"\s+", "", s, flags=re.UNICODE) + warn("""checkpy.lib.removeWhiteSpace() is deprecated. Instead use: + import re + re.sub(r"\s+", "", text) + """, DeprecationWarning, stacklevel=2) + return re.sub(r"\s+", "", s, flags=re.UNICODE) def getPositiveIntegersFromString(s): - warn("""checkpy.lib.getPositiveIntegersFromString() is deprecated. Instead use: - import re - [int(i) for i in re.findall(r"\d+", text)] - """, DeprecationWarning, stacklevel=2) - return [int(i) for i in re.findall(r"\d+", s)] + warn("""checkpy.lib.getPositiveIntegersFromString() is deprecated. Instead use: + import re + [int(i) for i in re.findall(r"\d+", text)] + """, DeprecationWarning, stacklevel=2) + return [int(i) for i in re.findall(r"\d+", s)] def getNumbersFromString(s): - warn("""checkpy.lib.getNumbersFromString() is deprecated. Instead use: - lib.static.getNumbersFrom(s) - """, DeprecationWarning, stacklevel=2) - return [eval(n) for n in re.findall(r"[-+]?\d*\.\d+|[-+]?\d+", s)] + warn("""checkpy.lib.getNumbersFromString() is deprecated. Instead use: + lib.static.getNumbersFrom(s) + """, DeprecationWarning, stacklevel=2) + return [eval(n) for n in re.findall(r"[-+]?\d*\.\d+|[-+]?\d+", s)] def getLine(text, lineNumber): - warn("""checkpy.lib.getLine() is deprecated. Instead try: - lines = text.split("\\n") - assert len(lines) >= lineNumber + 1 - line = lines[lineNumber] - """, DeprecationWarning, stacklevel=2) - lines = text.split("\n") - try: - return lines[lineNumber] - except IndexError: - raise IndexError("Expected to have atleast {} lines in:\n{}".format(lineNumber + 1, text)) + warn("""checkpy.lib.getLine() is deprecated. Instead try: + lines = text.split("\\n") + assert len(lines) >= lineNumber + 1 + line = lines[lineNumber] + """, DeprecationWarning, stacklevel=2) + lines = text.split("\n") + try: + return lines[lineNumber] + except IndexError: + raise IndexError("Expected to have atleast {} lines in:\n{}".format(lineNumber + 1, text)) def fileExists(fileName): - warn("""checkpy.lib.fileExists() is deprecated. Use pathlib.Path instead: - from pathlib import Path - Path(filename).exists() - """, DeprecationWarning, stacklevel=2) - return path.Path(fileName).exists() + warn("""checkpy.lib.fileExists() is deprecated. Use pathlib.Path instead: + from pathlib import Path + Path(filename).exists() + """, DeprecationWarning, stacklevel=2) + return path.Path(fileName).exists() def download(fileName, source): - warn("""checkpy.lib.download() is deprecated. Use requests to download files: - import requests - url = 'http://google.com/favicon.ico' - r = requests.get(url, allow_redirects=True) - with open('google.ico', 'wb') as f: - f.write(r.content) - """, DeprecationWarning, stacklevel=2) - try: - r = requests.get(source) - except requests.exceptions.ConnectionError as e: - raise exception.DownloadError(message = "Oh no! It seems like there is no internet connection available?!") + warn("""checkpy.lib.download() is deprecated. Use requests to download files: + import requests + url = 'http://google.com/favicon.ico' + r = requests.get(url, allow_redirects=True) + with open('google.ico', 'wb') as f: + f.write(r.content) + """, DeprecationWarning, stacklevel=2) + try: + r = requests.get(source) + except requests.exceptions.ConnectionError as e: + raise exception.DownloadError(message = "Oh no! It seems like there is no internet connection available?!") - if not r.ok: - raise exception.DownloadError(message = "Failed to download {} because: {}".format(source, r.reason)) + if not r.ok: + raise exception.DownloadError(message = "Failed to download {} because: {}".format(source, r.reason)) - with open(str(fileName), "wb+") as target: - target.write(r.content) + with open(str(fileName), "wb+") as target: + target.write(r.content) def require(fileName, source=None): - warn("""checkpy.lib.require() is deprecated. Use requests to download files: - import requests - url = 'http://google.com/favicon.ico' - r = requests.get(url, allow_redirects=True) - with open('google.ico', 'wb') as f: - f.write(r.content) - """, DeprecationWarning, stacklevel=2) - if source: - download(fileName, source) - return - - filePath = checkpy.USERPATH / fileName - - if not fileExists(str(filePath)): - raise exception.CheckpyError("Required file {} does not exist".format(fileName)) - - shutil.copyfile(filePath, pathlib.Path.cwd() / fileName) \ No newline at end of file + warn("""checkpy.lib.require() is deprecated. Use requests to download files: + import requests + url = 'http://google.com/favicon.ico' + r = requests.get(url, allow_redirects=True) + with open('google.ico', 'wb') as f: + f.write(r.content) + """, DeprecationWarning, stacklevel=2) + if source: + download(fileName, source) + return + + filePath = checkpy.USERPATH / fileName + + if not fileExists(str(filePath)): + raise exception.CheckpyError(message="Required file {} does not exist".format(fileName)) + + shutil.copyfile(filePath, pathlib.Path.cwd() / fileName) \ No newline at end of file diff --git a/checkpy/lib/builder.py b/checkpy/lib/builder.py index 9f21bc0..79a11dd 100644 --- a/checkpy/lib/builder.py +++ b/checkpy/lib/builder.py @@ -321,7 +321,7 @@ def params(self) -> List[str]: """The exact parameter names and order that the function accepts.""" if self._params is None: raise checkpy.entities.exception.CheckpyError( - f"params are not set for function builder test {self._name}()" + message=f"params are not set for function builder test {self._name}()" ) return self._params @@ -344,7 +344,7 @@ def returned(self) -> Any: """What the last function call returned.""" if not self.wasCalled: raise checkpy.entities.exception.CheckpyError( - f"function was never called for function builder test {self._name}" + message=f"function was never called for function builder test {self._name}" ) return self._returned @@ -394,7 +394,13 @@ def timeout(self) -> int: @timeout.setter def timeout(self, newTimeout: int): self._timeout = newTimeout - checkpy.tester.getActiveTest().timeout = self.timeout + + test = checkpy.tester.getActiveTest() + if test is None: + raise checkpy.entities.exception.CheckpyError( + message=f"Cannot change timeout while there is no test running." + ) + test.timeout = self.timeout @property def description(self) -> str: @@ -406,7 +412,13 @@ def description(self, newDescription: str): if not self.isDescriptionMutable: return self._description = newDescription - checkpy.tester.getActiveTest().description = self.description + + test = checkpy.tester.getActiveTest() + if test is None: + raise checkpy.entities.exception.CheckpyError( + message=f"Cannot change description while there is no test running." + ) + test.description = self.description @property def isDescriptionMutable(self): @@ -437,4 +449,10 @@ def setDescriptionFormatter(self, formatter: Callable[[str, "FunctionState"], st `state.setDescriptionFormatter(lambda descr, state: f"Testing your function {state.name}: {descr}")` """ self._descriptionFormatter = formatter - checkpy.tester.getActiveTest().description = self.description + + test = checkpy.tester.getActiveTest() + if test is None: + raise checkpy.entities.exception.CheckpyError( + message=f"Cannot change descriptionFormatter while there is no test running." + ) + test.description = self.description diff --git a/checkpy/lib/explanation.py b/checkpy/lib/explanation.py index 5203c9a..5a50a83 100644 --- a/checkpy/lib/explanation.py +++ b/checkpy/lib/explanation.py @@ -72,6 +72,11 @@ def simplifyAssertionMessage(assertion: Union[str, AssertionError]) -> str: # Find the line containing assert ..., this is what will be substituted match = re.compile(r".*assert .*").search(message) + + # If there is no line starting with "assert ", nothing to do + if match is None: + return message + assertLine = match.group(0) # Always include any lines before the assert line (a custom message) @@ -94,7 +99,14 @@ def simplifyAssertionMessage(assertion: Union[str, AssertionError]) -> str: # This prevents previous substitutions from interfering with new substitutions # For instance (2 == 1) + where 2 = foo(1) => (foo(1) == 1) where 1 = ... if newIndent <= oldIndent: - end = re.search(re.escape(oldSub), assertLine).end() + cutttingMatch = re.search(re.escape(oldSub), assertLine) + if cutttingMatch is None: + raise checkpy.entities.exception.CheckpyError( + message=f"parsing the assertion '{message}' failed." + f" Please create an issue over at https://github.com/Jelleas/CheckPy/issues" + f" and copy-paste this entire message." + ) + end = cutttingMatch.end() result += assertLine[:end] assertLine = assertLine[end:] oldSub = "" @@ -105,6 +117,12 @@ def simplifyAssertionMessage(assertion: Union[str, AssertionError]) -> str: # Find the left (the original) and the right (the substitute) match = substitutionRegex.match(substitution) + if match is None: + raise checkpy.entities.exception.CheckpyError( + message=f"parsing the assertion '{message}' failed." + f" Please create an issue over at https://github.com/Jelleas/CheckPy/issues" + f" and copy-paste this entire message." + ) left, right = match.group(1), match.group(2) # If the right contains any checkpy function or module, skip diff --git a/checkpy/lib/monkeypatch.py b/checkpy/lib/monkeypatch.py index 7e7b7a3..a23b98d 100644 --- a/checkpy/lib/monkeypatch.py +++ b/checkpy/lib/monkeypatch.py @@ -54,7 +54,7 @@ def patchNumpy(): class _PrintableFunction(_Function): - def __init__(self, function, docs): + def __init__(self, function: _Callable, docs: str): super().__init__(function) self._docs = docs diff --git a/checkpy/lib/sandbox.py b/checkpy/lib/sandbox.py index 6e00741..b17ba42 100644 --- a/checkpy/lib/sandbox.py +++ b/checkpy/lib/sandbox.py @@ -4,7 +4,7 @@ import shutil import tempfile from pathlib import Path -from typing import Iterable, List, Set, Union +from typing import List, Set, Union from checkpy.entities.exception import TooManyFilesError, MissingRequiredFiles @@ -15,16 +15,54 @@ DEFAULT_FILE_LIMIT = 10000 -def exclude(*patterns: Iterable[Union[str, Path]]): +def exclude(*patterns: Union[str, Path]): + """ + Exclude all files matching patterns from the check's sandbox. + + If this is the first call to only/include/exclude/require initialize the sandbox: + * Create a temp dir + * Copy over all files from current dir (except those files excluded through exclude()) + + Patterns are shell globs in the same style as .gitignore. + """ config.exclude(*patterns) -def include(*patterns: Iterable[Union[str, Path]]): +def include(*patterns: Union[str, Path]): + """ + Include all files matching patterns from the check's sandbox. + + If this is the first call to only/include/exclude/require initialize the sandbox: + * Create a temp dir + * Copy over all files from current dir (except those files excluded through exclude()) + + Patterns are shell globs in the same style as .gitignore. + """ config.include(*patterns) -def only(*patterns: Iterable[Union[str, Path]]): +def only(*patterns: Union[str, Path]): + """ + Only files matching patterns will be in the check's sandbox. + + If this is the first call to only/include/exclude/require initialize the sandbox: + * Create a temp dir + * Copy over all files from current dir (except those files excluded through exclude()) + + Patterns are shell globs in the same style as .gitignore. + """ config.only(*patterns) -def require(*filePaths: Iterable[Union[str, Path]]): +def require(*filePaths: Union[str, Path]): + """ + Include all files in the check's sandbox. + Raises checkpy.entities.exception.MissingRequiredFiles if any required file is missing. + Note that this function does not accept patterns (globs), but concrete filenames or paths. + + If this is the first call to only/include/exclude/require initialize the sandbox: + * Create a temp dir + * Copy over all files from current dir (except those files excluded through exclude()) + + Patterns are shell globs in the same style as .gitignore. + """ config.require(*filePaths) @@ -44,7 +82,7 @@ def _initSandbox(self): self.includedFiles = _glob("*", root=self.root) self.isSandboxed = True - def exclude(self, *patterns: Iterable[Union[str, Path]]): + def exclude(self, *patterns: Union[str, Path]): self._initSandbox() newExcluded: Set[str] = set() @@ -57,7 +95,7 @@ def exclude(self, *patterns: Iterable[Union[str, Path]]): self.onUpdate(self) - def include(self, *patterns: Iterable[Union[str, Path]]): + def include(self, *patterns: Union[str, Path]): self._initSandbox() newIncluded: Set[str] = set() @@ -70,7 +108,7 @@ def include(self, *patterns: Iterable[Union[str, Path]]): self.onUpdate(self) - def only(self, *patterns: Iterable[Union[str, Path]]): + def only(self, *patterns: Union[str, Path]): self._initSandbox() allFiles = self.includedFiles | self.excludedFiles @@ -79,7 +117,7 @@ def only(self, *patterns: Iterable[Union[str, Path]]): self.onUpdate(self) - def require(self, *filePaths: Iterable[Union[str, Path]]): + def require(self, *filePaths: Union[str, Path]): self._initSandbox() with cd(self.root): @@ -111,38 +149,37 @@ def sandbox(name: Union[str, Path]=""): return with tempfile.TemporaryDirectory() as dir: - dir = Path(Path(dir) / name) - dir.mkdir(exist_ok=True) + dirPath = Path(Path(dir) / name) + dirPath.mkdir(exist_ok=True) for f in config.includedFiles: - dest = (dir / f).absolute() + dest = (dirPath / f).absolute() dest.parent.mkdir(parents=True, exist_ok=True) shutil.copy(f, dest) - with cd(dir), sandboxConfig(): + with cd(dirPath), sandboxConfig(): yield @contextlib.contextmanager def conditionalSandbox(name: Union[str, Path]=""): - isSandboxed = False tempDir = None dir = None oldIncluded: Set[str] = set() oldExcluded: Set[str] = set() - def sync(config: Config): + def sync(config: Config, sandboxDir: Path): nonlocal oldIncluded, oldExcluded for f in config.excludedFiles - oldExcluded: - dest = (dir / f).absolute() + dest = (sandboxDir / f).absolute() try: os.remove(dest) except FileNotFoundError: pass for f in config.includedFiles - oldExcluded: - dest = (dir / f).absolute() + dest = (sandboxDir / f).absolute() dest.parent.mkdir(parents=True, exist_ok=True) origin = (config.root / f).absolute() shutil.copy(origin, dest) @@ -154,17 +191,15 @@ def onUpdate(config: Config): if config.missingRequiredFiles: raise MissingRequiredFiles(config.missingRequiredFiles) - nonlocal isSandboxed - if not isSandboxed: - isSandboxed = True - nonlocal tempDir + nonlocal tempDir + nonlocal dir + if dir is None or tempDir is None: tempDir = tempfile.TemporaryDirectory() - nonlocal dir dir = Path(Path(tempDir.name) / name) dir.mkdir(exist_ok=True) os.chdir(dir) - sync(config) + sync(config, dir) with sandboxConfig(onUpdate=onUpdate): try: @@ -196,7 +231,12 @@ def cd(dest: Union[str, Path]): os.chdir(origin) -def _glob(pattern: Union[str, Path], root: Union[str, Path]=None, skip_dirs: bool=False, limit: int=DEFAULT_FILE_LIMIT) -> Set[str]: +def _glob( + pattern: Union[str, Path], + root: Union[str, Path, None]=None, + skip_dirs: bool=False, + limit: int=DEFAULT_FILE_LIMIT + ) -> Set[str]: with cd(root) if root else contextlib.nullcontext(): pattern = str(pattern) @@ -212,7 +252,9 @@ def add_file(f): fname = str(Path(f)) all_files.add(fname) if len(all_files) > limit: - raise TooManyFilesError(limit) + raise TooManyFilesError( + message=f"found {len(all_files)} files but checkpy only accepts up to {limit} number of files" + ) # Expand dirs for file in files: diff --git a/checkpy/lib/static.py b/checkpy/lib/static.py index 639aa61..3588eab 100644 --- a/checkpy/lib/static.py +++ b/checkpy/lib/static.py @@ -27,6 +27,10 @@ def getSource(fileName: _Optional[_Union[str, _Path]]=None) -> str: """Get the contents of the file.""" if fileName is None: + if _checkpy.file is None: + raise _exception.CheckpyError( + message=f"Cannot call getSource() without passing fileName as argument if not test is running." + ) fileName = _checkpy.file.name with open(fileName) as f: @@ -36,6 +40,10 @@ def getSource(fileName: _Optional[_Union[str, _Path]]=None) -> str: def getSourceOfDefinitions(fileName: _Optional[_Union[str, _Path]]=None) -> str: """Get just the source code inside definitions (def / class) and any imports.""" if fileName is None: + if _checkpy.file is None: + raise _exception.CheckpyError( + message=f"Cannot call getSourceOfDefinitions() without passing fileName as argument if not test is running." + ) fileName = _checkpy.file.name source = getSource(fileName) @@ -45,11 +53,17 @@ def __init__(self): self.lineNumbers = set() def visit_ClassDef(self, node: _ast.ClassDef): - self.lineNumbers |= set(range(node.lineno - 1, node.end_lineno)) + if node.end_lineno is None: + self.lineNumbers.add(node.lineno - 1) + else: + self.lineNumbers |= set(range(node.lineno - 1, node.end_lineno)) super().generic_visit(node) def visit_FunctionDef(self, node: _ast.FunctionDef): - self.lineNumbers |= set(range(node.lineno - 1, node.end_lineno)) + if node.end_lineno is None: + self.lineNumbers.add(node.lineno - 1) + else: + self.lineNumbers |= set(range(node.lineno - 1, node.end_lineno)) super().generic_visit(node) def visit_Import(self, node: _ast.Import): @@ -192,7 +206,7 @@ def getAstNodes(*types: type, source: _Optional[str]=None) -> _List[_ast.AST]: """ for type_ in types: if type_.__module__ != _ast.__name__: - raise _exception.CheckpyError(f"{type_} passed to getAstNodes() is not of type ast.AST") + raise _exception.CheckpyError(message=f"{type_} passed to getAstNodes() is not of type ast.AST") nodes: _List[_ast.AST] = [] @@ -238,7 +252,8 @@ def __init__(self, fileName: _Optional[str]=None): def __contains__(self, item: type) -> bool: if item.__module__ != _ast.__name__: raise _checkpy.entities.exception.CheckpyError( - f"{item} is not of type {_ast.AST}. Can only search for {_ast.AST} types in AbstractSyntaxTree." + message=f"{item} is not of type {_ast.AST}." + f" Can only search for {_ast.AST} types in AbstractSyntaxTree." ) self.foundNodes = getAstNodes(item, source=self.source) diff --git a/checkpy/printer/printer.py b/checkpy/printer/printer.py index 5fea015..5562236 100644 --- a/checkpy/printer/printer.py +++ b/checkpy/printer/printer.py @@ -1,11 +1,13 @@ -from checkpy.entities import exception - import os import traceback +import typing import colorama colorama.init() +from checkpy.entities import exception +import checkpy.tests + DEBUG_MODE = False SILENT_MODE = False @@ -22,7 +24,7 @@ class _Smileys: CONFUSED = ":S" NEUTRAL = ":|" -def display(testResult): +def display(testResult: checkpy.tests.TestResult) -> str: color, smiley = _selectColorAndSmiley(testResult) msg = "{}{} {}{}".format(color, smiley, testResult.description, _Colors.ENDC) if testResult.message: @@ -39,48 +41,48 @@ def display(testResult): print(msg) return msg -def displayTestName(testName): +def displayTestName(testName: str) -> str: msg = "{}Testing: {}{}".format(_Colors.NAME, testName, _Colors.ENDC) if not SILENT_MODE: print(msg) return msg -def displayUpdate(fileName): +def displayUpdate(fileName: str) -> str: msg = "{}Updated: {}{}".format(_Colors.WARNING, os.path.basename(fileName), _Colors.ENDC) if not SILENT_MODE: print(msg) return msg -def displayRemoved(fileName): +def displayRemoved(fileName: str) -> str: msg = "{}Removed: {}{}".format(_Colors.WARNING, os.path.basename(fileName), _Colors.ENDC) if not SILENT_MODE: print(msg) return msg -def displayAdded(fileName): +def displayAdded(fileName: str) -> str: msg = "{}Added: {}{}".format(_Colors.WARNING, os.path.basename(fileName), _Colors.ENDC) if not SILENT_MODE: print(msg) return msg -def displayCustom(message): +def displayCustom(message: str) -> str: if not SILENT_MODE: print(message) return message -def displayWarning(message): +def displayWarning(message: str) -> str: msg = "{}Warning: {}{}".format(_Colors.WARNING, message, _Colors.ENDC) if not SILENT_MODE: print(msg) return msg -def displayError(message): +def displayError(message: str) -> str: msg = "{}{} {}{}".format(_Colors.WARNING, _Smileys.CONFUSED, message, _Colors.ENDC) if not SILENT_MODE: print(msg) return msg -def _selectColorAndSmiley(testResult): +def _selectColorAndSmiley(testResult: checkpy.tests.TestResult) -> typing.Tuple[str, str]: if testResult.hasPassed: return _Colors.PASS, _Smileys.HAPPY if type(testResult.message) is exception.SourceException: diff --git a/checkpy/tester/discovery.py b/checkpy/tester/discovery.py index 30c290e..07b8a10 100644 --- a/checkpy/tester/discovery.py +++ b/checkpy/tester/discovery.py @@ -31,6 +31,7 @@ def getTestNames(moduleName: str) -> Optional[List[str]]: for (dirPath, subdirs, files) in os.walk(testsPath): if moduleName in dirPath: return [f[:-len("test.py")] for f in files if f.lower().endswith("test.py")] + return None def getTestPaths(testFileName: str, module: str="") -> List[pathlib.Path]: testFilePaths: List[pathlib.Path] = [] diff --git a/checkpy/tester/tester.py b/checkpy/tester/tester.py index c8a1b2c..7855bbc 100644 --- a/checkpy/tester/tester.py +++ b/checkpy/tester/tester.py @@ -37,11 +37,11 @@ def test(testName: str, module="", debugMode=False, silentMode=False) -> "Tester result = TesterResult(testName) - path = discovery.getPath(testName) - if not path: + discoveredPath = discovery.getPath(testName) + if discoveredPath is None: result.addOutput(printer.displayError("File not found: {}".format(testName))) return result - path = str(path) + path = str(discoveredPath) fileName = os.path.basename(path) filePath = os.path.dirname(path) @@ -86,7 +86,7 @@ def test(testName: str, module="", debugMode=False, silentMode=False) -> "Tester return testerResult -def testModule(module: ModuleType, debugMode=False, silentMode=False) -> Optional[List["TesterResult"]]: +def testModule(module: str, debugMode=False, silentMode=False) -> Optional[List["TesterResult"]]: printer.printer.SILENT_MODE = silentMode testNames = discovery.getTestNames(module) @@ -94,7 +94,7 @@ def testModule(module: ModuleType, debugMode=False, silentMode=False) -> Optiona printer.displayError("no tests found in module: {}".format(module)) return None - return [test(testName, module = module, debugMode = debugMode, silentMode = silentMode) for testName in testNames] + return [test(testName, module=module, debugMode=debugMode, silentMode=silentMode) for testName in testNames] def _runTests(moduleName: str, fileName: str, debugMode=False, silentMode=False) -> "TesterResult": @@ -222,7 +222,7 @@ def run(self): with conditionalSandbox(): module = importlib.import_module(self.moduleName) - module._fileName = self.filePath.name + module._fileName = self.filePath.name # type: ignore [attr-defined] self._runTestsFromModule(module) diff --git a/checkpy/tests.py b/checkpy/tests.py index c34dea3..0109ec0 100644 --- a/checkpy/tests.py +++ b/checkpy/tests.py @@ -1,7 +1,7 @@ import inspect import traceback -from typing import Any, Dict, List, Set, Tuple, Union, Callable, Iterable, Optional +from typing import Any, Dict, Set, Tuple, Union, Callable, Iterable, Optional from checkpy import caches from checkpy.entities import exception @@ -22,7 +22,7 @@ def testDecorator(testFunction: Callable) -> TestFunction: def failed( - *preconditions: List["TestFunction"], + *preconditions: "TestFunction", priority: Optional[int]=None, timeout: Optional[int]=None, hide: bool=True @@ -33,7 +33,7 @@ def failedDecorator(testFunction: Callable) -> FailedTestFunction: def passed( - *preconditions: List["TestFunction"], + *preconditions: "TestFunction", priority: Optional[int]=None, timeout: Optional[int]=None, hide: bool=True @@ -50,9 +50,9 @@ class Test: def __init__(self, fileName: str, priority: int, - timeout: int=None, - onDescriptionChange=lambda self: None, - onTimeoutChange=lambda self: None + timeout: Optional[int]=None, + onDescriptionChange: Callable[["Test"], None]=lambda self: None, + onTimeoutChange: Callable[["Test"], None]=lambda self: None ): self._fileName = fileName self._priority = priority @@ -125,7 +125,13 @@ def __setattr__(self, __name: str, __value: Any) -> None: class TestResult(object): - def __init__(self, hasPassed: Union[bool, None], description: str, message: str, exception: Exception=None): + def __init__( + self, + hasPassed: Optional[bool], + description: str, + message: str, + exception: Optional[Exception]=None + ): self._hasPassed = hasPassed self._description = description self._message = message @@ -168,7 +174,7 @@ def __init__( self._function = function self.isTestFunction = True self.priority = self._getPriority(priority) - self.dependencies = getattr(self._function, "dependencies", set()) + self.dependencies: Set[TestFunction] = getattr(self._function, "dependencies", set()) self.timeout = self._getTimeout(timeout) self.__name__ = function.__name__ @@ -222,13 +228,13 @@ def runMethod(): def useDocStringDescription(self, test: Test) -> None: if getattr(self._function, "isTestFunction", False): - self._function.useDocStringDescription(test) + self._function.useDocStringDescription(test) # type: ignore [attr-defined] - if self._function.__doc__ != None: + if self._function.__doc__ is not None: test.description = self._function.__doc__ def _getPriority(self, priority: Optional[int]) -> int: - if priority != None: + if priority is not None: TestFunction._previousPriority = priority return priority @@ -241,7 +247,7 @@ def _getPriority(self, priority: Optional[int]) -> int: return TestFunction._previousPriority def _getTimeout(self, timeout: Optional[int]) -> int: - if timeout != None: + if timeout is not None: return timeout inheritedTimeout = getattr(self._function, "timeout", None) @@ -294,14 +300,14 @@ def runMethod(): def requireDocstringIfNotHidden(self, test: Test) -> None: if not self.shouldHide and test.description == Test.PLACEHOLDER_DESCRIPTION: - raise exception.TestError(f"Test {self.__name__} requires a docstring description if hide=False") + raise exception.TestError(message=f"Test {self.__name__} requires a docstring description if hide=False") @staticmethod def shouldRun(testResults: Iterable[TestResult]) -> bool: return not any(t is None for t in testResults) and not any(t.hasPassed for t in testResults) def _getHide(self, hide: Optional[bool]) -> bool: - if hide != None: + if hide is not None: return hide inheritedHide = getattr(self._function, "hide", None) @@ -311,7 +317,6 @@ def _getHide(self, hide: Optional[bool]) -> bool: return True - class PassedTestFunction(FailedTestFunction): HIDE_MESSAGE = "can't check until another check passes" From c0315f2b42595fcb6f9878619b04dacd01168b72 Mon Sep 17 00:00:00 2001 From: Jelleas Date: Fri, 28 Jul 2023 14:27:38 +0200 Subject: [PATCH 215/269] tabs => spaces --- checkpy/__main__.py | 176 +++++----- checkpy/assertlib/basic.py | 42 +-- checkpy/caches.py | 82 ++--- checkpy/database/database.py | 148 ++++---- checkpy/downloader/downloader.py | 282 +++++++-------- checkpy/entities/exception.py | 70 ++-- checkpy/entities/path.py | 192 +++++------ checkpy/interactive.py | 76 ++-- checkpy/lib/sandbox.py | 370 ++++++++++---------- checkpy/lib/type.py | 60 ++-- checkpy/printer/printer.py | 114 +++--- checkpy/tester/discovery.py | 52 +-- checkpy/tester/tester.py | 512 +++++++++++++-------------- checkpy/tests.py | 576 +++++++++++++++---------------- 14 files changed, 1376 insertions(+), 1376 deletions(-) diff --git a/checkpy/__main__.py b/checkpy/__main__.py index 041b278..8758f23 100644 --- a/checkpy/__main__.py +++ b/checkpy/__main__.py @@ -10,93 +10,93 @@ def main(): - warnings.filterwarnings("ignore") - - parser = argparse.ArgumentParser( - description = - """ - checkPy: a python testing framework for education. - You are running Python version {}.{}.{} and checkpy version {}. - """ - .format(sys.version_info[0], sys.version_info[1], sys.version_info[2], pkg_resources.get_distribution("checkpy").version) - ) - - parser.add_argument("-module", action="store", dest="module", help="provide a module name or path to run all tests from the module, or target a module for a specific test") - parser.add_argument("-download", action="store", dest="githubLink", help="download tests from a Github repository and exit") - parser.add_argument("-register", action="store", dest="localLink", help="register a local folder that contains tests and exit") - parser.add_argument("-update", action="store_true", help="update all downloaded tests and exit") - parser.add_argument("-list", action="store_true", help="list all download locations and exit") - parser.add_argument("-clean", action="store_true", help="remove all tests from the tests folder and exit") - parser.add_argument("--dev", action="store_true", help="get extra information to support the development of tests") - parser.add_argument("--silent", action="store_true", help="do not print test results to stdout") - parser.add_argument("--json", action="store_true", help="return output as json, implies silent") - parser.add_argument("--gh-auth", action="store", help="username:personal_access_token for authentication with GitHub. Only used to increase the GitHub api's rate limit.") - parser.add_argument("files", action="store", nargs="*", help="names of files to be tested") - args = parser.parse_args() - - rootPath = os.sep.join(os.path.abspath(os.path.dirname(__file__)).split(os.sep)[:-1]) - if rootPath not in sys.path: - sys.path.append(rootPath) - - if args.gh_auth: - split_auth = args.gh_auth.split(":") - - if len(split_auth) != 2: - printer.displayError("Invalid --gh-auth option. {} is not of the form username:personal_access_token. Note the :".format(args.gh_auth)) - return - - downloader.set_gh_auth(*split_auth) - - if args.githubLink: - downloader.download(args.githubLink) - return - - if args.localLink: - downloader.register(args.localLink) - return - - if args.update: - downloader.update() - return - - if args.list: - downloader.list() - return - - if args.clean: - downloader.clean() - return - - if args.json: - args.silent = True - - if args.files: - downloader.updateSilently() - - results = [] - for f in args.files: - if args.module: - result = tester.test(f, module=args.module, debugMode=args.dev, silentMode=args.silent) - else: - result = tester.test(f, debugMode=args.dev, silentMode=args.silent) - results.append(result) - - if args.json: - print(json.dumps([r.asDict() for r in results], indent=4)) - return - - if args.module: - downloader.updateSilently() - moduleResults = tester.testModule(args.module, debugMode=args.dev, silentMode=args.silent) - - if args.json: - if moduleResults is None: - print("[]") - else: - print(json.dumps([r.asDict() for r in moduleResults], indent=4)) - return - - parser.print_help() + warnings.filterwarnings("ignore") + + parser = argparse.ArgumentParser( + description = + """ + checkPy: a python testing framework for education. + You are running Python version {}.{}.{} and checkpy version {}. + """ + .format(sys.version_info[0], sys.version_info[1], sys.version_info[2], pkg_resources.get_distribution("checkpy").version) + ) + + parser.add_argument("-module", action="store", dest="module", help="provide a module name or path to run all tests from the module, or target a module for a specific test") + parser.add_argument("-download", action="store", dest="githubLink", help="download tests from a Github repository and exit") + parser.add_argument("-register", action="store", dest="localLink", help="register a local folder that contains tests and exit") + parser.add_argument("-update", action="store_true", help="update all downloaded tests and exit") + parser.add_argument("-list", action="store_true", help="list all download locations and exit") + parser.add_argument("-clean", action="store_true", help="remove all tests from the tests folder and exit") + parser.add_argument("--dev", action="store_true", help="get extra information to support the development of tests") + parser.add_argument("--silent", action="store_true", help="do not print test results to stdout") + parser.add_argument("--json", action="store_true", help="return output as json, implies silent") + parser.add_argument("--gh-auth", action="store", help="username:personal_access_token for authentication with GitHub. Only used to increase the GitHub api's rate limit.") + parser.add_argument("files", action="store", nargs="*", help="names of files to be tested") + args = parser.parse_args() + + rootPath = os.sep.join(os.path.abspath(os.path.dirname(__file__)).split(os.sep)[:-1]) + if rootPath not in sys.path: + sys.path.append(rootPath) + + if args.gh_auth: + split_auth = args.gh_auth.split(":") + + if len(split_auth) != 2: + printer.displayError("Invalid --gh-auth option. {} is not of the form username:personal_access_token. Note the :".format(args.gh_auth)) + return + + downloader.set_gh_auth(*split_auth) + + if args.githubLink: + downloader.download(args.githubLink) + return + + if args.localLink: + downloader.register(args.localLink) + return + + if args.update: + downloader.update() + return + + if args.list: + downloader.list() + return + + if args.clean: + downloader.clean() + return + + if args.json: + args.silent = True + + if args.files: + downloader.updateSilently() + + results = [] + for f in args.files: + if args.module: + result = tester.test(f, module=args.module, debugMode=args.dev, silentMode=args.silent) + else: + result = tester.test(f, debugMode=args.dev, silentMode=args.silent) + results.append(result) + + if args.json: + print(json.dumps([r.asDict() for r in results], indent=4)) + return + + if args.module: + downloader.updateSilently() + moduleResults = tester.testModule(args.module, debugMode=args.dev, silentMode=args.silent) + + if args.json: + if moduleResults is None: + print("[]") + else: + print(json.dumps([r.asDict() for r in moduleResults], indent=4)) + return + + parser.print_help() if __name__ == "__main__": - main() + main() diff --git a/checkpy/assertlib/basic.py b/checkpy/assertlib/basic.py index 21053f1..a5a0688 100644 --- a/checkpy/assertlib/basic.py +++ b/checkpy/assertlib/basic.py @@ -4,50 +4,50 @@ import warnings warnings.warn( - """checkpy.assertlib is deprecated. Use `assert` statements instead.""", - DeprecationWarning, - stacklevel=2 + """checkpy.assertlib is deprecated. Use `assert` statements instead.""", + DeprecationWarning, + stacklevel=2 ) def exact(actual, expected): - return actual == expected + return actual == expected def exactAndSameType(actual, expected): - return exact(actual, expected) and sameType(actual, expected) + return exact(actual, expected) and sameType(actual, expected) def between(actual, lower, upper): - return lower <= actual <= upper + return lower <= actual <= upper def ignoreWhiteSpace(actual, expected): - return exact(lib.removeWhiteSpace(actual), lib.removeWhiteSpace(expected)) + return exact(lib.removeWhiteSpace(actual), lib.removeWhiteSpace(expected)) def contains(actual, expectedElement): - return expectedElement in actual + return expectedElement in actual def containsOnly(actual, expectedElements): - return len([el for el in actual if el not in expectedElements]) == 0 - + return len([el for el in actual if el not in expectedElements]) == 0 + def sameType(actual, expected): - return type(actual) is type(expected) + return type(actual) is type(expected) def match(actual, expectedRegEx): - return True if re.match(expectedRegEx, actual) else False + return True if re.match(expectedRegEx, actual) else False def sameLength(actual, expected): - return len(actual) == len(expected) + return len(actual) == len(expected) def fileExists(fileName): - return os.path.isfile(fileName) + return os.path.isfile(fileName) def numberOnLine(number, line, deviation = 0): - return any(between(n, number - deviation, number + deviation) for n in lib.getNumbersFromString(line)) + return any(between(n, number - deviation, number + deviation) for n in lib.getNumbersFromString(line)) def fileContainsFunctionCalls(fileName, *functionNames): - source = lib.removeComments(lib.source(fileName)) - fCallInSrc = lambda fName, src : re.match(re.compile(r".*{}[ \t]*(.*?).*".format(fName), re.DOTALL), src) - return all(fCallInSrc(fName, source) for fName in functionNames) + source = lib.removeComments(lib.source(fileName)) + fCallInSrc = lambda fName, src : re.match(re.compile(r".*{}[ \t]*(.*?).*".format(fName), re.DOTALL), src) + return all(fCallInSrc(fName, source) for fName in functionNames) def fileContainsFunctionDefinitions(fileName, *functionNames): - source = lib.removeComments(lib.source(fileName)) - fDefInSrc = lambda fName, src : re.match(re.compile(r".*def[ \t]+{}[ \t]*(.*?).*".format(fName), re.DOTALL), src) - return all(fDefInSrc(fName, source) for fName in functionNames) + source = lib.removeComments(lib.source(fileName)) + fDefInSrc = lambda fName, src : re.match(re.compile(r".*def[ \t]+{}[ \t]*(.*?).*".format(fName), re.DOTALL), src) + return all(fDefInSrc(fName, source) for fName in functionNames) diff --git a/checkpy/caches.py b/checkpy/caches.py index 2a164e8..c5597d6 100644 --- a/checkpy/caches.py +++ b/checkpy/caches.py @@ -4,64 +4,64 @@ _caches = [] class _Cache(dict): - """A dict() subclass that appends a self-reference to _caches""" - def __init__(self, *args, **kwargs): - super(_Cache, self).__init__(*args, **kwargs) - _caches.append(self) + """A dict() subclass that appends a self-reference to _caches""" + def __init__(self, *args, **kwargs): + super(_Cache, self).__init__(*args, **kwargs) + _caches.append(self) _testCache = _Cache() def cache(*keys): - """cache decorator + """cache decorator - Caches input and output of a function. If arguments are passed to - the decorator, take those as key for the cache. Otherwise use the - function arguments and sys.argv as key. + Caches input and output of a function. If arguments are passed to + the decorator, take those as key for the cache. Otherwise use the + function arguments and sys.argv as key. - sys.argv is used here because of user-written code like this: + sys.argv is used here because of user-written code like this: - import sys - my_variable = sys.argv[1] - def my_function(): - print(my_variable) + import sys + my_variable = sys.argv[1] + def my_function(): + print(my_variable) - Depending on the state of sys.argv during execution of the module, - the outcome of my_function() changes. - """ - def cacheWrapper(func): - localCache = _Cache() + Depending on the state of sys.argv during execution of the module, + the outcome of my_function() changes. + """ + def cacheWrapper(func): + localCache = _Cache() - @wraps(func) - def cachedFuncWrapper(*args, **kwargs): - if keys: - key = str(keys) - else: - key = str(args) + str(kwargs) + str(sys.argv) - if key not in localCache: - localCache[key] = func(*args, **kwargs) - return localCache[key] - return cachedFuncWrapper + @wraps(func) + def cachedFuncWrapper(*args, **kwargs): + if keys: + key = str(keys) + else: + key = str(args) + str(kwargs) + str(sys.argv) + if key not in localCache: + localCache[key] = func(*args, **kwargs) + return localCache[key] + return cachedFuncWrapper - return cacheWrapper + return cacheWrapper def cacheTestResult(testFunction): - def wrapper(runFunction): - @wraps(runFunction) - def runFunctionWrapper(*args, **kwargs): - result = runFunction(*args, **kwargs) - _testCache[testFunction.__name__] = result - return result - return runFunctionWrapper - return wrapper + def wrapper(runFunction): + @wraps(runFunction) + def runFunctionWrapper(*args, **kwargs): + result = runFunction(*args, **kwargs) + _testCache[testFunction.__name__] = result + return result + return runFunctionWrapper + return wrapper def getCachedTestResult(testFunction): - return _testCache[testFunction.__name__] + return _testCache[testFunction.__name__] def clearAllCaches(): - for cache in _caches: - cache.clear() - _testCache.clear() + for cache in _caches: + cache.clear() + _testCache.clear() diff --git a/checkpy/database/database.py b/checkpy/database/database.py index 7932f63..38555b5 100644 --- a/checkpy/database/database.py +++ b/checkpy/database/database.py @@ -10,112 +10,112 @@ @contextlib.contextmanager def database() -> Generator[tinydb.TinyDB, None, None]: - _DBPATH.touch() - try: - db = tinydb.TinyDB(str(_DBPATH)) - yield db - finally: - db.close() + _DBPATH.touch() + try: + db = tinydb.TinyDB(str(_DBPATH)) + yield db + finally: + db.close() @contextlib.contextmanager def githubTable()-> Generator[Table, None, None]: - with database() as db: - yield db.table("github") + with database() as db: + yield db.table("github") @contextlib.contextmanager def localTable() -> Generator[Table, None, None]: - with database() as db: - yield db.table("local") + with database() as db: + yield db.table("local") def clean(): - with database() as db: - db.drop_tables() + with database() as db: + db.drop_tables() def forEachTestsPath() -> Iterable[pathlib.Path]: - for path in forEachGithubPath(): - yield path + for path in forEachGithubPath(): + yield path - for path in forEachLocalPath(): - yield path + for path in forEachLocalPath(): + yield path def forEachUserAndRepo() -> Iterable[Tuple[str, str]]: - with githubTable() as table: - return [(entry["user"], entry["repo"]) for entry in table.all()] + with githubTable() as table: + return [(entry["user"], entry["repo"]) for entry in table.all()] def forEachGithubPath() -> Iterable[pathlib.Path]: - with githubTable() as table: - for entry in table.all(): - yield pathlib.Path(entry["path"]) + with githubTable() as table: + for entry in table.all(): + yield pathlib.Path(entry["path"]) def forEachLocalPath() -> Iterable[pathlib.Path]: - with localTable() as table: - for entry in table.all(): - yield pathlib.Path(entry["path"]) + with localTable() as table: + for entry in table.all(): + yield pathlib.Path(entry["path"]) def isKnownGithub(username: str, repoName: str) -> bool: - query = tinydb.Query() - with githubTable() as table: - return table.contains((query.user == username) & (query.repo == repoName)) + query = tinydb.Query() + with githubTable() as table: + return table.contains((query.user == username) & (query.repo == repoName)) def addToGithubTable(username: str, repoName: str, releaseId: str, releaseTag: str): - if not isKnownGithub(username, repoName): - path = str(checkpy.CHECKPYPATH / "tests" / repoName) - - with githubTable() as table: - table.insert({ - "user" : username, - "repo" : repoName, - "path" : path, - "release" : releaseId, - "tag" : releaseTag, - "timestamp" : time.time() - }) + if not isKnownGithub(username, repoName): + path = str(checkpy.CHECKPYPATH / "tests" / repoName) + + with githubTable() as table: + table.insert({ + "user" : username, + "repo" : repoName, + "path" : path, + "release" : releaseId, + "tag" : releaseTag, + "timestamp" : time.time() + }) def addToLocalTable(localPath: pathlib.Path): - query = tinydb.Query() - with localTable() as table: - if not table.search(query.path == str(localPath)): - table.insert({ - "path" : str(localPath) - }) + query = tinydb.Query() + with localTable() as table: + if not table.search(query.path == str(localPath)): + table.insert({ + "path" : str(localPath) + }) def updateGithubTable(username: str, repoName: str, releaseId: str, releaseTag: str): - query = tinydb.Query() - path = str(checkpy.CHECKPYPATH / "tests" / repoName) - with githubTable() as table: - table.update({ - "user" : username, - "repo" : repoName, - "path" : path, - "release" : releaseId, - "tag" : releaseTag, - "timestamp" : time.time() - }, query.user == username and query.repo == repoName) + query = tinydb.Query() + path = str(checkpy.CHECKPYPATH / "tests" / repoName) + with githubTable() as table: + table.update({ + "user" : username, + "repo" : repoName, + "path" : path, + "release" : releaseId, + "tag" : releaseTag, + "timestamp" : time.time() + }, query.user == username and query.repo == repoName) def timestampGithub(username: str, repoName: str) -> float: - query = tinydb.Query() - with githubTable() as table: - return table.search(query.user == username and query.repo == repoName)[0]["timestamp"] + query = tinydb.Query() + with githubTable() as table: + return table.search(query.user == username and query.repo == repoName)[0]["timestamp"] def setTimestampGithub(username: str, repoName: str): - query = tinydb.Query() - with githubTable() as table: - table.update( - {"timestamp" : time.time()}, - query.user == username and query.repo == repoName - ) + query = tinydb.Query() + with githubTable() as table: + table.update( + {"timestamp" : time.time()}, + query.user == username and query.repo == repoName + ) def githubPath(username: str, repoName: str) -> pathlib.Path: - query = tinydb.Query() - with githubTable() as table: - return pathlib.Path(table.search(query.user == username and query.repo == repoName)[0]["path"]) + query = tinydb.Query() + with githubTable() as table: + return pathlib.Path(table.search(query.user == username and query.repo == repoName)[0]["path"]) def releaseId(username: str, repoName: str) -> str: - query = tinydb.Query() - with githubTable() as table: - return table.search(query.user == username and query.repo == repoName)[0]["release"] + query = tinydb.Query() + with githubTable() as table: + return table.search(query.user == username and query.repo == repoName)[0]["release"] def releaseTag(username: str, repoName: str) -> str: - query = tinydb.Query() - with githubTable() as table: - return table.search(query.user == username and query.repo == repoName)[0]["tag"] + query = tinydb.Query() + with githubTable() as table: + return table.search(query.user == username and query.repo == repoName)[0]["tag"] diff --git a/checkpy/downloader/downloader.py b/checkpy/downloader/downloader.py index ee178e0..1243757 100644 --- a/checkpy/downloader/downloader.py +++ b/checkpy/downloader/downloader.py @@ -16,195 +16,195 @@ personal_access_token: Optional[str] = None def set_gh_auth(username: str, pat: str): - global user, personal_access_token - user = username - personal_access_token = pat + global user, personal_access_token + user = username + personal_access_token = pat def download(githubLink: str): - if githubLink.endswith("/"): - githubLink = githubLink[:-1] + if githubLink.endswith("/"): + githubLink = githubLink[:-1] - if "/" not in githubLink: - printer.displayError(f"{githubLink} is not a valid download location") - return + if "/" not in githubLink: + printer.displayError(f"{githubLink} is not a valid download location") + return - username = githubLink.split("/")[-2].lower() - repoName = githubLink.split("/")[-1].lower() + username = githubLink.split("/")[-2].lower() + repoName = githubLink.split("/")[-1].lower() - try: - _syncRelease(username, repoName) - _download(username, repoName) - except exception.DownloadError as e: - printer.displayError(str(e)) + try: + _syncRelease(username, repoName) + _download(username, repoName) + except exception.DownloadError as e: + printer.displayError(str(e)) def register(localLink: Union[str, pathlib.Path]): - path = pathlib.Path(localLink) + path = pathlib.Path(localLink) - if not path.exists(): - printer.displayError("{} does not exist") - return + if not path.exists(): + printer.displayError("{} does not exist") + return - database.addToLocalTable(path) + database.addToLocalTable(path) def update(): - for username, repoName in database.forEachUserAndRepo(): - try: - _syncRelease(username, repoName) - _download(username, repoName) - except exception.DownloadError as e: - printer.displayError(str(e)) + for username, repoName in database.forEachUserAndRepo(): + try: + _syncRelease(username, repoName) + _download(username, repoName) + except exception.DownloadError as e: + printer.displayError(str(e)) def list(): - for username, repoName in database.forEachUserAndRepo(): - printer.displayCustom(f"Github: {repoName} from {username}") - for path in database.forEachLocalPath(): - printer.displayCustom(f"Local: {path}") + for username, repoName in database.forEachUserAndRepo(): + printer.displayCustom(f"Github: {repoName} from {username}") + for path in database.forEachLocalPath(): + printer.displayCustom(f"Local: {path}") def clean(): - for path in database.forEachGithubPath(): - shutil.rmtree(str(path), ignore_errors=True) - database.clean() - printer.displayCustom("Removed all tests") - return + for path in database.forEachGithubPath(): + shutil.rmtree(str(path), ignore_errors=True) + database.clean() + printer.displayCustom("Removed all tests") + return def updateSilently(): - for username, repoName in database.forEachUserAndRepo(): - # only attempt update if 300 sec have passed - if time.time() - database.timestampGithub(username, repoName) < 300: - continue - - database.setTimestampGithub(username, repoName) - try: - if _newReleaseAvailable(username, repoName): - _download(username, repoName) - except exception.DownloadError as e: - pass + for username, repoName in database.forEachUserAndRepo(): + # only attempt update if 300 sec have passed + if time.time() - database.timestampGithub(username, repoName) < 300: + continue + + database.setTimestampGithub(username, repoName) + try: + if _newReleaseAvailable(username, repoName): + _download(username, repoName) + except exception.DownloadError as e: + pass def _newReleaseAvailable(githubUserName: str, githubRepoName: str) -> bool: - # unknown/new download - if not database.isKnownGithub(githubUserName, githubRepoName): - return True - releaseJson = _getReleaseJson(githubUserName, githubRepoName) + # unknown/new download + if not database.isKnownGithub(githubUserName, githubRepoName): + return True + releaseJson = _getReleaseJson(githubUserName, githubRepoName) - # new release id found - if releaseJson["id"] != database.releaseId(githubUserName, githubRepoName): - database.updateGithubTable(githubUserName, githubRepoName, releaseJson["id"], releaseJson["tag_name"]) - return True + # new release id found + if releaseJson["id"] != database.releaseId(githubUserName, githubRepoName): + database.updateGithubTable(githubUserName, githubRepoName, releaseJson["id"], releaseJson["tag_name"]) + return True - # no new release found - return False + # no new release found + return False def _syncRelease(githubUserName: str, githubRepoName: str): - releaseJson = _getReleaseJson(githubUserName, githubRepoName) + releaseJson = _getReleaseJson(githubUserName, githubRepoName) - if database.isKnownGithub(githubUserName, githubRepoName): - database.updateGithubTable(githubUserName, githubRepoName, releaseJson["id"], releaseJson["tag_name"]) - else: - database.addToGithubTable(githubUserName, githubRepoName, releaseJson["id"], releaseJson["tag_name"]) + if database.isKnownGithub(githubUserName, githubRepoName): + database.updateGithubTable(githubUserName, githubRepoName, releaseJson["id"], releaseJson["tag_name"]) + else: + database.addToGithubTable(githubUserName, githubRepoName, releaseJson["id"], releaseJson["tag_name"]) def _getReleaseJson(githubUserName: str, githubRepoName: str) -> Dict: - """ - This performs one api call, beware of rate limit!!! - Returns a dictionary representing the json returned by github - In case of an error, raises an exception.DownloadError - """ - apiReleaseLink = f"https://api.github.com/repos/{githubUserName}/{githubRepoName}/releases/latest" - - global user - global personal_access_token - try: - if user and personal_access_token: - r = requests.get(apiReleaseLink, auth=(user, personal_access_token)) - else: - r = requests.get(apiReleaseLink) - except requests.exceptions.ConnectionError as e: - raise exception.DownloadError(message="Oh no! It seems like there is no internet connection available?!") - - # exceeded rate limit, - if r.status_code == 403: - raise exception.DownloadError(message=f"Tried finding new releases from {githubUserName}/{githubRepoName} but exceeded the rate limit, try again within an hour!") - - # no releases found or page not found - if r.status_code == 404: - raise exception.DownloadError(message=f"Failed to check for new tests from {githubUserName}/{githubRepoName} because: no releases found (404)") - - # random error - if not r.ok: - raise exception.DownloadError(message=f"Failed to sync releases from {githubUserName}/{githubRepoName} because: {r.reason}") - - return r.json() + """ + This performs one api call, beware of rate limit!!! + Returns a dictionary representing the json returned by github + In case of an error, raises an exception.DownloadError + """ + apiReleaseLink = f"https://api.github.com/repos/{githubUserName}/{githubRepoName}/releases/latest" + + global user + global personal_access_token + try: + if user and personal_access_token: + r = requests.get(apiReleaseLink, auth=(user, personal_access_token)) + else: + r = requests.get(apiReleaseLink) + except requests.exceptions.ConnectionError as e: + raise exception.DownloadError(message="Oh no! It seems like there is no internet connection available?!") + + # exceeded rate limit, + if r.status_code == 403: + raise exception.DownloadError(message=f"Tried finding new releases from {githubUserName}/{githubRepoName} but exceeded the rate limit, try again within an hour!") + + # no releases found or page not found + if r.status_code == 404: + raise exception.DownloadError(message=f"Failed to check for new tests from {githubUserName}/{githubRepoName} because: no releases found (404)") + + # random error + if not r.ok: + raise exception.DownloadError(message=f"Failed to sync releases from {githubUserName}/{githubRepoName} because: {r.reason}") + + return r.json() # download tests for githubUserName and githubRepoName from what is known in downloadlocations.json # use _syncRelease() to force an update in downloadLocations.json def _download(githubUserName: str, githubRepoName: str): - githubLink = f"https://github.com/{githubUserName}/{githubRepoName}" - zipLink = githubLink + f"/archive/{database.releaseTag(githubUserName, githubRepoName)}.zip" + githubLink = f"https://github.com/{githubUserName}/{githubRepoName}" + zipLink = githubLink + f"/archive/{database.releaseTag(githubUserName, githubRepoName)}.zip" - try: - r = requests.get(zipLink) - except requests.exceptions.ConnectionError as e: - raise exception.DownloadError(message = "Oh no! It seems like there is no internet connection available?!") + try: + r = requests.get(zipLink) + except requests.exceptions.ConnectionError as e: + raise exception.DownloadError(message = "Oh no! It seems like there is no internet connection available?!") - if not r.ok: - raise exception.DownloadError(message = f"Failed to download {githubLink} because: {r.reason}") + if not r.ok: + raise exception.DownloadError(message = f"Failed to download {githubLink} because: {r.reason}") - f = io.BytesIO(r.content) + f = io.BytesIO(r.content) - with zf.ZipFile(f) as z: - destPath = database.githubPath(githubUserName, githubRepoName) + with zf.ZipFile(f) as z: + destPath = database.githubPath(githubUserName, githubRepoName) - existingTests: Set[pathlib.Path] = set() - for path, subdirs, files in os.walk(destPath): - for fil in files: - existingTests.add((pathlib.Path(path) / fil).relative_to(destPath)) + existingTests: Set[pathlib.Path] = set() + for path, subdirs, files in os.walk(destPath): + for fil in files: + existingTests.add((pathlib.Path(path) / fil).relative_to(destPath)) - newTests: Set[pathlib.Path] = set() - for name in z.namelist(): - if name.endswith(".py"): - newTests.add(pathlib.Path(pathlib.Path(name).as_posix().split("tests/")[1])) + newTests: Set[pathlib.Path] = set() + for name in z.namelist(): + if name.endswith(".py"): + newTests.add(pathlib.Path(pathlib.Path(name).as_posix().split("tests/")[1])) - for filePath in [fp for fp in existingTests - newTests if fp]: - printer.displayRemoved(str(filePath)) + for filePath in [fp for fp in existingTests - newTests if fp]: + printer.displayRemoved(str(filePath)) - for filePath in [fp for fp in newTests - existingTests if fp.suffix == ".py"]: - printer.displayAdded(str(filePath)) + for filePath in [fp for fp in newTests - existingTests if fp.suffix == ".py"]: + printer.displayAdded(str(filePath)) - for filePath in existingTests - newTests: - (destPath / filePath).unlink() # remove file + for filePath in existingTests - newTests: + (destPath / filePath).unlink() # remove file - _extractTests(z, destPath) + _extractTests(z, destPath) - printer.displayCustom(f"Finished downloading: {githubLink}") + printer.displayCustom(f"Finished downloading: {githubLink}") def _extractTests(zipfile: zf.ZipFile, destPath: pathlib.Path): - if not destPath.exists(): - os.makedirs(str(destPath)) + if not destPath.exists(): + os.makedirs(str(destPath)) - for path in [pathlib.Path(name) for name in zipfile.namelist()]: - _extractTest(zipfile, path, destPath) + for path in [pathlib.Path(name) for name in zipfile.namelist()]: + _extractTest(zipfile, path, destPath) def _extractTest(zipfile: zf.ZipFile, path: pathlib.Path, destPath: pathlib.Path): - if not "tests/" in path.as_posix(): - return + if not "tests/" in path.as_posix(): + return - subfolderPath = pathlib.Path(path.as_posix().split("tests/")[1]) - filePath = destPath / subfolderPath + subfolderPath = pathlib.Path(path.as_posix().split("tests/")[1]) + filePath = destPath / subfolderPath - if path.suffix == ".py": - _extractFile(zipfile, path, filePath) - elif subfolderPath and not filePath.exists(): - os.makedirs(str(filePath)) + if path.suffix == ".py": + _extractFile(zipfile, path, filePath) + elif subfolderPath and not filePath.exists(): + os.makedirs(str(filePath)) def _extractFile(zipfile: zf.ZipFile, path: pathlib.Path, filePath: pathlib.Path): - zipPathString = path.as_posix() - if filePath.is_file(): - with zipfile.open(zipPathString) as new, open(str(filePath), "r") as existing: - # read file, decode, strip trailing whitespace, remove carrier return - newText = ''.join(new.read().decode('utf-8').strip().splitlines()) - existingText = ''.join(existing.read().strip().splitlines()) - if newText != existingText: - printer.displayUpdate(str(path)) - - with zipfile.open(zipPathString) as source, open(str(filePath), "wb+") as target: - shutil.copyfileobj(source, target) + zipPathString = path.as_posix() + if filePath.is_file(): + with zipfile.open(zipPathString) as new, open(str(filePath), "r") as existing: + # read file, decode, strip trailing whitespace, remove carrier return + newText = ''.join(new.read().decode('utf-8').strip().splitlines()) + existingText = ''.join(existing.read().strip().splitlines()) + if newText != existingText: + printer.displayUpdate(str(path)) + + with zipfile.open(zipPathString) as source, open(str(filePath), "wb+") as target: + shutil.copyfileobj(source, target) diff --git a/checkpy/entities/exception.py b/checkpy/entities/exception.py index 6a24ca0..69e4b26 100644 --- a/checkpy/entities/exception.py +++ b/checkpy/entities/exception.py @@ -1,54 +1,54 @@ import typing as _typing class CheckpyError(Exception): - def __init__( - self, - exception: - _typing.Optional[Exception]=None, - message: str="", - output: str="", - stacktrace: str="" - ): - self._exception = exception - self._message = message - self._output = output - self._stacktrace = stacktrace - - def output(self) -> str: - return self._output - - def stacktrace(self) -> str: - return self._stacktrace - - def __str__(self): - if self._exception: - return "\"{}\" occured {}".format(repr(self._exception), self._message) - return "{} -> {}".format(self.__class__.__name__, self._message) - - def __repr__(self): - return self.__str__() + def __init__( + self, + exception: + _typing.Optional[Exception]=None, + message: str="", + output: str="", + stacktrace: str="" + ): + self._exception = exception + self._message = message + self._output = output + self._stacktrace = stacktrace + + def output(self) -> str: + return self._output + + def stacktrace(self) -> str: + return self._stacktrace + + def __str__(self): + if self._exception: + return "\"{}\" occured {}".format(repr(self._exception), self._message) + return "{} -> {}".format(self.__class__.__name__, self._message) + + def __repr__(self): + return self.__str__() class SourceException(CheckpyError): - pass + pass class InputError(CheckpyError): - pass + pass class TestError(CheckpyError): - pass + pass class DownloadError(CheckpyError): - pass + pass class ExitError(CheckpyError): - pass + pass class PathError(CheckpyError): - pass + pass class TooManyFilesError(CheckpyError): - pass + pass class MissingRequiredFiles(CheckpyError): - def __init__(self, missingFiles: _typing.List[str]): - super().__init__(message=f"Missing the following required files: {', '.join(missingFiles)}") \ No newline at end of file + def __init__(self, missingFiles: _typing.List[str]): + super().__init__(message=f"Missing the following required files: {', '.join(missingFiles)}") \ No newline at end of file diff --git a/checkpy/entities/path.py b/checkpy/entities/path.py index d72e51a..c0a51a8 100644 --- a/checkpy/entities/path.py +++ b/checkpy/entities/path.py @@ -5,141 +5,141 @@ import warnings warnings.warn( - """checkpy.entities.path is deprecated. Use pathlib.Path instead.""", - DeprecationWarning, - stacklevel=2 + """checkpy.entities.path is deprecated. Use pathlib.Path instead.""", + DeprecationWarning, + stacklevel=2 ) class Path(object): - def __init__(self, path): - path = os.path.normpath(path) - self._drive, path = os.path.splitdrive(path) + def __init__(self, path): + path = os.path.normpath(path) + self._drive, path = os.path.splitdrive(path) - items = str(path).split(os.path.sep) + items = str(path).split(os.path.sep) - if len(items) > 0: - # if path started with root, add root - if items[0] == "": - items[0] = os.path.sep + if len(items) > 0: + # if path started with root, add root + if items[0] == "": + items[0] = os.path.sep - # remove any empty items (for instance because of "/") - self._items = [item for item in items if item] + # remove any empty items (for instance because of "/") + self._items = [item for item in items if item] - @property - def fileName(self): - return list(self)[-1] + @property + def fileName(self): + return list(self)[-1] - @property - def folderName(self): - return list(self)[-2] + @property + def folderName(self): + return list(self)[-2] - def containingFolder(self): - return Path(self._join(self._drive, list(self)[:-1])) + def containingFolder(self): + return Path(self._join(self._drive, list(self)[:-1])) - def isPythonFile(self): - return self.fileName.endswith(".py") + def isPythonFile(self): + return self.fileName.endswith(".py") - def absolutePath(self): - return Path(os.path.abspath(str(self))) + def absolutePath(self): + return Path(os.path.abspath(str(self))) - def exists(self): - return os.path.exists(str(self)) + def exists(self): + return os.path.exists(str(self)) - def walk(self): - for path, subdirs, files in os.walk(str(self)): - yield Path(path), subdirs, files + def walk(self): + for path, subdirs, files in os.walk(str(self)): + yield Path(path), subdirs, files - def copyTo(self, destination): - shutil.copyfile(str(self), str(destination)) + def copyTo(self, destination): + shutil.copyfile(str(self), str(destination)) - def pathFromFolder(self, folderName): - path = "" - seen = False - items = [] - for item in self: - if seen: - items.append(item) - if item == folderName: - seen = True + def pathFromFolder(self, folderName): + path = "" + seen = False + items = [] + for item in self: + if seen: + items.append(item) + if item == folderName: + seen = True - if not seen: - raise exception.PathError(message = "folder {} does not exist in {}".format(folderName, self)) - return Path(self._join(self._drive, items)) + if not seen: + raise exception.PathError(message = "folder {} does not exist in {}".format(folderName, self)) + return Path(self._join(self._drive, items)) - def __add__(self, other): - if sys.version_info >= (3,0): - supportedTypes = [str, bytes, Path] - else: - supportedTypes = [str, unicode, Path] + def __add__(self, other): + if sys.version_info >= (3,0): + supportedTypes = [str, bytes, Path] + else: + supportedTypes = [str, unicode, Path] - if not any(isinstance(other, t) for t in supportedTypes): - raise exception.PathError(message = "can't add {} to Path only {}".format(type(other), supportedTypes)) + if not any(isinstance(other, t) for t in supportedTypes): + raise exception.PathError(message = "can't add {} to Path only {}".format(type(other), supportedTypes)) - if not isinstance(other, Path): - other = Path(other) + if not isinstance(other, Path): + other = Path(other) - # if other path starts with root, throw error - if list(other)[0] == os.path.sep: - raise exception.PathError(message = "can't add {} to Path because it starts at root") + # if other path starts with root, throw error + if list(other)[0] == os.path.sep: + raise exception.PathError(message = "can't add {} to Path because it starts at root") - return Path(self._join(self._drive, list(self) + list(other))) + return Path(self._join(self._drive, list(self) + list(other))) - def __sub__(self, other): - if sys.version_info >= (3,0): - supportedTypes = [str, bytes, Path] - else: - supportedTypes = [str, unicode, Path] + def __sub__(self, other): + if sys.version_info >= (3,0): + supportedTypes = [str, bytes, Path] + else: + supportedTypes = [str, unicode, Path] - if not any(isinstance(other, t) for t in supportedTypes): - raise exception.PathError(message = "can't subtract {} from Path only {}".format(type(other), supportedTypes)) + if not any(isinstance(other, t) for t in supportedTypes): + raise exception.PathError(message = "can't subtract {} from Path only {}".format(type(other), supportedTypes)) - if not isinstance(other, Path): - other = Path(other) + if not isinstance(other, Path): + other = Path(other) - myItems = list(self) - otherItems = list(other) + myItems = list(self) + otherItems = list(other) - for items in (myItems, otherItems): - if len(items) >= 1 and items[0] != os.path.sep and items[0] != ".": - items.insert(0, ".") + for items in (myItems, otherItems): + if len(items) >= 1 and items[0] != os.path.sep and items[0] != ".": + items.insert(0, ".") - for i in range(min(len(myItems), len(otherItems))): - if myItems[i] != otherItems[i]: - raise exception.PathError(message = "tried subtracting, but subdirs do not match: {} and {}".format(self, other)) + for i in range(min(len(myItems), len(otherItems))): + if myItems[i] != otherItems[i]: + raise exception.PathError(message = "tried subtracting, but subdirs do not match: {} and {}".format(self, other)) - return Path(self._join(self._drive, myItems[len(otherItems):])) + return Path(self._join(self._drive, myItems[len(otherItems):])) - def __iter__(self): - for item in self._items: - yield item + def __iter__(self): + for item in self._items: + yield item - def __hash__(self): - return hash(repr(self)) + def __hash__(self): + return hash(repr(self)) - def __eq__(self, other): - return isinstance(other, type(self)) and repr(self) == repr(other) + def __eq__(self, other): + return isinstance(other, type(self)) and repr(self) == repr(other) - def __contains__(self, item): - return str(item) in list(self) + def __contains__(self, item): + return str(item) in list(self) - def __len__(self): - return len(self._items) + def __len__(self): + return len(self._items) - def __str__(self): - return self._join(self._drive, list(self)) + def __str__(self): + return self._join(self._drive, list(self)) - def __repr__(self): - return "/".join([item for item in self]) + def __repr__(self): + return "/".join([item for item in self]) - def _join(self, drive, items): - result = drive - for item in items: - result = os.path.join(result, item) - return result + def _join(self, drive, items): + result = drive + for item in items: + result = os.path.join(result, item) + return result def current(): - return Path(os.getcwd()) + return Path(os.getcwd()) userPath = Path(os.getcwd()) diff --git a/checkpy/interactive.py b/checkpy/interactive.py index 1372908..d7cb7cf 100644 --- a/checkpy/interactive.py +++ b/checkpy/interactive.py @@ -1,43 +1,43 @@ from checkpy.downloader import download, update def testModule(moduleName, debugMode = False, silentMode = False): - """ - Test all files from module - """ - from . import caches - caches.clearAllCaches() - from . import tester - from . import downloader - downloader.updateSilently() - results = tester.testModule(moduleName, debugMode = debugMode, silentMode = silentMode) - try: - if __IPYTHON__: # type: ignore [name-defined] - try: - import matplotlib.pyplot - matplotlib.pyplot.close("all") - except: - pass - except: - pass - return results + """ + Test all files from module + """ + from . import caches + caches.clearAllCaches() + from . import tester + from . import downloader + downloader.updateSilently() + results = tester.testModule(moduleName, debugMode = debugMode, silentMode = silentMode) + try: + if __IPYTHON__: # type: ignore [name-defined] + try: + import matplotlib.pyplot + matplotlib.pyplot.close("all") + except: + pass + except: + pass + return results def test(fileName, debugMode = False, silentMode = False): - """ - Run tests for a single file - """ - from . import caches - caches.clearAllCaches() - from . import tester - from . import downloader - downloader.updateSilently() - result = tester.test(fileName, debugMode = debugMode, silentMode = silentMode) - try: - if __IPYTHON__: # type: ignore [name-defined] - try: - import matplotlib.pyplot - matplotlib.pyplot.close("all") - except: - pass - except: - pass - return result \ No newline at end of file + """ + Run tests for a single file + """ + from . import caches + caches.clearAllCaches() + from . import tester + from . import downloader + downloader.updateSilently() + result = tester.test(fileName, debugMode = debugMode, silentMode = silentMode) + try: + if __IPYTHON__: # type: ignore [name-defined] + try: + import matplotlib.pyplot + matplotlib.pyplot.close("all") + except: + pass + except: + pass + return result \ No newline at end of file diff --git a/checkpy/lib/sandbox.py b/checkpy/lib/sandbox.py index b17ba42..dad07a8 100644 --- a/checkpy/lib/sandbox.py +++ b/checkpy/lib/sandbox.py @@ -16,124 +16,124 @@ def exclude(*patterns: Union[str, Path]): - """ - Exclude all files matching patterns from the check's sandbox. - - If this is the first call to only/include/exclude/require initialize the sandbox: + """ + Exclude all files matching patterns from the check's sandbox. + + If this is the first call to only/include/exclude/require initialize the sandbox: * Create a temp dir * Copy over all files from current dir (except those files excluded through exclude()) - Patterns are shell globs in the same style as .gitignore. - """ - config.exclude(*patterns) + Patterns are shell globs in the same style as .gitignore. + """ + config.exclude(*patterns) def include(*patterns: Union[str, Path]): - """ - Include all files matching patterns from the check's sandbox. - - If this is the first call to only/include/exclude/require initialize the sandbox: + """ + Include all files matching patterns from the check's sandbox. + + If this is the first call to only/include/exclude/require initialize the sandbox: * Create a temp dir * Copy over all files from current dir (except those files excluded through exclude()) - Patterns are shell globs in the same style as .gitignore. - """ - config.include(*patterns) + Patterns are shell globs in the same style as .gitignore. + """ + config.include(*patterns) def only(*patterns: Union[str, Path]): - """ - Only files matching patterns will be in the check's sandbox. - - If this is the first call to only/include/exclude/require initialize the sandbox: + """ + Only files matching patterns will be in the check's sandbox. + + If this is the first call to only/include/exclude/require initialize the sandbox: * Create a temp dir * Copy over all files from current dir (except those files excluded through exclude()) - Patterns are shell globs in the same style as .gitignore. - """ - config.only(*patterns) + Patterns are shell globs in the same style as .gitignore. + """ + config.only(*patterns) def require(*filePaths: Union[str, Path]): - """ - Include all files in the check's sandbox. - Raises checkpy.entities.exception.MissingRequiredFiles if any required file is missing. - Note that this function does not accept patterns (globs), but concrete filenames or paths. - - If this is the first call to only/include/exclude/require initialize the sandbox: + """ + Include all files in the check's sandbox. + Raises checkpy.entities.exception.MissingRequiredFiles if any required file is missing. + Note that this function does not accept patterns (globs), but concrete filenames or paths. + + If this is the first call to only/include/exclude/require initialize the sandbox: * Create a temp dir * Copy over all files from current dir (except those files excluded through exclude()) - Patterns are shell globs in the same style as .gitignore. - """ - config.require(*filePaths) + Patterns are shell globs in the same style as .gitignore. + """ + config.require(*filePaths) class Config: - def __init__(self, onUpdate=lambda config: None): - self.includedFiles: Set[str] = set() - self.excludedFiles: Set[str] = set() - self.missingRequiredFiles: List[str] = [] - self.isSandboxed = False - self.root = Path.cwd() - self.onUpdate = onUpdate + def __init__(self, onUpdate=lambda config: None): + self.includedFiles: Set[str] = set() + self.excludedFiles: Set[str] = set() + self.missingRequiredFiles: List[str] = [] + self.isSandboxed = False + self.root = Path.cwd() + self.onUpdate = onUpdate - def _initSandbox(self): - if self.isSandboxed: - return + def _initSandbox(self): + if self.isSandboxed: + return - self.includedFiles = _glob("*", root=self.root) - self.isSandboxed = True + self.includedFiles = _glob("*", root=self.root) + self.isSandboxed = True - def exclude(self, *patterns: Union[str, Path]): - self._initSandbox() + def exclude(self, *patterns: Union[str, Path]): + self._initSandbox() - newExcluded: Set[str] = set() + newExcluded: Set[str] = set() - for pattern in patterns: - newExcluded |= _glob(pattern, root=self.root) + for pattern in patterns: + newExcluded |= _glob(pattern, root=self.root) - self.includedFiles -= newExcluded - self.excludedFiles.update(newExcluded) + self.includedFiles -= newExcluded + self.excludedFiles.update(newExcluded) - self.onUpdate(self) + self.onUpdate(self) - def include(self, *patterns: Union[str, Path]): - self._initSandbox() + def include(self, *patterns: Union[str, Path]): + self._initSandbox() - newIncluded: Set[str] = set() + newIncluded: Set[str] = set() - for pattern in patterns: - newIncluded |= _glob(pattern, root=self.root) + for pattern in patterns: + newIncluded |= _glob(pattern, root=self.root) - self.excludedFiles -= newIncluded - self.includedFiles.update(newIncluded) + self.excludedFiles -= newIncluded + self.includedFiles.update(newIncluded) - self.onUpdate(self) + self.onUpdate(self) - def only(self, *patterns: Union[str, Path]): - self._initSandbox() + def only(self, *patterns: Union[str, Path]): + self._initSandbox() - allFiles = self.includedFiles | self.excludedFiles - self.includedFiles = set.union(*[_glob(p, root=self.root) for p in patterns]) - self.excludedFiles = allFiles - self.includedFiles + allFiles = self.includedFiles | self.excludedFiles + self.includedFiles = set.union(*[_glob(p, root=self.root) for p in patterns]) + self.excludedFiles = allFiles - self.includedFiles - self.onUpdate(self) + self.onUpdate(self) - def require(self, *filePaths: Union[str, Path]): - self._initSandbox() + def require(self, *filePaths: Union[str, Path]): + self._initSandbox() - with cd(self.root): - for fp in filePaths: - fp = str(fp) - if not Path(fp).exists(): - self.missingRequiredFiles.append(fp) - else: - try: - self.excludedFiles.remove(fp) - except KeyError: - pass - else: - self.includedFiles.add(fp) + with cd(self.root): + for fp in filePaths: + fp = str(fp) + if not Path(fp).exists(): + self.missingRequiredFiles.append(fp) + else: + try: + self.excludedFiles.remove(fp) + except KeyError: + pass + else: + self.includedFiles.add(fp) - self.onUpdate(self) + self.onUpdate(self) config = Config() @@ -141,129 +141,129 @@ def require(self, *filePaths: Union[str, Path]): @contextlib.contextmanager def sandbox(name: Union[str, Path]=""): - if config.missingRequiredFiles: - raise MissingRequiredFiles(config.missingRequiredFiles) + if config.missingRequiredFiles: + raise MissingRequiredFiles(config.missingRequiredFiles) - if not config.isSandboxed: - yield - return + if not config.isSandboxed: + yield + return - with tempfile.TemporaryDirectory() as dir: - dirPath = Path(Path(dir) / name) - dirPath.mkdir(exist_ok=True) + with tempfile.TemporaryDirectory() as dir: + dirPath = Path(Path(dir) / name) + dirPath.mkdir(exist_ok=True) - for f in config.includedFiles: - dest = (dirPath / f).absolute() - dest.parent.mkdir(parents=True, exist_ok=True) - shutil.copy(f, dest) + for f in config.includedFiles: + dest = (dirPath / f).absolute() + dest.parent.mkdir(parents=True, exist_ok=True) + shutil.copy(f, dest) - with cd(dirPath), sandboxConfig(): - yield + with cd(dirPath), sandboxConfig(): + yield @contextlib.contextmanager def conditionalSandbox(name: Union[str, Path]=""): - tempDir = None - dir = None - - oldIncluded: Set[str] = set() - oldExcluded: Set[str] = set() - - def sync(config: Config, sandboxDir: Path): - nonlocal oldIncluded, oldExcluded - for f in config.excludedFiles - oldExcluded: - dest = (sandboxDir / f).absolute() - try: - os.remove(dest) - except FileNotFoundError: - pass - - for f in config.includedFiles - oldExcluded: - dest = (sandboxDir / f).absolute() - dest.parent.mkdir(parents=True, exist_ok=True) - origin = (config.root / f).absolute() - shutil.copy(origin, dest) - - oldIncluded = set(config.includedFiles) - oldExcluded = set(config.excludedFiles) - - def onUpdate(config: Config): - if config.missingRequiredFiles: - raise MissingRequiredFiles(config.missingRequiredFiles) - - nonlocal tempDir - nonlocal dir - if dir is None or tempDir is None: - tempDir = tempfile.TemporaryDirectory() - dir = Path(Path(tempDir.name) / name) - dir.mkdir(exist_ok=True) - os.chdir(dir) - - sync(config, dir) - - with sandboxConfig(onUpdate=onUpdate): - try: - yield - finally: - os.chdir(config.root) - if tempDir: - tempDir.cleanup() + tempDir = None + dir = None + + oldIncluded: Set[str] = set() + oldExcluded: Set[str] = set() + + def sync(config: Config, sandboxDir: Path): + nonlocal oldIncluded, oldExcluded + for f in config.excludedFiles - oldExcluded: + dest = (sandboxDir / f).absolute() + try: + os.remove(dest) + except FileNotFoundError: + pass + + for f in config.includedFiles - oldExcluded: + dest = (sandboxDir / f).absolute() + dest.parent.mkdir(parents=True, exist_ok=True) + origin = (config.root / f).absolute() + shutil.copy(origin, dest) + + oldIncluded = set(config.includedFiles) + oldExcluded = set(config.excludedFiles) + + def onUpdate(config: Config): + if config.missingRequiredFiles: + raise MissingRequiredFiles(config.missingRequiredFiles) + + nonlocal tempDir + nonlocal dir + if dir is None or tempDir is None: + tempDir = tempfile.TemporaryDirectory() + dir = Path(Path(tempDir.name) / name) + dir.mkdir(exist_ok=True) + os.chdir(dir) + + sync(config, dir) + + with sandboxConfig(onUpdate=onUpdate): + try: + yield + finally: + os.chdir(config.root) + if tempDir: + tempDir.cleanup() @contextlib.contextmanager def sandboxConfig(onUpdate=lambda config: None): - global config - oldConfig = config - try: - config = Config(onUpdate=onUpdate) - yield config - finally: - config = oldConfig + global config + oldConfig = config + try: + config = Config(onUpdate=onUpdate) + yield config + finally: + config = oldConfig @contextlib.contextmanager def cd(dest: Union[str, Path]): - origin = Path.cwd() - try: - os.chdir(dest) - yield dest - finally: - os.chdir(origin) + origin = Path.cwd() + try: + os.chdir(dest) + yield dest + finally: + os.chdir(origin) def _glob( - pattern: Union[str, Path], - root: Union[str, Path, None]=None, - skip_dirs: bool=False, - limit: int=DEFAULT_FILE_LIMIT - ) -> Set[str]: - with cd(root) if root else contextlib.nullcontext(): - pattern = str(pattern) - - # Implicit recursive iff no / in pattern and starts with * - if "/" not in pattern and pattern.startswith("*"): - pattern = f"**/{pattern}" - - files = glob.iglob(pattern, recursive=True) - - all_files = set() - - def add_file(f): - fname = str(Path(f)) - all_files.add(fname) - if len(all_files) > limit: - raise TooManyFilesError( - message=f"found {len(all_files)} files but checkpy only accepts up to {limit} number of files" - ) - - # Expand dirs - for file in files: - if os.path.isdir(file) and not skip_dirs: - for f in _glob(f"{file}/**/*", skip_dirs=True): - if not os.path.isdir(f): - add_file(f) - else: - add_file(file) - - return all_files + pattern: Union[str, Path], + root: Union[str, Path, None]=None, + skip_dirs: bool=False, + limit: int=DEFAULT_FILE_LIMIT + ) -> Set[str]: + with cd(root) if root else contextlib.nullcontext(): + pattern = str(pattern) + + # Implicit recursive iff no / in pattern and starts with * + if "/" not in pattern and pattern.startswith("*"): + pattern = f"**/{pattern}" + + files = glob.iglob(pattern, recursive=True) + + all_files = set() + + def add_file(f): + fname = str(Path(f)) + all_files.add(fname) + if len(all_files) > limit: + raise TooManyFilesError( + message=f"found {len(all_files)} files but checkpy only accepts up to {limit} number of files" + ) + + # Expand dirs + for file in files: + if os.path.isdir(file) and not skip_dirs: + for f in _glob(f"{file}/**/*", skip_dirs=True): + if not os.path.isdir(f): + add_file(f) + else: + add_file(file) + + return all_files diff --git a/checkpy/lib/type.py b/checkpy/lib/type.py index 58519ad..070e0f9 100644 --- a/checkpy/lib/type.py +++ b/checkpy/lib/type.py @@ -1,37 +1,37 @@ import typeguard class Type: - """ - An equals and not equals comparible type annotation. + """ + An equals and not equals comparible type annotation. - assert [1, 2.0, None] == Type(List[Union[int, float, None]]) - assert [1, 2, 3] == Type(Iterable[int]) - assert {1: "foo"} != Type(Dict[int, str]) - assert (1, "foo", 3) != Type(Tuple[int, str, int]) + assert [1, 2.0, None] == Type(List[Union[int, float, None]]) + assert [1, 2, 3] == Type(Iterable[int]) + assert {1: "foo"} != Type(Dict[int, str]) + assert (1, "foo", 3) != Type(Tuple[int, str, int]) - This is built on top of typeguard.check_type, see docs @ - https://typeguard.readthedocs.io/en/stable/api.html#typeguard.check_type - """ - def __init__(self, type_: type): - self._type = type_ + This is built on top of typeguard.check_type, see docs @ + https://typeguard.readthedocs.io/en/stable/api.html#typeguard.check_type + """ + def __init__(self, type_: type): + self._type = type_ - def __eq__(self, __value: object) -> bool: - isEq = True - def callback(err: typeguard.TypeCheckError, memo: typeguard.TypeCheckMemo): - nonlocal isEq - isEq = False - typeguard.check_type(__value, self._type, typecheck_fail_callback=callback) - return isEq + def __eq__(self, __value: object) -> bool: + isEq = True + def callback(err: typeguard.TypeCheckError, memo: typeguard.TypeCheckMemo): + nonlocal isEq + isEq = False + typeguard.check_type(__value, self._type, typecheck_fail_callback=callback) + return isEq - def __repr__(self) -> str: - return (str(self._type) - .replace("typing.", "") - .replace("", "int") - .replace("", "float") - .replace("", "bool") - .replace("", "str") - .replace("", "list") - .replace("", "tuple") - .replace("", "dict") - .replace("", "set") - ) + def __repr__(self) -> str: + return (str(self._type) + .replace("typing.", "") + .replace("", "int") + .replace("", "float") + .replace("", "bool") + .replace("", "str") + .replace("", "list") + .replace("", "tuple") + .replace("", "dict") + .replace("", "set") + ) diff --git a/checkpy/printer/printer.py b/checkpy/printer/printer.py index 5562236..81cc43e 100644 --- a/checkpy/printer/printer.py +++ b/checkpy/printer/printer.py @@ -12,81 +12,81 @@ SILENT_MODE = False class _Colors: - PASS = '\033[92m' - WARNING = '\033[93m' - FAIL = '\033[91m' - NAME = '\033[96m' - ENDC = '\033[0m' + PASS = '\033[92m' + WARNING = '\033[93m' + FAIL = '\033[91m' + NAME = '\033[96m' + ENDC = '\033[0m' class _Smileys: - HAPPY = ":)" - SAD = ":(" - CONFUSED = ":S" - NEUTRAL = ":|" + HAPPY = ":)" + SAD = ":(" + CONFUSED = ":S" + NEUTRAL = ":|" def display(testResult: checkpy.tests.TestResult) -> str: - color, smiley = _selectColorAndSmiley(testResult) - msg = "{}{} {}{}".format(color, smiley, testResult.description, _Colors.ENDC) - if testResult.message: - msg += "\n " + "\n ".join(testResult.message.split("\n")) + color, smiley = _selectColorAndSmiley(testResult) + msg = "{}{} {}{}".format(color, smiley, testResult.description, _Colors.ENDC) + if testResult.message: + msg += "\n " + "\n ".join(testResult.message.split("\n")) - if DEBUG_MODE and testResult.exception: - exc = testResult.exception - if hasattr(exc, "stacktrace"): - stack = str(exc.stacktrace()) - else: - stack = "".join(traceback.format_tb(testResult.exception.__traceback__)) - msg += "\n" + stack - if not SILENT_MODE: - print(msg) - return msg + if DEBUG_MODE and testResult.exception: + exc = testResult.exception + if hasattr(exc, "stacktrace"): + stack = str(exc.stacktrace()) + else: + stack = "".join(traceback.format_tb(testResult.exception.__traceback__)) + msg += "\n" + stack + if not SILENT_MODE: + print(msg) + return msg def displayTestName(testName: str) -> str: - msg = "{}Testing: {}{}".format(_Colors.NAME, testName, _Colors.ENDC) - if not SILENT_MODE: - print(msg) - return msg + msg = "{}Testing: {}{}".format(_Colors.NAME, testName, _Colors.ENDC) + if not SILENT_MODE: + print(msg) + return msg def displayUpdate(fileName: str) -> str: - msg = "{}Updated: {}{}".format(_Colors.WARNING, os.path.basename(fileName), _Colors.ENDC) - if not SILENT_MODE: - print(msg) - return msg + msg = "{}Updated: {}{}".format(_Colors.WARNING, os.path.basename(fileName), _Colors.ENDC) + if not SILENT_MODE: + print(msg) + return msg def displayRemoved(fileName: str) -> str: - msg = "{}Removed: {}{}".format(_Colors.WARNING, os.path.basename(fileName), _Colors.ENDC) - if not SILENT_MODE: - print(msg) - return msg + msg = "{}Removed: {}{}".format(_Colors.WARNING, os.path.basename(fileName), _Colors.ENDC) + if not SILENT_MODE: + print(msg) + return msg def displayAdded(fileName: str) -> str: - msg = "{}Added: {}{}".format(_Colors.WARNING, os.path.basename(fileName), _Colors.ENDC) - if not SILENT_MODE: - print(msg) - return msg + msg = "{}Added: {}{}".format(_Colors.WARNING, os.path.basename(fileName), _Colors.ENDC) + if not SILENT_MODE: + print(msg) + return msg def displayCustom(message: str) -> str: - if not SILENT_MODE: - print(message) - return message + if not SILENT_MODE: + print(message) + return message def displayWarning(message: str) -> str: - msg = "{}Warning: {}{}".format(_Colors.WARNING, message, _Colors.ENDC) - if not SILENT_MODE: - print(msg) - return msg + msg = "{}Warning: {}{}".format(_Colors.WARNING, message, _Colors.ENDC) + if not SILENT_MODE: + print(msg) + return msg def displayError(message: str) -> str: - msg = "{}{} {}{}".format(_Colors.WARNING, _Smileys.CONFUSED, message, _Colors.ENDC) - if not SILENT_MODE: - print(msg) - return msg + msg = "{}{} {}{}".format(_Colors.WARNING, _Smileys.CONFUSED, message, _Colors.ENDC) + if not SILENT_MODE: + print(msg) + return msg def _selectColorAndSmiley(testResult: checkpy.tests.TestResult) -> typing.Tuple[str, str]: - if testResult.hasPassed: - return _Colors.PASS, _Smileys.HAPPY - if type(testResult.message) is exception.SourceException: - return _Colors.WARNING, _Smileys.CONFUSED - if testResult.hasPassed is None: - return _Colors.WARNING, _Smileys.NEUTRAL - return _Colors.FAIL, _Smileys.SAD + if testResult.hasPassed: + return _Colors.PASS, _Smileys.HAPPY + if type(testResult.message) is exception.SourceException: + return _Colors.WARNING, _Smileys.CONFUSED + if testResult.hasPassed is None: + return _Colors.WARNING, _Smileys.NEUTRAL + return _Colors.FAIL, _Smileys.SAD diff --git a/checkpy/tester/discovery.py b/checkpy/tester/discovery.py index 07b8a10..b1b0870 100644 --- a/checkpy/tester/discovery.py +++ b/checkpy/tester/discovery.py @@ -4,39 +4,39 @@ from typing import Optional, List, Union def testExists(testName: str, module: str="") -> bool: - testFileName = testName.split(".")[0] + "Test.py" - testPaths = getTestPaths(testFileName, module=module) - return len(testPaths) > 0 + testFileName = testName.split(".")[0] + "Test.py" + testPaths = getTestPaths(testFileName, module=module) + return len(testPaths) > 0 def getPath(path: Union[str, pathlib.Path]) -> Optional[pathlib.Path]: - filePath = os.path.dirname(path) - if not filePath: - filePath = os.path.dirname(os.path.abspath(path)) + filePath = os.path.dirname(path) + if not filePath: + filePath = os.path.dirname(os.path.abspath(path)) - fileName = os.path.basename(path) + fileName = os.path.basename(path) - if "." in fileName: - path = pathlib.Path(os.path.join(filePath, fileName)) - return path if path.exists() else None + if "." in fileName: + path = pathlib.Path(os.path.join(filePath, fileName)) + return path if path.exists() else None - for extension in [".py", ".ipynb"]: - path = pathlib.Path(os.path.join(filePath, fileName + extension)) - if path.exists(): - return path + for extension in [".py", ".ipynb"]: + path = pathlib.Path(os.path.join(filePath, fileName + extension)) + if path.exists(): + return path - return None + return None def getTestNames(moduleName: str) -> Optional[List[str]]: - for testsPath in database.forEachTestsPath(): - for (dirPath, subdirs, files) in os.walk(testsPath): - if moduleName in dirPath: - return [f[:-len("test.py")] for f in files if f.lower().endswith("test.py")] - return None + for testsPath in database.forEachTestsPath(): + for (dirPath, subdirs, files) in os.walk(testsPath): + if moduleName in dirPath: + return [f[:-len("test.py")] for f in files if f.lower().endswith("test.py")] + return None def getTestPaths(testFileName: str, module: str="") -> List[pathlib.Path]: - testFilePaths: List[pathlib.Path] = [] - for testsPath in database.forEachTestsPath(): - for (dirPath, dirNames, fileNames) in os.walk(testsPath): - if testFileName in fileNames and (not module or module in dirPath): - testFilePaths.append(pathlib.Path(dirPath)) - return testFilePaths + testFilePaths: List[pathlib.Path] = [] + for testsPath in database.forEachTestsPath(): + for (dirPath, dirNames, fileNames) in os.walk(testsPath): + if testFileName in fileNames and (not module or module in dirPath): + testFilePaths.append(pathlib.Path(dirPath)) + return testFilePaths diff --git a/checkpy/tester/tester.py b/checkpy/tester/tester.py index 7855bbc..255cdd7 100644 --- a/checkpy/tester/tester.py +++ b/checkpy/tester/tester.py @@ -29,294 +29,294 @@ def getActiveTest() -> Optional[Test]: - return _activeTest + return _activeTest def test(testName: str, module="", debugMode=False, silentMode=False) -> "TesterResult": - printer.printer.SILENT_MODE = silentMode + printer.printer.SILENT_MODE = silentMode - result = TesterResult(testName) + result = TesterResult(testName) - discoveredPath = discovery.getPath(testName) - if discoveredPath is None: - result.addOutput(printer.displayError("File not found: {}".format(testName))) - return result - path = str(discoveredPath) + discoveredPath = discovery.getPath(testName) + if discoveredPath is None: + result.addOutput(printer.displayError("File not found: {}".format(testName))) + return result + path = str(discoveredPath) - fileName = os.path.basename(path) - filePath = os.path.dirname(path) + fileName = os.path.basename(path) + filePath = os.path.dirname(path) - if filePath not in sys.path: - sys.path.append(filePath) + if filePath not in sys.path: + sys.path.append(filePath) - testFileName = fileName.split(".")[0] + "Test.py" - testPaths = discovery.getTestPaths(testFileName, module = module) + testFileName = fileName.split(".")[0] + "Test.py" + testPaths = discovery.getTestPaths(testFileName, module = module) - if not testPaths: - result.addOutput(printer.displayError("No test found for {}".format(fileName))) - return result + if not testPaths: + result.addOutput(printer.displayError("No test found for {}".format(fileName))) + return result - if len(testPaths) > 1: - result.addOutput(printer.displayWarning("Found {} tests: {}, using: {}".format(len(testPaths), testPaths, testPaths[0]))) + if len(testPaths) > 1: + result.addOutput(printer.displayWarning("Found {} tests: {}, using: {}".format(len(testPaths), testPaths, testPaths[0]))) - testFilePath = str(testPaths[0]) + testFilePath = str(testPaths[0]) - if testFilePath not in sys.path: - sys.path.append(testFilePath) + if testFilePath not in sys.path: + sys.path.append(testFilePath) - if path.endswith(".ipynb"): - if subprocess.call(['jupyter', 'nbconvert', '--to', 'script', path]) != 0: - result.addOutput(printer.displayError("Failed to convert Jupyter notebook to .py")) - return result + if path.endswith(".ipynb"): + if subprocess.call(['jupyter', 'nbconvert', '--to', 'script', path]) != 0: + result.addOutput(printer.displayError("Failed to convert Jupyter notebook to .py")) + return result - path = path.replace(".ipynb", ".py") + path = path.replace(".ipynb", ".py") - # remove all magic lines from notebook - with open(path, "r") as f: - lines = f.readlines() - with open(path, "w") as f: - f.write("".join([l for l in lines if "get_ipython" not in l])) + # remove all magic lines from notebook + with open(path, "r") as f: + lines = f.readlines() + with open(path, "w") as f: + f.write("".join([l for l in lines if "get_ipython" not in l])) - testerResult = _runTests(testFileName.split(".")[0], path, debugMode = debugMode, silentMode = silentMode) + testerResult = _runTests(testFileName.split(".")[0], path, debugMode = debugMode, silentMode = silentMode) - if path.endswith(".ipynb"): - os.remove(path) + if path.endswith(".ipynb"): + os.remove(path) - testerResult.output = result.output + testerResult.output - return testerResult + testerResult.output = result.output + testerResult.output + return testerResult def testModule(module: str, debugMode=False, silentMode=False) -> Optional[List["TesterResult"]]: - printer.printer.SILENT_MODE = silentMode - testNames = discovery.getTestNames(module) + printer.printer.SILENT_MODE = silentMode + testNames = discovery.getTestNames(module) - if not testNames: - printer.displayError("no tests found in module: {}".format(module)) - return None + if not testNames: + printer.displayError("no tests found in module: {}".format(module)) + return None - return [test(testName, module=module, debugMode=debugMode, silentMode=silentMode) for testName in testNames] + return [test(testName, module=module, debugMode=debugMode, silentMode=silentMode) for testName in testNames] def _runTests(moduleName: str, fileName: str, debugMode=False, silentMode=False) -> "TesterResult": - ctx = multiprocessing.get_context("spawn") - - signalQueue: "Queue[_Signal]" = ctx.Queue() - resultQueue: "Queue[TesterResult]" = ctx.Queue() - tester = _Tester(moduleName, pathlib.Path(fileName), debugMode, silentMode, signalQueue, resultQueue) - p = ctx.Process(target=tester.run, name="Tester") - p.start() - - start = time.time() - isTiming = False - - while p.is_alive(): - while not signalQueue.empty(): - signal = signalQueue.get() - - if signal.description is not None: - description = signal.description - if signal.isTiming is not None: - isTiming = signal.isTiming - if signal.timeout is not None: - timeout = signal.timeout - if signal.resetTimer: - start = time.time() - - if isTiming and time.time() - start > timeout: - result = TesterResult(pathlib.Path(fileName).name) - result.addOutput(printer.displayError("Timeout ({} seconds) reached during: {}".format(timeout, description))) - p.terminate() - p.join() - return result - - if not resultQueue.empty(): - p.terminate() - p.join() - break - - time.sleep(0.1) - - if not resultQueue.empty(): - return resultQueue.get() - - raise exception.CheckpyError(message="An error occured while testing. The testing process exited unexpectedly.") + ctx = multiprocessing.get_context("spawn") + + signalQueue: "Queue[_Signal]" = ctx.Queue() + resultQueue: "Queue[TesterResult]" = ctx.Queue() + tester = _Tester(moduleName, pathlib.Path(fileName), debugMode, silentMode, signalQueue, resultQueue) + p = ctx.Process(target=tester.run, name="Tester") + p.start() + + start = time.time() + isTiming = False + + while p.is_alive(): + while not signalQueue.empty(): + signal = signalQueue.get() + + if signal.description is not None: + description = signal.description + if signal.isTiming is not None: + isTiming = signal.isTiming + if signal.timeout is not None: + timeout = signal.timeout + if signal.resetTimer: + start = time.time() + + if isTiming and time.time() - start > timeout: + result = TesterResult(pathlib.Path(fileName).name) + result.addOutput(printer.displayError("Timeout ({} seconds) reached during: {}".format(timeout, description))) + p.terminate() + p.join() + return result + + if not resultQueue.empty(): + p.terminate() + p.join() + break + + time.sleep(0.1) + + if not resultQueue.empty(): + return resultQueue.get() + + raise exception.CheckpyError(message="An error occured while testing. The testing process exited unexpectedly.") class TesterResult(object): - def __init__(self, name: str): - self.name = name - self.nTests = 0 - self.nPassedTests = 0 - self.nFailedTests = 0 - self.nRunTests = 0 - self.output: List[str] = [] - self.testResults: List[TestResult] = [] - - def addOutput(self, output: str): - self.output.append(output) - - def addResult(self, testResult: TestResult): - self.testResults.append(testResult) - - def asDict(self) -> Dict[str, Union[str, int, List]]: - return { - "name": self.name, - "nTests": self.nTests, - "nPassed": self.nPassedTests, - "nFailed": self.nFailedTests, - "nRun": self.nRunTests, - "output": self.output, - "results": [tr.asDict() for tr in self.testResults] - } + def __init__(self, name: str): + self.name = name + self.nTests = 0 + self.nPassedTests = 0 + self.nFailedTests = 0 + self.nRunTests = 0 + self.output: List[str] = [] + self.testResults: List[TestResult] = [] + + def addOutput(self, output: str): + self.output.append(output) + + def addResult(self, testResult: TestResult): + self.testResults.append(testResult) + + def asDict(self) -> Dict[str, Union[str, int, List]]: + return { + "name": self.name, + "nTests": self.nTests, + "nPassed": self.nPassedTests, + "nFailed": self.nFailedTests, + "nRun": self.nRunTests, + "output": self.output, + "results": [tr.asDict() for tr in self.testResults] + } class _Signal(object): - def __init__( - self, - isTiming: Optional[bool]=None, - resetTimer: Optional[bool]=None, - description: Optional[str]=None, - timeout: Optional[int]=None - ): - self.isTiming = isTiming - self.resetTimer = resetTimer - self.description = description - self.timeout = timeout + def __init__( + self, + isTiming: Optional[bool]=None, + resetTimer: Optional[bool]=None, + description: Optional[str]=None, + timeout: Optional[int]=None + ): + self.isTiming = isTiming + self.resetTimer = resetTimer + self.description = description + self.timeout = timeout class _Tester(object): - def __init__( - self, - moduleName: str, - filePath: pathlib.Path, - debugMode: bool, - silentMode: bool, - signalQueue: "Queue[_Signal]", - resultQueue: "Queue[TesterResult]" - ): - self.moduleName = moduleName - self.filePath = filePath.absolute() - self.debugMode = debugMode - self.silentMode = silentMode - self.signalQueue = signalQueue - self.resultQueue = resultQueue - - def run(self): - printer.printer.DEBUG_MODE = self.debugMode - printer.printer.SILENT_MODE = self.silentMode - - warnings.filterwarnings("ignore") - if self.debugMode: - warnings.simplefilter('always', DeprecationWarning) - - checkpy.file = self.filePath - - # overwrite argv so that it seems the file was run directly - sys.argv = [self.filePath.name] - - # have pytest (dessert) rewrite the asserts in the AST - with dessert.rewrite_assertions_context(): - - # TODO: should be a cleaner way to inject "pytest_assertrepr_compare" - dessert.util._reprcompare = explainCompare - - with conditionalSandbox(): - module = importlib.import_module(self.moduleName) - module._fileName = self.filePath.name # type: ignore [attr-defined] - - self._runTestsFromModule(module) - - def _runTestsFromModule(self, module: ModuleType): - self._sendSignal(_Signal(isTiming = False)) - - result = TesterResult(self.filePath.name) - result.addOutput(printer.displayTestName(self.filePath.name)) - - if hasattr(module, "before"): - try: - module.before() - except Exception as e: - result.addOutput(printer.displayError("Something went wrong at setup:\n{}".format(e))) - return - - testFunctions = [method for method in module.__dict__.values() if getattr(method, "isTestFunction", False)] - result.nTests = len(testFunctions) - - testResults = self._runTests(testFunctions) - - result.nRunTests = len(testResults) - result.nPassedTests = len([tr for tr in testResults if tr.hasPassed]) - result.nFailedTests = len([tr for tr in testResults if not tr.hasPassed]) - - for testResult in testResults: - result.addResult(testResult) - result.addOutput(printer.display(testResult)) - - if hasattr(module, "after"): - try: - module.after() - except Exception as e: - result.addOutput(printer.displayError("Something went wrong at closing:\n{}".format(e))) - - self._sendResult(result) - - def _runTests(self, testFunctions: Iterable[TestFunction]) -> List[TestResult]: - cachedResults: Dict[Test, Optional[TestResult]] = {} - - def handleDescriptionChange(test: Test): - self._sendSignal(_Signal( - description=test.description - )) - - def handleTimeoutChange(test: Test): - self._sendSignal(_Signal( - isTiming=True, - resetTimer=True, - timeout=test.timeout - )) - - global _activeTest - - # run tests in noncolliding execution order - for testFunction in self._getTestFunctionsInExecutionOrder(testFunctions): - test = Test( - self.filePath.name, - testFunction.priority, - timeout=testFunction.timeout, - onDescriptionChange=handleDescriptionChange, - onTimeoutChange=handleTimeoutChange - ) - - _activeTest = test - - run = testFunction(test) - - self._sendSignal(_Signal( - isTiming=True, - resetTimer=True, - description=test.description, - timeout=test.timeout - )) - - cachedResults[test] = run() - - _activeTest = None - - self._sendSignal(_Signal(isTiming=False)) - - # return test results in specified order - sortedResults = [cachedResults[test] for test in sorted(cachedResults)] - return [result for result in sortedResults if result is not None] - - def _sendResult(self, result: TesterResult): - self.resultQueue.put(result) - - def _sendSignal(self, signal: _Signal): - self.signalQueue.put(signal) - - def _getTestFunctionsInExecutionOrder(self, testFunctions: Iterable[TestFunction]) -> List[TestFunction]: - sortedTFs: List[TestFunction] = [] - for tf in testFunctions: - dependencies = self._getTestFunctionsInExecutionOrder(tf.dependencies) + [tf] - sortedTFs.extend([t for t in dependencies if t not in sortedTFs]) - return sortedTFs + def __init__( + self, + moduleName: str, + filePath: pathlib.Path, + debugMode: bool, + silentMode: bool, + signalQueue: "Queue[_Signal]", + resultQueue: "Queue[TesterResult]" + ): + self.moduleName = moduleName + self.filePath = filePath.absolute() + self.debugMode = debugMode + self.silentMode = silentMode + self.signalQueue = signalQueue + self.resultQueue = resultQueue + + def run(self): + printer.printer.DEBUG_MODE = self.debugMode + printer.printer.SILENT_MODE = self.silentMode + + warnings.filterwarnings("ignore") + if self.debugMode: + warnings.simplefilter('always', DeprecationWarning) + + checkpy.file = self.filePath + + # overwrite argv so that it seems the file was run directly + sys.argv = [self.filePath.name] + + # have pytest (dessert) rewrite the asserts in the AST + with dessert.rewrite_assertions_context(): + + # TODO: should be a cleaner way to inject "pytest_assertrepr_compare" + dessert.util._reprcompare = explainCompare + + with conditionalSandbox(): + module = importlib.import_module(self.moduleName) + module._fileName = self.filePath.name # type: ignore [attr-defined] + + self._runTestsFromModule(module) + + def _runTestsFromModule(self, module: ModuleType): + self._sendSignal(_Signal(isTiming = False)) + + result = TesterResult(self.filePath.name) + result.addOutput(printer.displayTestName(self.filePath.name)) + + if hasattr(module, "before"): + try: + module.before() + except Exception as e: + result.addOutput(printer.displayError("Something went wrong at setup:\n{}".format(e))) + return + + testFunctions = [method for method in module.__dict__.values() if getattr(method, "isTestFunction", False)] + result.nTests = len(testFunctions) + + testResults = self._runTests(testFunctions) + + result.nRunTests = len(testResults) + result.nPassedTests = len([tr for tr in testResults if tr.hasPassed]) + result.nFailedTests = len([tr for tr in testResults if not tr.hasPassed]) + + for testResult in testResults: + result.addResult(testResult) + result.addOutput(printer.display(testResult)) + + if hasattr(module, "after"): + try: + module.after() + except Exception as e: + result.addOutput(printer.displayError("Something went wrong at closing:\n{}".format(e))) + + self._sendResult(result) + + def _runTests(self, testFunctions: Iterable[TestFunction]) -> List[TestResult]: + cachedResults: Dict[Test, Optional[TestResult]] = {} + + def handleDescriptionChange(test: Test): + self._sendSignal(_Signal( + description=test.description + )) + + def handleTimeoutChange(test: Test): + self._sendSignal(_Signal( + isTiming=True, + resetTimer=True, + timeout=test.timeout + )) + + global _activeTest + + # run tests in noncolliding execution order + for testFunction in self._getTestFunctionsInExecutionOrder(testFunctions): + test = Test( + self.filePath.name, + testFunction.priority, + timeout=testFunction.timeout, + onDescriptionChange=handleDescriptionChange, + onTimeoutChange=handleTimeoutChange + ) + + _activeTest = test + + run = testFunction(test) + + self._sendSignal(_Signal( + isTiming=True, + resetTimer=True, + description=test.description, + timeout=test.timeout + )) + + cachedResults[test] = run() + + _activeTest = None + + self._sendSignal(_Signal(isTiming=False)) + + # return test results in specified order + sortedResults = [cachedResults[test] for test in sorted(cachedResults)] + return [result for result in sortedResults if result is not None] + + def _sendResult(self, result: TesterResult): + self.resultQueue.put(result) + + def _sendSignal(self, signal: _Signal): + self.signalQueue.put(signal) + + def _getTestFunctionsInExecutionOrder(self, testFunctions: Iterable[TestFunction]) -> List[TestFunction]: + sortedTFs: List[TestFunction] = [] + for tf in testFunctions: + dependencies = self._getTestFunctionsInExecutionOrder(tf.dependencies) + [tf] + sortedTFs.extend([t for t in dependencies if t not in sortedTFs]) + return sortedTFs diff --git a/checkpy/tests.py b/checkpy/tests.py index 0109ec0..d403873 100644 --- a/checkpy/tests.py +++ b/checkpy/tests.py @@ -13,313 +13,313 @@ def test( - priority: Optional[int]=None, - timeout: Optional[int]=None - ) -> Callable[[Callable], "TestFunction"]: - def testDecorator(testFunction: Callable) -> TestFunction: - return TestFunction(testFunction, priority=priority, timeout=timeout) - return testDecorator + priority: Optional[int]=None, + timeout: Optional[int]=None + ) -> Callable[[Callable], "TestFunction"]: + def testDecorator(testFunction: Callable) -> TestFunction: + return TestFunction(testFunction, priority=priority, timeout=timeout) + return testDecorator def failed( - *preconditions: "TestFunction", - priority: Optional[int]=None, - timeout: Optional[int]=None, - hide: bool=True - ) -> Callable[[Callable], "FailedTestFunction"]: - def failedDecorator(testFunction: Callable) -> FailedTestFunction: - return FailedTestFunction(testFunction, preconditions, priority=priority, timeout=timeout, hide=hide) - return failedDecorator + *preconditions: "TestFunction", + priority: Optional[int]=None, + timeout: Optional[int]=None, + hide: bool=True + ) -> Callable[[Callable], "FailedTestFunction"]: + def failedDecorator(testFunction: Callable) -> FailedTestFunction: + return FailedTestFunction(testFunction, preconditions, priority=priority, timeout=timeout, hide=hide) + return failedDecorator def passed( - *preconditions: "TestFunction", - priority: Optional[int]=None, - timeout: Optional[int]=None, - hide: bool=True - ) -> Callable[[Callable], "PassedTestFunction"]: - def passedDecorator(testFunction: Callable) -> PassedTestFunction: - return PassedTestFunction(testFunction, preconditions, priority=priority, timeout=timeout, hide=hide) - return passedDecorator + *preconditions: "TestFunction", + priority: Optional[int]=None, + timeout: Optional[int]=None, + hide: bool=True + ) -> Callable[[Callable], "PassedTestFunction"]: + def passedDecorator(testFunction: Callable) -> PassedTestFunction: + return PassedTestFunction(testFunction, preconditions, priority=priority, timeout=timeout, hide=hide) + return passedDecorator class Test: - DEFAULT_TIMEOUT = 10 - PLACEHOLDER_DESCRIPTION = "placeholder test description" - - def __init__(self, - fileName: str, - priority: int, - timeout: Optional[int]=None, - onDescriptionChange: Callable[["Test"], None]=lambda self: None, - onTimeoutChange: Callable[["Test"], None]=lambda self: None - ): - self._fileName = fileName - self._priority = priority - - self._onDescriptionChange = onDescriptionChange - self._onTimeoutChange = onTimeoutChange - - self._description = Test.PLACEHOLDER_DESCRIPTION - self._timeout = Test.DEFAULT_TIMEOUT if timeout is None else timeout - - def __lt__(self, other): - return self._priority < other._priority - - @property - def fileName(self) -> str: - return self._fileName - - @staticmethod - def test() -> Union[bool, Tuple[bool, str]]: - raise NotImplementedError() - - @staticmethod - def success(info: str) -> str: - return "" - - @staticmethod - def fail(info: str) -> str: - return info - - @staticmethod - def exception(exception: Exception) -> Exception: - return exception - - @staticmethod - def dependencies() -> Set["TestFunction"]: - return set() - - @property - def description(self) -> str: - return self._description - - @description.setter - def description(self, newDescription: Union[str, Callable[[], str]]): - if callable(newDescription): - self._description = newDescription() - else: - self._description = newDescription - - self._onDescriptionChange(self) - - @property - def timeout(self) -> int: - return self._timeout - - @timeout.setter - def timeout(self, new_timeout: Union[int, Callable[[], int]]): - if callable(new_timeout): - self._timeout = new_timeout() - else: - self._timeout = new_timeout - - self._onTimeoutChange(self) - - def __setattr__(self, __name: str, __value: Any) -> None: - value = __value - if __name in ["fail", "success", "exception"]: - if not callable(__value): - value = lambda *args, **kwargs: __value - super().__setattr__(__name, value) + DEFAULT_TIMEOUT = 10 + PLACEHOLDER_DESCRIPTION = "placeholder test description" + + def __init__(self, + fileName: str, + priority: int, + timeout: Optional[int]=None, + onDescriptionChange: Callable[["Test"], None]=lambda self: None, + onTimeoutChange: Callable[["Test"], None]=lambda self: None + ): + self._fileName = fileName + self._priority = priority + + self._onDescriptionChange = onDescriptionChange + self._onTimeoutChange = onTimeoutChange + + self._description = Test.PLACEHOLDER_DESCRIPTION + self._timeout = Test.DEFAULT_TIMEOUT if timeout is None else timeout + + def __lt__(self, other): + return self._priority < other._priority + + @property + def fileName(self) -> str: + return self._fileName + + @staticmethod + def test() -> Union[bool, Tuple[bool, str]]: + raise NotImplementedError() + + @staticmethod + def success(info: str) -> str: + return "" + + @staticmethod + def fail(info: str) -> str: + return info + + @staticmethod + def exception(exception: Exception) -> Exception: + return exception + + @staticmethod + def dependencies() -> Set["TestFunction"]: + return set() + + @property + def description(self) -> str: + return self._description + + @description.setter + def description(self, newDescription: Union[str, Callable[[], str]]): + if callable(newDescription): + self._description = newDescription() + else: + self._description = newDescription + + self._onDescriptionChange(self) + + @property + def timeout(self) -> int: + return self._timeout + + @timeout.setter + def timeout(self, new_timeout: Union[int, Callable[[], int]]): + if callable(new_timeout): + self._timeout = new_timeout() + else: + self._timeout = new_timeout + + self._onTimeoutChange(self) + + def __setattr__(self, __name: str, __value: Any) -> None: + value = __value + if __name in ["fail", "success", "exception"]: + if not callable(__value): + value = lambda *args, **kwargs: __value + super().__setattr__(__name, value) class TestResult(object): - def __init__( - self, - hasPassed: Optional[bool], - description: str, - message: str, - exception: Optional[Exception]=None - ): - self._hasPassed = hasPassed - self._description = description - self._message = message - self._exception = exception - - @property - def description(self): - return self._description - - @property - def message(self): - return self._message - - @property - def hasPassed(self): - return self._hasPassed - - @property - def exception(self): - return self._exception - - def asDict(self) -> Dict[str, Union[bool, None, str]]: - return { - "passed": self.hasPassed, - "description": str(self.description), - "message": str(self.message), - "exception": str(self.exception) - } + def __init__( + self, + hasPassed: Optional[bool], + description: str, + message: str, + exception: Optional[Exception]=None + ): + self._hasPassed = hasPassed + self._description = description + self._message = message + self._exception = exception + + @property + def description(self): + return self._description + + @property + def message(self): + return self._message + + @property + def hasPassed(self): + return self._hasPassed + + @property + def exception(self): + return self._exception + + def asDict(self) -> Dict[str, Union[bool, None, str]]: + return { + "passed": self.hasPassed, + "description": str(self.description), + "message": str(self.message), + "exception": str(self.exception) + } class TestFunction: - _previousPriority = -1 - - def __init__( - self, - function: Callable, - priority: Optional[int]=None, - timeout: Optional[int]=None - ): - self._function = function - self.isTestFunction = True - self.priority = self._getPriority(priority) - self.dependencies: Set[TestFunction] = getattr(self._function, "dependencies", set()) - self.timeout = self._getTimeout(timeout) - self.__name__ = function.__name__ - - def __call__(self, test: Test) -> Callable[[], Union["TestResult", None]]: - self.useDocStringDescription(test) - - @caches.cacheTestResult(self) - def runMethod(): - with conditionalSandbox(): - try: - if getattr(self._function, "isTestFunction", False): - result = self._function(test)() - elif inspect.getfullargspec(self._function).args: - result = self._function(test) - else: - result = self._function() - - if result is None: - if test.test != Test.test: - result = test.test() - else: - result = True - - if type(result) == tuple: - hasPassed, info = result - else: - hasPassed, info = result, "" - except AssertionError as e: - # last = traceback.extract_tb(e.__traceback__)[-1] - # print(last, dir(last), last.line, last.lineno) - - assertMsg = simplifyAssertionMessage(str(e)) - failMsg = test.fail("") - if failMsg and not failMsg.endswith("\n"): - failMsg += "\n" - msg = failMsg + assertMsg - - return TestResult(False, test.description, msg) - except exception.CheckpyError as e: - return TestResult(False, test.description, str(test.exception(e)), exception=e) - except Exception as e: - e = exception.TestError( - exception = e, - message = "while testing", - stacktrace = traceback.format_exc()) - return TestResult(False, test.description, str(test.exception(e)), exception=e) - - return TestResult(hasPassed, test.description, test.success(info) if hasPassed else test.fail(info)) - - return runMethod - - def useDocStringDescription(self, test: Test) -> None: - if getattr(self._function, "isTestFunction", False): - self._function.useDocStringDescription(test) # type: ignore [attr-defined] - - if self._function.__doc__ is not None: - test.description = self._function.__doc__ - - def _getPriority(self, priority: Optional[int]) -> int: - if priority is not None: - TestFunction._previousPriority = priority - return priority - - inheritedPriority = getattr(self._function, "priority", None) - if inheritedPriority: - TestFunction._previousPriority = inheritedPriority - return inheritedPriority - - TestFunction._previousPriority += 1 - return TestFunction._previousPriority - - def _getTimeout(self, timeout: Optional[int]) -> int: - if timeout is not None: - return timeout - - inheritedTimeout = getattr(self._function, "timeout", None) - if inheritedTimeout: - return inheritedTimeout - - return Test.DEFAULT_TIMEOUT + _previousPriority = -1 + + def __init__( + self, + function: Callable, + priority: Optional[int]=None, + timeout: Optional[int]=None + ): + self._function = function + self.isTestFunction = True + self.priority = self._getPriority(priority) + self.dependencies: Set[TestFunction] = getattr(self._function, "dependencies", set()) + self.timeout = self._getTimeout(timeout) + self.__name__ = function.__name__ + + def __call__(self, test: Test) -> Callable[[], Union["TestResult", None]]: + self.useDocStringDescription(test) + + @caches.cacheTestResult(self) + def runMethod(): + with conditionalSandbox(): + try: + if getattr(self._function, "isTestFunction", False): + result = self._function(test)() + elif inspect.getfullargspec(self._function).args: + result = self._function(test) + else: + result = self._function() + + if result is None: + if test.test != Test.test: + result = test.test() + else: + result = True + + if type(result) == tuple: + hasPassed, info = result + else: + hasPassed, info = result, "" + except AssertionError as e: + # last = traceback.extract_tb(e.__traceback__)[-1] + # print(last, dir(last), last.line, last.lineno) + + assertMsg = simplifyAssertionMessage(str(e)) + failMsg = test.fail("") + if failMsg and not failMsg.endswith("\n"): + failMsg += "\n" + msg = failMsg + assertMsg + + return TestResult(False, test.description, msg) + except exception.CheckpyError as e: + return TestResult(False, test.description, str(test.exception(e)), exception=e) + except Exception as e: + e = exception.TestError( + exception = e, + message = "while testing", + stacktrace = traceback.format_exc()) + return TestResult(False, test.description, str(test.exception(e)), exception=e) + + return TestResult(hasPassed, test.description, test.success(info) if hasPassed else test.fail(info)) + + return runMethod + + def useDocStringDescription(self, test: Test) -> None: + if getattr(self._function, "isTestFunction", False): + self._function.useDocStringDescription(test) # type: ignore [attr-defined] + + if self._function.__doc__ is not None: + test.description = self._function.__doc__ + + def _getPriority(self, priority: Optional[int]) -> int: + if priority is not None: + TestFunction._previousPriority = priority + return priority + + inheritedPriority = getattr(self._function, "priority", None) + if inheritedPriority: + TestFunction._previousPriority = inheritedPriority + return inheritedPriority + + TestFunction._previousPriority += 1 + return TestFunction._previousPriority + + def _getTimeout(self, timeout: Optional[int]) -> int: + if timeout is not None: + return timeout + + inheritedTimeout = getattr(self._function, "timeout", None) + if inheritedTimeout: + return inheritedTimeout + + return Test.DEFAULT_TIMEOUT class FailedTestFunction(TestFunction): - HIDE_MESSAGE = "can't check until another check fails" - - def __init__( - self, - function: Callable, - preconditions: Iterable[TestFunction], - priority: Optional[int]=None, - timeout: Optional[int]=None, - hide: Optional[bool]=None - ): - super().__init__(function=function, priority=priority, timeout=timeout) - self.preconditions = preconditions - self.shouldHide = self._getHide(hide) - - def __call__(self, test: Test) -> Callable[[], Union["TestResult", None]]: - self.useDocStringDescription(test) - - @caches.cacheTestResult(self) - def runMethod(): - if getattr(self._function, "isTestFunction", False): - run = self._function(test) - else: - run = TestFunction.__call__(self, test) - - self.requireDocstringIfNotHidden(test) - - testResults = [caches.getCachedTestResult(t) for t in self.preconditions] - if self.shouldRun(testResults): - return run() - - if self.shouldHide: - return None - - return TestResult( - None, - test.description, - self.HIDE_MESSAGE - ) - return runMethod - - def requireDocstringIfNotHidden(self, test: Test) -> None: - if not self.shouldHide and test.description == Test.PLACEHOLDER_DESCRIPTION: - raise exception.TestError(message=f"Test {self.__name__} requires a docstring description if hide=False") - - @staticmethod - def shouldRun(testResults: Iterable[TestResult]) -> bool: - return not any(t is None for t in testResults) and not any(t.hasPassed for t in testResults) - - def _getHide(self, hide: Optional[bool]) -> bool: - if hide is not None: - return hide - - inheritedHide = getattr(self._function, "hide", None) - if inheritedHide: - return inheritedHide - - return True + HIDE_MESSAGE = "can't check until another check fails" + + def __init__( + self, + function: Callable, + preconditions: Iterable[TestFunction], + priority: Optional[int]=None, + timeout: Optional[int]=None, + hide: Optional[bool]=None + ): + super().__init__(function=function, priority=priority, timeout=timeout) + self.preconditions = preconditions + self.shouldHide = self._getHide(hide) + + def __call__(self, test: Test) -> Callable[[], Union["TestResult", None]]: + self.useDocStringDescription(test) + + @caches.cacheTestResult(self) + def runMethod(): + if getattr(self._function, "isTestFunction", False): + run = self._function(test) + else: + run = TestFunction.__call__(self, test) + + self.requireDocstringIfNotHidden(test) + + testResults = [caches.getCachedTestResult(t) for t in self.preconditions] + if self.shouldRun(testResults): + return run() + + if self.shouldHide: + return None + + return TestResult( + None, + test.description, + self.HIDE_MESSAGE + ) + return runMethod + + def requireDocstringIfNotHidden(self, test: Test) -> None: + if not self.shouldHide and test.description == Test.PLACEHOLDER_DESCRIPTION: + raise exception.TestError(message=f"Test {self.__name__} requires a docstring description if hide=False") + + @staticmethod + def shouldRun(testResults: Iterable[TestResult]) -> bool: + return not any(t is None for t in testResults) and not any(t.hasPassed for t in testResults) + + def _getHide(self, hide: Optional[bool]) -> bool: + if hide is not None: + return hide + + inheritedHide = getattr(self._function, "hide", None) + if inheritedHide: + return inheritedHide + + return True class PassedTestFunction(FailedTestFunction): - HIDE_MESSAGE = "can't check until another check passes" + HIDE_MESSAGE = "can't check until another check passes" - @staticmethod - def shouldRun(testResults: Iterable[TestResult]) -> bool: - return not any(t is None for t in testResults) and all(t.hasPassed for t in testResults) + @staticmethod + def shouldRun(testResults: Iterable[TestResult]) -> bool: + return not any(t is None for t in testResults) and all(t.hasPassed for t in testResults) From 1a6e962c4355c9a40b8a754e5d611d1c71f771d9 Mon Sep 17 00:00:00 2001 From: Jelleas Date: Fri, 28 Jul 2023 18:25:20 +0200 Subject: [PATCH 216/269] builder >> declarative, no more .build() --- checkpy/__init__.py | 4 +- checkpy/lib/{builder.py => declarative.py} | 83 ++++++++++------------ checkpy/tester/tester.py | 21 +++--- checkpy/tests.py | 21 +++--- 4 files changed, 63 insertions(+), 66 deletions(-) rename checkpy/lib/{builder.py => declarative.py} (86%) diff --git a/checkpy/__init__.py b/checkpy/__init__.py index 8fd4b7a..43d4f7c 100644 --- a/checkpy/__init__.py +++ b/checkpy/__init__.py @@ -10,7 +10,7 @@ import dessert as _dessert with _dessert.rewrite_assertions_context(): - from checkpy.lib import builder + from checkpy.lib import declarative from checkpy.tests import test, failed, passed from checkpy.lib.basic import outputOf, getModule, getFunction @@ -31,7 +31,7 @@ "Type", "static", "monkeypatch", - "builder", + "declarative", "only", "include", "exclude", diff --git a/checkpy/lib/builder.py b/checkpy/lib/declarative.py similarity index 86% rename from checkpy/lib/builder.py rename to checkpy/lib/declarative.py index 79a11dd..df99e2d 100644 --- a/checkpy/lib/builder.py +++ b/checkpy/lib/declarative.py @@ -2,7 +2,7 @@ A declarative approach to writing checks through method chaining. For example: ``` -testSquare = (builder +testSquare = test(timeout=3)(declarative .function("square") # assert function square() is defined .params("x") # assert that square() accepts one parameter called x .returnType("int") # assert that the function always returns an integer @@ -10,7 +10,6 @@ .returns(4) # assert that the function returns 4 .call(3) # now call the function with argument 3 .returns(9) # assert that the function returns 9 - .build() # done, build the test ) """ @@ -35,37 +34,37 @@ class function: """ A declarative approach to writing checks through method chaining. Each method adds a part of a test on a stack. - Upon `.build()` a checkpy test is created that executes each entry in the stack. For example: ``` - testSquare = ( - function("square") # assert function square() is defined + from checkpy import * + + testSquare = test(timeout=3)(declarative + .function("square") # assert function square() is defined .params("x") # assert that square() accepts one parameter called x .returnType("int") # assert that the function always returns an integer .call(2) # call the function with argument 2 .returns(4) # assert that the function returns 4 .call(3) # now call the function with argument 3 .returns(9) # assert that the function returns 9 - .build() # done, build the test ) - # Builders can be reused as long as build() is not called. For example: + # This `function` object can be reused for multiple tests. For example: - squareBuilder = ( - function("square") + square = (declarative + .function("square") .params("x") .returnType("int") ) - testSquare2 = squareBuilder.call(2).returns(4).build() # do remember to call build()! - testSquare3 = squareBuilder.call(3).returns(9).build() # do remember to call build()! + testSquare2 = test()(square.call(2).returns(4)) # A test is only created after calling checkpy's test decorator + testSquare3 = test()(square.call(3).returns(9)) # Tests created by this approach can depend and be depended on by other tests like normal: testSquare4 = passed(testSquare2, testSquare3)( - squareBuilder.call(4).returns(16).build() # testSquare4 will only run if both testSquare2 and 3 pass + square.call(4).returns(16) # testSquare4 will only run if both testSquare2 and 3 pass ) @passed(testSquare2) @@ -80,9 +79,9 @@ def testSquareError(): # testSquareError will only run if testSquare2 passes. """ def __init__(self, functionName: str, fileName: Optional[str]=None): self._initialState: FunctionState = FunctionState(functionName, fileName=fileName) - self._blocks: List[Callable[["FunctionState"], None]] = [] + self._stack: List[Callable[["FunctionState"], None]] = [] self._description: Optional[str] = None - self._blocks = self.name(functionName)._blocks + self._stack = self.name(functionName)._stack def name(self, functionName: str) -> Self: """Assert that a function with functionName is defined.""" @@ -199,7 +198,7 @@ def testCall(state: FunctionState): type_ = state.returnType returned = state.returned assert returned == checkpy.Type(type_), f"{state.getFunctionCallRepr()} returned: {returned}" - state.description = "" + state.description = f"calling function {state.getFunctionCallRepr()}" return self.do(testCall) @@ -224,9 +223,9 @@ def setDecription(state: FunctionState): self = self.do(setDecription) - # If the description block is the only block (after the mandatory name block), put it first - if len(self._blocks) == 2: - self._blocks.reverse() + # If the description step is the only step (after the mandatory name step), put it first + if len(self._stack) == 2: + self._stack.reverse() if self._description is None: self._description = description @@ -244,35 +243,29 @@ def checkDataFileIsUnchanged(state: "FunctionState"): with open("data.txt") as f: assert f.read() == "42\\n", "make sure not to change the file data.txt" - test = function("process_data").call("data.txt").do(checkDataFileIsUnchanged).build() + testDataUnchanged = test()(function("process_data").call("data.txt").do(checkDataFileIsUnchanged)) ``` """ self = deepcopy(self) - self._blocks.append(function) + self._stack.append(function) + + self.__name__ = f"declarative_function_test_{self._initialState.name}()_{uuid4()}" + self.__doc__ = self._description if self._description is not None else self._initialState.description + return self - - def build(self) -> checkpy.tests.TestFunction: - """ - Build the actual test (checkpy.tests.TestFunction). This should always be the last call. - Be sure to store the result in a global, to allow checkpy to discover the test. For instance: - - `testSquare = (function("square").call(3).returns(9).build())` - """ - blocks = list(self._blocks) + + def __call__(self) -> Callable[[], None]: + """Run the test.""" + stack = list(self._stack) state = deepcopy(self._initialState) - def testFunction(): - for block in blocks: - block(state) + for step in stack: + step(state) - if state.wasCalled: - state.description = f"{state.getFunctionCallRepr()} works as expected" - else: - state.description = f"{state.name} is correctly defined" - - testFunction.__name__ = f"builder_function_test_{state.name}()_{uuid4()}" - testFunction.__doc__ = self._description if self._description is not None else state.description - return checkpy.tests.test()(testFunction) + if state.wasCalled: + state.description = f"{state.getFunctionCallRepr()} works as expected" + else: + state.description = f"{state.name} is correctly defined" class FunctionState: @@ -292,8 +285,10 @@ def __init__(self, functionName: str, fileName: Optional[str]=None): self._kwargs: Dict[str, Any] = {} self._timeout: int = 10 self._isDescriptionMutable: bool = True - self._descriptionFormatter: Callable[[str, FunctionState], str] =\ - lambda descr, state: f"testing {state.name}()" + (f" >> {descr}" if descr else "") + + @staticmethod + def _descriptionFormatter(descr: str, state: "FunctionState") -> str: + return f"testing {state.name}()" + (f" >> {descr}" if descr else "") @property def name(self) -> str: @@ -321,7 +316,7 @@ def params(self) -> List[str]: """The exact parameter names and order that the function accepts.""" if self._params is None: raise checkpy.entities.exception.CheckpyError( - message=f"params are not set for function builder test {self._name}()" + message=f"params are not set for declarative function test {self._name}()" ) return self._params @@ -344,7 +339,7 @@ def returned(self) -> Any: """What the last function call returned.""" if not self.wasCalled: raise checkpy.entities.exception.CheckpyError( - message=f"function was never called for function builder test {self._name}" + message=f"function was never called for declarative function test {self._name}" ) return self._returned diff --git a/checkpy/tester/tester.py b/checkpy/tester/tester.py index 255cdd7..c7e11a8 100644 --- a/checkpy/tester/tester.py +++ b/checkpy/tester/tester.py @@ -9,18 +9,17 @@ from types import ModuleType from typing import Dict, Iterable, List, Optional, Union -import dessert - import os import pathlib import subprocess import sys import importlib -import multiprocessing -from multiprocessing.queues import Queue import time import warnings +import dessert +import multiprocessing as mp + __all__ = ["getActiveTest", "test", "testModule", "TesterResult"] @@ -96,12 +95,11 @@ def testModule(module: str, debugMode=False, silentMode=False) -> Optional[List[ return [test(testName, module=module, debugMode=debugMode, silentMode=silentMode) for testName in testNames] - def _runTests(moduleName: str, fileName: str, debugMode=False, silentMode=False) -> "TesterResult": - ctx = multiprocessing.get_context("spawn") + ctx = mp.get_context("spawn") - signalQueue: "Queue[_Signal]" = ctx.Queue() - resultQueue: "Queue[TesterResult]" = ctx.Queue() + signalQueue: "mp.Queue[_Signal]" = ctx.Queue() + resultQueue: "mp.Queue[TesterResult]" = ctx.Queue() tester = _Tester(moduleName, pathlib.Path(fileName), debugMode, silentMode, signalQueue, resultQueue) p = ctx.Process(target=tester.run, name="Tester") p.start() @@ -191,8 +189,8 @@ def __init__( filePath: pathlib.Path, debugMode: bool, silentMode: bool, - signalQueue: "Queue[_Signal]", - resultQueue: "Queue[TesterResult]" + signalQueue: "mp.Queue[_Signal]", + resultQueue: "mp.Queue[TesterResult]" ): self.moduleName = moduleName self.filePath = filePath.absolute() @@ -290,7 +288,7 @@ def handleTimeoutChange(test: Test): _activeTest = test run = testFunction(test) - + self._sendSignal(_Signal( isTiming=True, resetTimer=True, @@ -312,6 +310,7 @@ def _sendResult(self, result: TesterResult): self.resultQueue.put(result) def _sendSignal(self, signal: _Signal): + #return self.signalQueue.put(signal) def _getTestFunctionsInExecutionOrder(self, testFunctions: Iterable[TestFunction]) -> List[TestFunction]: diff --git a/checkpy/tests.py b/checkpy/tests.py index d403873..9ff6614 100644 --- a/checkpy/tests.py +++ b/checkpy/tests.py @@ -187,7 +187,8 @@ def runMethod(): try: if getattr(self._function, "isTestFunction", False): result = self._function(test)() - elif inspect.getfullargspec(self._function).args: + elif (len(inspect.getfullargspec(self._function).args) > + 1 if inspect.ismethod(self._function) else 0): result = self._function(test) else: result = self._function() @@ -238,11 +239,12 @@ def _getPriority(self, priority: Optional[int]) -> int: TestFunction._previousPriority = priority return priority - inheritedPriority = getattr(self._function, "priority", None) - if inheritedPriority: - TestFunction._previousPriority = inheritedPriority - return inheritedPriority - + if getattr(self._function, "isTestFunction", False): + inheritedPriority = getattr(self._function, "priority", None) + if inheritedPriority: + TestFunction._previousPriority = inheritedPriority + return inheritedPriority + TestFunction._previousPriority += 1 return TestFunction._previousPriority @@ -250,9 +252,10 @@ def _getTimeout(self, timeout: Optional[int]) -> int: if timeout is not None: return timeout - inheritedTimeout = getattr(self._function, "timeout", None) - if inheritedTimeout: - return inheritedTimeout + if getattr(self._function, "isTestFunction", False): + inheritedTimeout = getattr(self._function, "timeout", None) + if inheritedTimeout: + return inheritedTimeout return Test.DEFAULT_TIMEOUT From e54345794c09e706eecae49f097dcf512c0d81a9 Mon Sep 17 00:00:00 2001 From: Jelleas Date: Fri, 28 Jul 2023 18:26:44 +0200 Subject: [PATCH 217/269] space --- checkpy/lib/declarative.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/checkpy/lib/declarative.py b/checkpy/lib/declarative.py index df99e2d..6db75f4 100644 --- a/checkpy/lib/declarative.py +++ b/checkpy/lib/declarative.py @@ -41,7 +41,7 @@ class function: from checkpy import * testSquare = test(timeout=3)(declarative - .function("square") # assert function square() is defined + .function("square") # assert function square() is defined .params("x") # assert that square() accepts one parameter called x .returnType("int") # assert that the function always returns an integer .call(2) # call the function with argument 2 From c76e9df5089c0508808ab9d37e09d8a43d2c6678 Mon Sep 17 00:00:00 2001 From: Jelleas Date: Fri, 28 Jul 2023 21:08:12 +0200 Subject: [PATCH 218/269] declarative.function set description back to initial description if called within test --- checkpy/lib/declarative.py | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/checkpy/lib/declarative.py b/checkpy/lib/declarative.py index 6db75f4..38ea60c 100644 --- a/checkpy/lib/declarative.py +++ b/checkpy/lib/declarative.py @@ -254,19 +254,28 @@ def checkDataFileIsUnchanged(state: "FunctionState"): return self - def __call__(self) -> Callable[[], None]: + def __call__(self) -> "FunctionState": """Run the test.""" + test = checkpy.tester.getActiveTest() + initialDescription = "" + if test is not None and test.description != test.PLACEHOLDER_DESCRIPTION: + initialDescription = test.description + stack = list(self._stack) state = deepcopy(self._initialState) for step in stack: step(state) - if state.wasCalled: + if initialDescription: + state.description = initialDescription + elif state.wasCalled: state.description = f"{state.getFunctionCallRepr()} works as expected" else: state.description = f"{state.name} is correctly defined" + return state + class FunctionState: """ @@ -443,7 +452,7 @@ def setDescriptionFormatter(self, formatter: Callable[[str, "FunctionState"], st `state.setDescriptionFormatter(lambda descr, state: f"Testing your function {state.name}: {descr}")` """ - self._descriptionFormatter = formatter + self._descriptionFormatter = formatter # type:ignore [method-assign, assignment] test = checkpy.tester.getActiveTest() if test is None: From 273c743230e556c5084763b708adaa160847bed1 Mon Sep 17 00:00:00 2001 From: Jelleas Date: Fri, 28 Jul 2023 21:34:38 +0200 Subject: [PATCH 219/269] dropped support for return bool, only assert --- checkpy/lib/declarative.py | 1 + checkpy/tester/tester.py | 5 ++--- checkpy/tests.py | 23 +++++++++++------------ 3 files changed, 14 insertions(+), 15 deletions(-) diff --git a/checkpy/lib/declarative.py b/checkpy/lib/declarative.py index 38ea60c..78059cf 100644 --- a/checkpy/lib/declarative.py +++ b/checkpy/lib/declarative.py @@ -268,6 +268,7 @@ def __call__(self) -> "FunctionState": step(state) if initialDescription: + state.setDescriptionFormatter(lambda descr, state: descr) state.description = initialDescription elif state.wasCalled: state.description = f"{state.getFunctionCallRepr()} works as expected" diff --git a/checkpy/tester/tester.py b/checkpy/tester/tester.py index c7e11a8..d8f6166 100644 --- a/checkpy/tester/tester.py +++ b/checkpy/tester/tester.py @@ -288,7 +288,7 @@ def handleTimeoutChange(test: Test): _activeTest = test run = testFunction(test) - + self._sendSignal(_Signal( isTiming=True, resetTimer=True, @@ -301,7 +301,7 @@ def handleTimeoutChange(test: Test): _activeTest = None self._sendSignal(_Signal(isTiming=False)) - + # return test results in specified order sortedResults = [cachedResults[test] for test in sorted(cachedResults)] return [result for result in sortedResults if result is not None] @@ -310,7 +310,6 @@ def _sendResult(self, result: TesterResult): self.resultQueue.put(result) def _sendSignal(self, signal: _Signal): - #return self.signalQueue.put(signal) def _getTestFunctionsInExecutionOrder(self, testFunctions: Iterable[TestFunction]) -> List[TestFunction]: diff --git a/checkpy/tests.py b/checkpy/tests.py index 9ff6614..f05000b 100644 --- a/checkpy/tests.py +++ b/checkpy/tests.py @@ -186,23 +186,22 @@ def runMethod(): with conditionalSandbox(): try: if getattr(self._function, "isTestFunction", False): - result = self._function(test)() + self._function(test)() elif (len(inspect.getfullargspec(self._function).args) > 1 if inspect.ismethod(self._function) else 0): - result = self._function(test) + self._function(test) else: - result = self._function() + self._function() - if result is None: - if test.test != Test.test: - result = test.test() - else: - result = True + # support for old-style tests + hasPassed, info = True, "" + if test.test != Test.test: + result = test.test() - if type(result) == tuple: - hasPassed, info = result - else: - hasPassed, info = result, "" + if type(result) == tuple: + hasPassed, info = result + else: + hasPassed = result except AssertionError as e: # last = traceback.extract_tb(e.__traceback__)[-1] # print(last, dir(last), last.line, last.lineno) From 1d2597234e8d4d967c990405e24761f9ebe88680 Mon Sep 17 00:00:00 2001 From: jelleas Date: Thu, 10 Aug 2023 18:57:29 +0200 Subject: [PATCH 220/269] brackets are important, requests broken on 3.10? (https://github.com/pytest-dev/pytest/issues/9174) --- checkpy/lib/basic.py | 4 ++-- checkpy/tests.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/checkpy/lib/basic.py b/checkpy/lib/basic.py index 22e6050..257daca 100644 --- a/checkpy/lib/basic.py +++ b/checkpy/lib/basic.py @@ -3,7 +3,6 @@ import os import pathlib import re -import requests import shutil import sys import traceback @@ -281,7 +280,8 @@ def download(fileName, source): r = requests.get(url, allow_redirects=True) with open('google.ico', 'wb') as f: f.write(r.content) - """, DeprecationWarning, stacklevel=2) + """, DeprecationWarning, stacklevel=2) + import requests try: r = requests.get(source) except requests.exceptions.ConnectionError as e: diff --git a/checkpy/tests.py b/checkpy/tests.py index f05000b..6408967 100644 --- a/checkpy/tests.py +++ b/checkpy/tests.py @@ -188,7 +188,7 @@ def runMethod(): if getattr(self._function, "isTestFunction", False): self._function(test)() elif (len(inspect.getfullargspec(self._function).args) > - 1 if inspect.ismethod(self._function) else 0): + (1 if inspect.ismethod(self._function) else 0)): self._function(test) else: self._function() From e8953b8e0756a542f5f8a6ad6ee5b60014e98218 Mon Sep 17 00:00:00 2001 From: jelleas Date: Thu, 10 Aug 2023 19:09:53 +0200 Subject: [PATCH 221/269] import requests before assert rewrite --- checkpy/__init__.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/checkpy/__init__.py b/checkpy/__init__.py index 43d4f7c..75f0140 100644 --- a/checkpy/__init__.py +++ b/checkpy/__init__.py @@ -7,8 +7,13 @@ # Path to the directory of checkpy CHECKPYPATH: _pathlib.Path = _pathlib.Path(__file__).parent -import dessert as _dessert +# TODO rm me once below is fixed: +# https://github.com/pytest-dev/pytest/issues/9174 +# importing requests before dessert/pytest assert rewrite prevents +# a ValueError on python3.10 +import requests as _requests +import dessert as _dessert with _dessert.rewrite_assertions_context(): from checkpy.lib import declarative From b8b34d706b1bf4592c57fb34beaff8ec47957201 Mon Sep 17 00:00:00 2001 From: Jelleas Date: Fri, 11 Aug 2023 16:12:38 +0200 Subject: [PATCH 222/269] declarative.function.__call__ takes an optional test --- checkpy/lib/declarative.py | 6 ++++-- checkpy/lib/static.py | 2 +- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/checkpy/lib/declarative.py b/checkpy/lib/declarative.py index 78059cf..2c67632 100644 --- a/checkpy/lib/declarative.py +++ b/checkpy/lib/declarative.py @@ -254,9 +254,11 @@ def checkDataFileIsUnchanged(state: "FunctionState"): return self - def __call__(self) -> "FunctionState": + def __call__(self, test: Optional[checkpy.tests.Test]=None) -> "FunctionState": """Run the test.""" - test = checkpy.tester.getActiveTest() + if test is None: + test = checkpy.tester.getActiveTest() + initialDescription = "" if test is not None and test.description != test.PLACEHOLDER_DESCRIPTION: initialDescription = test.description diff --git a/checkpy/lib/static.py b/checkpy/lib/static.py index 3588eab..e02afa2 100644 --- a/checkpy/lib/static.py +++ b/checkpy/lib/static.py @@ -238,7 +238,7 @@ class AbstractSyntaxTree: For instance: ``` - assert ast.For in AbstractSyntaxTree() # assert that a for-loop is present + assert ast.For not in AbstractSyntaxTree() # assert that a for-loop is not present assert ast.Mult in AbstractSyntaxTree() # assert that multiplication is present ``` """ From d3dc4763d9cf829ca7f0d91ebbbf902adecec399 Mon Sep 17 00:00:00 2001 From: Jelleas Date: Fri, 11 Aug 2023 16:41:25 +0200 Subject: [PATCH 223/269] preserve str quotes in getFunctionCallRepr --- checkpy/lib/declarative.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/checkpy/lib/declarative.py b/checkpy/lib/declarative.py index 2c67632..5865ea6 100644 --- a/checkpy/lib/declarative.py +++ b/checkpy/lib/declarative.py @@ -443,8 +443,8 @@ def getFunctionCallRepr(self): Note this method can only be called after a call to the tested function. Do be sure to check state.wasCalled! """ - argsRepr = ", ".join(str(arg) for arg in self.args) - kwargsRepr = ", ".join(f"{k}={v}" for k, v in self.kwargs.items()) + argsRepr = ", ".join(f'"{a}"' if isinstance(a, str) else str(a) for a in self.args) + kwargsRepr = ", ".join(f'{k}="{v}"' if isinstance(v, str) else f'{k}={v}' for k, v in self.kwargs.items()) repr = ', '.join([a for a in (argsRepr, kwargsRepr) if a]) return f"{self.name}({repr})" From 6ed4cab149271600857174ac617e28d2f13700b4 Mon Sep 17 00:00:00 2001 From: Jelleas Date: Fri, 11 Aug 2023 17:11:04 +0200 Subject: [PATCH 224/269] fix crash on just decl name check and fix initial descr --- checkpy/lib/declarative.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/checkpy/lib/declarative.py b/checkpy/lib/declarative.py index 5865ea6..bc561d1 100644 --- a/checkpy/lib/declarative.py +++ b/checkpy/lib/declarative.py @@ -81,7 +81,11 @@ def __init__(self, functionName: str, fileName: Optional[str]=None): self._initialState: FunctionState = FunctionState(functionName, fileName=fileName) self._stack: List[Callable[["FunctionState"], None]] = [] self._description: Optional[str] = None - self._stack = self.name(functionName)._stack + + name = self.name(functionName) + self._stack = name._stack + self.__name__ = name.__name__ + self.__doc__ = name.__doc__ def name(self, functionName: str) -> Self: """Assert that a function with functionName is defined.""" @@ -260,7 +264,9 @@ def __call__(self, test: Optional[checkpy.tests.Test]=None) -> "FunctionState": test = checkpy.tester.getActiveTest() initialDescription = "" - if test is not None and test.description != test.PLACEHOLDER_DESCRIPTION: + if test is not None\ + and test.description != test.PLACEHOLDER_DESCRIPTION\ + and test.description != self._initialState.description: initialDescription = test.description stack = list(self._stack) From f3f95d0b46a8385def6d05cfe82bb597cfe09a1f Mon Sep 17 00:00:00 2001 From: Jelleas Date: Sun, 13 Aug 2023 12:25:05 +0200 Subject: [PATCH 225/269] conditionalSandbox replaces sandbox, introduced sandbox.download --- checkpy/__init__.py | 3 +- checkpy/lib/__init__.py | 2 +- checkpy/lib/basic.py | 21 ------------- checkpy/lib/sandbox.py | 66 ++++++++++++++++++++++++++-------------- checkpy/tester/tester.py | 4 +-- checkpy/tests.py | 4 +-- 6 files changed, 51 insertions(+), 49 deletions(-) diff --git a/checkpy/__init__.py b/checkpy/__init__.py index 75f0140..4c3d257 100644 --- a/checkpy/__init__.py +++ b/checkpy/__init__.py @@ -19,7 +19,7 @@ from checkpy.tests import test, failed, passed from checkpy.lib.basic import outputOf, getModule, getFunction -from checkpy.lib.sandbox import only, include, exclude, require +from checkpy.lib.sandbox import only, include, exclude, require, download from checkpy.lib import static from checkpy.lib import monkeypatch from checkpy.lib.type import Type @@ -41,6 +41,7 @@ "include", "exclude", "require", + "download", "file", "approx" ] diff --git a/checkpy/lib/__init__.py b/checkpy/lib/__init__.py index 38e01e5..f65f5ef 100644 --- a/checkpy/lib/__init__.py +++ b/checkpy/lib/__init__.py @@ -14,7 +14,7 @@ from checkpy.lib.basic import getNumbersFromString from checkpy.lib.basic import getLine from checkpy.lib.basic import fileExists -from checkpy.lib.basic import download +from checkpy.lib.sandbox import download from checkpy.lib.basic import require diff --git a/checkpy/lib/basic.py b/checkpy/lib/basic.py index 257daca..736ed24 100644 --- a/checkpy/lib/basic.py +++ b/checkpy/lib/basic.py @@ -273,27 +273,6 @@ def fileExists(fileName): return path.Path(fileName).exists() -def download(fileName, source): - warn("""checkpy.lib.download() is deprecated. Use requests to download files: - import requests - url = 'http://google.com/favicon.ico' - r = requests.get(url, allow_redirects=True) - with open('google.ico', 'wb') as f: - f.write(r.content) - """, DeprecationWarning, stacklevel=2) - import requests - try: - r = requests.get(source) - except requests.exceptions.ConnectionError as e: - raise exception.DownloadError(message = "Oh no! It seems like there is no internet connection available?!") - - if not r.ok: - raise exception.DownloadError(message = "Failed to download {} because: {}".format(source, r.reason)) - - with open(str(fileName), "wb+") as target: - target.write(r.content) - - def require(fileName, source=None): warn("""checkpy.lib.require() is deprecated. Use requests to download files: import requests diff --git a/checkpy/lib/sandbox.py b/checkpy/lib/sandbox.py index dad07a8..164cfa5 100644 --- a/checkpy/lib/sandbox.py +++ b/checkpy/lib/sandbox.py @@ -6,7 +6,9 @@ from pathlib import Path from typing import List, Set, Union -from checkpy.entities.exception import TooManyFilesError, MissingRequiredFiles +import requests + +from checkpy.entities.exception import TooManyFilesError, MissingRequiredFiles, DownloadError __all__ = ["exclude", "include", "only", "require"] @@ -19,7 +21,7 @@ def exclude(*patterns: Union[str, Path]): """ Exclude all files matching patterns from the check's sandbox. - If this is the first call to only/include/exclude/require initialize the sandbox: + If this is the first call to only/include/exclude/require/download initialize the sandbox: * Create a temp dir * Copy over all files from current dir (except those files excluded through exclude()) @@ -31,7 +33,7 @@ def include(*patterns: Union[str, Path]): """ Include all files matching patterns from the check's sandbox. - If this is the first call to only/include/exclude/require initialize the sandbox: + If this is the first call to only/include/exclude/require/download initialize the sandbox: * Create a temp dir * Copy over all files from current dir (except those files excluded through exclude()) @@ -43,7 +45,7 @@ def only(*patterns: Union[str, Path]): """ Only files matching patterns will be in the check's sandbox. - If this is the first call to only/include/exclude/require initialize the sandbox: + If this is the first call to only/include/exclude/require/download initialize the sandbox: * Create a temp dir * Copy over all files from current dir (except those files excluded through exclude()) @@ -57,7 +59,7 @@ def require(*filePaths: Union[str, Path]): Raises checkpy.entities.exception.MissingRequiredFiles if any required file is missing. Note that this function does not accept patterns (globs), but concrete filenames or paths. - If this is the first call to only/include/exclude/require initialize the sandbox: + If this is the first call to only/include/exclude/require/download initialize the sandbox: * Create a temp dir * Copy over all files from current dir (except those files excluded through exclude()) @@ -66,11 +68,23 @@ def require(*filePaths: Union[str, Path]): config.require(*filePaths) +def download(fileName: str, source: str): + """ + Download a file from source and store it in fileName. + + If this is the first call to only/include/exclude/require/download initialize the sandbox: + * Create a temp dir + * Copy over all files from current dir (except those files excluded through exclude()) + """ + config.download(fileName, source) + + class Config: def __init__(self, onUpdate=lambda config: None): self.includedFiles: Set[str] = set() self.excludedFiles: Set[str] = set() self.missingRequiredFiles: List[str] = [] + self.downloads: List[Download] = [] self.isSandboxed = False self.root = Path.cwd() self.onUpdate = onUpdate @@ -135,34 +149,39 @@ def require(self, *filePaths: Union[str, Path]): self.onUpdate(self) + def download(self, fileName: str, source: str): + self.downloads.append(Download(fileName, source)) + self.onUpdate(self) config = Config() -@contextlib.contextmanager -def sandbox(name: Union[str, Path]=""): - if config.missingRequiredFiles: - raise MissingRequiredFiles(config.missingRequiredFiles) +class Download: + def __init__(self, fileName: str, source: str): + self.fileName: str = str(fileName) + self.source: str = str(source) + self._isDownloaded: bool = False - if not config.isSandboxed: - yield - return + def download(self): + if self._isDownloaded: + return - with tempfile.TemporaryDirectory() as dir: - dirPath = Path(Path(dir) / name) - dirPath.mkdir(exist_ok=True) + try: + r = requests.get(self.source, allow_redirects=True) + except requests.exceptions.ConnectionError: + raise DownloadError(message="Oh no! It seems like there is no internet connection available.") - for f in config.includedFiles: - dest = (dirPath / f).absolute() - dest.parent.mkdir(parents=True, exist_ok=True) - shutil.copy(f, dest) + if not r.ok: + raise DownloadError(message=f"Failed to download {self.source} because: {r.reason}") - with cd(dirPath), sandboxConfig(): - yield + with open(self.fileName, "wb+") as target: + target.write(r.content) + + self._isDownloaded = True @contextlib.contextmanager -def conditionalSandbox(name: Union[str, Path]=""): +def sandbox(name: Union[str, Path]=""): tempDir = None dir = None @@ -170,6 +189,9 @@ def conditionalSandbox(name: Union[str, Path]=""): oldExcluded: Set[str] = set() def sync(config: Config, sandboxDir: Path): + for dl in config.downloads: + dl.download() + nonlocal oldIncluded, oldExcluded for f in config.excludedFiles - oldExcluded: dest = (sandboxDir / f).absolute() diff --git a/checkpy/tester/tester.py b/checkpy/tester/tester.py index d8f6166..eeaef8a 100644 --- a/checkpy/tester/tester.py +++ b/checkpy/tester/tester.py @@ -2,7 +2,7 @@ from checkpy import printer from checkpy.entities import exception from checkpy.tester import discovery -from checkpy.lib.sandbox import conditionalSandbox +from checkpy.lib.sandbox import sandbox from checkpy.lib.explanation import explainCompare from checkpy.tests import Test, TestResult, TestFunction @@ -218,7 +218,7 @@ def run(self): # TODO: should be a cleaner way to inject "pytest_assertrepr_compare" dessert.util._reprcompare = explainCompare - with conditionalSandbox(): + with sandbox(): module = importlib.import_module(self.moduleName) module._fileName = self.filePath.name # type: ignore [attr-defined] diff --git a/checkpy/tests.py b/checkpy/tests.py index 6408967..eb1702e 100644 --- a/checkpy/tests.py +++ b/checkpy/tests.py @@ -5,7 +5,7 @@ from checkpy import caches from checkpy.entities import exception -from checkpy.lib.sandbox import conditionalSandbox +from checkpy.lib.sandbox import sandbox from checkpy.lib.explanation import simplifyAssertionMessage @@ -183,7 +183,7 @@ def __call__(self, test: Test) -> Callable[[], Union["TestResult", None]]: @caches.cacheTestResult(self) def runMethod(): - with conditionalSandbox(): + with sandbox(): try: if getattr(self._function, "isTestFunction", False): self._function(test)() From 2fdcd42181b944805e1d4827909aa4d387dc227d Mon Sep 17 00:00:00 2001 From: Jelleas Date: Sun, 13 Aug 2023 12:40:16 +0200 Subject: [PATCH 226/269] missing import --- checkpy/lib/basic.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/checkpy/lib/basic.py b/checkpy/lib/basic.py index 736ed24..0e94949 100644 --- a/checkpy/lib/basic.py +++ b/checkpy/lib/basic.py @@ -281,6 +281,8 @@ def require(fileName, source=None): with open('google.ico', 'wb') as f: f.write(r.content) """, DeprecationWarning, stacklevel=2) + from checkpy.lib import download + if source: download(fileName, source) return From 7c3f4ed8db622a81a374e536887b8e06fc367289 Mon Sep 17 00:00:00 2001 From: jelleas Date: Tue, 15 Aug 2023 16:52:21 +0200 Subject: [PATCH 227/269] fix hang on full pipe and crash when 'where' appears in assert --- checkpy/lib/explanation.py | 14 ++------------ checkpy/tester/tester.py | 13 ++++++++++--- 2 files changed, 12 insertions(+), 15 deletions(-) diff --git a/checkpy/lib/explanation.py b/checkpy/lib/explanation.py index 5a50a83..41fe550 100644 --- a/checkpy/lib/explanation.py +++ b/checkpy/lib/explanation.py @@ -63,7 +63,7 @@ def simplifyAssertionMessage(assertion: Union[str, AssertionError]) -> str: message = "\n".join(lines) # Find any substitution lines of the form where ... = ... from pytest - whereRegex = re.compile(r"\n[\s]*\+(\s*)(where|and)[\s]*(.*)") + whereRegex = re.compile(r"\n[\s]*\+(\s*)(where|and)[\s]*(.*) = (.*)") whereLines = whereRegex.findall(message) # If there are none, nothing to do @@ -88,7 +88,7 @@ def simplifyAssertionMessage(assertion: Union[str, AssertionError]) -> str: skipping = False # For each where line, apply the substitution on the first match - for indent, _, substitution in whereLines: + for indent, _, left, right in whereLines: newIndent = len(indent) # If the previous step was skipped, and the next step is more indented, keep skipping @@ -115,16 +115,6 @@ def simplifyAssertionMessage(assertion: Union[str, AssertionError]) -> str: oldIndent = newIndent skipping = False - # Find the left (the original) and the right (the substitute) - match = substitutionRegex.match(substitution) - if match is None: - raise checkpy.entities.exception.CheckpyError( - message=f"parsing the assertion '{message}' failed." - f" Please create an issue over at https://github.com/Jelleas/CheckPy/issues" - f" and copy-paste this entire message." - ) - left, right = match.group(1), match.group(2) - # If the right contains any checkpy function or module, skip if _shouldSkip(right): skipping = True diff --git a/checkpy/tester/tester.py b/checkpy/tester/tester.py index eeaef8a..903b00f 100644 --- a/checkpy/tester/tester.py +++ b/checkpy/tester/tester.py @@ -106,6 +106,7 @@ def _runTests(moduleName: str, fileName: str, debugMode=False, silentMode=False) start = time.time() isTiming = False + result: Optional[TesterResult] = None while p.is_alive(): while not signalQueue.empty(): @@ -128,6 +129,9 @@ def _runTests(moduleName: str, fileName: str, debugMode=False, silentMode=False) return result if not resultQueue.empty(): + # .get before .join to prevent hanging indefinitely due to a full pipe + # https://bugs.python.org/issue8426 + result = resultQueue.get() p.terminate() p.join() break @@ -135,9 +139,12 @@ def _runTests(moduleName: str, fileName: str, debugMode=False, silentMode=False) time.sleep(0.1) if not resultQueue.empty(): - return resultQueue.get() - - raise exception.CheckpyError(message="An error occured while testing. The testing process exited unexpectedly.") + result = resultQueue.get() + + if result is None: + raise exception.CheckpyError(message="An error occured while testing. The testing process exited unexpectedly.") + + return result class TesterResult(object): From 2b3685de7120f4c6fade2d36699bdcd91f84f5a1 Mon Sep 17 00:00:00 2001 From: jelleas Date: Wed, 30 Aug 2023 16:24:16 +0200 Subject: [PATCH 228/269] README.rst => .md, version bump --- .gitignore | 1 - README.md | 241 ++++++++++++++++++++++++++++++++++++++++++++ README.rst | 193 ----------------------------------- checkpy/__main__.py | 2 +- setup.py | 5 +- 5 files changed, 245 insertions(+), 197 deletions(-) create mode 100644 README.md delete mode 100644 README.rst diff --git a/.gitignore b/.gitignore index 408cee6..7702d49 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,5 @@ studentfiles checkpy/tests/ -readme.md *.json .DS_Store diff --git a/README.md b/README.md new file mode 100644 index 0000000..0c1ec1a --- /dev/null +++ b/README.md @@ -0,0 +1,241 @@ +## checkpy + +A Python tool for running tests on Python source files. Intended to be +used by students whom are taking courses from [Programming Lab at the UvA](https://www.proglab.nl). + +Check out for examples of tests. + +### Installation + + pip install checkpy + +To download tests, run checkPy with ``-d`` as follows: + + checkpy -d YOUR_GITHUB_TESTS_URL + +For instance: + + checkpy -d spcourse/tests + +Here spcourse/tests points to . You can also use the full url. This tests repository contains a test for `hello.py`. Here is how to run it: + + $ echo 'print("hello world")' > hello.py + $ checkpy hello + Testing: hello.py + :( prints "Hello, world!" + assert 'hello world\n' == 'Hello, world!\n' + - hello world + ? ^ + + Hello, world! + ? ^ + + + :) prints exactly 1 line of output + +### Usage + + usage: checkpy [-h] [-module MODULE] [-download GITHUBLINK] [-register LOCALLINK] [-update] [-list] [-clean] [--dev] + [--silent] [--json] [--gh-auth GH_AUTH] + [files ...] + + checkPy: a python testing framework for education. You are running Python version 3.10.6 and checkpy version 2.0.0. + + positional arguments: + files names of files to be tested + + options: + -h, --help show this help message and exit + -module MODULE provide a module name or path to run all tests from the module, or target a module for a + specific test + -download GITHUBLINK download tests from a Github repository and exit + -register LOCALLINK register a local folder that contains tests and exit + -update update all downloaded tests and exit + -list list all download locations and exit + -clean remove all tests from the tests folder and exit + --dev get extra information to support the development of tests + --silent do not print test results to stdout + --json return output as json, implies silent + --gh-auth GH_AUTH username:personal_access_token for authentication with GitHub. Only used to increase GitHub api's rate limit. + +To test a single file call: + + checkpy YOUR_FILE_NAME + +### An example + +Tests in checkpy are functions with assertions. For instance: + +```Py +from checkpy import * + +@test() +def printsHello(): + """prints Hello, world!""" + assert outputOf() == "Hello, world!\n" +``` + +checkpy's `test` decorator marks the function below as a test. The docstring is a short description of the test for the student. This test does just one thing, assert that the output of the student's code matches the expected output exactly. checkpy leverages pytest's assertion rewriting to autmatically create assertion messages. For instance, a student might see the following when running this test: + + $ checkpy hello + Testing: hello.py + :( prints Hello, world! + assert 'hello world\n' == 'Hello, world!\n' + - hello world + ? ^ + + Hello, world! + ? ^ + + + +### Writing tests + +Tests are discovered by filename. If you want to test a file called ``hello.py``, the corresponding test must be named ``helloTest.py``. These tests must be placed in a folder called `tests`. For instance: `tests/helloTest.py`. Tests are distributed via GitHub repositories, but for development purposes tests can also be "registered" locally via the `-r` flag. For instance: + + mkdir tests + touch tests/helloTest.py + checkpy -r tests/helloTest.py + +Once registered, checkpy will start looking in that directory for tests. Now we need a test. A test minimally consists of the following: + +```Py +from checkpy import * + +@test() +def printsHello(): + """prints Hello, world!""" + assert outputOf() == "Hello, world!\n" +``` + +A function marked as a test through checkpy's test decorator. The docstring is a short, generally one-line, description of the test for the student. Then at least one assert. + +> Quick tip, use only binary expressions in assertions and keep them relatively simple for students to understand. If a binary expression is not possible, or you do not want to spoil the output, raise your own assertionError instead: ```raise AssertionError("Your program did not output the answer to the ultimate question of life, the universe, and everything")```. + +While developing, you can run checkpy with the `--dev` flag to get verbose error messages and full tracebacks. So here we might do: + + $ checkpy --dev hello + Testing: hello.py + :( prints "Hello, world!" + assert 'hello world\n' == 'Hello, world!\n' + - hello world + ? ^ + + Hello, world! + ? ^ + + + :) prints exactly 1 line of output + +Check out for many examples of checkpy tests. + +### Short examples + +#### Dependencies between tests + +```Py +@test() +def exactHello(): + """prints \"Hello, world!\"""" + assert outputOf() == "Hello, world!\n" + +@failed(exactHello) +def oneLine(): + """prints exactly 1 line of output""" + assert outputOf().count("\n") == 1 + +@passed(exactHello) +def allGood(): + """Good job, everything is correct! You are ready to hand in.""" +``` + +#### Test functions + +```Py +@test() +def testSquare(): + """square(2) returns 4""" + assert getFunction("square")(4) == 4 +``` + +#### Give hints + +```Py +@test() +def testSquare(): + """square(2) returns 4""" + assert getFunction("square")(4) == 4, "did you remember to round your output?" +``` + +#### Handle randomness with pytest's `approx` + +```Py +@test() +def testThrowDice(): + """throw() returns 7 on average""" + throw = getFunction("throw") + avg = sum(throw() for i in range(1000)) / 1000 + assert avg == approx(7, abs=0.5) +``` + +#### Ban language constructs + +```Py +import ast + +@test() +def testSquare(): + """square(2) returns 4""" + assert ast.While not in static.AbstractSyntaxTree() + assert getFunction("square")(4) == 4 +``` + +#### Check types + +```Py +@test() +def testFibonacci(): + """""" + fibonacci = getFunction("fibonacci") + assert fibonacci(10) == Type(list[int]) + assert fibonacci(10) == [0, 1, 1, 2, 3, 5, 8, 13, 21, 34] +``` + +#### Configure which files should be present + +> Calling `only`, `include`, `exclude`, `require` or `download` has checkpy create a temporary directory to which the specified files are copied/downloaded. The tests then run in that directory. + +```Py +only("sentiment.py") +download("pos_words.txt", "https://github.com/spcourse/text/raw/main/en/sentiment/pos_words.txt") +download("neg_words.txt", "https://github.com/spcourse/text/raw/main/en/sentiment/neg_words.txt") + +@test() +def testPositiveSentiment(): + """recognises a positive sentence""" + ... +``` + +#### Change the timeout + +```Py +@test(timeout=60) +def exactHello(): + """prints \"Hello, world!\"""" + assert outputOf() == "Hello, world!\n" +``` + +#### Short declarative tests + +> This is a new style of tests for simple repetative use cases. Be sure to check out for many more examples. For example [sentimentTest.py](https://github.com/spcourse/tests/blob/676cf5f0d2b0fbc82c7580a76b4359af273b0ca7/tests/text/sentimentTest.py) + +```Py +correctForPos = test()(declarative + .function("sentiment_of_text") + .params("text") + .returnType(int) + .call("Coronet has the best lines of all day cruisers.") + .returns(1) + .description("recognises a positive sentence") +) +``` + +### Distributing tests + +checkpy downloads tests directly from Github repos. The requirement is that a folder called ``tests`` exists within the repo that contains only tests and folders (which checkpy treats as modules). There must also be at least one release in the Github repo. checkpy will automatically target the latest release. Call checkpy with the optional ``-d`` argument and pass your github repo url. Tests will then be automatically downloaded, installed and kept up to date. + + +### Testing checkpy + + python3 run_tests.py diff --git a/README.rst b/README.rst deleted file mode 100644 index 1f1ac22..0000000 --- a/README.rst +++ /dev/null @@ -1,193 +0,0 @@ -CheckPy -======= - -A Python tool for running tests on Python source files. Intended to be -used by students whom are taking courses in the `Minor -Programming `__ at the -`UvA `__. - -`CheckPy docs `__ - -Installation ------------- - -:: - - pip install checkpy - -Besides installing checkPy, you might want to download some tests along with it. Simply run checkPy with the ``-d`` arg as follows: - -:: - - checkpy -d YOUR_GITHUB_TESTS_URL - -Usage ------ - -:: - - usage: __main__.py [-h] [-module MODULE] [-download GITHUBLINK] [-update] - [-list] [-clean] [-dev] - [file] - - checkPy: a python testing framework for education. You are running Python - version 3.6.2 and checkpy version 0.3.21. - - positional arguments: - file name of file to be tested - - optional arguments: - -h, --help show this help message and exit - -module MODULE provide a module name or path to run all tests from - the module, or target a module for a specific test - -download GITHUBLINK download tests from a Github repository and exit - -update update all downloaded tests and exit - -list list all download locations and exit - -clean remove all tests from the tests folder and exit - --dev get extra information to support the development of - tests - -To simply test a single file, call: - -:: - - checkpy YOUR_FILE_NAME - -If you are unsure whether multiple tests exist with the same name, you can target a specific test by specifying its module: - -:: - - checkpy YOUR_FILE_NAME -m YOUR_MODULE_NAME - -If you want to test all files from a module within your current working directory, then this is the command for you: - -:: - - checkpy -m YOUR_MODULE_NAME - -Features --------- - -- Support for ordering of tests -- Execution of tests can be made dependable on the outcome of other - tests -- The test designer need not concern herself with exception handling - and printing -- The full scope of Python is available when designing tests -- Full control over displayed information -- Support for importing modules without executing scripts that are not - wrapped by ``if __name__ == "__main__"`` -- Support for overriding functions from imports in order to for - instance prevent blocking function calls -- Support for grouping tests in modules, - allowing the user to target tests from a specific module or run all tests in a module with a single command. -- No infinite loops, automatically kills tests after a user defined timeout. -- Tests are kept up to date as checkpy will periodically look for updates from the downloaded test repos. - - -An example ----------- - -Tests in checkPy are collections of abstract methods that you as a test -designer need to implement. A test may look something like the -following: - -.. code-block:: python - - 0| import checkpy.test as t - 1| import checkpy.assertlib as assertlib - 2| import checkpy.lib as lib - 3| @t.failed(exact) - 4| @t.test(1) - 5| def contains(test): - 6| test.test = lambda : assertlib.contains(lib.outputOf(test.fileName), "100") - 7| test.description = lambda : "contains 100 in the output" - 8| test.fail = lambda info : "the correct answer (100) cannot be found in the output" - -From top to bottom: - -- The decorator ``failed`` on line 3 defines a precondition. The test - ``exact`` must have failed for the following tests to execute. -- The decorator ``test`` on line 4 prescribes that the following method - creates a test with order number ``1``. Tests are executed in order, - lowest first. -- The method definition on line 5 describes the name of the test - (``contains``), and takes in an instance of ``Test`` found in - ``test.py``. This instance is provided by the decorator ``test`` on - the previous line. -- On line 6 the ``test`` method is bound to a lambda which describes - the test that is to be executed. In this case asserting that the - print output of ``test.fileName`` contains the number ``100``. - ``test.fileName`` refers to the to be tested - source file. Besides resulting in a boolean indicating passing or - failing the test, the test method may also return a message. This - message can be used in other methods to provide valuable information - to the user. In this case however, no message is provided. -- On line 7 the ``description`` method is bound to a lambda which when - called produces a string message describing the intent of the test. -- On line 8 the ``fail`` method is bound to a lambda. This method is - used to provide information that should be shown to the user in case - the test fails. The method takes in a - message (``info``) which comes from the second returned value of the - ``test`` method. This message can be used to relay information found during - execution of the test to the user. - -Writing tests -------------- - -Test methods are discovered in checkPy by filename. If you want to test -a file called ``foo.py``, the corresponding test must be named ``fooTest.py``. -checkPy assumes that all methods in the test file are tests, as such one -should not use the ``from ... import ...`` statement when importing -modules. - -A test minimally consists of the following: - -.. code-block:: python - - import checkpy.test as t - import checkpy.assertlib as assertlib - import checkpy.lib as lib - @t.test(0) - def someTest(test): - test.test = lambda : False - test.description = lambda : "some description" - -Here the method ``someTest`` is marked as test by the decorator -``test``. The abstract methods ``test`` and ``description`` are -implemented as these are the only methods that necessarily require -implementation. For more information on tests and their abstract methods -you should refer to ``test.py``. Note that besides defining the ``Test`` -class and its abstract methods, ``test.py`` also provides several -decorators for introducing test dependencies such as ``failed``. - -When providing a concrete implementation for the test method one should -take a closer look at ``lib.py`` and ``assertlib.py``. ``lib.py`` -provides a collection of useful functions to help implement tests. Most -notably ``getFunction`` and ``outputOf``. These provide the tester with -a function from the source file and the complete print output -respectively. Calling ``getFunction`` has checkpy import the to be -tested code and retrieves only said function from the resulting module. -``assertlib.py`` provides a collection of assertions that one may find useful when -implementing tests. - -For inspiration inspect some existing collections like the tests for `progNS `__, `progIK `__, `Semester of Code `__ or `progBG `__. - - -Distributing tests ------------------- - -CheckPy can download tests directly from Github repos. -The requirement is that a folder called ``tests`` exists within the repo that contains only tests and folders (which checkpy treats as modules). -There must also be at least one release in the Github repo. Checkpy will automatically target the latest release. -Simply call checkPy with the optional ``-d`` argument and pass your github repo url. -Tests will then be automatically downloaded and installed. - - -Testing CheckPy ---------------- - -:: - - python2 run_tests.py - python3 run_tests.py diff --git a/checkpy/__main__.py b/checkpy/__main__.py index 8758f23..8e13f99 100644 --- a/checkpy/__main__.py +++ b/checkpy/__main__.py @@ -30,7 +30,7 @@ def main(): parser.add_argument("--dev", action="store_true", help="get extra information to support the development of tests") parser.add_argument("--silent", action="store_true", help="do not print test results to stdout") parser.add_argument("--json", action="store_true", help="return output as json, implies silent") - parser.add_argument("--gh-auth", action="store", help="username:personal_access_token for authentication with GitHub. Only used to increase the GitHub api's rate limit.") + parser.add_argument("--gh-auth", action="store", help="username:personal_access_token for authentication with GitHub. Only used to increase GitHub api's rate limit.") parser.add_argument("files", action="store", nargs="*", help="names of files to be tested") args = parser.parse_args() diff --git a/setup.py b/setup.py index 5eade98..52e46fb 100644 --- a/setup.py +++ b/setup.py @@ -5,16 +5,17 @@ here = path.abspath(path.dirname(__file__)) # Get the long description from the README file -with open(path.join(here, 'README.rst'), encoding='utf-8') as f: +with open(path.join(here, 'README.md'), encoding='utf-8') as f: long_description = f.read() setup( name='checkPy', - version='0.4.11', + version='2.0.0', description='A simple python testing framework for educational purposes', long_description=long_description, + long_description_content_type='text/markdown', url='https://github.com/Jelleas/CheckPy', From 04a86b579d2f2c0c45a38cd2b1725326c8e4e517 Mon Sep 17 00:00:00 2001 From: jelleas Date: Wed, 30 Aug 2023 16:25:37 +0200 Subject: [PATCH 229/269] quotes --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 0c1ec1a..f1b0925 100644 --- a/README.md +++ b/README.md @@ -85,7 +85,7 @@ checkpy's `test` decorator marks the function below as a test. The docstring is ### Writing tests -Tests are discovered by filename. If you want to test a file called ``hello.py``, the corresponding test must be named ``helloTest.py``. These tests must be placed in a folder called `tests`. For instance: `tests/helloTest.py`. Tests are distributed via GitHub repositories, but for development purposes tests can also be "registered" locally via the `-r` flag. For instance: +Tests are discovered by filename. If you want to test a file called ``hello.py``, the corresponding test must be named ``helloTest.py``. These tests must be placed in a folder called `tests`. For instance: `tests/helloTest.py`. Tests are distributed via GitHub repositories, but for development purposes tests can also be registered locally via the `-r` flag. For instance: mkdir tests touch tests/helloTest.py From 2936d6693dd9022ee623cbabf4e42315117ec978 Mon Sep 17 00:00:00 2001 From: jelleas Date: Wed, 30 Aug 2023 16:27:54 +0200 Subject: [PATCH 230/269] phrasing --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index f1b0925..a9b62e8 100644 --- a/README.md +++ b/README.md @@ -233,7 +233,7 @@ correctForPos = test()(declarative ### Distributing tests -checkpy downloads tests directly from Github repos. The requirement is that a folder called ``tests`` exists within the repo that contains only tests and folders (which checkpy treats as modules). There must also be at least one release in the Github repo. checkpy will automatically target the latest release. Call checkpy with the optional ``-d`` argument and pass your github repo url. Tests will then be automatically downloaded, installed and kept up to date. +checkpy downloads tests directly from Github repos. The requirement is that a folder called ``tests`` exists within the repo that contains only tests and folders (which checkpy treats as modules). There must also be at least one release in the Github repo. checkpy will automatically target the latest release. To download tests call checkpy with the optional ``-d`` argument and pass your github repo url. checkpy will automatically keep tests up to date by checking for any new releases on GitHub. ### Testing checkpy From 1d3df8d8c3452d941670e20fb75bdcece2783dc6 Mon Sep 17 00:00:00 2001 From: Jelle van Assema Date: Wed, 30 Aug 2023 16:41:51 +0200 Subject: [PATCH 231/269] Create python-publish.yml --- .github/workflows/python-publish.yml | 39 ++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) create mode 100644 .github/workflows/python-publish.yml diff --git a/.github/workflows/python-publish.yml b/.github/workflows/python-publish.yml new file mode 100644 index 0000000..bdaab28 --- /dev/null +++ b/.github/workflows/python-publish.yml @@ -0,0 +1,39 @@ +# This workflow will upload a Python Package using Twine when a release is created +# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python#publishing-to-package-registries + +# This workflow uses actions that are not certified by GitHub. +# They are provided by a third-party and are governed by +# separate terms of service, privacy policy, and support +# documentation. + +name: Upload Python Package + +on: + release: + types: [published] + +permissions: + contents: read + +jobs: + deploy: + + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v3 + - name: Set up Python + uses: actions/setup-python@v3 + with: + python-version: '3.x' + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install build + - name: Build package + run: python -m build + - name: Publish package + uses: pypa/gh-action-pypi-publish@27b31702a0e7fc50959f5ad993c78deac1bdfc29 + with: + user: __token__ + password: ${{ secrets.PYPI_API_TOKEN }} From 2fe4823da9f0b35cd895671e4cb63863622c935f Mon Sep 17 00:00:00 2001 From: jelleas Date: Wed, 30 Aug 2023 16:54:53 +0200 Subject: [PATCH 232/269] workflow with OIDC --- .github/workflows/python-publish.yml | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/.github/workflows/python-publish.yml b/.github/workflows/python-publish.yml index bdaab28..eb86278 100644 --- a/.github/workflows/python-publish.yml +++ b/.github/workflows/python-publish.yml @@ -6,20 +6,23 @@ # separate terms of service, privacy policy, and support # documentation. +# https://github.com/marketplace/actions/pypi-publish + name: Upload Python Package on: release: types: [published] -permissions: - contents: read - jobs: - deploy: - + pypi-publish: + name: Upload release to PyPI runs-on: ubuntu-latest - + environment: + name: pypi + url: https://pypi.org/p/checkpy + permissions: + id-token: write # IMPORTANT: this permission is mandatory for trusted publishing steps: - uses: actions/checkout@v3 - name: Set up Python @@ -32,8 +35,5 @@ jobs: pip install build - name: Build package run: python -m build - - name: Publish package - uses: pypa/gh-action-pypi-publish@27b31702a0e7fc50959f5ad993c78deac1bdfc29 - with: - user: __token__ - password: ${{ secrets.PYPI_API_TOKEN }} + - name: Publish package distributions to PyPI + uses: pypa/gh-action-pypi-publish@release/v1 From c31d566347dec571cd6525bd15d2ceff1d4526f6 Mon Sep 17 00:00:00 2001 From: jelleas Date: Wed, 30 Aug 2023 17:09:26 +0200 Subject: [PATCH 233/269] gh-action-pypi-publish latest v --- .github/workflows/python-publish.yml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/python-publish.yml b/.github/workflows/python-publish.yml index eb86278..89f5602 100644 --- a/.github/workflows/python-publish.yml +++ b/.github/workflows/python-publish.yml @@ -10,6 +10,7 @@ name: Upload Python Package + on: release: types: [published] @@ -36,4 +37,6 @@ jobs: - name: Build package run: python -m build - name: Publish package distributions to PyPI - uses: pypa/gh-action-pypi-publish@release/v1 + uses: pypa/gh-action-pypi-publish@v1.8.10 + + From c16131c4dd4f034b8ee3c3a0193f708524bfd535 Mon Sep 17 00:00:00 2001 From: jelleas Date: Wed, 30 Aug 2023 17:23:06 +0200 Subject: [PATCH 234/269] revert action back to api token --- .github/workflows/python-publish.yml | 25 +++++++++++-------------- 1 file changed, 11 insertions(+), 14 deletions(-) diff --git a/.github/workflows/python-publish.yml b/.github/workflows/python-publish.yml index 89f5602..bdaab28 100644 --- a/.github/workflows/python-publish.yml +++ b/.github/workflows/python-publish.yml @@ -6,24 +6,20 @@ # separate terms of service, privacy policy, and support # documentation. -# https://github.com/marketplace/actions/pypi-publish - name: Upload Python Package - on: release: types: [published] +permissions: + contents: read + jobs: - pypi-publish: - name: Upload release to PyPI + deploy: + runs-on: ubuntu-latest - environment: - name: pypi - url: https://pypi.org/p/checkpy - permissions: - id-token: write # IMPORTANT: this permission is mandatory for trusted publishing + steps: - uses: actions/checkout@v3 - name: Set up Python @@ -36,7 +32,8 @@ jobs: pip install build - name: Build package run: python -m build - - name: Publish package distributions to PyPI - uses: pypa/gh-action-pypi-publish@v1.8.10 - - + - name: Publish package + uses: pypa/gh-action-pypi-publish@27b31702a0e7fc50959f5ad993c78deac1bdfc29 + with: + user: __token__ + password: ${{ secrets.PYPI_API_TOKEN }} From 9bad098e5ccc7913cf6dbd52162fdf6610786120 Mon Sep 17 00:00:00 2001 From: jelleas Date: Thu, 31 Aug 2023 15:01:12 +0200 Subject: [PATCH 235/269] getNumbersFrom redesign, now gets all numbers from a string --- checkpy/lib/static.py | 32 +++++++++++++++++++++----------- 1 file changed, 21 insertions(+), 11 deletions(-) diff --git a/checkpy/lib/static.py b/checkpy/lib/static.py index e02afa2..f784509 100644 --- a/checkpy/lib/static.py +++ b/checkpy/lib/static.py @@ -84,19 +84,29 @@ def visit_ImportFrom(self, node: _ast.ImportFrom): def getNumbersFrom(text: str) -> _List[_Union[int, float]]: """ - Get all Python parseable numbers from a string. - Numbers are assumed to be seperated by whitespace from other text. - whitespace = \s = [\\r\\n\\t\\f\\v ] + Get all numbers from a string. + Only the first dot in a number is used: + 7.3.4 produces float(7.3). The 4 is discarded. + 'e' is unsupported and considered a normal seperator: + 1e7 produces int(1) and int(7) + Whitespace (or generally non-digits) matters: + 7-8 produces int(7) and int(8) + 7 -8 produces int(7) and int(-8) + 7 - 8 produces int(7) and int(8) """ numbers: _List[_Union[int, float]] = [] - for elem in _re.split(r"\s", text): - try: - if "." in elem: - numbers.append(float(elem)) - else: - numbers.append(int(elem)) - except ValueError: - pass + + n = "" + for c in text + " ": + if c.isdigit() or c == "." or (c in "+-" and not n): + n += c + elif n: + # drop everything after the second '.' + if n.count(".") > 1: + n = ".".join(n.split(".")[:2]) + + numbers.append(float(n) if "." in n else int(n)) + n = "" return numbers From eda77c0d8ea1325160fd23fdfa63b35f9de75d36 Mon Sep 17 00:00:00 2001 From: jelleas Date: Tue, 5 Sep 2023 11:32:04 +0200 Subject: [PATCH 236/269] v2.0.1, fix static.AbstractSyntaxTree on py3.8 --- checkpy/lib/static.py | 3 ++- setup.py | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/checkpy/lib/static.py b/checkpy/lib/static.py index f784509..a289d09 100644 --- a/checkpy/lib/static.py +++ b/checkpy/lib/static.py @@ -260,7 +260,8 @@ def __init__(self, fileName: _Optional[str]=None): self.source: str = getSource(fileName=fileName) def __contains__(self, item: type) -> bool: - if item.__module__ != _ast.__name__: + # Python 3.8 and before use "_ast" for ast.Break.__module__ + if item.__module__ not in [_ast.__name__, "_ast"]: raise _checkpy.entities.exception.CheckpyError( message=f"{item} is not of type {_ast.AST}." f" Can only search for {_ast.AST} types in AbstractSyntaxTree." diff --git a/setup.py b/setup.py index 52e46fb..5f0fa1a 100644 --- a/setup.py +++ b/setup.py @@ -11,7 +11,7 @@ setup( name='checkPy', - version='2.0.0', + version='2.0.1', description='A simple python testing framework for educational purposes', long_description=long_description, From e0796d707a4403b9622aade8037376fe463793f1 Mon Sep 17 00:00:00 2001 From: jelleas Date: Tue, 5 Sep 2023 15:42:02 +0200 Subject: [PATCH 237/269] error if test's precondition is not a TestFunction --- checkpy/tests.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/checkpy/tests.py b/checkpy/tests.py index eb1702e..1e08cef 100644 --- a/checkpy/tests.py +++ b/checkpy/tests.py @@ -271,6 +271,14 @@ def __init__( hide: Optional[bool]=None ): super().__init__(function=function, priority=priority, timeout=timeout) + + for precond in preconditions: + if not isinstance(precond, TestFunction): + raise exception.CheckpyError( + f"{precond} is not a checkpy test and cannot be used as a dependency for test {function}." + f" Did you forget to use the @test() decorator for {precond}?" + ) + self.preconditions = preconditions self.shouldHide = self._getHide(hide) From 428061a5e8fdf9682680992f81b07ffd279759ee Mon Sep 17 00:00:00 2001 From: jelleas Date: Tue, 12 Sep 2023 11:21:20 +0200 Subject: [PATCH 238/269] rm checks for ast.AST types v2.0.2 --- checkpy/lib/static.py | 11 ----------- setup.py | 2 +- 2 files changed, 1 insertion(+), 12 deletions(-) diff --git a/checkpy/lib/static.py b/checkpy/lib/static.py index a289d09..c2b715a 100644 --- a/checkpy/lib/static.py +++ b/checkpy/lib/static.py @@ -214,10 +214,6 @@ def getAstNodes(*types: type, source: _Optional[str]=None) -> _List[_ast.AST]: getAstNodes(ast.Mult, ast.Add) # Will find all uses of multiplication (*) and addition (+) ``` """ - for type_ in types: - if type_.__module__ != _ast.__name__: - raise _exception.CheckpyError(message=f"{type_} passed to getAstNodes() is not of type ast.AST") - nodes: _List[_ast.AST] = [] class Visitor(_ast.NodeVisitor): @@ -260,12 +256,5 @@ def __init__(self, fileName: _Optional[str]=None): self.source: str = getSource(fileName=fileName) def __contains__(self, item: type) -> bool: - # Python 3.8 and before use "_ast" for ast.Break.__module__ - if item.__module__ not in [_ast.__name__, "_ast"]: - raise _checkpy.entities.exception.CheckpyError( - message=f"{item} is not of type {_ast.AST}." - f" Can only search for {_ast.AST} types in AbstractSyntaxTree." - ) - self.foundNodes = getAstNodes(item, source=self.source) return bool(self.foundNodes) \ No newline at end of file diff --git a/setup.py b/setup.py index 5f0fa1a..c530774 100644 --- a/setup.py +++ b/setup.py @@ -11,7 +11,7 @@ setup( name='checkPy', - version='2.0.1', + version='2.0.2', description='A simple python testing framework for educational purposes', long_description=long_description, From 9f9e2d9c0cf36a3756cce8efe1e823d0414253a3 Mon Sep 17 00:00:00 2001 From: jelleas Date: Mon, 18 Sep 2023 16:34:04 +0200 Subject: [PATCH 239/269] v2.0.3, fix ValueError on single '.' in line --- checkpy/lib/static.py | 4 +++- setup.py | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/checkpy/lib/static.py b/checkpy/lib/static.py index c2b715a..66961cd 100644 --- a/checkpy/lib/static.py +++ b/checkpy/lib/static.py @@ -105,7 +105,9 @@ def getNumbersFrom(text: str) -> _List[_Union[int, float]]: if n.count(".") > 1: n = ".".join(n.split(".")[:2]) - numbers.append(float(n) if "." in n else int(n)) + if n != ".": + numbers.append(float(n) if "." in n else int(n)) + n = "" return numbers diff --git a/setup.py b/setup.py index c530774..7d5f78c 100644 --- a/setup.py +++ b/setup.py @@ -11,7 +11,7 @@ setup( name='checkPy', - version='2.0.2', + version='2.0.3', description='A simple python testing framework for educational purposes', long_description=long_description, From 8d20c56437a47182087040e23dbe0bd17017bec6 Mon Sep 17 00:00:00 2001 From: jelleas Date: Wed, 20 Sep 2023 16:24:02 +0200 Subject: [PATCH 240/269] v2.0.4 fix ValueError on single +/- with static.getNumbersFrom --- checkpy/lib/static.py | 6 ++++-- setup.py | 2 +- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/checkpy/lib/static.py b/checkpy/lib/static.py index 66961cd..f6758ce 100644 --- a/checkpy/lib/static.py +++ b/checkpy/lib/static.py @@ -105,9 +105,11 @@ def getNumbersFrom(text: str) -> _List[_Union[int, float]]: if n.count(".") > 1: n = ".".join(n.split(".")[:2]) - if n != ".": + try: numbers.append(float(n) if "." in n else int(n)) - + except ValueError: + pass + n = "" return numbers diff --git a/setup.py b/setup.py index 7d5f78c..62e8d68 100644 --- a/setup.py +++ b/setup.py @@ -11,7 +11,7 @@ setup( name='checkPy', - version='2.0.3', + version='2.0.4', description='A simple python testing framework for educational purposes', long_description=long_description, From 21a7d65e9c0d36859bd1d8b38e19a5cfc94a98d8 Mon Sep 17 00:00:00 2001 From: jelleas Date: Thu, 28 Sep 2023 09:32:47 +0200 Subject: [PATCH 241/269] v2.0.5 ensure hasPassed is of type None/bool --- checkpy/tests.py | 4 ++++ setup.py | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/checkpy/tests.py b/checkpy/tests.py index 1e08cef..cfe6696 100644 --- a/checkpy/tests.py +++ b/checkpy/tests.py @@ -222,6 +222,10 @@ def runMethod(): stacktrace = traceback.format_exc()) return TestResult(False, test.description, str(test.exception(e)), exception=e) + # Ensure hasPassed is None or a boolean + # This is needed as boolean operators on np.bool_ return np.bool_ + hasPassed = hasPassed if hasPassed is None else bool(hasPassed) + return TestResult(hasPassed, test.description, test.success(info) if hasPassed else test.fail(info)) return runMethod diff --git a/setup.py b/setup.py index 62e8d68..64ea635 100644 --- a/setup.py +++ b/setup.py @@ -11,7 +11,7 @@ setup( name='checkPy', - version='2.0.4', + version='2.0.5', description='A simple python testing framework for educational purposes', long_description=long_description, From 3cb9be44ede32bb6b86f370cffcabd1fc37f07e1 Mon Sep 17 00:00:00 2001 From: jelleas Date: Thu, 28 Sep 2023 15:05:53 +0200 Subject: [PATCH 242/269] v2.0.6, download calls initSandbox --- checkpy/lib/sandbox.py | 2 ++ setup.py | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/checkpy/lib/sandbox.py b/checkpy/lib/sandbox.py index 164cfa5..c50a00a 100644 --- a/checkpy/lib/sandbox.py +++ b/checkpy/lib/sandbox.py @@ -150,6 +150,8 @@ def require(self, *filePaths: Union[str, Path]): self.onUpdate(self) def download(self, fileName: str, source: str): + self._initSandbox() + self.downloads.append(Download(fileName, source)) self.onUpdate(self) diff --git a/setup.py b/setup.py index 64ea635..c4af451 100644 --- a/setup.py +++ b/setup.py @@ -11,7 +11,7 @@ setup( name='checkPy', - version='2.0.5', + version='2.0.6', description='A simple python testing framework for educational purposes', long_description=long_description, From 37b0df53b6d0554880ab5242a3916f9efea02829 Mon Sep 17 00:00:00 2001 From: jelleas Date: Thu, 28 Sep 2023 15:19:23 +0200 Subject: [PATCH 243/269] readme fix --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index a9b62e8..fefa5e9 100644 --- a/README.md +++ b/README.md @@ -186,7 +186,7 @@ def testSquare(): ```Py @test() def testFibonacci(): - """""" + """fibonacci(10) returns the first ten fibonacci numbers""" fibonacci = getFunction("fibonacci") assert fibonacci(10) == Type(list[int]) assert fibonacci(10) == [0, 1, 1, 2, 3, 5, 8, 13, 21, 34] From 9c186fe0d99a1c04539f69aa7e9b0006d14eb7fd Mon Sep 17 00:00:00 2001 From: jelleas Date: Fri, 29 Sep 2023 15:28:13 +0200 Subject: [PATCH 244/269] v2.0.7, always assert expected == real in declarative --- checkpy/lib/declarative.py | 10 +++++----- setup.py | 2 +- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/checkpy/lib/declarative.py b/checkpy/lib/declarative.py index bc561d1..d4892e1 100644 --- a/checkpy/lib/declarative.py +++ b/checkpy/lib/declarative.py @@ -111,11 +111,11 @@ def testParams(state: FunctionState): real = state.function.parameters expected = state.params - assert len(real) == len(expected),\ + assert len(expected) == len(real),\ f"expected {len(expected)} parameter(s), your function {state.name}() takes"\ f" {len(real)} parameter(s)" - assert real == expected,\ + assert expected == real,\ f"parameters should exactly match the requested function definition" state.description = "" @@ -138,7 +138,7 @@ def returns(self, expected: Any) -> Self: """Assert that the last call returns expected.""" def testReturned(state: FunctionState): state.description = f"{state.getFunctionCallRepr()} should return {expected}" - assert state.returned == expected, f"{state.getFunctionCallRepr()} returned: {state.returned}" + assert expected == state.returned, f"{state.getFunctionCallRepr()} returned: {state.returned}" state.description = "" return self.do(testReturned) @@ -154,7 +154,7 @@ def testStdout(state: FunctionState): state.description = f"{state.getFunctionCallRepr()} should print {descrStr}" actual = state.function.printOutput - assert actual == expected + assert expected == actual state.description = "" return self.do(testStdout) @@ -201,7 +201,7 @@ def testCall(state: FunctionState): state.description = f"{state.getFunctionCallRepr()} returns a value of type {checkpy.Type(state.returnType)}" type_ = state.returnType returned = state.returned - assert returned == checkpy.Type(type_), f"{state.getFunctionCallRepr()} returned: {returned}" + assert checkpy.Type(type_) == returned, f"{state.getFunctionCallRepr()} returned: {returned}" == returned state.description = f"calling function {state.getFunctionCallRepr()}" return self.do(testCall) diff --git a/setup.py b/setup.py index c4af451..b5bedc2 100644 --- a/setup.py +++ b/setup.py @@ -11,7 +11,7 @@ setup( name='checkPy', - version='2.0.6', + version='2.0.7', description='A simple python testing framework for educational purposes', long_description=long_description, From 4e7cecb5b2ae510830a8a18a17ee03fca7aff5b8 Mon Sep 17 00:00:00 2001 From: jelleas Date: Wed, 4 Oct 2023 22:13:05 +0200 Subject: [PATCH 245/269] reduce overhead of entities.Function, inline _captureStdout --- checkpy/entities/function.py | 55 ++++++++++++------------------------ 1 file changed, 18 insertions(+), 37 deletions(-) diff --git a/checkpy/entities/function.py b/checkpy/entities/function.py index 0d81426..347b4b0 100644 --- a/checkpy/entities/function.py +++ b/checkpy/entities/function.py @@ -1,7 +1,6 @@ import os import sys import re -import contextlib import inspect import io import typing @@ -15,12 +14,17 @@ def __init__(self, function: typing.Callable): self._printOutput = "" def __call__(self, *args, **kwargs) -> typing.Any: - old = sys.stdout + oldStdout = sys.stdout + oldStderr = sys.stderr + _outStreamListener.content = "" + try: - with self._captureStdout() as listener: - outcome = self._function(*args, **kwargs) - self._printOutput = listener.content - return outcome + sys.stdout = _outStreamListener.stream + sys.stderr = _devnull + + outcome = self._function(*args, **kwargs) + self._printOutput = _outStreamListener.content + return outcome except Exception as e: if isinstance(e,TypeError): no_arguments = re.search(r"(\w+\(\)) takes (\d+) positional arguments but (\d+) were given", str(e)) @@ -29,7 +33,6 @@ def __call__(self, *args, **kwargs) -> typing.Any: exception=None, message=f"{no_arguments.group(1)} should take {no_arguments.group(3)} arguments but takes {no_arguments.group(2)} instead" ) - sys.stdout = old argumentNames = self.arguments nArgs = len(args) + len(kwargs) @@ -44,6 +47,9 @@ def __call__(self, *args, **kwargs) -> typing.Any: argsRepr = ','.join(str(arg) for arg in args) message = f"while trying to exectute {self.name}({argsRepr})" raise exception.SourceException(exception = e, message = message) + finally: + sys.stderr = oldStderr + sys.stdout = oldStdout @property def name(self) -> str: @@ -68,30 +74,6 @@ def printOutput(self) -> str: def __repr__(self): return self._function.__name__ - @contextlib.contextmanager - def _captureStdout(self) -> typing.Generator["_StreamListener", None, None]: - """ - capture sys.stdout in _outStream - (a _Stream that is an instance of StringIO extended with the Observer pattern) - returns a _StreamListener on said stream - """ - outStreamListener = _StreamListener(_outStream) - old_stdout = sys.stdout - old_stderr = sys.stderr - - try: - outStreamListener.start() - sys.stdout = outStreamListener.stream - sys.stderr = open(os.devnull) - yield outStreamListener - except: - raise - finally: - sys.stderr.close() - sys.stderr = old_stderr - sys.stdout = old_stdout - outStreamListener.stop() - class _Stream(io.StringIO): def __init__(self, *args, **kwargs): io.StringIO.__init__(self, *args, **kwargs) @@ -121,7 +103,7 @@ def _onUpdate(self, content: str): class _StreamListener: def __init__(self, stream: _Stream): self._stream = stream - self._content = "" + self.content = "" def start(self): self.stream.register(self) @@ -130,14 +112,13 @@ def stop(self): self.stream.unregister(self) def update(self, content: str): - self._content += content - - @property - def content(self) -> str: - return self._content + self.content += content @property def stream(self) -> _Stream: return self._stream _outStream = _Stream() +_outStreamListener = _StreamListener(_outStream) +_outStreamListener.start() +_devnull = open(os.devnull) \ No newline at end of file From d4d549f9fa4bbe58b2b4f52cf47f23f841c820d9 Mon Sep 17 00:00:00 2001 From: jelleas Date: Wed, 4 Oct 2023 22:50:44 +0200 Subject: [PATCH 246/269] only capture stdout in first entities.Function call --- checkpy/entities/function.py | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/checkpy/entities/function.py b/checkpy/entities/function.py index 347b4b0..ed5ac11 100644 --- a/checkpy/entities/function.py +++ b/checkpy/entities/function.py @@ -8,7 +8,9 @@ import checkpy.entities.exception as exception -class Function(object): +class Function: + _isFirstFunctionCalled = True + def __init__(self, function: typing.Callable): self._function = function self._printOutput = "" @@ -16,11 +18,15 @@ def __init__(self, function: typing.Callable): def __call__(self, *args, **kwargs) -> typing.Any: oldStdout = sys.stdout oldStderr = sys.stderr - _outStreamListener.content = "" + oldIsFirstFunctionCalled = Function._isFirstFunctionCalled try: - sys.stdout = _outStreamListener.stream - sys.stderr = _devnull + # iff this Function is the first one called, capture stdout + if Function._isFirstFunctionCalled: + Function._isFirstFunctionCalled = False + _outStreamListener.content = "" + sys.stdout = _outStreamListener.stream + sys.stderr = _devnull outcome = self._function(*args, **kwargs) self._printOutput = _outStreamListener.content @@ -48,8 +54,10 @@ def __call__(self, *args, **kwargs) -> typing.Any: message = f"while trying to exectute {self.name}({argsRepr})" raise exception.SourceException(exception = e, message = message) finally: - sys.stderr = oldStderr - sys.stdout = oldStdout + if oldIsFirstFunctionCalled: + Function._isFirstFunctionCalled = True + sys.stderr = oldStderr + sys.stdout = oldStdout @property def name(self) -> str: From 9846d371746c572ea9f0f31bd38010db80d02e19 Mon Sep 17 00:00:00 2001 From: jelleas Date: Thu, 5 Oct 2023 11:49:00 +0200 Subject: [PATCH 247/269] v2.0.8, less ovearhead in function calls --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index b5bedc2..d7e5f1e 100644 --- a/setup.py +++ b/setup.py @@ -11,7 +11,7 @@ setup( name='checkPy', - version='2.0.7', + version='2.0.8', description='A simple python testing framework for educational purposes', long_description=long_description, From 96974358c83d2b66e445823f26fb511deb44fcc3 Mon Sep 17 00:00:00 2001 From: Jelleas Date: Thu, 26 Oct 2023 21:30:13 +0200 Subject: [PATCH 248/269] includeFromTests --- checkpy/__init__.py | 7 ++++- checkpy/lib/sandbox.py | 57 ++++++++++++++++++++++++++++++++++++---- checkpy/tester/tester.py | 25 ++++++++++++------ 3 files changed, 75 insertions(+), 14 deletions(-) diff --git a/checkpy/__init__.py b/checkpy/__init__.py index 4c3d257..9f8b47b 100644 --- a/checkpy/__init__.py +++ b/checkpy/__init__.py @@ -19,7 +19,7 @@ from checkpy.tests import test, failed, passed from checkpy.lib.basic import outputOf, getModule, getFunction -from checkpy.lib.sandbox import only, include, exclude, require, download +from checkpy.lib.sandbox import only, include, includeFromTests, exclude, require, download from checkpy.lib import static from checkpy.lib import monkeypatch from checkpy.lib.type import Type @@ -39,6 +39,7 @@ "declarative", "only", "include", + "includeFromTests", "exclude", "require", "download", @@ -46,4 +47,8 @@ "approx" ] +# To be tested file file: _typing.Optional[_pathlib.Path] = None + +# Path to the tests directory +testPath: _typing.Optional[_pathlib.Path] = None \ No newline at end of file diff --git a/checkpy/lib/sandbox.py b/checkpy/lib/sandbox.py index c50a00a..20a346f 100644 --- a/checkpy/lib/sandbox.py +++ b/checkpy/lib/sandbox.py @@ -4,14 +4,16 @@ import shutil import tempfile from pathlib import Path -from typing import List, Set, Union +from typing import List, Set, Optional, Union import requests +import checkpy + from checkpy.entities.exception import TooManyFilesError, MissingRequiredFiles, DownloadError -__all__ = ["exclude", "include", "only", "require"] +__all__ = ["exclude", "include", "includeFromTests", "only", "require", "download"] DEFAULT_FILE_LIMIT = 10000 @@ -31,7 +33,7 @@ def exclude(*patterns: Union[str, Path]): def include(*patterns: Union[str, Path]): """ - Include all files matching patterns from the check's sandbox. + Include all files matching patterns to the check's sandbox. If this is the first call to only/include/exclude/require/download initialize the sandbox: * Create a temp dir @@ -41,6 +43,18 @@ def include(*patterns: Union[str, Path]): """ config.include(*patterns) +def includeFromTests(*patterns: Union[str, Path]): + """ + Include all files matching patterns from the tests directory to the check's sandbox. + + If this is the first call to only/include/exclude/require/download initialize the sandbox: + * Create a temp dir + * Copy over all files from current dir (except those files excluded through exclude()) + + Patterns are shell globs in the same style as .gitignore. + """ + config.includeFromTests(*patterns) + def only(*patterns: Union[str, Path]): """ Only files matching patterns will be in the check's sandbox. @@ -67,7 +81,6 @@ def require(*filePaths: Union[str, Path]): """ config.require(*filePaths) - def download(fileName: str, source: str): """ Download a file from source and store it in fileName. @@ -85,6 +98,7 @@ def __init__(self, onUpdate=lambda config: None): self.excludedFiles: Set[str] = set() self.missingRequiredFiles: List[str] = [] self.downloads: List[Download] = [] + self.includedFroms: List[IncludedFrom] = [] self.isSandboxed = False self.root = Path.cwd() self.onUpdate = onUpdate @@ -122,6 +136,16 @@ def include(self, *patterns: Union[str, Path]): self.onUpdate(self) + def includeFromTests(self, *patterns: Union[str, Path]): + self._initSandbox() + + included: Set[str] = set() + for pattern in patterns: + included |= _glob(pattern, root=checkpy.testPath) + self.includedFroms.extend( + IncludedFrom((checkpy.testPath / source).resolve(), checkpy.testPath) for source in included) + self.onUpdate(self) + def only(self, *patterns: Union[str, Path]): self._initSandbox() @@ -158,11 +182,31 @@ def download(self, fileName: str, source: str): config = Config() +class IncludedFrom: + def __init__(self, source: Path, root: Path): + self.source = source + self.root = root + self._isIncluded = False + + def include(self): + if self._isIncluded: + return + + if self.source.is_relative_to(self.root): + dest = (Path.cwd() / self.source.relative_to(self.root)).absolute() + dest.parent.mkdir(parents=True, exist_ok=True) + else: + dest = (Path.cwd() / self.source.name).absolute() + + origin = self.source.absolute() + shutil.copy(origin, dest) + self._isIncluded = True + class Download: def __init__(self, fileName: str, source: str): self.fileName: str = str(fileName) self.source: str = str(source) - self._isDownloaded: bool = False + self._isDownloaded = False def download(self): if self._isDownloaded: @@ -194,6 +238,9 @@ def sync(config: Config, sandboxDir: Path): for dl in config.downloads: dl.download() + for includedFrom in config.includedFroms: + includedFrom.include() + nonlocal oldIncluded, oldExcluded for f in config.excludedFiles - oldExcluded: dest = (sandboxDir / f).absolute() diff --git a/checkpy/tester/tester.py b/checkpy/tester/tester.py index 903b00f..b97a25f 100644 --- a/checkpy/tester/tester.py +++ b/checkpy/tester/tester.py @@ -49,7 +49,7 @@ def test(testName: str, module="", debugMode=False, silentMode=False) -> "Tester sys.path.append(filePath) testFileName = fileName.split(".")[0] + "Test.py" - testPaths = discovery.getTestPaths(testFileName, module = module) + testPaths = discovery.getTestPaths(testFileName, module=module) if not testPaths: result.addOutput(printer.displayError("No test found for {}".format(fileName))) @@ -58,10 +58,10 @@ def test(testName: str, module="", debugMode=False, silentMode=False) -> "Tester if len(testPaths) > 1: result.addOutput(printer.displayWarning("Found {} tests: {}, using: {}".format(len(testPaths), testPaths, testPaths[0]))) - testFilePath = str(testPaths[0]) + testPath = testPaths[0] - if testFilePath not in sys.path: - sys.path.append(testFilePath) + if str(testPath) not in sys.path: + sys.path.append(str(testPath)) if path.endswith(".ipynb"): if subprocess.call(['jupyter', 'nbconvert', '--to', 'script', path]) != 0: @@ -76,7 +76,13 @@ def test(testName: str, module="", debugMode=False, silentMode=False) -> "Tester with open(path, "w") as f: f.write("".join([l for l in lines if "get_ipython" not in l])) - testerResult = _runTests(testFileName.split(".")[0], path, debugMode = debugMode, silentMode = silentMode) + testerResult = _runTests( + testFileName.split(".")[0], + testPath, + path, + debugMode=debugMode, + silentMode=silentMode + ) if path.endswith(".ipynb"): os.remove(path) @@ -95,12 +101,12 @@ def testModule(module: str, debugMode=False, silentMode=False) -> Optional[List[ return [test(testName, module=module, debugMode=debugMode, silentMode=silentMode) for testName in testNames] -def _runTests(moduleName: str, fileName: str, debugMode=False, silentMode=False) -> "TesterResult": +def _runTests(moduleName: str, testPath: pathlib.Path, fileName: str, debugMode=False, silentMode=False) -> "TesterResult": ctx = mp.get_context("spawn") signalQueue: "mp.Queue[_Signal]" = ctx.Queue() resultQueue: "mp.Queue[TesterResult]" = ctx.Queue() - tester = _Tester(moduleName, pathlib.Path(fileName), debugMode, silentMode, signalQueue, resultQueue) + tester = _Tester(moduleName, testPath, pathlib.Path(fileName), debugMode, silentMode, signalQueue, resultQueue) p = ctx.Process(target=tester.run, name="Tester") p.start() @@ -189,10 +195,11 @@ def __init__( self.timeout = timeout -class _Tester(object): +class _Tester: def __init__( self, moduleName: str, + testPath: pathlib.Path, filePath: pathlib.Path, debugMode: bool, silentMode: bool, @@ -200,6 +207,7 @@ def __init__( resultQueue: "mp.Queue[TesterResult]" ): self.moduleName = moduleName + self.testPath = testPath self.filePath = filePath.absolute() self.debugMode = debugMode self.silentMode = silentMode @@ -215,6 +223,7 @@ def run(self): warnings.simplefilter('always', DeprecationWarning) checkpy.file = self.filePath + checkpy.testPath = self.testPath # overwrite argv so that it seems the file was run directly sys.argv = [self.filePath.name] From a631cab3199b115a15ab5699376a723fe6acc521 Mon Sep 17 00:00:00 2001 From: Jelleas Date: Thu, 26 Oct 2023 21:38:37 +0200 Subject: [PATCH 249/269] typo --- checkpy/entities/function.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/checkpy/entities/function.py b/checkpy/entities/function.py index ed5ac11..27d480b 100644 --- a/checkpy/entities/function.py +++ b/checkpy/entities/function.py @@ -51,7 +51,7 @@ def __call__(self, *args, **kwargs) -> typing.Any: message = "while trying to execute {}({})".format(self.name, representation) else: argsRepr = ','.join(str(arg) for arg in args) - message = f"while trying to exectute {self.name}({argsRepr})" + message = f"while trying to execute {self.name}({argsRepr})" raise exception.SourceException(exception = e, message = message) finally: if oldIsFirstFunctionCalled: From 3139a72f720d2e1815a05ec87f937018ef324926 Mon Sep 17 00:00:00 2001 From: jelleas Date: Thu, 23 Nov 2023 12:03:42 +0100 Subject: [PATCH 250/269] bump to v2.0.9 --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index d7e5f1e..eafb1af 100644 --- a/setup.py +++ b/setup.py @@ -11,7 +11,7 @@ setup( name='checkPy', - version='2.0.8', + version='2.0.9', description='A simple python testing framework for educational purposes', long_description=long_description, From 3adccba8acbeba6a94ec2a7fde7871d5670860c7 Mon Sep 17 00:00:00 2001 From: jelleas Date: Thu, 23 Nov 2023 18:04:02 +0100 Subject: [PATCH 251/269] fix downloader.py not downloading anything but .py files --- checkpy/downloader/downloader.py | 20 +++++++++++--------- setup.py | 2 +- 2 files changed, 12 insertions(+), 10 deletions(-) diff --git a/checkpy/downloader/downloader.py b/checkpy/downloader/downloader.py index 1243757..b72f33d 100644 --- a/checkpy/downloader/downloader.py +++ b/checkpy/downloader/downloader.py @@ -154,23 +154,25 @@ def _download(githubUserName: str, githubRepoName: str): with zf.ZipFile(f) as z: destPath = database.githubPath(githubUserName, githubRepoName) - existingTests: Set[pathlib.Path] = set() + existingFiles: Set[pathlib.Path] = set() for path, subdirs, files in os.walk(destPath): for fil in files: - existingTests.add((pathlib.Path(path) / fil).relative_to(destPath)) + existingFiles.add((pathlib.Path(path) / fil).relative_to(destPath)) - newTests: Set[pathlib.Path] = set() + newFiles: Set[pathlib.Path] = set() for name in z.namelist(): - if name.endswith(".py"): - newTests.add(pathlib.Path(pathlib.Path(name).as_posix().split("tests/")[1])) + if name: + path: str = pathlib.Path(name).as_posix() + if "tests/" in path: + newFiles.add(pathlib.Path(path.split("tests/")[1])) - for filePath in [fp for fp in existingTests - newTests if fp]: + for filePath in [fp for fp in existingFiles - newFiles if fp.suffix == ".py"]: printer.displayRemoved(str(filePath)) - for filePath in [fp for fp in newTests - existingTests if fp.suffix == ".py"]: + for filePath in [fp for fp in newFiles - existingFiles if fp.suffix == ".py"]: printer.displayAdded(str(filePath)) - for filePath in existingTests - newTests: + for filePath in existingFiles - newFiles: (destPath / filePath).unlink() # remove file _extractTests(z, destPath) @@ -191,7 +193,7 @@ def _extractTest(zipfile: zf.ZipFile, path: pathlib.Path, destPath: pathlib.Path subfolderPath = pathlib.Path(path.as_posix().split("tests/")[1]) filePath = destPath / subfolderPath - if path.suffix == ".py": + if path.suffix: _extractFile(zipfile, path, filePath) elif subfolderPath and not filePath.exists(): os.makedirs(str(filePath)) diff --git a/setup.py b/setup.py index eafb1af..4753721 100644 --- a/setup.py +++ b/setup.py @@ -11,7 +11,7 @@ setup( name='checkPy', - version='2.0.9', + version='2.0.10', description='A simple python testing framework for educational purposes', long_description=long_description, From afc14b972ac50a1e599e229da6fa4af821316399 Mon Sep 17 00:00:00 2001 From: jelleas Date: Tue, 16 Jan 2024 11:47:59 +0100 Subject: [PATCH 252/269] fix include/require not overwriting exclude --- checkpy/lib/sandbox.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/checkpy/lib/sandbox.py b/checkpy/lib/sandbox.py index 20a346f..28c59f5 100644 --- a/checkpy/lib/sandbox.py +++ b/checkpy/lib/sandbox.py @@ -249,7 +249,7 @@ def sync(config: Config, sandboxDir: Path): except FileNotFoundError: pass - for f in config.includedFiles - oldExcluded: + for f in config.includedFiles - oldIncluded: dest = (sandboxDir / f).absolute() dest.parent.mkdir(parents=True, exist_ok=True) origin = (config.root / f).absolute() From bd0503196fef91e898437437153f087a154f6572 Mon Sep 17 00:00:00 2001 From: jelleas Date: Tue, 16 Jan 2024 14:14:20 +0100 Subject: [PATCH 253/269] catch MissingRequiredFiles from .require() --- checkpy/entities/exception.py | 3 ++- checkpy/tester/tester.py | 10 ++++++++-- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/checkpy/entities/exception.py b/checkpy/entities/exception.py index 69e4b26..1f93d31 100644 --- a/checkpy/entities/exception.py +++ b/checkpy/entities/exception.py @@ -51,4 +51,5 @@ class TooManyFilesError(CheckpyError): class MissingRequiredFiles(CheckpyError): def __init__(self, missingFiles: _typing.List[str]): - super().__init__(message=f"Missing the following required files: {', '.join(missingFiles)}") \ No newline at end of file + super().__init__(message=f"Missing the following required file{'s' if len(missingFiles) != 1 else ''}: {', '.join(missingFiles)}") + self.missingFiles = tuple(missingFiles) \ No newline at end of file diff --git a/checkpy/tester/tester.py b/checkpy/tester/tester.py index b97a25f..0a080ba 100644 --- a/checkpy/tester/tester.py +++ b/checkpy/tester/tester.py @@ -235,13 +235,19 @@ def run(self): dessert.util._reprcompare = explainCompare with sandbox(): - module = importlib.import_module(self.moduleName) + try: + module = importlib.import_module(self.moduleName) + except exception.MissingRequiredFiles as e: + result = TesterResult(self.filePath.name) + result.addOutput(printer.displayError(e)) + self._sendResult(result) + return module._fileName = self.filePath.name # type: ignore [attr-defined] self._runTestsFromModule(module) def _runTestsFromModule(self, module: ModuleType): - self._sendSignal(_Signal(isTiming = False)) + self._sendSignal(_Signal(isTiming=False)) result = TesterResult(self.filePath.name) result.addOutput(printer.displayTestName(self.filePath.name)) From f385c670761bef7d3ae8eeaa749bd783dff81336 Mon Sep 17 00:00:00 2001 From: jelleas Date: Tue, 16 Jan 2024 14:39:36 +0100 Subject: [PATCH 254/269] fix crash on downloading non utf-8 files --- checkpy/downloader/downloader.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/checkpy/downloader/downloader.py b/checkpy/downloader/downloader.py index b72f33d..cdeb3de 100644 --- a/checkpy/downloader/downloader.py +++ b/checkpy/downloader/downloader.py @@ -202,8 +202,15 @@ def _extractFile(zipfile: zf.ZipFile, path: pathlib.Path, filePath: pathlib.Path zipPathString = path.as_posix() if filePath.is_file(): with zipfile.open(zipPathString) as new, open(str(filePath), "r") as existing: - # read file, decode, strip trailing whitespace, remove carrier return - newText = ''.join(new.read().decode('utf-8').strip().splitlines()) + # read file and decode + try: + newText = new.read().decode('utf-8') + except UnicodeDecodeError: + # Skip any non utf-8 file + return + + # strip trailing whitespace, remove carrier return + newText = ''.join(newText.strip().splitlines()) existingText = ''.join(existing.read().strip().splitlines()) if newText != existingText: printer.displayUpdate(str(path)) From 3a9689d868c556b37177ed29266f9207dd165b3d Mon Sep 17 00:00:00 2001 From: jelleas Date: Fri, 2 Feb 2024 13:16:01 +0100 Subject: [PATCH 255/269] v2.0.11 --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 4753721..363c5c6 100644 --- a/setup.py +++ b/setup.py @@ -11,7 +11,7 @@ setup( name='checkPy', - version='2.0.10', + version='2.0.11', description='A simple python testing framework for educational purposes', long_description=long_description, From 2122db5cc782137c9198fb88f0cac942f62d0073 Mon Sep 17 00:00:00 2001 From: jelleas Date: Thu, 21 Mar 2024 14:39:49 +0100 Subject: [PATCH 256/269] v2.0.12, checkpy.interactive.testOffline --- checkpy/interactive.py | 68 ++++++++++++----- checkpy/tester/discovery.py | 14 +++- checkpy/tester/tester.py | 148 ++++++++++++++++++++++-------------- setup.py | 2 +- 4 files changed, 155 insertions(+), 77 deletions(-) diff --git a/checkpy/interactive.py b/checkpy/interactive.py index d7cb7cf..0e47b1f 100644 --- a/checkpy/interactive.py +++ b/checkpy/interactive.py @@ -1,36 +1,71 @@ -from checkpy.downloader import download, update +from checkpy.tester import TesterResult +from checkpy.tester import runTests as _runTests +from checkpy.tester import runTestsSynchronously as _runTestsSynchronously +from checkpy import caches as _caches +import checkpy.tester.discovery as _discovery +import pathlib as _pathlib +from typing import List, Optional -def testModule(moduleName, debugMode = False, silentMode = False): +__all__ = ["testModule", "test", "testOffline"] + + +def testModule(moduleName: str, debugMode=False, silentMode=False) -> Optional[List[TesterResult]]: """ Test all files from module """ - from . import caches - caches.clearAllCaches() + _caches.clearAllCaches() + from . import tester from . import downloader downloader.updateSilently() + results = tester.testModule(moduleName, debugMode = debugMode, silentMode = silentMode) - try: - if __IPYTHON__: # type: ignore [name-defined] - try: - import matplotlib.pyplot - matplotlib.pyplot.close("all") - except: - pass - except: - pass + + _closeAllMatplotlib() + return results -def test(fileName, debugMode = False, silentMode = False): +def test(fileName: str, debugMode=False, silentMode=False) -> TesterResult: """ Run tests for a single file """ - from . import caches - caches.clearAllCaches() + _caches.clearAllCaches() + from . import tester from . import downloader downloader.updateSilently() + result = tester.test(fileName, debugMode = debugMode, silentMode = silentMode) + + _closeAllMatplotlib() + + return result + +def testOffline(fileName: str, testPath: str | _pathlib.Path, multiprocessing=True, debugMode=False, silentMode=False) -> TesterResult: + """ + Run a test offline. + Takes in the name of file to be tested and an absolute path to the tests directory. + If multiprocessing is True (by default), runs all tests in a seperate process. All tests run in the same process otherwise. + """ + _caches.clearAllCaches() + + fileStem = fileName.split(".")[0] + filePath = _discovery.getPath(fileStem) + + testModuleName = f"{fileStem}Test" + testFileName = f"{fileStem}Test.py" + testPath = _discovery.getTestPathsFrom(testFileName, _pathlib.Path(testPath))[0] + + if multiprocessing: + result = _runTests(testModuleName, testPath, filePath, debugMode, silentMode) + else: + result = _runTestsSynchronously(testModuleName, testPath, filePath, debugMode, silentMode) + + _closeAllMatplotlib() + + return result + +def _closeAllMatplotlib(): try: if __IPYTHON__: # type: ignore [name-defined] try: @@ -40,4 +75,3 @@ def test(fileName, debugMode = False, silentMode = False): pass except: pass - return result \ No newline at end of file diff --git a/checkpy/tester/discovery.py b/checkpy/tester/discovery.py index b1b0870..2c89740 100644 --- a/checkpy/tester/discovery.py +++ b/checkpy/tester/discovery.py @@ -35,8 +35,14 @@ def getTestNames(moduleName: str) -> Optional[List[str]]: def getTestPaths(testFileName: str, module: str="") -> List[pathlib.Path]: testFilePaths: List[pathlib.Path] = [] - for testsPath in database.forEachTestsPath(): - for (dirPath, dirNames, fileNames) in os.walk(testsPath): - if testFileName in fileNames and (not module or module in dirPath): - testFilePaths.append(pathlib.Path(dirPath)) + for testPath in database.forEachTestsPath(): + testFilePaths.extend(getTestPathsFrom(testFileName, testPath, module=module)) return testFilePaths + +def getTestPathsFrom(testFileName: str, path: pathlib.Path, module: str="") -> List[pathlib.Path]: + """Get all testPaths from a tests folder (path).""" + testFilePaths: List[pathlib.Path] = [] + for (dirPath, _, fileNames) in os.walk(path): + if testFileName in fileNames and (not module or module in dirPath): + testFilePaths.append(pathlib.Path(dirPath)) + return testFilePaths \ No newline at end of file diff --git a/checkpy/tester/tester.py b/checkpy/tester/tester.py index 0a080ba..2fa6576 100644 --- a/checkpy/tester/tester.py +++ b/checkpy/tester/tester.py @@ -9,8 +9,10 @@ from types import ModuleType from typing import Dict, Iterable, List, Optional, Union +import contextlib import os import pathlib +import queue import subprocess import sys import importlib @@ -21,7 +23,7 @@ import multiprocessing as mp -__all__ = ["getActiveTest", "test", "testModule", "TesterResult"] +__all__ = ["getActiveTest", "test", "testModule", "TesterResult", "runTests", "runTestsSynchronously"] _activeTest: Optional[Test] = None @@ -45,9 +47,6 @@ def test(testName: str, module="", debugMode=False, silentMode=False) -> "Tester fileName = os.path.basename(path) filePath = os.path.dirname(path) - if filePath not in sys.path: - sys.path.append(filePath) - testFileName = fileName.split(".")[0] + "Test.py" testPaths = discovery.getTestPaths(testFileName, module=module) @@ -60,9 +59,6 @@ def test(testName: str, module="", debugMode=False, silentMode=False) -> "Tester testPath = testPaths[0] - if str(testPath) not in sys.path: - sys.path.append(str(testPath)) - if path.endswith(".ipynb"): if subprocess.call(['jupyter', 'nbconvert', '--to', 'script', path]) != 0: result.addOutput(printer.displayError("Failed to convert Jupyter notebook to .py")) @@ -76,13 +72,14 @@ def test(testName: str, module="", debugMode=False, silentMode=False) -> "Tester with open(path, "w") as f: f.write("".join([l for l in lines if "get_ipython" not in l])) - testerResult = _runTests( - testFileName.split(".")[0], - testPath, - path, - debugMode=debugMode, - silentMode=silentMode - ) + with _addToSysPath(filePath): + testerResult = runTests( + testFileName.split(".")[0], + testPath, + path, + debugMode=debugMode, + silentMode=silentMode + ) if path.endswith(".ipynb"): os.remove(path) @@ -101,56 +98,84 @@ def testModule(module: str, debugMode=False, silentMode=False) -> Optional[List[ return [test(testName, module=module, debugMode=debugMode, silentMode=silentMode) for testName in testNames] -def _runTests(moduleName: str, testPath: pathlib.Path, fileName: str, debugMode=False, silentMode=False) -> "TesterResult": - ctx = mp.get_context("spawn") - - signalQueue: "mp.Queue[_Signal]" = ctx.Queue() - resultQueue: "mp.Queue[TesterResult]" = ctx.Queue() - tester = _Tester(moduleName, testPath, pathlib.Path(fileName), debugMode, silentMode, signalQueue, resultQueue) - p = ctx.Process(target=tester.run, name="Tester") - p.start() - - start = time.time() - isTiming = False +def runTests(moduleName: str, testPath: pathlib.Path, fileName: str, debugMode=False, silentMode=False) -> "TesterResult": result: Optional[TesterResult] = None - while p.is_alive(): - while not signalQueue.empty(): - signal = signalQueue.get() - - if signal.description is not None: - description = signal.description - if signal.isTiming is not None: - isTiming = signal.isTiming - if signal.timeout is not None: - timeout = signal.timeout - if signal.resetTimer: - start = time.time() - - if isTiming and time.time() - start > timeout: - result = TesterResult(pathlib.Path(fileName).name) - result.addOutput(printer.displayError("Timeout ({} seconds) reached during: {}".format(timeout, description))) - p.terminate() - p.join() - return result + with _addToSysPath(testPath): + ctx = mp.get_context("spawn") + + signalQueue: "mp.Queue[_Signal]" = ctx.Queue() + resultQueue: "mp.Queue[TesterResult]" = ctx.Queue() + tester = _Tester(moduleName, testPath, pathlib.Path(fileName), debugMode, silentMode, signalQueue, resultQueue) + p = ctx.Process(target=tester.run, name="Tester") + p.start() + + start = time.time() + isTiming = False + + while p.is_alive(): + while not signalQueue.empty(): + signal = signalQueue.get() + + if signal.description is not None: + description = signal.description + if signal.isTiming is not None: + isTiming = signal.isTiming + if signal.timeout is not None: + timeout = signal.timeout + if signal.resetTimer: + start = time.time() + + if isTiming and time.time() - start > timeout: + result = TesterResult(pathlib.Path(fileName).name) + result.addOutput(printer.displayError("Timeout ({} seconds) reached during: {}".format(timeout, description))) + p.terminate() + p.join() + return result + + if not resultQueue.empty(): + # .get before .join to prevent hanging indefinitely due to a full pipe + # https://bugs.python.org/issue8426 + result = resultQueue.get() + p.terminate() + p.join() + break + + time.sleep(0.1) if not resultQueue.empty(): - # .get before .join to prevent hanging indefinitely due to a full pipe - # https://bugs.python.org/issue8426 result = resultQueue.get() - p.terminate() - p.join() - break - time.sleep(0.1) + if result is None: + raise exception.CheckpyError(message="An error occured while testing. The testing process exited unexpectedly.") - if not resultQueue.empty(): - result = resultQueue.get() + return result - if result is None: - raise exception.CheckpyError(message="An error occured while testing. The testing process exited unexpectedly.") - return result +def runTestsSynchronously(moduleName: str, testPath: pathlib.Path, fileName: str, debugMode=False, silentMode=False) -> "TesterResult": + signalQueue = queue.Queue() + resultQueue = queue.Queue() + + tester = _Tester( + moduleName=moduleName, + testPath=testPath, + filePath=pathlib.Path(fileName), + debugMode=debugMode, + silentMode=silentMode, + signalQueue=signalQueue, + resultQueue=resultQueue + ) + + with _addToSysPath(testPath): + try: + old_debug_mode = printer.printer.DEBUG_MODE + old_silent_mode = printer.printer.SILENT_MODE + tester.run() + finally: + printer.printer.DEBUG_MODE = old_debug_mode + printer.printer.SILENT_MODE = old_silent_mode + + return resultQueue.get() class TesterResult(object): @@ -340,3 +365,16 @@ def _getTestFunctionsInExecutionOrder(self, testFunctions: Iterable[TestFunction dependencies = self._getTestFunctionsInExecutionOrder(tf.dependencies) + [tf] sortedTFs.extend([t for t in dependencies if t not in sortedTFs]) return sortedTFs + +@contextlib.contextmanager +def _addToSysPath(path: str): + addedToPath = False + path = str(path) + try: + if path not in sys.path: + addedToPath = True + sys.path.append(path) + yield + finally: + if addedToPath and path in sys.path: + sys.path.remove(path) \ No newline at end of file diff --git a/setup.py b/setup.py index 363c5c6..246cea9 100644 --- a/setup.py +++ b/setup.py @@ -11,7 +11,7 @@ setup( name='checkPy', - version='2.0.11', + version='2.0.12', description='A simple python testing framework for educational purposes', long_description=long_description, From 5663af79c40807d4c2d48019fc319a05a06fd5af Mon Sep 17 00:00:00 2001 From: Martijn Stegeman Date: Mon, 14 Oct 2024 19:54:32 +0200 Subject: [PATCH 257/269] Use importlib.metadata instead of pkg_resources.get_distribution (deprecated) --- checkpy/__main__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/checkpy/__main__.py b/checkpy/__main__.py index 8e13f99..09c05e7 100644 --- a/checkpy/__main__.py +++ b/checkpy/__main__.py @@ -5,7 +5,7 @@ from checkpy import tester from checkpy import printer import json -import pkg_resources +import importlib.metadata import warnings @@ -18,7 +18,7 @@ def main(): checkPy: a python testing framework for education. You are running Python version {}.{}.{} and checkpy version {}. """ - .format(sys.version_info[0], sys.version_info[1], sys.version_info[2], pkg_resources.get_distribution("checkpy").version) + .format(sys.version_info[0], sys.version_info[1], sys.version_info[2], importlib.metadata.version("checkpy")) ) parser.add_argument("-module", action="store", dest="module", help="provide a module name or path to run all tests from the module, or target a module for a specific test") From 5e5c9d591e3d7a488a937098334aeb719704c636 Mon Sep 17 00:00:00 2001 From: Martijn Stegeman Date: Mon, 14 Oct 2024 19:58:14 +0200 Subject: [PATCH 258/269] Add double escape to string inside docstring --- checkpy/lib/basic.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/checkpy/lib/basic.py b/checkpy/lib/basic.py index 0e94949..81c403e 100644 --- a/checkpy/lib/basic.py +++ b/checkpy/lib/basic.py @@ -232,7 +232,7 @@ def input(prompt=None): def removeWhiteSpace(s): warn("""checkpy.lib.removeWhiteSpace() is deprecated. Instead use: import re - re.sub(r"\s+", "", text) + re.sub(r"\\s+", "", text) """, DeprecationWarning, stacklevel=2) return re.sub(r"\s+", "", s, flags=re.UNICODE) @@ -240,7 +240,7 @@ def removeWhiteSpace(s): def getPositiveIntegersFromString(s): warn("""checkpy.lib.getPositiveIntegersFromString() is deprecated. Instead use: import re - [int(i) for i in re.findall(r"\d+", text)] + [int(i) for i in re.findall(r"\\d+", text)] """, DeprecationWarning, stacklevel=2) return [int(i) for i in re.findall(r"\d+", s)] From db699c5fcadcb656b009e0dee6639fb8a5a18d7b Mon Sep 17 00:00:00 2001 From: jelleas Date: Thu, 17 Oct 2024 12:10:53 +0200 Subject: [PATCH 259/269] v2.0.13 replace re.match with re.search in declarative.function.stdoutRegex --- checkpy/lib/declarative.py | 3 ++- checkpy/lib/static.py | 2 +- setup.py | 2 +- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/checkpy/lib/declarative.py b/checkpy/lib/declarative.py index d4892e1..c2ddee0 100644 --- a/checkpy/lib/declarative.py +++ b/checkpy/lib/declarative.py @@ -163,6 +163,7 @@ def stdoutRegex(self, regex: Union[str, re.Pattern], readable: Optional[str]=Non """ Assert that the last call printed output matching regex. If readable is passed, show that instead of the regex in the test's output. + Uses built-in re.search underneath the hood to find the first match. """ def testStdoutRegex(state: FunctionState): nonlocal regex @@ -176,7 +177,7 @@ def testStdoutRegex(state: FunctionState): actual = state.function.printOutput - match = regex.match(actual) + match = regex.search(actual) if not match: if readable: raise AssertionError(f"The printed output does not match the expected output. This is expected:\n" diff --git a/checkpy/lib/static.py b/checkpy/lib/static.py index f6758ce..08b303c 100644 --- a/checkpy/lib/static.py +++ b/checkpy/lib/static.py @@ -29,7 +29,7 @@ def getSource(fileName: _Optional[_Union[str, _Path]]=None) -> str: if fileName is None: if _checkpy.file is None: raise _exception.CheckpyError( - message=f"Cannot call getSource() without passing fileName as argument if not test is running." + message=f"Cannot call getSource() without passing fileName as argument if no test is running." ) fileName = _checkpy.file.name diff --git a/setup.py b/setup.py index 246cea9..ff9916a 100644 --- a/setup.py +++ b/setup.py @@ -11,7 +11,7 @@ setup( name='checkPy', - version='2.0.12', + version='2.0.13', description='A simple python testing framework for educational purposes', long_description=long_description, From e5533f7b5fd7bda6bb334736181b60dd56bb5c68 Mon Sep 17 00:00:00 2001 From: jelleas Date: Mon, 17 Feb 2025 11:19:55 +0100 Subject: [PATCH 260/269] 2.0.14 --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index ff9916a..ff5bfa8 100644 --- a/setup.py +++ b/setup.py @@ -11,7 +11,7 @@ setup( name='checkPy', - version='2.0.13', + version='2.0.14', description='A simple python testing framework for educational purposes', long_description=long_description, From c9ffcc61bc920ed48fd5ad915ccc742375e3247b Mon Sep 17 00:00:00 2001 From: jelleas Date: Fri, 25 Jul 2025 15:08:00 +0200 Subject: [PATCH 261/269] checkpy.context --- checkpy/__init__.py | 16 +++++++++- checkpy/__main__.py | 15 +++++++--- checkpy/interactive.py | 35 ++++++++++++++++++---- checkpy/printer/printer.py | 22 +++++++------- checkpy/tester/tester.py | 61 ++++++++++++++++++++++---------------- 5 files changed, 100 insertions(+), 49 deletions(-) diff --git a/checkpy/__init__.py b/checkpy/__init__.py index 9f8b47b..bac2014 100644 --- a/checkpy/__init__.py +++ b/checkpy/__init__.py @@ -51,4 +51,18 @@ file: _typing.Optional[_pathlib.Path] = None # Path to the tests directory -testPath: _typing.Optional[_pathlib.Path] = None \ No newline at end of file +testPath: _typing.Optional[_pathlib.Path] = None + +class _Context: + def __init__(self, debug=False, json=False, silent=False): + self.debug = debug + self.json = json + self.silent = silent + + def __reduce__(self): + return ( + _Context, + (self.debug, self.json, self.silent) + ) + +context = _Context() \ No newline at end of file diff --git a/checkpy/__main__.py b/checkpy/__main__.py index 09c05e7..6158aa4 100644 --- a/checkpy/__main__.py +++ b/checkpy/__main__.py @@ -1,9 +1,11 @@ import sys import os import argparse +from checkpy import context from checkpy import downloader from checkpy import tester from checkpy import printer +from checkpy.tester import TesterResult import json import importlib.metadata import warnings @@ -69,16 +71,21 @@ def main(): if args.json: args.silent = True + context.silent = True + context.json = True + + if args.dev: + context.debug = True if args.files: downloader.updateSilently() - results = [] + results: list[TesterResult] = [] for f in args.files: if args.module: - result = tester.test(f, module=args.module, debugMode=args.dev, silentMode=args.silent) + result = tester.test(f, module=args.module) else: - result = tester.test(f, debugMode=args.dev, silentMode=args.silent) + result = tester.test(f) results.append(result) if args.json: @@ -87,7 +94,7 @@ def main(): if args.module: downloader.updateSilently() - moduleResults = tester.testModule(args.module, debugMode=args.dev, silentMode=args.silent) + moduleResults = tester.testModule(args.module) if args.json: if moduleResults is None: diff --git a/checkpy/interactive.py b/checkpy/interactive.py index 0e47b1f..c2b0ea2 100644 --- a/checkpy/interactive.py +++ b/checkpy/interactive.py @@ -2,6 +2,8 @@ from checkpy.tester import runTests as _runTests from checkpy.tester import runTestsSynchronously as _runTestsSynchronously from checkpy import caches as _caches +import checkpy +import copy import checkpy.tester.discovery as _discovery import pathlib as _pathlib from typing import List, Optional @@ -19,7 +21,14 @@ def testModule(moduleName: str, debugMode=False, silentMode=False) -> Optional[L from . import downloader downloader.updateSilently() - results = tester.testModule(moduleName, debugMode = debugMode, silentMode = silentMode) + try: + oldContext = copy.copy(checkpy.context) + checkpy.context.silent = silentMode + checkpy.context.debug = debugMode + + results = tester.testModule(moduleName) + finally: + checkpy.context = oldContext _closeAllMatplotlib() @@ -35,7 +44,14 @@ def test(fileName: str, debugMode=False, silentMode=False) -> TesterResult: from . import downloader downloader.updateSilently() - result = tester.test(fileName, debugMode = debugMode, silentMode = silentMode) + try: + oldContext = copy.copy(checkpy.context) + checkpy.context.silent = silentMode + checkpy.context.debug = debugMode + + result = tester.test(fileName) + finally: + checkpy.context = oldContext _closeAllMatplotlib() @@ -56,10 +72,17 @@ def testOffline(fileName: str, testPath: str | _pathlib.Path, multiprocessing=Tr testFileName = f"{fileStem}Test.py" testPath = _discovery.getTestPathsFrom(testFileName, _pathlib.Path(testPath))[0] - if multiprocessing: - result = _runTests(testModuleName, testPath, filePath, debugMode, silentMode) - else: - result = _runTestsSynchronously(testModuleName, testPath, filePath, debugMode, silentMode) + try: + oldContext = copy.copy(checkpy.context) + checkpy.context.silent = silentMode + checkpy.context.debug = debugMode + + if multiprocessing: + result = _runTests(testModuleName, testPath, filePath) + else: + result = _runTestsSynchronously(testModuleName, testPath, filePath) + finally: + checkpy.context = oldContext _closeAllMatplotlib() diff --git a/checkpy/printer/printer.py b/checkpy/printer/printer.py index 81cc43e..a740497 100644 --- a/checkpy/printer/printer.py +++ b/checkpy/printer/printer.py @@ -5,12 +5,10 @@ import colorama colorama.init() +import checkpy from checkpy.entities import exception import checkpy.tests -DEBUG_MODE = False -SILENT_MODE = False - class _Colors: PASS = '\033[92m' WARNING = '\033[93m' @@ -30,55 +28,55 @@ def display(testResult: checkpy.tests.TestResult) -> str: if testResult.message: msg += "\n " + "\n ".join(testResult.message.split("\n")) - if DEBUG_MODE and testResult.exception: + if checkpy.context.debug and testResult.exception: exc = testResult.exception if hasattr(exc, "stacktrace"): stack = str(exc.stacktrace()) else: stack = "".join(traceback.format_tb(testResult.exception.__traceback__)) msg += "\n" + stack - if not SILENT_MODE: + if not checkpy.context.silent: print(msg) return msg def displayTestName(testName: str) -> str: msg = "{}Testing: {}{}".format(_Colors.NAME, testName, _Colors.ENDC) - if not SILENT_MODE: + if not checkpy.context.silent: print(msg) return msg def displayUpdate(fileName: str) -> str: msg = "{}Updated: {}{}".format(_Colors.WARNING, os.path.basename(fileName), _Colors.ENDC) - if not SILENT_MODE: + if not checkpy.context.silent: print(msg) return msg def displayRemoved(fileName: str) -> str: msg = "{}Removed: {}{}".format(_Colors.WARNING, os.path.basename(fileName), _Colors.ENDC) - if not SILENT_MODE: + if not checkpy.context.silent: print(msg) return msg def displayAdded(fileName: str) -> str: msg = "{}Added: {}{}".format(_Colors.WARNING, os.path.basename(fileName), _Colors.ENDC) - if not SILENT_MODE: + if not checkpy.context.silent: print(msg) return msg def displayCustom(message: str) -> str: - if not SILENT_MODE: + if not checkpy.context.silent: print(message) return message def displayWarning(message: str) -> str: msg = "{}Warning: {}{}".format(_Colors.WARNING, message, _Colors.ENDC) - if not SILENT_MODE: + if not checkpy.context.silent: print(msg) return msg def displayError(message: str) -> str: msg = "{}{} {}{}".format(_Colors.WARNING, _Smileys.CONFUSED, message, _Colors.ENDC) - if not SILENT_MODE: + if not checkpy.context.silent: print(msg) return msg diff --git a/checkpy/tester/tester.py b/checkpy/tester/tester.py index 2fa6576..5c6d6cd 100644 --- a/checkpy/tester/tester.py +++ b/checkpy/tester/tester.py @@ -9,6 +9,7 @@ from types import ModuleType from typing import Dict, Iterable, List, Optional, Union +import copy import contextlib import os import pathlib @@ -33,8 +34,17 @@ def getActiveTest() -> Optional[Test]: return _activeTest -def test(testName: str, module="", debugMode=False, silentMode=False) -> "TesterResult": - printer.printer.SILENT_MODE = silentMode +def test( + testName: str, + module="", + debugMode: Union[bool, None]=None, + silentMode: Union[bool, None]=None + ) -> "TesterResult": + if debugMode is not None: + checkpy.context.debug = debugMode + + if silentMode is not None: + checkpy.context.silent = silentMode result = TesterResult(testName) @@ -76,9 +86,7 @@ def test(testName: str, module="", debugMode=False, silentMode=False) -> "Tester testerResult = runTests( testFileName.split(".")[0], testPath, - path, - debugMode=debugMode, - silentMode=silentMode + path ) if path.endswith(".ipynb"): @@ -88,17 +96,26 @@ def test(testName: str, module="", debugMode=False, silentMode=False) -> "Tester return testerResult -def testModule(module: str, debugMode=False, silentMode=False) -> Optional[List["TesterResult"]]: - printer.printer.SILENT_MODE = silentMode +def testModule( + module: str, + debugMode: Union[bool, None]=None, + silentMode: Union[bool, None]=None + ) -> Optional[List["TesterResult"]]: + if debugMode is not None: + checkpy.context.debug = debugMode + + if silentMode is not None: + checkpy.context.silent = silentMode + testNames = discovery.getTestNames(module) if not testNames: printer.displayError("no tests found in module: {}".format(module)) return None - return [test(testName, module=module, debugMode=debugMode, silentMode=silentMode) for testName in testNames] + return [test(testName, module=module) for testName in testNames] -def runTests(moduleName: str, testPath: pathlib.Path, fileName: str, debugMode=False, silentMode=False) -> "TesterResult": +def runTests(moduleName: str, testPath: pathlib.Path, fileName: str) -> "TesterResult": result: Optional[TesterResult] = None with _addToSysPath(testPath): @@ -106,7 +123,7 @@ def runTests(moduleName: str, testPath: pathlib.Path, fileName: str, debugMode=F signalQueue: "mp.Queue[_Signal]" = ctx.Queue() resultQueue: "mp.Queue[TesterResult]" = ctx.Queue() - tester = _Tester(moduleName, testPath, pathlib.Path(fileName), debugMode, silentMode, signalQueue, resultQueue) + tester = _Tester(moduleName, testPath, pathlib.Path(fileName), signalQueue, resultQueue) p = ctx.Process(target=tester.run, name="Tester") p.start() @@ -152,7 +169,7 @@ def runTests(moduleName: str, testPath: pathlib.Path, fileName: str, debugMode=F return result -def runTestsSynchronously(moduleName: str, testPath: pathlib.Path, fileName: str, debugMode=False, silentMode=False) -> "TesterResult": +def runTestsSynchronously(moduleName: str, testPath: pathlib.Path, fileName: str) -> "TesterResult": signalQueue = queue.Queue() resultQueue = queue.Queue() @@ -160,25 +177,21 @@ def runTestsSynchronously(moduleName: str, testPath: pathlib.Path, fileName: str moduleName=moduleName, testPath=testPath, filePath=pathlib.Path(fileName), - debugMode=debugMode, - silentMode=silentMode, signalQueue=signalQueue, resultQueue=resultQueue ) with _addToSysPath(testPath): try: - old_debug_mode = printer.printer.DEBUG_MODE - old_silent_mode = printer.printer.SILENT_MODE + old_context = copy.copy(checkpy.context) tester.run() finally: - printer.printer.DEBUG_MODE = old_debug_mode - printer.printer.SILENT_MODE = old_silent_mode + checkpy.context = old_context return resultQueue.get() -class TesterResult(object): +class TesterResult: def __init__(self, name: str): self.name = name self.nTests = 0 @@ -206,7 +219,7 @@ def asDict(self) -> Dict[str, Union[str, int, List]]: } -class _Signal(object): +class _Signal: def __init__( self, isTiming: Optional[bool]=None, @@ -226,25 +239,21 @@ def __init__( moduleName: str, testPath: pathlib.Path, filePath: pathlib.Path, - debugMode: bool, - silentMode: bool, signalQueue: "mp.Queue[_Signal]", resultQueue: "mp.Queue[TesterResult]" ): self.moduleName = moduleName self.testPath = testPath self.filePath = filePath.absolute() - self.debugMode = debugMode - self.silentMode = silentMode self.signalQueue = signalQueue self.resultQueue = resultQueue + self._context = checkpy.context def run(self): - printer.printer.DEBUG_MODE = self.debugMode - printer.printer.SILENT_MODE = self.silentMode + checkpy.context = self._context warnings.filterwarnings("ignore") - if self.debugMode: + if checkpy.context.debug: warnings.simplefilter('always', DeprecationWarning) checkpy.file = self.filePath From 1cf4c3656ba18809664f5d1b2a03913e78776bff Mon Sep 17 00:00:00 2001 From: jelleas Date: Fri, 25 Jul 2025 17:17:53 +0200 Subject: [PATCH 262/269] start on checkpy.lib.io --- checkpy/__init__.py | 5 +- checkpy/entities/function.py | 75 +++------------------- checkpy/lib/__init__.py | 2 + checkpy/lib/basic.py | 75 +++++----------------- checkpy/lib/io.py | 118 +++++++++++++++++++++++++++++++++++ checkpy/tester/tester.py | 6 +- checkpy/tests.py | 41 ++++++++---- 7 files changed, 180 insertions(+), 142 deletions(-) create mode 100644 checkpy/lib/io.py diff --git a/checkpy/__init__.py b/checkpy/__init__.py index bac2014..435c1a6 100644 --- a/checkpy/__init__.py +++ b/checkpy/__init__.py @@ -54,15 +54,16 @@ testPath: _typing.Optional[_pathlib.Path] = None class _Context: - def __init__(self, debug=False, json=False, silent=False): + def __init__(self, debug=False, json=False, silent=False, stdoutLimit=1000): self.debug = debug self.json = json self.silent = silent + self.stdoutLimit = stdoutLimit def __reduce__(self): return ( _Context, - (self.debug, self.json, self.silent) + (self.debug, self.json, self.silent, self.stdoutLimit) ) context = _Context() \ No newline at end of file diff --git a/checkpy/entities/function.py b/checkpy/entities/function.py index 27d480b..e263ffb 100644 --- a/checkpy/entities/function.py +++ b/checkpy/entities/function.py @@ -6,30 +6,23 @@ import typing import checkpy.entities.exception as exception +import checkpy.lib +import checkpy.lib.io class Function: - _isFirstFunctionCalled = True - def __init__(self, function: typing.Callable): self._function = function self._printOutput = "" def __call__(self, *args, **kwargs) -> typing.Any: - oldStdout = sys.stdout - oldStderr = sys.stderr - oldIsFirstFunctionCalled = Function._isFirstFunctionCalled - try: - # iff this Function is the first one called, capture stdout - if Function._isFirstFunctionCalled: - Function._isFirstFunctionCalled = False - _outStreamListener.content = "" - sys.stdout = _outStreamListener.stream - sys.stderr = _devnull + with checkpy.lib.io.captureStdout() as _outStreamListener: + outcome = self._function(*args, **kwargs) + + self._printOutput = _outStreamListener.content + checkpy.lib.addOutput(self._printOutput) - outcome = self._function(*args, **kwargs) - self._printOutput = _outStreamListener.content return outcome except Exception as e: if isinstance(e,TypeError): @@ -53,11 +46,6 @@ def __call__(self, *args, **kwargs) -> typing.Any: argsRepr = ','.join(str(arg) for arg in args) message = f"while trying to execute {self.name}({argsRepr})" raise exception.SourceException(exception = e, message = message) - finally: - if oldIsFirstFunctionCalled: - Function._isFirstFunctionCalled = True - sys.stderr = oldStderr - sys.stdout = oldStdout @property def name(self) -> str: @@ -81,52 +69,3 @@ def printOutput(self) -> str: def __repr__(self): return self._function.__name__ - -class _Stream(io.StringIO): - def __init__(self, *args, **kwargs): - io.StringIO.__init__(self, *args, **kwargs) - self._listeners: typing.List["_StreamListener"] = [] - - def register(self, listener: "_StreamListener"): - self._listeners.append(listener) - - def unregister(self, listener: "_StreamListener"): - self._listeners.remove(listener) - - def write(self, text: str): - """Overwrites StringIO.write to update all listeners""" - io.StringIO.write(self, text) - self._onUpdate(text) - - def writelines(self, lines: typing.Iterable): - """Overwrites StringIO.writelines to update all listeners""" - io.StringIO.writelines(self, lines) - for line in lines: - self._onUpdate(line) - - def _onUpdate(self, content: str): - for listener in self._listeners: - listener.update(content) - -class _StreamListener: - def __init__(self, stream: _Stream): - self._stream = stream - self.content = "" - - def start(self): - self.stream.register(self) - - def stop(self): - self.stream.unregister(self) - - def update(self, content: str): - self.content += content - - @property - def stream(self) -> _Stream: - return self._stream - -_outStream = _Stream() -_outStreamListener = _StreamListener(_outStream) -_outStreamListener.start() -_devnull = open(os.devnull) \ No newline at end of file diff --git a/checkpy/lib/__init__.py b/checkpy/lib/__init__.py index f65f5ef..cca6163 100644 --- a/checkpy/lib/__init__.py +++ b/checkpy/lib/__init__.py @@ -1,4 +1,5 @@ from checkpy.lib.basic import * +from checkpy.lib.io import * from checkpy.lib.static import getSource from checkpy.lib.static import getSourceOfDefinitions from checkpy.lib.static import removeComments @@ -14,6 +15,7 @@ from checkpy.lib.basic import getNumbersFromString from checkpy.lib.basic import getLine from checkpy.lib.basic import fileExists +from checkpy.lib.basic import addOutput from checkpy.lib.sandbox import download from checkpy.lib.basic import require diff --git a/checkpy/lib/basic.py b/checkpy/lib/basic.py index 81c403e..cb15be2 100644 --- a/checkpy/lib/basic.py +++ b/checkpy/lib/basic.py @@ -1,5 +1,4 @@ import contextlib -import io import os import pathlib import re @@ -13,18 +12,17 @@ from warnings import warn import checkpy +import checkpy.tester from checkpy.entities import path, exception, function from checkpy import caches from checkpy.lib.static import getSource - +import checkpy.lib.io __all__ = [ "getFunction", "getModule", "outputOf", "getModuleAndOutputOf", - "captureStdin", - "captureStdout" ] @@ -121,12 +119,12 @@ def getModuleAndOutputOf( output = "" excep = None - with captureStdout() as stdout, captureStdin() as stdin: + with checkpy.lib.io.captureStdout() as stdoutListener: # fill stdin with args if stdinArgs: for arg in stdinArgs: - stdin.write(str(arg) + "\n") - stdin.seek(0) + sys.stdin.write(str(arg) + "\n") + sys.stdin.seek(0) # if argv given, overwrite sys.argv if argv: @@ -156,12 +154,12 @@ def getModuleAndOutputOf( excep = exception.SourceException( exception = e, message = "while trying to import the code", - output = stdout.getvalue(), + output = stdoutListener.content, stacktrace = traceback.format_exc()) except SystemExit as e: excep = exception.ExitError( message = "exit({}) while trying to import the code".format(int(e.args[0])), - output = stdout.getvalue(), + output = stdoutListener.content, stacktrace = traceback.format_exc()) # wrap every function in mod with Function @@ -173,60 +171,21 @@ def getModuleAndOutputOf( if argv: sys.argv = argv - output = stdout.getvalue() + output = stdoutListener.content if excep: raise excep return mod, output -@contextlib.contextmanager -def captureStdout(stdout: Optional[TextIO]=None): - old_stdout = sys.stdout - old_stderr = sys.stderr - - if stdout is None: - stdout = io.StringIO() - - try: - sys.stdout = stdout - sys.stderr = open(os.devnull) - yield stdout - except: - raise - finally: - sys.stderr.close() - sys.stdout = old_stdout - sys.stderr = old_stderr - - -@contextlib.contextmanager -def captureStdin(stdin: Optional[TextIO]=None): - def newInput(oldInput): - def input(prompt=None): - try: - return oldInput() - except EOFError: - e = exception.InputError( - message="You requested too much user input", - stacktrace=traceback.format_exc()) - raise e - return input - - oldInput = input - __builtins__["input"] = newInput(oldInput) - old = sys.stdin - if stdin is None: - stdin = io.StringIO() - sys.stdin = stdin - - try: - yield stdin - except: - raise - finally: - sys.stdin = old - __builtins__["input"] = oldInput +def addOutput(output: str): + """ + Add output to the active test's output. + If no active test is found, this function does nothing. + """ + test = checkpy.tester.getActiveTest() + if test is not None: + test.addOutput(output) def removeWhiteSpace(s): @@ -292,4 +251,4 @@ def require(fileName, source=None): if not fileExists(str(filePath)): raise exception.CheckpyError(message="Required file {} does not exist".format(fileName)) - shutil.copyfile(filePath, pathlib.Path.cwd() / fileName) \ No newline at end of file + shutil.copyfile(filePath, pathlib.Path.cwd() / fileName) diff --git a/checkpy/lib/io.py b/checkpy/lib/io.py new file mode 100644 index 0000000..a925429 --- /dev/null +++ b/checkpy/lib/io.py @@ -0,0 +1,118 @@ +import contextlib +import io +import os +import sys +import traceback +import typing + +import checkpy.entities.exception as exception + +__all__ = [ + # "captureStdin", + "captureStdout" +] + +@contextlib.contextmanager +def captureStdout() -> typing.Generator["_StreamListener", None, None]: + listener = _StreamListener(sys.stdout) + try: + listener.start() + yield listener + except: + raise + finally: + listener.stop() + + +@contextlib.contextmanager +def replaceStdout() -> typing.Generator["_Stream", None, None]: + old_stdout = sys.stdout + old_stderr = sys.stderr + + stdout = _Stream() + + try: + sys.stdout = stdout + sys.stderr = open(os.devnull) + yield stdout + except: + raise + finally: + sys.stderr.close() + sys.stdout = old_stdout + sys.stderr = old_stderr + + +@contextlib.contextmanager +def replaceStdin() -> typing.Generator[typing.TextIO, None, None]: + def newInput(oldInput): + def input(prompt=None): + try: + return oldInput() + except EOFError: + e = exception.InputError( + message="You requested too much user input", + stacktrace=traceback.format_exc()) + raise e + return input + + oldInput = input + old = sys.stdin + + stdin = io.StringIO() + + try: + __builtins__["input"] = newInput(oldInput) + sys.stdin = stdin + yield stdin + except: + raise + finally: + sys.stdin = old + __builtins__["input"] = oldInput + + +class _Stream(io.StringIO): + def __init__(self, *args, **kwargs): + io.StringIO.__init__(self, *args, **kwargs) + self._listeners: typing.List["_StreamListener"] = [] + + def register(self, listener: "_StreamListener"): + self._listeners.append(listener) + + def unregister(self, listener: "_StreamListener"): + self._listeners.remove(listener) + + def write(self, text: str): + """Overwrites StringIO.write to update all listeners""" + io.StringIO.write(self, text) + self._onUpdate(text) + + def writelines(self, lines: typing.Iterable): + """Overwrites StringIO.writelines to update all listeners""" + io.StringIO.writelines(self, lines) + for line in lines: + self._onUpdate(line) + + def _onUpdate(self, content: str): + for listener in self._listeners: + listener.update(content) + + +class _StreamListener: + def __init__(self, stream: _Stream): + self._stream = stream + self.content = "" + + def start(self): + self.stream.register(self) + + def stop(self): + self.stream.unregister(self) + + def update(self, content: str): + self.content += content + + @property + def stream(self) -> _Stream: + return self._stream \ No newline at end of file diff --git a/checkpy/tester/tester.py b/checkpy/tester/tester.py index 5c6d6cd..f42cdcd 100644 --- a/checkpy/tester/tester.py +++ b/checkpy/tester/tester.py @@ -5,6 +5,7 @@ from checkpy.lib.sandbox import sandbox from checkpy.lib.explanation import explainCompare from checkpy.tests import Test, TestResult, TestFunction +import checkpy.lib.io from types import ModuleType from typing import Dict, Iterable, List, Optional, Union @@ -331,7 +332,7 @@ def handleTimeoutChange(test: Test): global _activeTest - # run tests in noncolliding execution order + # run tests in non-colliding execution order for testFunction in self._getTestFunctionsInExecutionOrder(testFunctions): test = Test( self.filePath.name, @@ -352,7 +353,8 @@ def handleTimeoutChange(test: Test): timeout=test.timeout )) - cachedResults[test] = run() + with checkpy.lib.io.replaceStdout() as stdout, checkpy.lib.io.replaceStdin() as stdin: + cachedResults[test] = run() _activeTest = None diff --git a/checkpy/tests.py b/checkpy/tests.py index cfe6696..7067e46 100644 --- a/checkpy/tests.py +++ b/checkpy/tests.py @@ -48,12 +48,12 @@ class Test: PLACEHOLDER_DESCRIPTION = "placeholder test description" def __init__(self, - fileName: str, - priority: int, - timeout: Optional[int]=None, - onDescriptionChange: Callable[["Test"], None]=lambda self: None, - onTimeoutChange: Callable[["Test"], None]=lambda self: None - ): + fileName: str, + priority: int, + timeout: Optional[int]=None, + onDescriptionChange: Callable[["Test"], None]=lambda self: None, + onTimeoutChange: Callable[["Test"], None]=lambda self: None + ): self._fileName = fileName self._priority = priority @@ -63,6 +63,8 @@ def __init__(self, self._description = Test.PLACEHOLDER_DESCRIPTION self._timeout = Test.DEFAULT_TIMEOUT if timeout is None else timeout + self._output: list[str] = [] + def __lt__(self, other): return self._priority < other._priority @@ -116,6 +118,13 @@ def timeout(self, new_timeout: Union[int, Callable[[], int]]): self._onTimeoutChange(self) + @property + def output(self) -> str: + return "\n".join(self._output) + + def addOutput(self, output: str) -> None: + self._output.append(output) + def __setattr__(self, __name: str, __value: Any) -> None: value = __value if __name in ["fail", "success", "exception"]: @@ -130,12 +139,14 @@ def __init__( hasPassed: Optional[bool], description: str, message: str, + output: str, exception: Optional[Exception]=None ): self._hasPassed = hasPassed self._description = description self._message = message self._exception = exception + self._output = output @property def description(self): @@ -149,6 +160,10 @@ def message(self): def hasPassed(self): return self._hasPassed + @property + def output(self): + return self._output + @property def exception(self): return self._exception @@ -158,7 +173,8 @@ def asDict(self) -> Dict[str, Union[bool, None, str]]: "passed": self.hasPassed, "description": str(self.description), "message": str(self.message), - "exception": str(self.exception) + "exception": str(self.exception), + "output": str(self.output) } @@ -212,21 +228,21 @@ def runMethod(): failMsg += "\n" msg = failMsg + assertMsg - return TestResult(False, test.description, msg) + return TestResult(False, test.description, msg, test.output) except exception.CheckpyError as e: - return TestResult(False, test.description, str(test.exception(e)), exception=e) + return TestResult(False, test.description, str(test.exception(e)), test.output, exception=e) except Exception as e: e = exception.TestError( exception = e, message = "while testing", stacktrace = traceback.format_exc()) - return TestResult(False, test.description, str(test.exception(e)), exception=e) + return TestResult(False, test.description, str(test.exception(e)), test.output, exception=e) # Ensure hasPassed is None or a boolean # This is needed as boolean operators on np.bool_ return np.bool_ hasPassed = hasPassed if hasPassed is None else bool(hasPassed) - return TestResult(hasPassed, test.description, test.success(info) if hasPassed else test.fail(info)) + return TestResult(hasPassed, test.description, test.success(info) if hasPassed else test.fail(info), test.output) return runMethod @@ -308,7 +324,8 @@ def runMethod(): return TestResult( None, test.description, - self.HIDE_MESSAGE + self.HIDE_MESSAGE, + test.output ) return runMethod From 52e93a2945a8f7bc0e856ae214e7a2465f281f4d Mon Sep 17 00:00:00 2001 From: jelleas Date: Mon, 11 Aug 2025 13:37:39 +0200 Subject: [PATCH 263/269] fix tests --- tests/unittests/function_test.py | 24 ++- tests/unittests/lib_test.py | 178 ++++++----------- tests/unittests/path_test.py | 316 ------------------------------- 3 files changed, 73 insertions(+), 445 deletions(-) delete mode 100644 tests/unittests/path_test.py diff --git a/tests/unittests/function_test.py b/tests/unittests/function_test.py index 3c00407..9f62eb9 100644 --- a/tests/unittests/function_test.py +++ b/tests/unittests/function_test.py @@ -1,17 +1,22 @@ import unittest -import os -import shutil import checkpy.lib as lib import checkpy.entities.exception as exception from checkpy.entities.function import Function -class TestFunctionName(unittest.TestCase): +class TestFunction(unittest.TestCase): + def setUp(self): + context = lib.io.replaceStdout() + context.__enter__() + self.addCleanup(context.__exit__, None, None, None) + + +class TestFunctionName(TestFunction): def test_name(self): def foo(): pass self.assertEqual(Function(foo).name, "foo") -class TestFunctionArguments(unittest.TestCase): +class TestFunctionArguments(TestFunction): def test_noArgs(self): def foo(): pass @@ -32,7 +37,7 @@ def foo(bar, baz = None): pass self.assertEqual(Function(foo).arguments, ["bar", "baz"]) -class TestFunctionCall(unittest.TestCase): +class TestFunctionCall(TestFunction): def test_dummy(self): def foo(): return None @@ -63,15 +68,8 @@ def foo(): with self.assertRaises(exception.SourceException): f() - def test_noStdoutSideEfects(self): - def foo(): - print("bar") - f = Function(foo) - with lib.captureStdout() as stdout: - f() - self.assertTrue(len(stdout.read()) == 0) -class TestFunctionPrintOutput(unittest.TestCase): +class TestFunctionPrintOutput(TestFunction): def test_noOutput(self): def foo(): pass diff --git a/tests/unittests/lib_test.py b/tests/unittests/lib_test.py index 1c0b924..fc72c81 100644 --- a/tests/unittests/lib_test.py +++ b/tests/unittests/lib_test.py @@ -1,7 +1,9 @@ import unittest +from io import StringIO import os import shutil -import sys +import tempfile + import checkpy.lib as lib import checkpy.caches as caches import checkpy.entities.exception as exception @@ -9,13 +11,24 @@ class Base(unittest.TestCase): def setUp(self): + self.tempdir = tempfile.mkdtemp() + self.addCleanup(shutil.rmtree, self.tempdir) + os.chdir(self.tempdir) + self.fileName = "dummy.py" self.source = "def f(x):" +\ " return x * 2" self.write(self.source) + stdout_context = lib.io.replaceStdout() + stdout_context.__enter__() + self.addCleanup(stdout_context.__exit__, None, None, None) + + stdin_context = lib.io.replaceStdin() + stdin_context.__enter__() + self.addCleanup(stdin_context.__exit__, None, None, None) + def tearDown(self): - os.remove(self.fileName) caches.clearAllCaches() def write(self, source): @@ -30,29 +43,6 @@ def test_fileDoesNotExist(self): def test_fileExists(self): self.assertTrue(lib.fileExists(self.fileName)) -class TestRequire(Base): - def setUp(self): - super(TestRequire, self).setUp() - self.cwd = os.getcwd() - self.dirname = "testrequire" - os.mkdir(self.dirname) - - def tearDown(self): - super(TestRequire, self).tearDown() - os.chdir(self.cwd) - shutil.rmtree(self.dirname) - - def test_fileDownload(self): - fileName = "inowexist.random" - lib.require(fileName, "https://raw.githubusercontent.com/Jelleas/tests/master/tests/someTest.py") - self.assertTrue(os.path.isfile(fileName)) - os.remove(fileName) - - def test_fileLocalCopy(self): - os.chdir(self.dirname) - lib.require(self.fileName) - self.assertTrue(os.path.isfile(self.fileName)) - class TestSource(Base): def test_expectedOutput(self): @@ -295,120 +285,76 @@ def dummy(): class TestDownload(unittest.TestCase): + def setUp(self): + self.tempdir = tempfile.mkdtemp() + self.addCleanup(shutil.rmtree, self.tempdir) + os.chdir(self.tempdir) + def test_fileDownload(self): fileName = "someTest.py" - lib.download(fileName, "https://raw.githubusercontent.com/Jelleas/tests/master/tests/{}".format(fileName)) - self.assertTrue(os.path.isfile(fileName)) - os.remove(fileName) - + + with lib.sandbox.sandbox(): + lib.download(fileName, "https://raw.githubusercontent.com/Jelleas/tests/master/tests/{}".format(fileName)) + self.assertTrue(os.path.isfile(fileName)) + def test_fileDownloadRename(self): fileName = "someRandomFileName.name" - lib.download(fileName, "https://raw.githubusercontent.com/Jelleas/tests/master/tests/someTest.py") - self.assertTrue(os.path.isfile(fileName)) - os.remove(fileName) - - -class TestRemoveWhiteSpace(unittest.TestCase): - def test_remove(self): - s = lib.removeWhiteSpace(" \t foo\t\t bar ") - self.assertEqual(s, "foobar") - -class TestGetPositiveIntegersFromString(unittest.TestCase): - def test_only(self): - s = "foo1bar 2 baz" - self.assertEqual(lib.getPositiveIntegersFromString(s), [1,2]) - - def test_order(self): - s = "3 1 2" - self.assertEqual(lib.getPositiveIntegersFromString(s), [3,1,2]) - - def test_negatives(self): - s = "-2" - self.assertEqual(lib.getPositiveIntegersFromString(s), [2]) - - def test_floats(self): - s = "2.0" - self.assertEqual(lib.getPositiveIntegersFromString(s), [2, 0]) - - -class TestGetNumbersFromString(unittest.TestCase): - def test_only(self): - s = "foo1bar 2 baz" - self.assertEqual(lib.getNumbersFromString(s), [1,2]) - - def test_order(self): - s = "3 1 2" - self.assertEqual(lib.getNumbersFromString(s), [3,1,2]) - - def test_negatives(self): - s = "-2" - self.assertEqual(lib.getNumbersFromString(s), [-2]) - - def test_floats(self): - s = "2.0" - self.assertEqual(lib.getNumbersFromString(s), [2.0]) - - -class TestGetLine(unittest.TestCase): - def test_empty(self): - s = "" - with self.assertRaises(IndexError): - lib.getLine(s, 1) - - def test_oneLine(self): - s = "foo" - self.assertEqual(lib.getLine(s, 0), s) - - def test_multiLine(self): - s = "foo\nbar" - self.assertEqual(lib.getLine(s, 1), "bar") - - def test_oneLineTooFar(self): - s = "foo\nbar" - with self.assertRaises(IndexError): - lib.getLine(s, 2) + with lib.sandbox.sandbox(): + lib.download(fileName, "https://raw.githubusercontent.com/Jelleas/tests/master/tests/someTest.py") + self.assertTrue(os.path.isfile(fileName)) class TestCaptureStdout(unittest.TestCase): def test_blank(self): - with lib.captureStdout() as stdout: - self.assertTrue(len(stdout.getvalue()) == 0) + with lib.io.replaceStdout(): + with lib.io.captureStdout() as stdout: + self.assertTrue(len(stdout.content) == 0) def test_noOutput(self): - with lib.captureStdout() as stdout: - print("foo") - self.assertEqual("foo\n", stdout.getvalue()) + with lib.io.replaceStdout(): + with lib.io.captureStdout() as stdout: + print("foo") + self.assertEqual("foo\n", stdout.content) def test_noLeakage(self): import sys - with lib.captureStdout() as stdout: - print("foo") - self.assertTrue(len(sys.stdout.getvalue()) == 0) - -class TestCaptureStdin(unittest.TestCase): - def setUp(self): - self.getInput = lambda : input if sys.version_info >= (3,0) else raw_input - + try: + original_stdout = sys.stdout + mock_stdout = StringIO() + sys.stdout = mock_stdout + + with lib.io.replaceStdout(): + with lib.io.captureStdout(): + print("foo") + + self.assertEqual(len(mock_stdout.getvalue()), 0) + self.assertEqual(len(sys.stdout.getvalue()), 0) + finally: + sys.stdout = original_stdout + mock_stdout.close() + +class TestReplaceStdin(unittest.TestCase): def test_noInput(self): - with lib.captureStdin() as stdin: + with lib.io.replaceStdin() as stdin: with self.assertRaises(exception.InputError): - self.getInput()() + input() def test_oneInput(self): - with lib.captureStdin() as stdin: + with lib.io.replaceStdin() as stdin: stdin.write("foo\n") stdin.seek(0) - self.assertEqual(self.getInput()(), "foo") + self.assertEqual(input(), "foo") with self.assertRaises(exception.InputError): - self.getInput()() + input() def test_noLeakage(self): - with lib.captureStdin() as stdin, lib.captureStdout() as stdout: - stdin.write("foo\n") - stdin.seek(0) - self.assertEqual(self.getInput()("hello!"), "foo") - self.assertTrue(len(stdout.read()) == 0) + with lib.io.replaceStdout(): + with lib.io.replaceStdin() as stdin, lib.io.captureStdout() as stdout: + stdin.write("foo\n") + stdin.seek(0) + self.assertEqual(input("hello!"), "foo") + self.assertTrue(len(stdout.content) == 0) if __name__ == '__main__': diff --git a/tests/unittests/path_test.py b/tests/unittests/path_test.py deleted file mode 100644 index e86538e..0000000 --- a/tests/unittests/path_test.py +++ /dev/null @@ -1,316 +0,0 @@ -import unittest -import os -import shutil -from checkpy.entities.path import Path -import checkpy.entities.exception as exception - -class TestPathFileName(unittest.TestCase): - def test_name(self): - path = Path("foo.txt") - self.assertEqual(path.fileName, "foo.txt") - - def test_nestedName(self): - path = Path("/foo/bar/baz.txt") - self.assertEqual(path.fileName, "baz.txt") - - def test_extraSlash(self): - path = Path("/foo/bar/baz.txt/") - self.assertEqual(path.fileName, "baz.txt") - -class TestPathFolderName(unittest.TestCase): - def test_name(self): - path = Path("/foo/bar/baz.txt") - self.assertEqual(path.folderName, "bar") - - def test_extraSlash(self): - path = Path("/foo/bar/baz.txt/") - self.assertEqual(path.folderName, "bar") - -class TestPathContainingFolder(unittest.TestCase): - def test_empty(self): - path = Path("") - self.assertEqual(str(path.containingFolder()), ".") - - def test_file(self): - path = Path("/foo/bar/baz.txt") - self.assertEqual(str(path.containingFolder()), os.path.normpath("/foo/bar")) - - def test_folder(self): - path = Path("/foo/bar/baz/") - self.assertEqual(str(path.containingFolder()), os.path.normpath("/foo/bar")) - -class TestPathIsPythonFile(unittest.TestCase): - def test_noPythonFile(self): - path = Path("/foo/bar/baz.txt") - self.assertFalse(path.isPythonFile()) - - def test_pythonFile(self): - path = Path("/foo/bar/baz.py") - self.assertTrue(path.isPythonFile()) - - def test_folder(self): - path = Path("/foo/bar/baz/") - self.assertFalse(path.isPythonFile()) - - path = Path("/foo/bar/baz") - self.assertFalse(path.isPythonFile()) - -class TestPathExists(unittest.TestCase): - def setUp(self): - self.fileName = "dummy.py" - with open(self.fileName, "w") as f: - pass - - def tearDown(self): - os.remove(self.fileName) - - def test_doesNotExist(self): - path = Path("foo/bar/baz.py") - self.assertFalse(path.exists()) - - def test_exists(self): - path = Path("dummy.py") - self.assertTrue(path.isPythonFile()) - -class TestPathWalk(unittest.TestCase): - def setUp(self): - self.dirName = "dummy" - os.mkdir(self.dirName) - - def tearDown(self): - if os.path.exists(self.dirName): - shutil.rmtree(self.dirName) - - def test_oneDir(self): - paths = [] - for path, subdirs, files in Path(self.dirName).walk(): - paths.append(path) - self.assertTrue(len(paths) == 1) - self.assertEqual(str(paths[0]), self.dirName) - - def test_oneDirOneFile(self): - fileName = "dummy.py" - with open(os.path.join(self.dirName, fileName), "w") as f: - pass - ps = [] - fs = [] - for path, subdirs, files in Path(self.dirName).walk(): - ps.append(path) - fs.extend(files) - self.assertTrue(len(ps) == 1) - self.assertEqual(str(ps[0]), self.dirName) - self.assertTrue(len(fs) == 1) - self.assertEqual(str(fs[0]), fileName) - - def test_nestedDirs(self): - otherDir = os.path.join(self.dirName, "dummy2") - os.mkdir(otherDir) - fileName = "dummy.py" - with open(os.path.join(self.dirName, fileName), "w") as f: - pass - ps = [] - fs = [] - for path, subdirs, files in Path(self.dirName).walk(): - ps.append(path) - fs.extend(files) - self.assertTrue(len(ps) == 2) - self.assertEqual(str(ps[0]), self.dirName) - self.assertEqual(str(ps[1]), otherDir) - self.assertTrue(len(fs) == 1) - self.assertEqual(str(fs[0]), fileName) - -class TestPathCopyTo(unittest.TestCase): - def setUp(self): - self.fileName = "dummy.txt" - self.content = "foo" - with open(self.fileName, "w") as f: - f.write(self.content) - self.target = "dummy.py" - - def tearDown(self): - os.remove(self.fileName) - if os.path.exists(self.target): - os.remove(self.target) - - def test_noFile(self): - fileName = "idonotexist.py" - path = Path(fileName) - with self.assertRaises(IOError): - path.copyTo(".") - self.assertFalse(os.path.exists(fileName)) - - def test_file(self): - path = Path(self.fileName) - path.copyTo(self.target) - self.assertTrue(os.path.exists(self.fileName)) - self.assertTrue(os.path.exists(self.target)) - -class TestPathPathFromFolder(unittest.TestCase): - def test_empty(self): - path = Path("") - with self.assertRaises(exception.PathError): - path.pathFromFolder("idonotexist") - - def test_folderNotInpath(self): - path = Path("/foo/bar/baz") - with self.assertRaises(exception.PathError): - path.pathFromFolder("quux") - - def test_folderInpath(self): - path = Path("/foo/bar/baz") - self.assertEqual(str(path.pathFromFolder("bar")), "baz") - self.assertEqual(str(path.pathFromFolder("foo")), os.path.normpath("bar/baz")) - -class TestPathAdd(unittest.TestCase): - def test_string(self): - path = Path("foo/bar") - self.assertEqual(str(path + "baz"), os.path.normpath("foo/bar/baz")) - with self.assertRaises(exception.PathError): - path + "/baz" - - path = Path("foo/bar/") - self.assertEqual(str(path + "baz"), os.path.normpath("foo/bar/baz")) - with self.assertRaises(exception.PathError): - path + "/baz" - - def test_path(self): - path = Path("foo/bar") - self.assertEqual(str(path + Path("baz")), os.path.normpath("foo/bar/baz")) - with self.assertRaises(exception.PathError): - path + Path("/baz") - - path = Path("foo/bar/") - self.assertEqual(str(path + Path("baz")), os.path.normpath("foo/bar/baz")) - with self.assertRaises(exception.PathError): - path + Path("/baz") - - def test_unsupportedType(self): - path = Path("foo/bar") - with self.assertRaises(exception.PathError): - path + 1 - -class TestPathSub(unittest.TestCase): - def test_empty(self): - path = Path("") - self.assertEqual(str(path - ""), ".") - self.assertEqual(str(path - Path("")), ".") - - path = Path("foo/bar/baz") - self.assertEqual(str(path - ""), os.path.normpath("foo/bar/baz")) - self.assertEqual(str(path - Path("")), os.path.normpath("foo/bar/baz")) - - path = Path("./foo/bar/baz") - self.assertEqual(str(path - ""), os.path.normpath("foo/bar/baz")) - self.assertEqual(str(path - Path("")), os.path.normpath("foo/bar/baz")) - - path = Path("/foo/bar/baz") - with self.assertRaises(exception.PathError): - path - "" - with self.assertRaises(exception.PathError): - path - Path("") - - def test_string(self): - path = Path("foo/bar/baz") - self.assertEqual(str(path - "foo/bar"), "baz") - with self.assertRaises(exception.PathError): - path - "/foo/bar" - - path = Path("/foo/bar/baz") - with self.assertRaises(exception.PathError): - path - "foo/bar" - self.assertEqual(str(path - "/foo/bar"), "baz") - - def test_path(self): - path = Path("foo/bar/baz") - self.assertEqual(str(path - Path("foo/bar")), "baz") - with self.assertRaises(exception.PathError): - path - "/foo/bar" - - path = Path("/foo/bar/baz") - with self.assertRaises(exception.PathError): - path - Path("foo/bar") - self.assertEqual(str(path - Path("/foo/bar")), "baz") - -class TestPathIter(unittest.TestCase): - def test_empty(self): - path = Path("") - self.assertEqual(list(path), ["."]) - - def test_root(self): - path = Path("/") - self.assertEqual(list(path), [os.path.sep]) - - def test_path(self): - path = Path("foo/bar/baz") - self.assertEqual(list(path), ["foo", "bar", "baz"]) - -class TestPathContains(unittest.TestCase): - def test_root(self): - path = Path("/") - self.assertTrue(os.path.normpath("/") in path) - self.assertFalse("." in path) - - def test_current(self): - path = Path(".") - self.assertTrue("." in path) - self.assertFalse("/" in path) - - path = Path("") - self.assertTrue("." in path) - self.assertFalse("/" in path) - - def test_localPath(self): - path = Path("foo/bar/baz") - for d in ["foo", "bar", "baz"]: - self.assertTrue(d in path) - - def test_absPath(self): - path = Path("/foo/bar/baz") - self.assertTrue(os.path.normpath("/") in path) - for d in ["foo", "bar", "baz"]: - self.assertTrue(d in path) - -class TestPathLen(unittest.TestCase): - def test_root(self): - path = Path("/") - self.assertEqual(len(path), 1) - - def test_current(self): - path = Path(".") - self.assertEqual(len(path), 1) - - def test_localPath(self): - path = Path("foo/bar/baz") - self.assertEqual(len(path), 3) - path = Path("foo/bar/baz/") - self.assertEqual(len(path), 3) - - def test_absPath(self): - path = Path("/foo/bar/baz") - self.assertEqual(len(path), 4) - path = Path("/foo/bar/baz/") - self.assertEqual(len(path), 4) - -class TestPathStr(unittest.TestCase): - def test_root(self): - path = Path("/") - self.assertEqual(str(path), os.path.normpath("/")) - - def test_current(self): - path = Path(".") - self.assertEqual(str(path), os.path.normpath(".")) - - def test_localPath(self): - path = Path("foo/bar/baz") - self.assertEqual(str(path), os.path.normpath("foo/bar/baz")) - path = Path("foo/bar/baz/") - self.assertEqual(str(path), os.path.normpath("foo/bar/baz/")) - - def test_absPath(self): - path = Path("/foo/bar/baz") - self.assertEqual(str(path), os.path.normpath("/foo/bar/baz")) - path = Path("/foo/bar/baz/") - self.assertEqual(str(path), os.path.normpath("/foo/bar/baz/")) - -if __name__ == '__main__': - unittest.main() From c894469c8f44c838de191e8c87792c9e8d1c194b Mon Sep 17 00:00:00 2001 From: jelleas Date: Mon, 11 Aug 2025 14:27:38 +0200 Subject: [PATCH 264/269] add output on getFunction, getModule, outputOf --- checkpy/lib/basic.py | 57 +++++++++++++++++++++++++++++++++++++------- 1 file changed, 48 insertions(+), 9 deletions(-) diff --git a/checkpy/lib/basic.py b/checkpy/lib/basic.py index cb15be2..42ba562 100644 --- a/checkpy/lib/basic.py +++ b/checkpy/lib/basic.py @@ -8,7 +8,7 @@ from pathlib import Path from types import ModuleType -from typing import Any, Iterable, List, Optional, Tuple, TextIO, Union +from typing import Any, Iterable, List, Optional, Tuple, Union from warnings import warn import checkpy @@ -25,7 +25,6 @@ "getModuleAndOutputOf", ] - def getFunction( functionName: str, fileName: Optional[Union[str, Path]]=None, @@ -35,15 +34,22 @@ def getFunction( ignoreExceptions: Iterable[Exception]=(), overwriteAttributes: Iterable[Tuple[str, Any]]=() ) -> function.Function: - """Run the file then get the function with functionName""" - return getattr(getModule( + """Run the file, ignore any side effects, then get the function with functionName""" + module = _getModuleAndOutputOf( fileName=fileName, src=src, argv=argv, stdinArgs=stdinArgs, ignoreExceptions=ignoreExceptions, overwriteAttributes=overwriteAttributes - ), functionName) + )[0] + + if functionName not in module.__dict__: + raise AssertionError( + f"Function '{functionName}' not found in module '{module.__name__}'" + ) + + return getattr(module, functionName) def outputOf( @@ -55,7 +61,7 @@ def outputOf( overwriteAttributes: Iterable[Tuple[str, Any]]=() ) -> str: """Get the output after running the file.""" - _, output = getModuleAndOutputOf( + _, output = _getModuleAndOutputOf( fileName=fileName, src=src, argv=argv, @@ -63,6 +69,7 @@ def outputOf( ignoreExceptions=ignoreExceptions, overwriteAttributes=overwriteAttributes ) + checkpy.lib.addOutput(output) return output @@ -75,7 +82,7 @@ def getModule( overwriteAttributes: Iterable[Tuple[str, Any]]=() ) -> ModuleType: """Get the python Module after running the file.""" - mod, _ = getModuleAndOutputOf( + mod, output = _getModuleAndOutputOf( fileName=fileName, src=src, argv=argv, @@ -83,10 +90,10 @@ def getModule( ignoreExceptions=ignoreExceptions, overwriteAttributes=overwriteAttributes ) + checkpy.lib.addOutput(output) return mod -@caches.cache() def getModuleAndOutputOf( fileName: Optional[Union[str, Path]]=None, src: Optional[str]=None, @@ -98,6 +105,36 @@ def getModuleAndOutputOf( """ This function handles most of checkpy's under the hood functionality + fileName (optional): the name of the file to run + src (optional): the source code to run + argv (optional): set sys.argv to argv before importing, + stdinArgs (optional): arguments passed to stdin + ignoreExceptions (optional): exceptions that will silently pass while importing + overwriteAttributes (optional): attributes to overwrite in the imported module + """ + mod, output = _getModuleAndOutputOf( + fileName=fileName, + src=src, + argv=argv, + stdinArgs=stdinArgs, + ignoreExceptions=ignoreExceptions, + overwriteAttributes=overwriteAttributes + ) + checkpy.lib.addOutput(output) + return mod, output + +@caches.cache() +def _getModuleAndOutputOf( + fileName: Optional[Union[str, Path]]=None, + src: Optional[str]=None, + argv: Optional[List[str]]=None, + stdinArgs: Optional[List[str]]=None, + ignoreExceptions: Iterable[Exception]=(), + overwriteAttributes: Iterable[Tuple[str, Any]]=() + ) -> Tuple[ModuleType, str]: + """ + This function handles most of checkpy's under the hood functionality + fileName (optional): the name of the file to run src (optional): the source code to run argv (optional): set sys.argv to argv before importing, @@ -108,7 +145,7 @@ def getModuleAndOutputOf( if fileName is None: if checkpy.file is None: raise checkpy.entities.exception.CheckpyError( - message=f"Cannot call getSourceOfDefinitions() without passing fileName as argument if not test is running." + message=f"Cannot call getModuleAndOutputOf() without passing fileName as argument if not test is running." ) fileName = checkpy.file.name @@ -151,12 +188,14 @@ def getModuleAndOutputOf( except exception.CheckpyError as e: excep = e except Exception as e: + checkpy.lib.addOutput(stdoutListener.content) excep = exception.SourceException( exception = e, message = "while trying to import the code", output = stdoutListener.content, stacktrace = traceback.format_exc()) except SystemExit as e: + checkpy.lib.addOutput(stdoutListener.content) excep = exception.ExitError( message = "exit({}) while trying to import the code".format(int(e.args[0])), output = stdoutListener.content, From 60308180d1e427d63407b694af64b6ac08fb6659 Mon Sep 17 00:00:00 2001 From: jelleas Date: Mon, 11 Aug 2025 15:00:36 +0200 Subject: [PATCH 265/269] formatOutput --- checkpy/tests.py | 44 +++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 43 insertions(+), 1 deletion(-) diff --git a/checkpy/tests.py b/checkpy/tests.py index 7067e46..f910d20 100644 --- a/checkpy/tests.py +++ b/checkpy/tests.py @@ -120,8 +120,50 @@ def timeout(self, new_timeout: Union[int, Callable[[], int]]): @property def output(self) -> str: - return "\n".join(self._output) + from checkpy import context # avoid circular import + stdoutLimit = context.stdoutLimit + + output = "\n".join(self._output) + + return self._formatOutput(output, stdoutLimit) + @staticmethod + def _formatOutput(text: str, maxChars: int) -> str: + if len(text) < maxChars: + return text + + lines = text.split('\n') + # return str(lines) + firstPart = [] + lastPart = [] + + # Collect the first part of the text + totalChars = 0 + for line in lines: + # Accept up to maxChars // 2 for first part + if totalChars + len(line) + 1 > maxChars // 2: + break + firstPart.append(line) + + totalChars += len(line) + 1 # +1 for the newline character + + # Collect the last part of the text + totalChars = 0 + for line in reversed(lines): + # Accept up to maxChars // 2 for first part + if totalChars + len(line) + 1 > maxChars // 2: + break + lastPart.insert(0, line) + + totalChars += len(line) + 1 # +1 for the newline character + + # Combine the parts with the omitted message + nLinesOmitted = len(lines) - len(firstPart) - len(lastPart) + sep = f"<<< {nLinesOmitted} lines omitted >>>" + result = '\n'.join(firstPart) + '\n' + sep + '\n' + '\n'.join(lastPart) + + return result + def addOutput(self, output: str) -> None: self._output.append(output) From 3ff14d6249acd66b4c7637fb079e119ab9415145 Mon Sep 17 00:00:00 2001 From: jelleas Date: Mon, 11 Aug 2025 15:22:58 +0200 Subject: [PATCH 266/269] --output-limit --- checkpy/__init__.py | 6 +++--- checkpy/__main__.py | 15 +++++++++------ checkpy/tests.py | 29 ++++++++++++++++++++--------- 3 files changed, 32 insertions(+), 18 deletions(-) diff --git a/checkpy/__init__.py b/checkpy/__init__.py index 435c1a6..9da967c 100644 --- a/checkpy/__init__.py +++ b/checkpy/__init__.py @@ -54,16 +54,16 @@ testPath: _typing.Optional[_pathlib.Path] = None class _Context: - def __init__(self, debug=False, json=False, silent=False, stdoutLimit=1000): + def __init__(self, debug=False, json=False, silent=False, outputLimit=1000): self.debug = debug self.json = json self.silent = silent - self.stdoutLimit = stdoutLimit + self.outputLimit = outputLimit def __reduce__(self): return ( _Context, - (self.debug, self.json, self.silent, self.stdoutLimit) + (self.debug, self.json, self.silent, self.outputLimit) ) context = _Context() \ No newline at end of file diff --git a/checkpy/__main__.py b/checkpy/__main__.py index 6158aa4..b2cfaab 100644 --- a/checkpy/__main__.py +++ b/checkpy/__main__.py @@ -23,16 +23,17 @@ def main(): .format(sys.version_info[0], sys.version_info[1], sys.version_info[2], importlib.metadata.version("checkpy")) ) - parser.add_argument("-module", action="store", dest="module", help="provide a module name or path to run all tests from the module, or target a module for a specific test") - parser.add_argument("-download", action="store", dest="githubLink", help="download tests from a Github repository and exit") - parser.add_argument("-register", action="store", dest="localLink", help="register a local folder that contains tests and exit") - parser.add_argument("-update", action="store_true", help="update all downloaded tests and exit") - parser.add_argument("-list", action="store_true", help="list all download locations and exit") - parser.add_argument("-clean", action="store_true", help="remove all tests from the tests folder and exit") + parser.add_argument("-module", "--module", action="store", dest="module", help="provide a module name or path to run all tests from the module, or target a module for a specific test") + parser.add_argument("-download", "--download", action="store", dest="githubLink", help="download tests from a Github repository and exit") + parser.add_argument("-register", "--register", action="store", dest="localLink", help="register a local folder that contains tests and exit") + parser.add_argument("-update", "--update", action="store_true", help="update all downloaded tests and exit") + parser.add_argument("-list", "--list", action="store_true", help="list all download locations and exit") + parser.add_argument("-clean", "--clean", action="store_true", help="remove all tests from the tests folder and exit") parser.add_argument("--dev", action="store_true", help="get extra information to support the development of tests") parser.add_argument("--silent", action="store_true", help="do not print test results to stdout") parser.add_argument("--json", action="store_true", help="return output as json, implies silent") parser.add_argument("--gh-auth", action="store", help="username:personal_access_token for authentication with GitHub. Only used to increase GitHub api's rate limit.") + parser.add_argument("--output-limit", action="store", type=int, default=1000, dest="outputLimit", help="limit the number of characters stored for each test's output field. Default is 1000. Set to 0 to disable this limit.") parser.add_argument("files", action="store", nargs="*", help="names of files to be tested") args = parser.parse_args() @@ -40,6 +41,8 @@ def main(): if rootPath not in sys.path: sys.path.append(rootPath) + context.outputLimit = args.outputLimit + if args.gh_auth: split_auth = args.gh_auth.split(":") diff --git a/checkpy/tests.py b/checkpy/tests.py index f910d20..61c0b28 100644 --- a/checkpy/tests.py +++ b/checkpy/tests.py @@ -121,15 +121,15 @@ def timeout(self, new_timeout: Union[int, Callable[[], int]]): @property def output(self) -> str: from checkpy import context # avoid circular import - stdoutLimit = context.stdoutLimit + outputLimit = context.outputLimit output = "\n".join(self._output) - return self._formatOutput(output, stdoutLimit) + return self._formatOutput(output, outputLimit) @staticmethod def _formatOutput(text: str, maxChars: int) -> str: - if len(text) < maxChars: + if maxChars <= 0 or len(text) < maxChars: return text lines = text.split('\n') @@ -141,7 +141,11 @@ def _formatOutput(text: str, maxChars: int) -> str: totalChars = 0 for line in lines: # Accept up to maxChars // 2 for first part - if totalChars + len(line) + 1 > maxChars // 2: + if totalChars + len(line) > maxChars // 2: + if all(l == "" or l.isspace() for l in firstPart): + # If the first part is empty, show up to maxChars // 2 + firstPart.append(line[:maxChars // 2] + "<<< output truncated >>>") + break firstPart.append(line) @@ -149,9 +153,12 @@ def _formatOutput(text: str, maxChars: int) -> str: # Collect the last part of the text totalChars = 0 - for line in reversed(lines): + for line in reversed(lines[len(firstPart):]): # Accept up to maxChars // 2 for first part - if totalChars + len(line) + 1 > maxChars // 2: + if totalChars + len(line) > maxChars // 2: + if all(l == "" or l.isspace() for l in lastPart): + # If the last part is empty, show up to maxChars // 2 + lastPart.insert(0, "<<< output truncated >>>" + line[-(maxChars // 2):]) break lastPart.insert(0, line) @@ -159,9 +166,13 @@ def _formatOutput(text: str, maxChars: int) -> str: # Combine the parts with the omitted message nLinesOmitted = len(lines) - len(firstPart) - len(lastPart) - sep = f"<<< {nLinesOmitted} lines omitted >>>" - result = '\n'.join(firstPart) + '\n' + sep + '\n' + '\n'.join(lastPart) - + + if nLinesOmitted > 0: + sep = f"<<< {nLinesOmitted} lines omitted >>>" + result = '\n'.join(('\n'.join(firstPart), sep, '\n'.join(lastPart))) + else: + result = '\n'.join(('\n'.join(firstPart), '\n'.join(lastPart))) + return result def addOutput(self, output: str) -> None: From 538b1962bf96e7e21f37f05da99592b66322007a Mon Sep 17 00:00:00 2001 From: jelleas Date: Tue, 12 Aug 2025 16:15:04 +0200 Subject: [PATCH 267/269] bump 2.1.0 --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index ff5bfa8..77ba983 100644 --- a/setup.py +++ b/setup.py @@ -11,7 +11,7 @@ setup( name='checkPy', - version='2.0.14', + version='2.1.0', description='A simple python testing framework for educational purposes', long_description=long_description, From fefd96f2c8b88e6b1118b68b95cc53e7e9cd087a Mon Sep 17 00:00:00 2001 From: jelleas Date: Thu, 14 Aug 2025 15:42:26 +0200 Subject: [PATCH 268/269] gh releases => commits on default branch --- README.md | 7 ++- checkpy/__main__.py | 2 +- checkpy/database/database.py | 30 ++++++---- checkpy/downloader/downloader.py | 96 ++++++++++++++++++++------------ setup.py | 2 +- 5 files changed, 86 insertions(+), 51 deletions(-) diff --git a/README.md b/README.md index fefa5e9..eeca4b6 100644 --- a/README.md +++ b/README.md @@ -53,7 +53,9 @@ Here spcourse/tests points to . You can also --dev get extra information to support the development of tests --silent do not print test results to stdout --json return output as json, implies silent - --gh-auth GH_AUTH username:personal_access_token for authentication with GitHub. Only used to increase GitHub api's rate limit. + --gh-auth GH_AUTH username:personal_access_token for authentication with GitHub. + --output-limit OUTPUTLIMIT + limit the number of characters stored for each test's output field. Default is 1000. Set to 0 to disable this limit. To test a single file call: @@ -233,8 +235,7 @@ correctForPos = test()(declarative ### Distributing tests -checkpy downloads tests directly from Github repos. The requirement is that a folder called ``tests`` exists within the repo that contains only tests and folders (which checkpy treats as modules). There must also be at least one release in the Github repo. checkpy will automatically target the latest release. To download tests call checkpy with the optional ``-d`` argument and pass your github repo url. checkpy will automatically keep tests up to date by checking for any new releases on GitHub. - +checkpy downloads tests directly from Github repos. The requirement is that a folder called ``tests`` exists within the repo that contains only tests and folders (which checkpy treats as modules). checkpy will pull from the default branch. To download tests call checkpy with the optional ``-d`` argument and pass your github repo url. checkpy will automatically keep tests up to date by checking for any new commits on GitHub. ### Testing checkpy diff --git a/checkpy/__main__.py b/checkpy/__main__.py index b2cfaab..eca9567 100644 --- a/checkpy/__main__.py +++ b/checkpy/__main__.py @@ -32,7 +32,7 @@ def main(): parser.add_argument("--dev", action="store_true", help="get extra information to support the development of tests") parser.add_argument("--silent", action="store_true", help="do not print test results to stdout") parser.add_argument("--json", action="store_true", help="return output as json, implies silent") - parser.add_argument("--gh-auth", action="store", help="username:personal_access_token for authentication with GitHub. Only used to increase GitHub api's rate limit.") + parser.add_argument("--gh-auth", action="store", help="username:personal_access_token for authentication with GitHub.") parser.add_argument("--output-limit", action="store", type=int, default=1000, dest="outputLimit", help="limit the number of characters stored for each test's output field. Default is 1000. Set to 0 to disable this limit.") parser.add_argument("files", action="store", nargs="*", help="names of files to be tested") args = parser.parse_args() diff --git a/checkpy/database/database.py b/checkpy/database/database.py index 38555b5..fece0c5 100644 --- a/checkpy/database/database.py +++ b/checkpy/database/database.py @@ -57,7 +57,12 @@ def isKnownGithub(username: str, repoName: str) -> bool: with githubTable() as table: return table.contains((query.user == username) & (query.repo == repoName)) -def addToGithubTable(username: str, repoName: str, releaseId: str, releaseTag: str): +def addToGithubTable( + username: str, + repoName: str, + commitMessage: str, + commitSha: str + ): if not isKnownGithub(username, repoName): path = str(checkpy.CHECKPYPATH / "tests" / repoName) @@ -66,8 +71,8 @@ def addToGithubTable(username: str, repoName: str, releaseId: str, releaseTag: s "user" : username, "repo" : repoName, "path" : path, - "release" : releaseId, - "tag" : releaseTag, + "message" : commitMessage, + "sha" : commitSha, "timestamp" : time.time() }) @@ -79,7 +84,12 @@ def addToLocalTable(localPath: pathlib.Path): "path" : str(localPath) }) -def updateGithubTable(username: str, repoName: str, releaseId: str, releaseTag: str): +def updateGithubTable( + username: str, + repoName: str, + commitMessage: str, + commitSha: str + ): query = tinydb.Query() path = str(checkpy.CHECKPYPATH / "tests" / repoName) with githubTable() as table: @@ -87,8 +97,8 @@ def updateGithubTable(username: str, repoName: str, releaseId: str, releaseTag: "user" : username, "repo" : repoName, "path" : path, - "release" : releaseId, - "tag" : releaseTag, + "message" : commitMessage, + "sha" : commitSha, "timestamp" : time.time() }, query.user == username and query.repo == repoName) @@ -110,12 +120,12 @@ def githubPath(username: str, repoName: str) -> pathlib.Path: with githubTable() as table: return pathlib.Path(table.search(query.user == username and query.repo == repoName)[0]["path"]) -def releaseId(username: str, repoName: str) -> str: +def commitSha(username: str, repoName: str) -> str: query = tinydb.Query() with githubTable() as table: - return table.search(query.user == username and query.repo == repoName)[0]["release"] + return table.search(query.user == username and query.repo == repoName)[0]["sha"] -def releaseTag(username: str, repoName: str) -> str: +def commitMessage(username: str, repoName: str) -> str: query = tinydb.Query() with githubTable() as table: - return table.search(query.user == username and query.repo == repoName)[0]["tag"] + return table.search(query.user == username and query.repo == repoName)[0]["message"] diff --git a/checkpy/downloader/downloader.py b/checkpy/downloader/downloader.py index cdeb3de..fff296f 100644 --- a/checkpy/downloader/downloader.py +++ b/checkpy/downloader/downloader.py @@ -32,7 +32,7 @@ def download(githubLink: str): repoName = githubLink.split("/")[-1].lower() try: - _syncRelease(username, repoName) + _syncCommit(username, repoName) _download(username, repoName) except exception.DownloadError as e: printer.displayError(str(e)) @@ -49,7 +49,7 @@ def register(localLink: Union[str, pathlib.Path]): def update(): for username, repoName in database.forEachUserAndRepo(): try: - _syncRelease(username, repoName) + _syncCommit(username, repoName) _download(username, repoName) except exception.DownloadError as e: printer.displayError(str(e)) @@ -75,79 +75,103 @@ def updateSilently(): database.setTimestampGithub(username, repoName) try: - if _newReleaseAvailable(username, repoName): + if _newCommitAvailable(username, repoName): _download(username, repoName) - except exception.DownloadError as e: + except exception.DownloadError: pass -def _newReleaseAvailable(githubUserName: str, githubRepoName: str) -> bool: +def _newCommitAvailable(githubUserName: str, githubRepoName: str) -> bool: # unknown/new download if not database.isKnownGithub(githubUserName, githubRepoName): return True - releaseJson = _getReleaseJson(githubUserName, githubRepoName) - - # new release id found - if releaseJson["id"] != database.releaseId(githubUserName, githubRepoName): - database.updateGithubTable(githubUserName, githubRepoName, releaseJson["id"], releaseJson["tag_name"]) + commitJson = _getLatestCommitJson(githubUserName, githubRepoName) + + # new commit found + if commitJson["sha"] != database.commitSha(githubUserName, githubRepoName): + database.updateGithubTable( + githubUserName, + githubRepoName, + commitJson["commit"]["message"], + commitJson["sha"], + ) return True - # no new release found + # no new commit found return False -def _syncRelease(githubUserName: str, githubRepoName: str): - releaseJson = _getReleaseJson(githubUserName, githubRepoName) +def _syncCommit(githubUserName: str, githubRepoName: str): + commitJson = _getLatestCommitJson(githubUserName, githubRepoName) if database.isKnownGithub(githubUserName, githubRepoName): - database.updateGithubTable(githubUserName, githubRepoName, releaseJson["id"], releaseJson["tag_name"]) + database.updateGithubTable( + githubUserName, + githubRepoName, + commitJson["commit"]["message"], + commitJson["sha"], + ) else: - database.addToGithubTable(githubUserName, githubRepoName, releaseJson["id"], releaseJson["tag_name"]) - + database.addToGithubTable( + githubUserName, + githubRepoName, + commitJson["commit"]["message"], + commitJson["sha"], + ) + +def _get_with_auth(url: str) -> requests.Response: + """ + Get a url with authentication if available. + Returns a requests.Response object. + """ + global user + global personal_access_token + if user and personal_access_token: + return requests.get(url, auth=(user, personal_access_token)) + else: + return requests.get(url) -def _getReleaseJson(githubUserName: str, githubRepoName: str) -> Dict: +def _getLatestCommitJson(githubUserName: str, githubRepoName: str) -> Dict: """ + Get the latest commit from the default branch of the given repository. This performs one api call, beware of rate limit!!! Returns a dictionary representing the json returned by github In case of an error, raises an exception.DownloadError """ - apiReleaseLink = f"https://api.github.com/repos/{githubUserName}/{githubRepoName}/releases/latest" + apiCommitLink = f"https://api.github.com/repos/{githubUserName}/{githubRepoName}/commits" - global user - global personal_access_token try: - if user and personal_access_token: - r = requests.get(apiReleaseLink, auth=(user, personal_access_token)) - else: - r = requests.get(apiReleaseLink) + r = _get_with_auth(apiCommitLink) except requests.exceptions.ConnectionError as e: raise exception.DownloadError(message="Oh no! It seems like there is no internet connection available?!") # exceeded rate limit, if r.status_code == 403: - raise exception.DownloadError(message=f"Tried finding new releases from {githubUserName}/{githubRepoName} but exceeded the rate limit, try again within an hour!") + raise exception.DownloadError(message=f"Tried finding new commits from {githubUserName}/{githubRepoName} but exceeded the rate limit, try again within an hour!") - # no releases found or page not found + # no commits found or page not found if r.status_code == 404: - raise exception.DownloadError(message=f"Failed to check for new tests from {githubUserName}/{githubRepoName} because: no releases found (404)") + raise exception.DownloadError(message=f"Failed to check for new commits from {githubUserName}/{githubRepoName} because: no commits found (404)") # random error if not r.ok: - raise exception.DownloadError(message=f"Failed to sync releases from {githubUserName}/{githubRepoName} because: {r.reason}") + raise exception.DownloadError(message=f"Failed to get commits from {githubUserName}/{githubRepoName} because: {r.reason}") - return r.json() + return r.json()[0] -# download tests for githubUserName and githubRepoName from what is known in downloadlocations.json -# use _syncRelease() to force an update in downloadLocations.json +# download tests for githubUserName and githubRepoName from what is known in db +# use _syncCommit() to force an update in db def _download(githubUserName: str, githubRepoName: str): - githubLink = f"https://github.com/{githubUserName}/{githubRepoName}" - zipLink = githubLink + f"/archive/{database.releaseTag(githubUserName, githubRepoName)}.zip" + sha = database.commitSha(githubUserName, githubRepoName) + zipUrl = f'https://api.github.com/repos/{githubUserName}/{githubRepoName}/zipball/{sha}' try: - r = requests.get(zipLink) + r = _get_with_auth(zipUrl) except requests.exceptions.ConnectionError as e: raise exception.DownloadError(message = "Oh no! It seems like there is no internet connection available?!") + gitHubUrl = f'https://github.com/{githubUserName}/{githubRepoName}' # just for feedback + if not r.ok: - raise exception.DownloadError(message = f"Failed to download {githubLink} because: {r.reason}") + raise exception.DownloadError(message = f"Failed to download {gitHubUrl} because: {r.reason}") f = io.BytesIO(r.content) @@ -177,7 +201,7 @@ def _download(githubUserName: str, githubRepoName: str): _extractTests(z, destPath) - printer.displayCustom(f"Finished downloading: {githubLink}") + printer.displayCustom(f"Finished downloading: {gitHubUrl}") def _extractTests(zipfile: zf.ZipFile, destPath: pathlib.Path): if not destPath.exists(): diff --git a/setup.py b/setup.py index 77ba983..ed09cc5 100644 --- a/setup.py +++ b/setup.py @@ -11,7 +11,7 @@ setup( name='checkPy', - version='2.1.0', + version='2.1.1', description='A simple python testing framework for educational purposes', long_description=long_description, From bb017d705b528907d1f85ea5a66cd05dfb8a3a27 Mon Sep 17 00:00:00 2001 From: jelleas Date: Fri, 29 Aug 2025 15:29:21 +0200 Subject: [PATCH 269/269] fix bug multiple stdin in same check --- checkpy/caches.py | 1 + checkpy/lib/basic.py | 5 +++++ setup.py | 2 +- 3 files changed, 7 insertions(+), 1 deletion(-) diff --git a/checkpy/caches.py b/checkpy/caches.py index c5597d6..99f1ffc 100644 --- a/checkpy/caches.py +++ b/checkpy/caches.py @@ -40,6 +40,7 @@ def cachedFuncWrapper(*args, **kwargs): key = str(args) + str(kwargs) + str(sys.argv) if key not in localCache: localCache[key] = func(*args, **kwargs) + return localCache[key] return cachedFuncWrapper diff --git a/checkpy/lib/basic.py b/checkpy/lib/basic.py index 42ba562..64a2c84 100644 --- a/checkpy/lib/basic.py +++ b/checkpy/lib/basic.py @@ -157,6 +157,11 @@ def _getModuleAndOutputOf( excep = None with checkpy.lib.io.captureStdout() as stdoutListener: + + # flush stdin + sys.stdin.seek(0) + sys.stdin.flush() + # fill stdin with args if stdinArgs: for arg in stdinArgs: diff --git a/setup.py b/setup.py index ed09cc5..09e4d38 100644 --- a/setup.py +++ b/setup.py @@ -11,7 +11,7 @@ setup( name='checkPy', - version='2.1.1', + version='2.1.2', description='A simple python testing framework for educational purposes', long_description=long_description,