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