init
This commit is contained in:
commit
f4c09d21fe
18 changed files with 443 additions and 0 deletions
7
.gitignore
vendored
Normal file
7
.gitignore
vendored
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
/venv
|
||||||
|
|
||||||
|
/dist
|
||||||
|
/src/okipy.egg-info
|
||||||
|
|
||||||
|
__pycache__
|
||||||
|
.mypy_cache
|
3
.vscode/settings.json
vendored
Normal file
3
.vscode/settings.json
vendored
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
{
|
||||||
|
"python.analysis.typeCheckingMode": "standard",
|
||||||
|
}
|
21
LICENCE
Normal file
21
LICENCE
Normal file
|
@ -0,0 +1,21 @@
|
||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) [year] [fullname]
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
36
README.md
Normal file
36
README.md
Normal file
|
@ -0,0 +1,36 @@
|
||||||
|
# Oki.py
|
||||||
|
|
||||||
|
Minimal, typed, functional and dynamic test library.
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
```py
|
||||||
|
def main():
|
||||||
|
suite = Suite()
|
||||||
|
|
||||||
|
@suite.test()
|
||||||
|
def adds_correctly(ctx):
|
||||||
|
assert 2 + 2 == 4
|
||||||
|
|
||||||
|
@suite.test()
|
||||||
|
def adds_incorrectly(ctx):
|
||||||
|
print("this must work")
|
||||||
|
assert 2 + 2 == 5
|
||||||
|
|
||||||
|
@suite.test()
|
||||||
|
def it_doesnt_fail(ctx):
|
||||||
|
print("hope this doesnt fail ...")
|
||||||
|
print("oh, this will fail", file=sys.stderr)
|
||||||
|
raise Exception("Expected failure.")
|
||||||
|
|
||||||
|
@suite.test()
|
||||||
|
def it_fails(ctx):
|
||||||
|
with ctx.assert_raises():
|
||||||
|
raise Exception("Expected failure.")
|
||||||
|
|
||||||
|
suite.run()
|
||||||
|
```
|
||||||
|
|
||||||
|
Results in this output when run :
|
||||||
|
|
||||||
|

