Skip to content

Commit

Permalink
Merge pull request #21 from timbernat/unit-test-setup
Browse files Browse the repository at this point in the history
Unit test setup
  • Loading branch information
timbernat authored Aug 29, 2024
2 parents c30cb95 + 5dafd0e commit c7f191a
Show file tree
Hide file tree
Showing 48 changed files with 321 additions and 59 deletions.
1 change: 1 addition & 0 deletions polymerist/data/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
'''Additional data shipped along with polymerist source code'''
57 changes: 57 additions & 0 deletions polymerist/genutils/pkginspect.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
'''For checking whether object are valid Python modules and packages, and if so for gathering info from within them'''

from typing import Union
from pathlib import Path

from importlib.resources import (
Package,
files as get_package_path
)
from importlib.resources._common import get_package, from_package, resolve


def is_module(module : Package) -> bool:
'''Determine whether a given Package-like (i.e. str or ModuleType) is a valid Python module
This will return True for packages, bottom-level modules (i.e. *.py) and Python scripts'''
try:
resolve(module)
return True
except ModuleNotFoundError:
return False

def is_package(package : Package) -> bool:
'''Determine whether a given Package-like (i.e. str or ModuleType) is a valid Python package'''
try:
get_package(package)
return True
except (ModuleNotFoundError, TypeError):
return False


def get_resource_path_within_package(relative_path : Union[str, Path], package : Package) -> Path:
'''Get the Path to a resource (i.e. either a directory or a file) which lives within a Python package'''
package_path : Path = get_package_path(package) # will also implicitly check that the provided package exists as a module
resource_path = package_path / relative_path # concat to Path here means string inputs for relative_path are valid without explicit conversion

if not resource_path.exists(): # if this block is reached, it means "package" is a real module and resource path is DEFINED relative to package's path, so the below message is valid
raise ValueError(f'{resolve(package).__name__} contains no resource "{relative_path}"')

return resource_path

def get_dir_path_within_package(relative_path : Union[str, Path], package : Package) -> Path:
'''Get the Path to a directory which lives within a Python package'''
dir_path : Path = get_resource_path_within_package(package=package, relative_path=relative_path) # performs all check associated with getting the resource

if not dir_path.is_dir():
raise NotADirectoryError(f'{resolve(package).__name__} contains "{dir_path}", but it is not a directory')

return dir_path

def get_file_path_within_package(relative_path : Union[str, Path], package : Package) -> Path:
'''Get the Path to a (non-directory) file which lives within a Python package'''
file_path : Path = get_resource_path_within_package(package=package, relative_path=relative_path) # performs all check associated with getting the resource

if not file_path.is_file():
raise FileNotFoundError(f'{resolve(package).__name__} contains no file "{file_path}"')

return file_path
59 changes: 0 additions & 59 deletions polymerist/genutils/sequences/discernment/_tests.py

This file was deleted.

1 change: 1 addition & 0 deletions polymerist/tests/analysis/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
'''Unit tests for `analysis` package'''
1 change: 1 addition & 0 deletions polymerist/tests/data/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
'''Reference data used to load or verify unit tests'''
1 change: 1 addition & 0 deletions polymerist/tests/data/sample.dat
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
I contain pointless sample text for debugging purposes!
1 change: 1 addition & 0 deletions polymerist/tests/genutils/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
'''Unit tests for `genutils` package'''
1 change: 1 addition & 0 deletions polymerist/tests/genutils/decorators/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
'''Unit tests for `decorators` package'''
1 change: 1 addition & 0 deletions polymerist/tests/genutils/fileutils/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
'''Unit tests for `fileutils` package'''
1 change: 1 addition & 0 deletions polymerist/tests/genutils/fileutils/jsonio/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
'''Unit tests for `jsonio` package'''
1 change: 1 addition & 0 deletions polymerist/tests/genutils/logutils/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
'''Unit tests for `logutils` package'''
1 change: 1 addition & 0 deletions polymerist/tests/genutils/sequences/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
'''Unit tests for `sequences` package'''
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
'''Unit tests for `discernment` package'''
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
'''Unit tests for DISCERNMENT-related functionality'''

import pytest
from polymerist.genutils.pkginspect import get_file_path_within_package
from polymerist.tests import data as testdata

import json
from pathlib import Path

from polymerist.genutils.sequences.discernment.inventory import SymbolInventory
from polymerist.genutils.sequences.discernment.strategies import DISCERNMENTStrategy


# DEFINE/LOAD HARD-CODED INPUTS AND EXPECTED OUTPUTS TO A PARTICULAR DISCERNMENT PROBLEM
@pytest.fixture(scope='module')
def word() -> str:
return 'accg'

@pytest.fixture(scope='module')
def choice_bins() -> str:
return ('bbc','aced','bad','daea','fccce','g','abcd','fggegc')

