This commit is contained in:
JOLIMAITRE Matthieu 2024-05-21 03:19:53 +02:00
commit f4c09d21fe
18 changed files with 443 additions and 0 deletions

7
.gitignore vendored Normal file
View file

@ -0,0 +1,7 @@
/venv
/dist
/src/okipy.egg-info
__pycache__
.mypy_cache

3
.vscode/settings.json vendored Normal file
View file

@ -0,0 +1,3 @@
{
"python.analysis.typeCheckingMode": "standard",
}

21
LICENCE Normal file
View 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
View 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 :
![test output screenshot](./assets/output.png)

BIN
assets/output.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

14
build.sh Executable file
View 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
View 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
View 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
View 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
View file

@ -0,0 +1,6 @@
#!/bin/sh
set -e
cd "$(dirname "$(realpath "$0")")"
./clean.sh
./build.sh

30
pyproject.toml Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View file

@ -0,0 +1 @@
# Marker file for PEP 561. The mypy package uses inline types.

20
src/okipy/utils.py Normal file
View 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