|
BIN
assets/output.png
Normal file
BIN
assets/output.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 54 KiB |
14
build.sh
Executable file
14
build.sh
Executable file
|
@ -0,0 +1,14 @@
|
||||||
|
#!/bin/sh
|
||||||
|
set -e
|
||||||
|
cd "$(dirname "$(realpath "$0")")"
|
||||||
|
|
||||||
|
if ! [ -d venv ]
|
||||||
|
then ./setup.sh
|
||||||
|
fi
|
||||||
|
|
||||||
|
source venv/bin/activate
|
||||||
|
|
||||||
|
rm -fr dist
|
||||||
|
|
||||||
|
python -m build
|
||||||
|
python -m twine check dist/*
|
9
clean.sh
Executable file
9
clean.sh
Executable file
|
@ -0,0 +1,9 @@
|
||||||
|
#!/bin/sh
|
||||||
|
set -e
|
||||||
|
cd "$(dirname "$(realpath "$0")")"
|
||||||
|
|
||||||
|
rm -fr venv
|
||||||
|
rm -fr dist
|
||||||
|
rm -fr .mypy_cache
|
||||||
|
rm -fr src/okipy/__pycache__
|
||||||
|
rm -fr src/okipy.egg-info
|
15
example/inlines.py
Normal file
15
example/inlines.py
Normal file
|
@ -0,0 +1,15 @@
|
||||||
|
|
||||||
|
from os.path import dirname, join
|
||||||
|
import sys
|
||||||
|
sys.path.append(join(dirname(dirname(__file__)), "src"))
|
||||||
|
|
||||||
|
from okipy import test
|
||||||
|
|
||||||
|
|
||||||
|
def add(a, b):
|
||||||
|
return a + b
|
||||||
|
|
||||||
|
|
||||||
|
@test()
|
||||||
|
def it_works(ctx):
|
||||||
|
pass
|
73
example/tests.py
Executable file
73
example/tests.py
Executable file
|
@ -0,0 +1,73 @@
|
||||||
|
#!/usr/bin/env -S python
|
||||||
|
|
||||||
|
from os.path import dirname, join
|
||||||
|
import sys
|
||||||
|
sys.path.append(join(dirname(dirname(__file__)), "src"))
|
||||||
|
from okipy import Suite, Ctx
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
suite = Suite()
|
||||||
|
|
||||||
|
@suite.test()
|
||||||
|
def adds_correctly(ctx: Ctx):
|
||||||
|
assert 2 + 2 == 4
|
||||||
|
|
||||||
|
@suite.test()
|
||||||
|
def adds_incorrectly(ctx: Ctx):
|
||||||
|
print("this must work")
|
||||||
|
assert 2 + 2 == 5
|
||||||
|
|
||||||
|
@suite.test()
|
||||||
|
def it_doesnt_fail(ctx: Ctx):
|
||||||
|
print("hope this doesnt fail ...")
|
||||||
|
print("oh, this will fail", file=sys.stderr)
|
||||||
|
raise Exception("Expected failure.")
|
||||||
|
|
||||||
|
@suite.test()
|
||||||
|
def it_fails(ctx: Ctx):
|
||||||
|
with ctx.assert_raises():
|
||||||
|
raise Exception("Expected failure.")
|
||||||
|
|
||||||
|
suite.run(sys.argv[1:])
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__": main()
|
||||||
|
|
||||||
|
|
||||||
|
"""
|
||||||
|
prints the following :
|
||||||
|
|
||||||
|
|
||||||
|
Running 4 / 4 tests
|
||||||
|
|
||||||
|
Ok adds_correctly
|
||||||
|
Err adds_incorrectly
|
||||||
|
Err it_doesnt_fail
|
||||||
|
Ok it_fails
|
||||||
|
Failed 2 / 4 tests
|
||||||
|
|
||||||
|
Fail 1 adds_incorrectly
|
||||||
|
Traceback (most recent call last):
|
||||||
|
File "/media/hdd0/Projects/okipy/./src/okipy/lib.py", line 46, in run
|
||||||
|
self.procedure(Ctx(self))
|
||||||
|
File "/media/hdd0/Projects/okipy/./example/tests.py", line 19, in adds_incorrectly
|
||||||
|
assert 2 + 2 == 5
|
||||||
|
AssertionError
|
||||||
|
Stdout 1 lines
|
||||||
|
this must work
|
||||||
|
|
||||||
|
|
||||||
|
Fail 2 it_doesnt_fail
|
||||||
|
Traceback (most recent call last):
|
||||||
|
File "/media/hdd0/Projects/okipy/./src/okipy/lib.py", line 46, in run
|
||||||
|
self.procedure(Ctx(self))
|
||||||
|
File "/media/hdd0/Projects/okipy/./example/tests.py", line 25, in it_doesnt_fail
|
||||||
|
raise Exception("Expected failure.")
|
||||||
|
Exception: Expected failure.
|
||||||
|
Stdout 1 lines
|
||||||
|
hope this doesnt fail ...
|
||||||
|
Stderr 1 lines
|
||||||
|
oh, this will fail
|
||||||
|
|
||||||
|
"""
|
6
publish.sh
Executable file
6
publish.sh
Executable file
|
@ -0,0 +1,6 @@
|
||||||
|
#!/bin/sh
|
||||||
|
set -e
|
||||||
|
cd "$(dirname "$(realpath "$0")")"
|
||||||
|
|
||||||
|
./clean.sh
|
||||||
|
./build.sh
|
30
pyproject.toml
Normal file
30
pyproject.toml
Normal file
|
@ -0,0 +1,30 @@
|
||||||
|
|
||||||
|
[project]
|
||||||
|
name = "okipy"
|
||||||
|
version = "1.0.0"
|
||||||
|
description = "Minimal, typed, functional and dynamic test library."
|
||||||
|
keywords = ["test", "functional", "library", "testing", "dynamic", "minimal"]
|
||||||
|
|
||||||
|
classifiers = [
|
||||||
|
"License :: OSI Approved :: MIT License",
|
||||||
|
"Programming Language :: Python",
|
||||||
|
"Programming Language :: Python :: 3",
|
||||||
|
"Operating System :: OS Independent",
|
||||||
|
]
|
||||||
|
|
||||||
|
readme = "README.md"
|
||||||
|
license = { file = "LICENSE" }
|
||||||
|
authors = [{ name = "Matthieu Jolimaitre", email = "matthieu@imagevo.fr" }]
|
||||||
|
|
||||||
|
dependencies = []
|
||||||
|
requires-python = ">=3.9"
|
||||||
|
|
||||||
|
[project.urls]
|
||||||
|
Homepage = "https://git.barnulf.net/mb/okipy"
|
||||||
|
|
||||||
|
[project.scripts]
|
||||||
|
oki = "okipy:main"
|
||||||
|
|
||||||
|
[build-system]
|
||||||
|
requires = ["setuptools"]
|
||||||
|
build-backend = "setuptools.build_meta"
|
9
setup.sh
Executable file
9
setup.sh
Executable file
|
@ -0,0 +1,9 @@
|
||||||
|
#!/bin/sh
|
||||||
|
set -e
|
||||||
|
cd "$(dirname "$(realpath "$0")")"
|
||||||
|
|
||||||
|
python3 -m venv venv
|
||||||
|
source venv/bin/activate
|
||||||
|
|
||||||
|
# dev dependencies
|
||||||
|
python -m pip install mypy build setuptools twine
|
3
src/okipy/__init__.py
Normal file
3
src/okipy/__init__.py
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
|
||||||
|
from .lib import Suite, Test, Ctx, TestFailure, test, get_inline_suite
|
||||||
|
from .main import main
|
10
src/okipy/colors.py
Normal file
10
src/okipy/colors.py
Normal file
|
@ -0,0 +1,10 @@
|
||||||
|
|
||||||
|
|
||||||
|
def create_format(start: str, end: str = "\033[0m"):
|
||||||
|
def format(text: str): return start + text + end
|
||||||
|
return format
|
||||||
|
|
||||||
|
red = create_format("\033[0;31m")
|
||||||
|
green = create_format("\033[0;32m")
|
||||||
|
bold = create_format("\033[1m")
|
||||||
|
yellow = create_format("\033[0;33m")
|
137
src/okipy/lib.py
Normal file
137
src/okipy/lib.py
Normal file
|
@ -0,0 +1,137 @@
|
||||||
|
|
||||||
|
from typing import Any, Callable, Optional, Union
|
||||||
|
from contextlib import contextmanager
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from traceback import print_exception
|
||||||
|
|
||||||
|
from .utils import CaptureOutput
|
||||||
|
from .colors import red, green, bold, yellow
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Ctx:
|
||||||
|
"""
|
||||||
|
Test context.
|
||||||
|
"""
|
||||||
|
test: "Test"
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def assert_raises(self, except_kind: type[BaseException] = BaseException):
|
||||||
|
try:
|
||||||
|
yield
|
||||||
|
except except_kind as exc:
|
||||||
|
return exc
|
||||||
|
raise AssertionError(f"No exception raised in test {self.test.name}.")
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class TestFailure:
|
||||||
|
test: "Test"
|
||||||
|
stdout: list[str]
|
||||||
|
stderr: list[str]
|
||||||
|
exception: Optional[BaseException]
|
||||||
|
|
||||||
|
def print(self, index: Union[int, None] = None):
|
||||||
|
print()
|
||||||
|
fail_name = self.test.name if index is None else str(index + 1) + ' ' + self.test.name
|
||||||
|
print(f" {red('Fail')} {bold(fail_name)}")
|
||||||
|
print_exception(self.exception)
|
||||||
|
|
||||||
|
if len(self.stdout) > 0:
|
||||||
|
print(f" {yellow('Stdout')} {bold(str(len(self.stdout)))} {yellow('lines')}")
|
||||||
|
for line in self.stdout:
|
||||||
|
print(line)
|
||||||
|
|
||||||
|
if len(self.stderr) > 0:
|
||||||
|
print(f" {yellow('Stderr')} {bold(str(len(self.stderr)))} {yellow('lines')}")
|
||||||
|
for line in self.stderr:
|
||||||
|
print(line)
|
||||||
|
print()
|
||||||
|
|
||||||
|
|
||||||
|
class Test:
|
||||||
|
name: str
|
||||||
|
procedure: Callable[[Ctx], Any]
|
||||||
|
|
||||||
|
def __init__(self, name: str, procedure: Callable[[Ctx], Any]) -> None:
|
||||||
|
self.name = name
|
||||||
|
self.procedure = procedure
|
||||||
|
|
||||||
|
def run(self):
|
||||||
|
with CaptureOutput() as capture:
|
||||||
|
try:
|
||||||
|
self.procedure(Ctx(self))
|
||||||
|
except BaseException as ex:
|
||||||
|
return TestFailure(self, capture.stdout, capture.stderr, ex)
|
||||||
|
|
||||||
|
|
||||||
|
class Suite:
|
||||||
|
"""
|
||||||
|
A suite of tests.
|
||||||
|
|
||||||
|
Append test with the `Suite.test` decorator :
|
||||||
|
```py
|
||||||
|
suite = Suite("Feur")
|
||||||
|
|
||||||
|
@suite.test()
|
||||||
|
def it_works(ctx):
|
||||||
|
assert 1 + 1 == 2
|
||||||
|
|
||||||
|
suite.run()
|
||||||
|
```
|
||||||
|
"""
|
||||||
|
name: Union[str, None]
|
||||||
|
tests: list[Test]
|
||||||
|
|
||||||
|
def __init__(self, name: Union[str, None] = None) -> None:
|
||||||
|
self.name = name
|
||||||
|
self.tests = []
|
||||||
|
|
||||||
|
def test(self):
|
||||||
|
def decorate(procedure: Callable[[Ctx], Any]) -> Callable[[Ctx], Any]:
|
||||||
|
name = procedure.__name__
|
||||||
|
self.tests.append(Test(name, procedure))
|
||||||
|
return procedure
|
||||||
|
return decorate
|
||||||
|
|
||||||
|
def run(self, filters: list[str] = []):
|
||||||
|
if self.name is not None:
|
||||||
|
print(" ", green("Suite"), bold(self.name))
|
||||||
|
to_run = [*self.filter_tests(filters)]
|
||||||
|
print(yellow('Running'), bold(str(len(to_run))), yellow('/'), bold(str(len(self.tests))), yellow('tests'))
|
||||||
|
print()
|
||||||
|
failures = list[TestFailure]()
|
||||||
|
for test in to_run:
|
||||||
|
failure = test.run()
|
||||||
|
if failure is None:
|
||||||
|
print(f" {green('Ok')} {bold(test.name)}")
|
||||||
|
else:
|
||||||
|
print(f" {red('Err')} {bold(test.name)}")
|
||||||
|
failures.append(failure)
|
||||||
|
print()
|
||||||
|
print("", yellow('Failed'), bold(str(len(failures))), yellow('/'), bold(str(len(to_run))), yellow('tests'))
|
||||||
|
for (index, failure) in enumerate(failures):
|
||||||
|
failure.print(index)
|
||||||
|
return failures
|
||||||
|
|
||||||
|
def filter_tests(self, filters: list[str]):
|
||||||
|
for test in self.tests:
|
||||||
|
oki = True
|
||||||
|
for filter in filters:
|
||||||
|
if filter not in test.name:
|
||||||
|
oki = False
|
||||||
|
if oki:
|
||||||
|
yield test
|
||||||
|
|
||||||
|
|
||||||
|
def get_inline_suite() -> Suite:
|
||||||
|
existing: Optional[Suite] = globals().get('_okipy_inline_suite')
|
||||||
|
if existing is None:
|
||||||
|
globals()['_okipy_inline_suite'] = existing = Suite()
|
||||||
|
return existing
|
||||||
|
|
||||||
|
|
||||||
|
def test():
|
||||||
|
def decorate(procedure: Callable[[Ctx], Any]) -> Callable[[Ctx], Any]:
|
||||||
|
return get_inline_suite().test()(procedure)
|
||||||
|
return decorate
|
49
src/okipy/main.py
Executable file
49
src/okipy/main.py
Executable file
|
@ -0,0 +1,49 @@
|
||||||
|
#!/usr/bin/env -S python
|
||||||
|
|
||||||
|
from argparse import ArgumentParser
|
||||||
|
from os.path import realpath, dirname
|
||||||
|
import subprocess
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
(source, filters) = parse_args()
|
||||||
|
path = realpath(source)
|
||||||
|
directory = dirname(path)
|
||||||
|
input = script_from_file(path, filters)
|
||||||
|
subprocess.run(["python"], text=True, cwd=directory, input=input)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_args():
|
||||||
|
parser = ArgumentParser(
|
||||||
|
prog='okipy',
|
||||||
|
description='Okipy test runner.'
|
||||||
|
)
|
||||||
|
parser.add_argument('source')
|
||||||
|
parser.add_argument('filters', nargs='*')
|
||||||
|
parsed = parser.parse_args()
|
||||||
|
dict = vars(parsed)
|
||||||
|
source = dict['source']
|
||||||
|
filters = dict['filters']
|
||||||
|
assert type(source) is str
|
||||||
|
assert type(filters) is list
|
||||||
|
return (source, filters)
|
||||||
|
|
||||||
|
|
||||||
|
def script_from_file(path: str, filters: list[str]):
|
||||||
|
content = read_text_file(path)
|
||||||
|
filters_strs = [f'"{filter}"' for filter in filters]
|
||||||
|
filters_str = f'[{",".join(filters_strs)}]'
|
||||||
|
return f"""
|
||||||
|
{content}
|
||||||
|
|
||||||
|
from okipy import get_inline_suite
|
||||||
|
get_inline_suite().run({filters_str})
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def read_text_file(path: str):
|
||||||
|
with open(path, "r") as file:
|
||||||
|
return "\n".join(file.readlines())
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__': main()
|
1
src/okipy/py.typed
Normal file
1
src/okipy/py.typed
Normal file
|
@ -0,0 +1 @@
|
||||||
|
# Marker file for PEP 561. The mypy package uses inline types.
|
20
src/okipy/utils.py
Normal file
20
src/okipy/utils.py
Normal file
|
@ -0,0 +1,20 @@
|
||||||
|
|
||||||
|
from io import StringIO
|
||||||
|
import sys
|
||||||
|
|
||||||
|
|
||||||
|
class CaptureOutput():
|
||||||
|
def __enter__(self):
|
||||||
|
self.stdout = list[str]()
|
||||||
|
self._stdout = sys.stdout
|
||||||
|
sys.stdout = self._str_stdout = StringIO()
|
||||||
|
self.stderr = list[str]()
|
||||||
|
self._stderr = sys.stderr
|
||||||
|
sys.stderr = self._str_stderr = StringIO()
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, *_args):
|
||||||
|
self.stdout.extend(self._str_stdout.getvalue().splitlines())
|
||||||
|
self.stderr.extend(self._str_stderr.getvalue().splitlines())
|
||||||
|
sys.stdout = self._stdout
|
||||||
|
sys.stderr = self._stderr
|
Loading…
Add table
Add a link
Reference in a new issue