@pytest.fixture(scope='module')
def symbol_inventory(choice_bins) -> SymbolInventory:
return SymbolInventory.from_bins(choice_bins)

@pytest.fixture(scope='module')
def solution_path() -> Path:
return get_file_path_within_package('correct_discernment_solution.json', testdata)

@pytest.fixture(scope='module')
def correct_solution(solution_path) -> set[tuple[int, ...]]:
with solution_path.open('r') as file:
solution = set(
tuple(indices)
for indices in json.load(file)
)
return solution


@pytest.mark.parametrize("ignore_multiplicities,unique_bins,DSClass", [(False, False, DSClass) for DSClass in DISCERNMENTStrategy.__subclasses__()]) # TODO: produce solutions with unique bins AND ignored multiplicities to fully test
class TestDISCERNMENTStrategies:
all_results : dict[str, set[int]] = {} # cache solutions to avoid tedoius recalculations
def test_preserves_symbol_inventory(
self,
word : str,
symbol_inventory : SymbolInventory,
correct_solution : set[tuple[int, ...]],
ignore_multiplicities : bool,
unique_bins : bool,
DSClass : type[DISCERNMENTStrategy],
) -> None:
'''Check to ensure that all implementations of DISCERNMENT solution strageties yield the same outputs and don't modify a provided symbol inventory
Raises failure-specific Exception if inconsistency is detected, terminates silently (no Exception, returns None) otherwise'''
mod_sym_inv = symbol_inventory.deepcopy() # create a copy of the symbol inventory to ensure any errant modifications do not affect other tests

method_name = DSClass.__name__
ds_strat = DSClass()
proposed_solution = set(
idxs
for idxs in ds_strat.enumerate_choice_labels(
word,
mod_sym_inv,
ignore_multiplicities=ignore_multiplicities,
unique_bins=unique_bins
)
)
self.all_results[method_name] = proposed_solution # cache for comparison in later tests
assert (mod_sym_inv == symbol_inventory), f'Algorithm {method_name} does not produce to correct enumeration of bin labels'

def test_solution_is_correct(
self,
word : str,
symbol_inventory : SymbolInventory,
correct_solution : set[tuple[int, ...]],
ignore_multiplicities : bool,
unique_bins : bool,
DSClass : type[DISCERNMENTStrategy],
) -> None:
method_name = DSClass.__name__
assert (self.all_results[method_name] == correct_solution), f'Algorithm {method_name} does not return symbol inventory to original state after completion'

def test_solution_strategies_are_consistent(
self,
word : str,
symbol_inventory : SymbolInventory,
correct_solution : set[tuple[int, ...]],
ignore_multiplicities : bool,
unique_bins : bool,
DSClass : type[DISCERNMENTStrategy],
) -> None:
method_name = DSClass.__name__
proposed_solution = self.all_results[method_name]

for other_method_name, other_solution in self.all_results.items(): # check against all other methods PRIOR TO INSERTION (minimal number of checks guaranteed to verify all pairwise checks)
# check both symmetric differences to make sure no solution sequences are unique to either method
assert (proposed_solution - other_solution == set()) and (other_solution - proposed_solution == set()), f'Algorithms {method_name} and {other_method_name} produce inconsistent solutions'
1 change: 1 addition & 0 deletions polymerist/tests/genutils/sequences/similarity/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
'''Unit tests for `similarity` package'''
119 changes: 119 additions & 0 deletions polymerist/tests/genutils/test_pkginspect.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
'''Unit tests for package inspection utilities'''

from types import ModuleType

import pytest
from pathlib import Path
import math, json # use these as test cases, since they are pretty stable in stdlib

from polymerist import polymerist # this is a dummy toplevel module, and NOt the entire polymerist package
from polymerist import genutils
from polymerist.genutils import pkginspect
from polymerist import tests



# TABULATED EXPECTED TESTS OUTPUTS
non_module_types = [ # types that are obviously not modules OR packages, and which should fail
bool, int, float, complex, tuple, list, dict, set, # str, Path # str and Path need to be tested separately
]

are_modules = [
('--not_a_module--', False), # deliberately weird to ensure this never accidentally clashes with a legit module name
(math, True),
('math', True), # test that the string -> module resolver also works as intended
(json, True),
('json', True),
(json.decoder, True),
('json.decoder', True),
(polymerist, True),
('polymerist.polymerist', True),
(genutils, True),
('polymerist.genutils', True),
]

are_packages = [
('--not_a_package--', False), # deliberately weird to ensure this never accidentally clashes with a legit module name
(math, False),
('math', False), # test that the string -> module resolver also works as intended
(json, True),
('json', True),
(json.decoder, False),
('json.decoder', False),
(polymerist, False),
('polymerist.polymerist', False),
(genutils, True),
('polymerist.genutils', True),
]


# MODULE AND PACKAGE PERCEPTION
@pytest.mark.parametrize('module, expected_output', are_modules)
def test_is_module(module : ModuleType, expected_output : bool) -> None:
'''See if Python module perception behaves as expected'''
assert pkginspect.is_module(module) == expected_output

@pytest.mark.parametrize('non_module_type', non_module_types)
def test_is_module_fail_on_invalid_types(non_module_type : type) -> None:
'''check that module perception fails on invalid inputs'''
with pytest.raises(AttributeError) as err_info:
instance = non_module_type() # create a default instance
_ = pkginspect.is_module(instance)

@pytest.mark.parametrize('module, expected_output', are_packages)
def test_is_package(module : ModuleType, expected_output : bool) -> None:
'''See if Python package perception behaves as expected'''
assert pkginspect.is_package(module) == expected_output

@pytest.mark.parametrize('non_module_type', non_module_types) # NOTE: these args are in fact deliberately NOT renamed to ".*package" from ".*module"
def test_is_module_fail_on_invalid_types(non_module_type : type) -> None:
'''check that package perception fails on invalid inputs'''
with pytest.raises(AttributeError) as err_info:
instance = non_module_type() # create a default instance
_ = pkginspect.is_package(instance)

# FETCHING DATA FROM PACKAGES
@pytest.mark.parametrize(
'rel_path, module',
[
('data', tests),
('data/sample.dat', tests),
pytest.param('daata/simple.dat', tests, marks=pytest.mark.xfail(raises=ValueError, reason="This isn't a real file", strict=True)),
('pkginspect.py', genutils),
pytest.param('fake/whatever.txt', pkginspect, marks=pytest.mark.xfail(raises=TypeError, reason="Module is not a package and therefore cannot contain resources", strict=True)),
]
)
def test_get_resource_path(rel_path : str, module : ModuleType) -> None:
'''Test fetching a resource (i.e. file OR dir) from a package'''
resource_path = pkginspect.get_resource_path_within_package(rel_path, module)
assert isinstance(resource_path, Path)

@pytest.mark.parametrize(
'rel_path, module',
[
pytest.param('data', tests, marks=pytest.mark.xfail(raises=FileNotFoundError, reason="This is a directory, NOT a file", strict=True)),
('data/sample.dat', tests),
pytest.param('daata/simple.dat', tests, marks=pytest.mark.xfail(raises=ValueError, reason="This isn't a real file", strict=True)),
('pkginspect.py', genutils),
pytest.param('fake/whatever.txt', pkginspect, marks=pytest.mark.xfail(raises=TypeError, reason="Module is not a package and therefore cannot contain resources", strict=True)),
]
)
def test_get_file_path(rel_path : str, module : ModuleType) -> None:
'''Test fetching a file (i.e. NOT a dir) from a package'''
resource_path = pkginspect.get_file_path_within_package(rel_path, module)
assert isinstance(resource_path, Path)

@pytest.mark.parametrize(
'rel_path, module',
[
('data', tests),
pytest.param('data/sample.dat', tests, marks=pytest.mark.xfail(raises=NotADirectoryError, reason='This IS a real file, but not a directory', strict=True)),
pytest.param('daata/simple.dat', tests, marks=pytest.mark.xfail(raises=ValueError, reason="This isn't a real file", strict=True)),
pytest.param('pkginspect.py', genutils, marks=pytest.mark.xfail(raises=NotADirectoryError, reason='This IS a real file, but not a directory', strict=True)),
pytest.param('fake/whatever.txt', pkginspect, marks=pytest.mark.xfail(raises=TypeError, reason="Module is not a package and therefore cannot contain resources", strict=True)),
]
)
def test_get_dir_path(rel_path : str, module : ModuleType) -> None:
'''Test fetching a dir (i.e. NOT a file) from a package'''
resource_path = pkginspect.get_dir_path_within_package(rel_path, module)
assert isinstance(resource_path, Path)
1 change: 1 addition & 0 deletions polymerist/tests/genutils/textual/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
'''Unit tests for `textual` package'''
1 change: 1 addition & 0 deletions polymerist/tests/genutils/treetools/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
'''Unit tests for `treetools` package'''
1 change: 1 addition & 0 deletions polymerist/tests/genutils/typetools/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
'''Unit tests for `typetools` package'''
1 change: 1 addition & 0 deletions polymerist/tests/graphics/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
'''Unit tests for `graphics` package'''
1 change: 1 addition & 0 deletions polymerist/tests/maths/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
'''Unit tests for `maths` package'''
1 change: 1 addition & 0 deletions polymerist/tests/maths/combinatorics/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
'''Unit tests for `combinatorics` package'''
1 change: 1 addition & 0 deletions polymerist/tests/maths/fractions/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
'''Unit tests for `fractions` package'''
1 change: 1 addition & 0 deletions polymerist/tests/maths/greek/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
'''Unit tests for `greek` package'''
Loading

0 comments on commit c7f191a

Please sign in to comment.