├── requirements ├── requirements.txt └── test_requirements.txt ├── MANIFEST.in ├── allpairspy ├── __init__.py ├── __version__.py ├── pairs_storage.py └── allpairs.py ├── pylama.ini ├── examples ├── example_ordered_dict.py ├── example1.1.py ├── example1.2.py ├── test_parameterize.py ├── example1.3.py ├── compare_to_others.py ├── example2.1.py └── example2.2.py ├── .github ├── FUNDING.yml └── workflows │ └── tests.yml ├── CHANGES.txt ├── LICENSE.txt ├── Makefile ├── pyproject.toml ├── tox.ini ├── .gitignore ├── setup.py ├── tests └── test_allpairs.py └── README.rst /requirements/requirements.txt: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /requirements/test_requirements.txt: -------------------------------------------------------------------------------- 1 | pytest>=6.0.1 2 | pytest-md-report>=0.3 3 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include LICENSE.txt 2 | include README.rst 3 | include setup.cfg 4 | include tox.ini 5 | 6 | recursive-include requirements * 7 | recursive-include tests * 8 | 9 | global-exclude __pycache__/* 10 | global-exclude *.pyc 11 | -------------------------------------------------------------------------------- /allpairspy/__init__.py: -------------------------------------------------------------------------------- 1 | from .__version__ import ( 2 | __author__, 3 | __author_email__, 4 | __license__, 5 | __maintainer__, 6 | __maintainer_email__, 7 | __version__, 8 | ) 9 | from .allpairs import AllPairs 10 | -------------------------------------------------------------------------------- /allpairspy/__version__.py: -------------------------------------------------------------------------------- 1 | __author__ = "MetaCommunications Engineering" 2 | __author_email__ = "metacomm@users.sourceforge.net" 3 | __maintainer__ = "Tsuyoshi Hombashi" 4 | __maintainer_email__ = "tsuyoshi.hombashi@gmail.com" 5 | __license__ = "MIT License" 6 | __version__ = "2.5.1" 7 | -------------------------------------------------------------------------------- /pylama.ini: -------------------------------------------------------------------------------- 1 | [pylama] 2 | skip = .eggs/*,.tox/*,*/.env/*,build/*,node_modules/*,_sandbox/*,build/*,docs/conf.py 3 | 4 | [pylama:pycodestyle] 5 | max_line_length = 100 6 | 7 | # E203: whitespace before ':' (for black) 8 | # W503: line break before binary operator (for black) 9 | ignore = E203,W503 10 | 11 | [pylama:pylint] 12 | max_line_length = 100 13 | 14 | [pylama:*/__init__.py] 15 | # W0611: imported but unused [pyflakes] 16 | ignore = W0611 17 | -------------------------------------------------------------------------------- /examples/example_ordered_dict.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | from collections import OrderedDict 4 | 5 | from allpairspy import AllPairs 6 | 7 | 8 | parameters = OrderedDict( 9 | { 10 | "brand": ["Brand X", "Brand Y"], 11 | "os": ["98", "NT", "2000", "XP"], 12 | "minute": [15, 30, 60], 13 | } 14 | ) 15 | 16 | print("PAIRWISE:") 17 | for i, pairs in enumerate(AllPairs(parameters)): 18 | print(f"{i:2d}: {pairs}") 19 | -------------------------------------------------------------------------------- /examples/example1.1.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | """ 4 | Demo of the basic functionality - just getting pairwise/n-wise combinations 5 | """ 6 | 7 | from allpairspy import AllPairs 8 | 9 | 10 | parameters = [ 11 | ["Brand X", "Brand Y"], 12 | ["98", "NT", "2000", "XP"], 13 | ["Internal", "Modem"], 14 | ["Salaried", "Hourly", "Part-Time", "Contr."], 15 | [6, 10, 15, 30, 60], 16 | ] 17 | # sample parameters are is taken from 18 | # http://www.stsc.hill.af.mil/consulting/sw_testing/improvement/cst.html 19 | 20 | print("PAIRWISE:") 21 | for i, pairs in enumerate(AllPairs(parameters)): 22 | print(f"{i:2d}: {pairs}") 23 | -------------------------------------------------------------------------------- /examples/example1.2.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | """ 4 | Demo of the basic functionality - just getting pairwise/n-wise combinations 5 | """ 6 | 7 | from allpairspy import AllPairs 8 | 9 | 10 | parameters = [ 11 | ["Brand X", "Brand Y"], 12 | ["98", "NT", "2000", "XP"], 13 | ["Internal", "Modem"], 14 | ["Salaried", "Hourly", "Part-Time", "Contr."], 15 | [6, 10, 15, 30, 60], 16 | ] 17 | # sample parameters are is taken from 18 | # http://www.stsc.hill.af.mil/consulting/sw_testing/improvement/cst.html 19 | 20 | print("TRIPLEWISE:") 21 | for i, pairs in enumerate(AllPairs(parameters, n=3)): 22 | print(f"{i:2d}: {pairs}") 23 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: thombashi 4 | patreon: # Replace with a single Patreon username 5 | open_collective: # Replace with a single Open Collective username 6 | ko_fi: # Replace with a single Ko-fi username 7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | liberapay: # Replace with a single Liberapay username 10 | issuehunt: # Replace with a single IssueHunt username 11 | otechie: # Replace with a single Otechie username 12 | custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] 13 | -------------------------------------------------------------------------------- /examples/test_parameterize.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | """ 4 | .. codeauthor:: Tsuyoshi Hombashi 5 | """ 6 | 7 | import pytest 8 | 9 | from allpairspy import AllPairs 10 | 11 | 12 | def function_to_be_tested(brand: str, operating_system: str, minute: int) -> bool: 13 | # do something 14 | 15 | return True 16 | 17 | 18 | class TestParameterized: 19 | @pytest.mark.parametrize( 20 | ["brand", "operating_system", "minute"], 21 | [ 22 | values 23 | for values in AllPairs( 24 | [["Brand X", "Brand Y"], ["98", "NT", "2000", "XP"], [10, 15, 30, 60]] 25 | ) 26 | ], 27 | ) 28 | def test(self, brand, operating_system, minute): 29 | assert function_to_be_tested(brand, operating_system, minute) 30 | -------------------------------------------------------------------------------- /examples/example1.3.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | """ 4 | Demo of the basic functionality - just getting pairwise combinations 5 | and skipping previously tested pairs. 6 | """ 7 | 8 | from allpairspy import AllPairs 9 | 10 | 11 | parameters = [ 12 | ["Brand X", "Brand Y"], 13 | ["98", "NT", "2000", "XP"], 14 | ["Internal", "Modem"], 15 | ["Salaried", "Hourly", "Part-Time", "Contr."], 16 | [6, 10, 15, 30, 60], 17 | ] 18 | # sample parameters are is taken from 19 | # http://www.stsc.hill.af.mil/consulting/sw_testing/improvement/cst.html 20 | 21 | tested = [ 22 | ["Brand X", "98", "Modem", "Hourly", 10], 23 | ["Brand X", "98", "Modem", "Hourly", 15], 24 | ["Brand Y", "NT", "Internal", "Part-Time", 10], 25 | ] 26 | 27 | print("PAIRWISE:") 28 | for i, pairs in enumerate(AllPairs(parameters, previously_tested=tested)): 29 | print(f"{i:2d}: {pairs}") 30 | -------------------------------------------------------------------------------- /CHANGES.txt: -------------------------------------------------------------------------------- 1 | Since 2.1.0: 2 | https://github.com/thombashi/allpairspy/releases 3 | 4 | Version 2.0.1 (April 4, 2009): 5 | 6 | - Moved examples into their own directory. 7 | 8 | - 'example2.2.py' re-written to get rid of frivolous usage of 9 | decorators (thanks to Tennis Smith for bringing 10 | the example's apparent complexity to our attention). 11 | 12 | - Easier installation through distutils-based setup.py script. 13 | 14 | - Created AllPairs project on SourceForge.net with home page at 15 | http://apps.sourceforge.net/trac/allpairs/. 16 | 17 | - Submitted the project to the Python Package Index at 18 | http://pypi.python.org/pypi. 19 | 20 | 21 | Version 2.0 (December 2007): 22 | 23 | - Ability to produce n-wise combinations. 24 | - Released as open source to MetaCommunications Engineering 25 | website (http://engineering.meta-comm.com). 26 | 27 | 28 | Version 1.0 (May 2002): 29 | 30 | Initial version, widely adopted at MetaCommunications Inc. for 31 | multiple testing projects. 32 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright (c) 2002-2009, MetaCommunications Inc. 2 | 3 | Permission is hereby granted, free of charge, to any person 4 | obtaining a copy of this software and associated documentation 5 | files (the "Software"), to deal in the Software without 6 | restriction, including without limitation the rights to use, 7 | copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | copies of the Software, and to permit persons to whom the 9 | Software is furnished to do so, subject to the following 10 | conditions: 11 | 12 | The above copyright notice and this permission notice shall be 13 | included in all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 16 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES 17 | OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 18 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT 19 | HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, 20 | WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 21 | FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 22 | OTHER DEALINGS IN THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | OWNER := thombashi 2 | PACKAGE := allpairspy 3 | BUILD_WORK_DIR := _work 4 | DIST_DIR := $(BUILD_WORK_DIR)/$(PACKAGE)/dist 5 | PKG_BUILD_DIR := $(BUILD_WORK_DIR)/$(PACKAGE) 6 | PYTHON := python3 7 | 8 | 9 | .PHONY: build 10 | build: clean 11 | @$(PYTHON) -m tox -e build 12 | ls -lh dist/* 13 | 14 | .PHONY: build-remote 15 | build-remote: clean 16 | @mkdir -p $(BUILD_WORK_DIR) 17 | @cd $(BUILD_WORK_DIR) && \ 18 | git clone --depth 1 https://github.com/$(OWNER)/$(PACKAGE).git && \ 19 | cd $(PACKAGE) && \ 20 | $(PYTHON) -m tox -e build 21 | ls -lh $(PKG_BUILD_DIR)/dist/* 22 | 23 | .PHONY: check 24 | check: 25 | @$(PYTHON) -m tox -e lint 26 | 27 | .PHONY: clean 28 | clean: 29 | @rm -rf $(BUILD_WORK_DIR) 30 | @$(PYTHON) -m tox -e clean 31 | 32 | .PHONY: fmt 33 | fmt: 34 | $(PYTHON) -m tox -e fmt 35 | 36 | .PHONY: release 37 | release: 38 | cd $(PKG_BUILD_DIR) && $(PYTHON) setup.py release --verbose 39 | $(MAKE) clean 40 | 41 | .PHONY: setup-ci 42 | setup-ci: 43 | @$(PYTHON) -m pip install -q --disable-pip-version-check --upgrade tox 44 | 45 | .PHONY: setup 46 | setup: setup-ci 47 | @$(PYTHON) -m pip install -q --disable-pip-version-check --upgrade -e .[test] releasecmd 48 | @$(PYTHON) -m pip check 49 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools>=61.0"] 3 | build-backend = "setuptools.build_meta" 4 | 5 | [tool.black] 6 | line-length = 100 7 | exclude = ''' 8 | /( 9 | \.eggs 10 | | \.git 11 | | \.mypy_cache 12 | | \.tox 13 | | \.venv 14 | | \.pytype 15 | | _build 16 | | buck-out 17 | | build 18 | | dist 19 | )/ 20 | | docs/conf.py 21 | ''' 22 | target-version = ['py37', 'py38', 'py39', 'py310'] 23 | 24 | [tool.coverage.run] 25 | source = ['allpairspy'] 26 | branch = true 27 | 28 | [tool.coverage.report] 29 | show_missing = true 30 | precision = 1 31 | exclude_lines = [ 32 | 'except ImportError', 33 | 'raise NotImplementedError', 34 | 'pass', 35 | 'ABCmeta', 36 | 'abstractmethod', 37 | 'abstractproperty', 38 | 'abstractclassmethod', 39 | 'warnings.warn', 40 | ] 41 | 42 | [tool.isort] 43 | known_third_party = [ 44 | ] 45 | include_trailing_comma = true 46 | line_length = 100 47 | lines_after_imports = 2 48 | multi_line_output = 3 49 | skip_glob = [ 50 | '*/.eggs/*', 51 | '*/.pytype/*', 52 | '*/.tox/*', 53 | ] 54 | 55 | [tool.pytest.ini_options] 56 | testpaths = [ 57 | "tests", 58 | ] 59 | 60 | md_report = true 61 | md_report_verbose = 0 62 | md_report_color = "auto" 63 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = 3 | py{37,38,39,310,311} 4 | pypy3 5 | build 6 | cov 7 | fmt 8 | lint 9 | 10 | [testenv] 11 | extras = 12 | test 13 | commands = 14 | pytest {posargs} 15 | 16 | [testenv:build] 17 | deps = 18 | build>=0.10 19 | twine 20 | wheel 21 | commands = 22 | python -m build 23 | twine check dist/*.whl dist/*.tar.gz 24 | 25 | [testenv:clean] 26 | skip_install = true 27 | deps = 28 | cleanpy>=0.4 29 | commands = 30 | cleanpy --all --exclude-envs . 31 | 32 | [testenv:cov] 33 | extras = 34 | test 35 | deps = 36 | coverage[toml] 37 | commands = 38 | coverage run -m pytest {posargs:-vv} 39 | coverage report -m 40 | 41 | [testenv:fmt] 42 | skip_install = true 43 | deps = 44 | autoflake>=2 45 | black>=23.1 46 | isort>=5 47 | commands = 48 | black setup.py examples tests allpairspy 49 | autoflake --in-place --recursive --remove-all-unused-imports --ignore-init-module-imports . 50 | isort . 51 | 52 | [testenv:lint] 53 | skip_install = true 54 | deps = 55 | black>=23.1 56 | codespell>=2 57 | pylama>=8.4.1 58 | commands = 59 | black --check setup.py examples tests allpairspy 60 | codespell allpairspy examples tests -q2 --check-filenames 61 | pylama 62 | -------------------------------------------------------------------------------- /examples/compare_to_others.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | from allpairspy import AllPairs 4 | 5 | 6 | """ 7 | Provided to make it easier to compare efficiency with other tools 8 | as per http://pairwise.org/tools.asp 9 | 10 | Current output is: 11 | 12 | 3^4: produces 9 rows 13 | 3^13: produces 17 rows 14 | 4^15 * 3^17 * 2^29: produces 37 rows 15 | 4^1 * 3^39 * 2^35: produces 27 rows 16 | 3^100: produces 29 rows 17 | 2^100: produces 15 rows 18 | 10^20: produces 219 rows 19 | 10^10: produces 172 rows 20 | """ 21 | 22 | 23 | def get_arrays(dimensions): 24 | opts = [] 25 | 26 | for d in dimensions: 27 | r = [] 28 | for _i in range(d[1]): 29 | r.append(range(d[0])) 30 | opts += r 31 | 32 | return opts 33 | 34 | 35 | def print_result(dimensions): 36 | header_list = [] 37 | for d in dimensions: 38 | header_list.append("%i^%i" % d) 39 | 40 | pairwise = AllPairs(get_arrays(dimensions)) 41 | n = len(list(pairwise)) 42 | 43 | print("{:s}: produces {:d} rows".format(" * ".join(header_list), n)) 44 | 45 | 46 | print_result(((3, 4),)) 47 | print_result(((3, 13),)) 48 | print_result(((4, 15), (3, 17), (2, 29))) 49 | print_result(((4, 1), (3, 39), (2, 35))) 50 | print_result(((3, 100),)) 51 | print_result(((2, 100),)) 52 | print_result(((10, 20),)) 53 | print_result(((10, 10),)) 54 | -------------------------------------------------------------------------------- /examples/example2.1.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | """ 4 | Demo of filtering capabilities 5 | """ 6 | 7 | from allpairspy import AllPairs 8 | 9 | 10 | def is_valid_combination(row): 11 | """ 12 | This is a filtering function. Filtering functions should return True 13 | if combination is valid and False otherwise. 14 | 15 | Test row that is passed here can be incomplete. 16 | To prevent search for unnecessary items filtering function 17 | is executed with found subset of data to validate it. 18 | """ 19 | 20 | n = len(row) 21 | 22 | if n > 1: 23 | # Brand Y does not support Windows 98 24 | if "98" == row[1] and "Brand Y" == row[0]: 25 | return False 26 | 27 | # Brand X does not work with XP 28 | if "XP" == row[1] and "Brand X" == row[0]: 29 | return False 30 | 31 | if n > 4: 32 | # Contractors are billed in 30 min increments 33 | if "Contr." == row[3] and row[4] < 30: 34 | return False 35 | 36 | return True 37 | 38 | 39 | # sample parameters are is taken from 40 | # http://www.stsc.hill.af.mil/consulting/sw_testing/improvement/cst.html 41 | parameters = [ 42 | ["Brand X", "Brand Y"], 43 | ["98", "NT", "2000", "XP"], 44 | ["Internal", "Modem"], 45 | ["Salaried", "Hourly", "Part-Time", "Contr."], 46 | [6, 10, 15, 30, 60], 47 | ] 48 | 49 | print("PAIRWISE:") 50 | for i, pairs in enumerate(AllPairs(parameters, filter_func=is_valid_combination)): 51 | print(f"{i:2d}: {pairs}") 52 | -------------------------------------------------------------------------------- /examples/example2.2.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | """ 4 | Another demo of filtering capabilities. 5 | Demonstrates how to use named parameters 6 | """ 7 | 8 | from allpairspy import AllPairs 9 | 10 | 11 | def is_valid_combination(values, names): 12 | dictionary = dict(zip(names, values)) 13 | 14 | """ 15 | Should return True if combination is valid and False otherwise. 16 | 17 | Dictionary that is passed here can be incomplete. 18 | To prevent search for unnecessary items filtering function 19 | is executed with found subset of data to validate it. 20 | """ 21 | 22 | rules = [ 23 | # Brand Y does not support Windows 98 24 | # Brand X does not work with XP 25 | # Contractors are billed in 30 min increments 26 | lambda d: "98" == d["os"] and "Brand Y" == d["brand"], 27 | lambda d: "XP" == d["os"] and "Brand X" == d["brand"], 28 | lambda d: "Contr." == d["employee"] and d["increment"] < 30, 29 | ] 30 | 31 | for rule in rules: 32 | try: 33 | if rule(dictionary): 34 | return False 35 | except KeyError: 36 | pass 37 | 38 | return True 39 | 40 | 41 | # sample parameters are is taken from 42 | # http://www.stsc.hill.af.mil/consulting/sw_testing/improvement/cst.html 43 | parameters = [ 44 | ("brand", ["Brand X", "Brand Y"]), 45 | ("os", ["98", "NT", "2000", "XP"]), 46 | ("network", ["Internal", "Modem"]), 47 | ("employee", ["Salaried", "Hourly", "Part-Time", "Contr."]), 48 | ("increment", [6, 10, 15, 30, 60]), 49 | ] 50 | 51 | pairwise = AllPairs( 52 | [x[1] for x in parameters], 53 | filter_func=lambda values: is_valid_combination(values, [x[0] for x in parameters]), 54 | ) 55 | 56 | print("PAIRWISE:") 57 | for i, pairs in enumerate(pairwise): 58 | print(f"{i:2d}: {pairs}") 59 | -------------------------------------------------------------------------------- /allpairspy/pairs_storage.py: -------------------------------------------------------------------------------- 1 | from itertools import combinations 2 | 3 | 4 | class Node: 5 | @property 6 | def id(self): 7 | return self.__node_id 8 | 9 | @property 10 | def counter(self): 11 | return self.__counter 12 | 13 | def __init__(self, node_id): 14 | self.__node_id = node_id 15 | self.__counter = 0 16 | self.in_ = set() 17 | self.out = set() 18 | 19 | def __str__(self): 20 | return str(self.__dict__) 21 | 22 | def inc_counter(self): 23 | self.__counter += 1 24 | 25 | 26 | key_cache = {} 27 | 28 | 29 | def key(items): 30 | if items in key_cache: 31 | return key_cache[items] 32 | 33 | key_value = tuple([x.id for x in items]) 34 | key_cache[items] = key_value 35 | 36 | return key_value 37 | 38 | 39 | class PairsStorage: 40 | def __init__(self, n): 41 | self.__n = n 42 | self.__nodes = {} 43 | self.__combs_arr = [set() for _i in range(n)] 44 | 45 | def __len__(self): 46 | return len(self.__combs_arr[-1]) 47 | 48 | def add_sequence(self, sequence): 49 | for i in range(1, self.__n + 1): 50 | for combination in combinations(sequence, i): 51 | self.__add_combination(combination) 52 | 53 | def get_node_info(self, item): 54 | return self.__nodes.get(item.id, Node(item.id)) 55 | 56 | def get_combs(self): 57 | return self.__combs_arr 58 | 59 | def __add_combination(self, combination): 60 | n = len(combination) 61 | assert n > 0 62 | 63 | self.__combs_arr[n - 1].add(key(combination)) 64 | if n == 1 and combination[0].id not in self.__nodes: 65 | self.__nodes[combination[0].id] = Node(combination[0].id) 66 | return 67 | 68 | ids = [x.id for x in combination] 69 | for i, id in enumerate(ids): 70 | curr = self.__nodes[id] 71 | curr.inc_counter() 72 | curr.in_.update(ids[:i]) 73 | curr.out.update(ids[i + 1 :]) 74 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | pip-wheel-metadata/ 24 | share/python-wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .nox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *.cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | 53 | # Translations 54 | *.mo 55 | *.pot 56 | 57 | # Django stuff: 58 | *.log 59 | local_settings.py 60 | db.sqlite3 61 | 62 | # Flask stuff: 63 | instance/ 64 | .webassets-cache 65 | 66 | # Scrapy stuff: 67 | .scrapy 68 | 69 | # Sphinx documentation 70 | docs/_build/ 71 | 72 | # PyBuilder 73 | target/ 74 | 75 | # Jupyter Notebook 76 | .ipynb_checkpoints 77 | 78 | # IPython 79 | profile_default/ 80 | ipython_config.py 81 | 82 | # pyenv 83 | .python-version 84 | 85 | # celery beat schedule file 86 | celerybeat-schedule 87 | 88 | # SageMath parsed files 89 | *.sage.py 90 | 91 | # Environments 92 | .env 93 | .venv 94 | env/ 95 | venv/ 96 | ENV/ 97 | env.bak/ 98 | venv.bak/ 99 | 100 | # Spyder project settings 101 | .spyderproject 102 | .spyproject 103 | 104 | # Rope project settings 105 | .ropeproject 106 | 107 | # mkdocs documentation 108 | /site 109 | 110 | # mypy 111 | .mypy_cache/ 112 | .dmypy.json 113 | dmypy.json 114 | 115 | # Pyre type checker 116 | .pyre/ 117 | 118 | # User settings 119 | _sandbox/ 120 | *_profile 121 | Untitled.ipynb 122 | 123 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | 3 | on: 4 | push: 5 | paths-ignore: 6 | - '.gitignore' 7 | - 'README.rst' 8 | pull_request: 9 | paths-ignore: 10 | - '.gitignore' 11 | - 'README.rst' 12 | 13 | jobs: 14 | build-package: 15 | runs-on: ubuntu-latest 16 | concurrency: 17 | group: ${{ github.event_name }}-${{ github.workflow }}-${{ github.ref_name }}-build 18 | cancel-in-progress: true 19 | timeout-minutes: 20 20 | container: 21 | image: ghcr.io/thombashi/python-ci:3.11 22 | 23 | steps: 24 | - uses: actions/checkout@v3 25 | 26 | - run: make build 27 | 28 | lint: 29 | runs-on: ubuntu-latest 30 | concurrency: 31 | group: ${{ github.event_name }}-${{ github.workflow }}-${{ github.ref_name }}-lint 32 | cancel-in-progress: true 33 | timeout-minutes: 20 34 | container: 35 | image: ghcr.io/thombashi/python-ci:3.11 36 | 37 | steps: 38 | - uses: actions/checkout@v3 39 | 40 | - run: make check 41 | 42 | unit-test: 43 | timeout-minutes: 20 44 | runs-on: ${{ matrix.os }} 45 | concurrency: 46 | group: ${{ github.event_name }}-${{ github.workflow }}-${{ github.ref_name }}-ut-${{ matrix.os }}-${{ matrix.python-version }} 47 | cancel-in-progress: true 48 | strategy: 49 | fail-fast: false 50 | matrix: 51 | python-version: ['3.7', '3.8', '3.9', '3.10', '3.11', 'pypy-3.10'] 52 | os: [ubuntu-latest, macos-latest, windows-latest] 53 | 54 | steps: 55 | - uses: actions/checkout@v3 56 | 57 | - name: Setup Python ${{ matrix.python-version }} 58 | uses: actions/setup-python@v4 59 | with: 60 | python-version: ${{ matrix.python-version }} 61 | cache: pip 62 | cache-dependency-path: | 63 | setup.py 64 | **/*requirements.txt 65 | tox.ini 66 | 67 | - name: Install pip 68 | run: python -m pip install --upgrade --disable-pip-version-check "pip>=21.1" 69 | 70 | - run: make setup-ci 71 | 72 | - name: Run tests 73 | run: tox -e cov 74 | env: 75 | PYTEST_DISCORD_WEBHOOK: ${{ secrets.PYTEST_DISCORD_WEBHOOK }} 76 | 77 | - name: Upload coverage report 78 | if: matrix.os == 'ubuntu-latest' && matrix.python-version == '3.8' 79 | run: | 80 | python -m pip install --upgrade --disable-pip-version-check coveralls tomli 81 | coveralls --service=github 82 | env: 83 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 84 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import os.path 2 | 3 | import setuptools 4 | 5 | 6 | MODULE_NAME = "allpairspy" 7 | REPOSITORY_URL = f"https://github.com/thombashi/{MODULE_NAME:s}" 8 | REQUIREMENT_DIR = "requirements" 9 | 10 | pkg_info = {} 11 | 12 | 13 | def get_release_command_class(): 14 | try: 15 | from releasecmd import ReleaseCommand 16 | except ImportError: 17 | return {} 18 | 19 | return {"release": ReleaseCommand} 20 | 21 | 22 | with open(os.path.join(MODULE_NAME, "__version__.py")) as f: 23 | exec(f.read(), pkg_info) 24 | 25 | with open("README.rst", encoding="utf8") as fp: 26 | long_description = fp.read() 27 | 28 | with open(os.path.join(REQUIREMENT_DIR, "requirements.txt")) as f: 29 | install_requires = [line.strip() for line in f if line.strip()] 30 | 31 | with open(os.path.join(REQUIREMENT_DIR, "test_requirements.txt")) as f: 32 | tests_requires = [line.strip() for line in f if line.strip()] 33 | 34 | setuptools.setup( 35 | name=MODULE_NAME, 36 | version=pkg_info["__version__"], 37 | url=REPOSITORY_URL, 38 | author=pkg_info["__author__"], 39 | author_email=pkg_info["__author_email__"], 40 | description="Pairwise test combinations generator", 41 | long_description=long_description, 42 | long_description_content_type="text/x-rst", 43 | license=pkg_info["__license__"], 44 | maintainer=pkg_info["__maintainer__"], 45 | maintainer_email=pkg_info["__maintainer_email__"], 46 | packages=setuptools.find_packages(exclude=["tests*"]), 47 | project_urls={ 48 | "Source": REPOSITORY_URL, 49 | "Tracker": f"{REPOSITORY_URL:s}/issues", 50 | }, 51 | python_requires=">=3.7", 52 | install_requires=install_requires, 53 | extras_require={"test": tests_requires}, 54 | classifiers=[ 55 | "Development Status :: 5 - Production/Stable", 56 | "Environment :: Console", 57 | "Intended Audience :: Developers", 58 | "Intended Audience :: Information Technology", 59 | "Intended Audience :: System Administrators", 60 | "License :: OSI Approved :: MIT License", 61 | "Natural Language :: English", 62 | "Operating System :: OS Independent", 63 | "Programming Language :: Python", 64 | "Programming Language :: Python :: 3", 65 | "Programming Language :: Python :: 3.7", 66 | "Programming Language :: Python :: 3.8", 67 | "Programming Language :: Python :: 3.9", 68 | "Programming Language :: Python :: 3.10", 69 | "Programming Language :: Python :: 3.11", 70 | "Programming Language :: Python :: 3 :: Only", 71 | "Programming Language :: Python :: Implementation :: CPython", 72 | "Programming Language :: Python :: Implementation :: PyPy", 73 | "Topic :: Software Development :: Libraries", 74 | "Topic :: Software Development :: Libraries :: Python Modules", 75 | "Topic :: Software Development :: Testing", 76 | "Topic :: Utilities", 77 | ], 78 | cmdclass=get_release_command_class(), 79 | ) 80 | -------------------------------------------------------------------------------- /allpairspy/allpairs.py: -------------------------------------------------------------------------------- 1 | from collections import OrderedDict, namedtuple 2 | from functools import cmp_to_key, reduce 3 | from itertools import combinations 4 | 5 | from .pairs_storage import PairsStorage, key 6 | 7 | 8 | class Item: 9 | @property 10 | def id(self): 11 | return self.__item_id 12 | 13 | @property 14 | def value(self): 15 | return self.__value 16 | 17 | @property 18 | def weights(self): 19 | return self.__weights 20 | 21 | def __init__(self, item_id, value): 22 | self.__item_id = item_id 23 | self.__value = value 24 | self.set_weights([]) 25 | 26 | def __str__(self): 27 | return str(self.__dict__) 28 | 29 | def set_weights(self, weights): 30 | self.__weights = weights 31 | 32 | 33 | def get_max_combination_number(prameter_matrix, n): 34 | param_len_list = [len(value_list) for value_list in prameter_matrix] 35 | 36 | return sum([reduce(lambda x, y: x * y, z) for z in combinations(param_len_list, n)]) 37 | 38 | 39 | def cmp_item(lhs, rhs): 40 | if lhs.weights == rhs.weights: 41 | return 0 42 | 43 | return -1 if lhs.weights < rhs.weights else 1 44 | 45 | 46 | class AllPairs: 47 | def __init__(self, parameters, filter_func=lambda x: True, previously_tested=None, n=2): 48 | """ 49 | TODO: check that input arrays are: 50 | - (optional) has no duplicated values inside single array / or compress such values 51 | """ 52 | 53 | if not previously_tested: 54 | previously_tested = [[]] 55 | 56 | self.__validate_parameter(parameters) 57 | 58 | self.__is_ordered_dict_param = isinstance(parameters, OrderedDict) 59 | self.__param_name_list = self.__extract_param_name_list(parameters) 60 | self.__pairs_class = namedtuple("Pairs", self.__param_name_list) 61 | 62 | self.__filter_func = filter_func 63 | self.__n = n 64 | self.__pairs = PairsStorage(n) 65 | 66 | value_matrix = self.__extract_value_matrix(parameters) 67 | self.__max_unique_pairs_expected = get_max_combination_number(value_matrix, n) 68 | self.__working_item_matrix = self.__get_working_item_matrix(value_matrix) 69 | 70 | for arr in previously_tested: 71 | if not arr: 72 | continue 73 | 74 | if len(arr) != len(self.__working_item_matrix): 75 | raise RuntimeError("previously tested combination is not complete") 76 | 77 | if not self.__filter_func(arr): 78 | raise ValueError("invalid tested combination is provided") 79 | 80 | tested = [] 81 | for i, val in enumerate(arr): 82 | idxs = [ 83 | Item(item.id, 0) for item in self.__working_item_matrix[i] if item.value == val 84 | ] 85 | 86 | if len(idxs) != 1: 87 | raise ValueError( 88 | "value from previously tested combination is not " 89 | "found in the parameters or found more than " 90 | "once" 91 | ) 92 | 93 | tested.append(idxs[0]) 94 | 95 | self.__pairs.add_sequence(tested) 96 | 97 | def __iter__(self): 98 | return self 99 | 100 | def next(self): 101 | return self.__next__() 102 | 103 | def __next__(self): 104 | assert len(self.__pairs) <= self.__max_unique_pairs_expected 105 | 106 | if len(self.__pairs) == self.__max_unique_pairs_expected: 107 | # no reasons to search further - all pairs are found 108 | raise StopIteration() 109 | 110 | previous_unique_pairs_count = len(self.__pairs) 111 | chosen_item_list = [None] * len(self.__working_item_matrix) 112 | indexes = [None] * len(self.__working_item_matrix) 113 | 114 | direction = 1 115 | i = 0 116 | 117 | while -1 < i < len(self.__working_item_matrix): 118 | if direction == 1: 119 | # move forward 120 | self.__resort_working_array(chosen_item_list[:i], i) 121 | indexes[i] = 0 122 | elif direction == 0 or direction == -1: 123 | # scan current array or go back 124 | indexes[i] += 1 125 | if indexes[i] >= len(self.__working_item_matrix[i]): 126 | direction = -1 127 | if i == 0: 128 | raise StopIteration() 129 | i += direction 130 | continue 131 | direction = 0 132 | else: 133 | raise ValueError(f"next(): unknown 'direction' code '{direction}'") 134 | 135 | chosen_item_list[i] = self.__working_item_matrix[i][indexes[i]] 136 | 137 | if self.__filter_func(self.__get_values(chosen_item_list[: i + 1])): 138 | assert direction > -1 139 | direction = 1 140 | else: 141 | direction = 0 142 | i += direction 143 | 144 | if len(self.__working_item_matrix) != len(chosen_item_list): 145 | raise StopIteration() 146 | 147 | self.__pairs.add_sequence(chosen_item_list) 148 | 149 | if len(self.__pairs) == previous_unique_pairs_count: 150 | # could not find new unique pairs - stop 151 | raise StopIteration() 152 | 153 | # replace returned array elements with real values and return it 154 | return self.__get_iteration_value(chosen_item_list) 155 | 156 | def __validate_parameter(self, value): 157 | if isinstance(value, OrderedDict): 158 | for parameter_list in value.values(): 159 | if not parameter_list: 160 | raise ValueError("each parameter arrays must have at least one item") 161 | 162 | return 163 | 164 | if len(value) < 2: 165 | raise ValueError("must provide more than one option") 166 | 167 | for parameter_list in value: 168 | if not parameter_list: 169 | raise ValueError("each parameter arrays must have at least one item") 170 | 171 | def __resort_working_array(self, chosen_item_list, num): 172 | for item in self.__working_item_matrix[num]: 173 | data_node = self.__pairs.get_node_info(item) 174 | 175 | new_combs = [ 176 | # numbers of new combinations to be created if this item is 177 | # appended to array 178 | {key(z) for z in combinations(chosen_item_list + [item], i + 1)} 179 | - self.__pairs.get_combs()[i] 180 | for i in range(0, self.__n) 181 | ] 182 | 183 | # weighting the node node that creates most of new pairs is the best 184 | weights = [-len(new_combs[-1])] 185 | 186 | # less used outbound connections most likely to produce more new 187 | # pairs while search continues 188 | weights.extend( 189 | [len(data_node.out)] 190 | + [len(x) for x in reversed(new_combs[:-1])] 191 | + [-data_node.counter] # less used node is better 192 | ) 193 | 194 | # otherwise we will prefer node with most of free inbound 195 | # connections; somehow it works out better ;) 196 | weights.append(-len(data_node.in_)) 197 | 198 | item.set_weights(weights) 199 | 200 | self.__working_item_matrix[num].sort(key=cmp_to_key(cmp_item)) 201 | 202 | def __get_working_item_matrix(self, parameter_matrix): 203 | return [ 204 | [ 205 | Item(f"a{param_idx:d}v{value_idx:d}", value) 206 | for value_idx, value in enumerate(value_list) 207 | ] 208 | for param_idx, value_list in enumerate(parameter_matrix) 209 | ] 210 | 211 | @staticmethod 212 | def __get_values(item_list): 213 | return [item.value for item in item_list] 214 | 215 | def __get_iteration_value(self, item_list): 216 | if not self.__param_name_list: 217 | return [item.value for item in item_list] 218 | 219 | return self.__pairs_class(*[item.value for item in item_list]) 220 | 221 | def __extract_param_name_list(self, parameters): 222 | if not self.__is_ordered_dict_param: 223 | return [] 224 | 225 | return list(parameters) 226 | 227 | def __extract_value_matrix(self, parameters): 228 | if not self.__is_ordered_dict_param: 229 | return parameters 230 | 231 | return [v for v in parameters.values()] 232 | -------------------------------------------------------------------------------- /tests/test_allpairs.py: -------------------------------------------------------------------------------- 1 | """ 2 | .. codeauthor:: Tsuyoshi Hombashi 3 | """ 4 | 5 | from collections import OrderedDict 6 | 7 | from allpairspy import AllPairs 8 | 9 | 10 | class Test_pairewise_OrderedDict: 11 | def test_normal(self): 12 | parameters = OrderedDict( 13 | {"brand": ["Brand X", "Brand Y"], "os": ["NT", "2000", "XP"], "minute": [15, 30, 60]} 14 | ) 15 | 16 | for pairs in AllPairs(parameters): 17 | assert pairs.brand == "Brand X" 18 | assert pairs.os == "NT" 19 | assert pairs.minute == 15 20 | break 21 | 22 | assert len(list(AllPairs(parameters))) == 9 23 | 24 | 25 | class Test_pairewise_list: 26 | # example1.1.py 27 | 28 | def test_normal(self): 29 | parameters = [ 30 | ["Brand X", "Brand Y"], 31 | ["98", "NT", "2000", "XP"], 32 | ["Internal", "Modem"], 33 | ["Salaried", "Hourly", "Part-Time", "Contr."], 34 | [6, 10, 15, 30, 60], 35 | ] 36 | 37 | assert list(AllPairs(parameters)) == [ 38 | ["Brand X", "98", "Internal", "Salaried", 6], 39 | ["Brand Y", "NT", "Modem", "Hourly", 6], 40 | ["Brand Y", "2000", "Internal", "Part-Time", 10], 41 | ["Brand X", "XP", "Modem", "Contr.", 10], 42 | ["Brand X", "2000", "Modem", "Part-Time", 15], 43 | ["Brand Y", "XP", "Internal", "Hourly", 15], 44 | ["Brand Y", "98", "Modem", "Salaried", 30], 45 | ["Brand X", "NT", "Internal", "Contr.", 30], 46 | ["Brand X", "98", "Internal", "Hourly", 60], 47 | ["Brand Y", "2000", "Modem", "Contr.", 60], 48 | ["Brand Y", "NT", "Modem", "Salaried", 60], 49 | ["Brand Y", "XP", "Modem", "Part-Time", 60], 50 | ["Brand Y", "2000", "Modem", "Hourly", 30], 51 | ["Brand Y", "98", "Modem", "Contr.", 15], 52 | ["Brand Y", "XP", "Modem", "Salaried", 15], 53 | ["Brand Y", "NT", "Modem", "Part-Time", 15], 54 | ["Brand Y", "XP", "Modem", "Part-Time", 30], 55 | ["Brand Y", "98", "Modem", "Part-Time", 6], 56 | ["Brand Y", "2000", "Modem", "Salaried", 6], 57 | ["Brand Y", "98", "Modem", "Salaried", 10], 58 | ["Brand Y", "XP", "Modem", "Contr.", 6], 59 | ["Brand Y", "NT", "Modem", "Hourly", 10], 60 | ] 61 | 62 | 63 | class Test_triplewise: 64 | # example1.2.py 65 | 66 | def test_normal(self): 67 | parameters = [ 68 | ["Brand X", "Brand Y"], 69 | ["98", "NT", "2000", "XP"], 70 | ["Internal", "Modem"], 71 | ["Salaried", "Hourly", "Part-Time", "Contr."], 72 | [6, 10, 15, 30, 60], 73 | ] 74 | 75 | assert list(AllPairs(parameters, n=3)) == [ 76 | ["Brand X", "98", "Internal", "Salaried", 6], 77 | ["Brand Y", "NT", "Modem", "Hourly", 6], 78 | ["Brand Y", "2000", "Modem", "Part-Time", 10], 79 | ["Brand X", "XP", "Internal", "Contr.", 10], 80 | ["Brand X", "XP", "Modem", "Part-Time", 6], 81 | ["Brand Y", "2000", "Internal", "Hourly", 15], 82 | ["Brand Y", "NT", "Internal", "Salaried", 10], 83 | ["Brand X", "98", "Modem", "Contr.", 15], 84 | ["Brand X", "98", "Modem", "Hourly", 10], 85 | ["Brand Y", "NT", "Modem", "Contr.", 30], 86 | ["Brand X", "XP", "Internal", "Hourly", 30], 87 | ["Brand X", "2000", "Modem", "Salaried", 30], 88 | ["Brand Y", "2000", "Internal", "Contr.", 6], 89 | ["Brand Y", "NT", "Internal", "Part-Time", 60], 90 | ["Brand Y", "XP", "Modem", "Salaried", 15], 91 | ["Brand X", "98", "Modem", "Part-Time", 60], 92 | ["Brand X", "XP", "Modem", "Salaried", 60], 93 | ["Brand X", "2000", "Internal", "Part-Time", 15], 94 | ["Brand X", "2000", "Modem", "Contr.", 60], 95 | ["Brand X", "98", "Modem", "Salaried", 10], 96 | ["Brand X", "98", "Modem", "Part-Time", 30], 97 | ["Brand X", "NT", "Modem", "Part-Time", 10], 98 | ["Brand Y", "NT", "Modem", "Salaried", 60], 99 | ["Brand Y", "NT", "Modem", "Hourly", 15], 100 | ["Brand Y", "NT", "Modem", "Hourly", 30], 101 | ["Brand Y", "NT", "Modem", "Hourly", 60], 102 | ["Brand Y", "NT", "Modem", "Hourly", 10], 103 | ] 104 | 105 | 106 | class Test_pairewise_w_tested: 107 | # example1.3.py 108 | 109 | def test_normal(self): 110 | parameters = [ 111 | ["Brand X", "Brand Y"], 112 | ["98", "NT", "2000", "XP"], 113 | ["Internal", "Modem"], 114 | ["Salaried", "Hourly", "Part-Time", "Contr."], 115 | [6, 10, 15, 30, 60], 116 | ] 117 | tested = [ 118 | ["Brand X", "98", "Modem", "Hourly", 10], 119 | ["Brand X", "98", "Modem", "Hourly", 15], 120 | ["Brand Y", "NT", "Internal", "Part-Time", 10], 121 | ] 122 | 123 | assert list(AllPairs(parameters, previously_tested=tested)) == [ 124 | ["Brand Y", "2000", "Modem", "Salaried", 6], 125 | ["Brand X", "XP", "Internal", "Contr.", 6], 126 | ["Brand Y", "XP", "Modem", "Contr.", 30], 127 | ["Brand X", "2000", "Internal", "Part-Time", 30], 128 | ["Brand Y", "98", "Internal", "Salaried", 60], 129 | ["Brand X", "NT", "Modem", "Salaried", 60], 130 | ["Brand Y", "XP", "Internal", "Hourly", 15], 131 | ["Brand Y", "NT", "Modem", "Hourly", 30], 132 | ["Brand Y", "2000", "Modem", "Part-Time", 15], 133 | ["Brand Y", "2000", "Modem", "Contr.", 10], 134 | ["Brand Y", "XP", "Modem", "Salaried", 10], 135 | ["Brand Y", "98", "Modem", "Part-Time", 6], 136 | ["Brand Y", "NT", "Modem", "Contr.", 15], 137 | ["Brand Y", "98", "Modem", "Contr.", 30], 138 | ["Brand Y", "XP", "Modem", "Part-Time", 60], 139 | ["Brand Y", "2000", "Modem", "Hourly", 60], 140 | ["Brand Y", "NT", "Modem", "Salaried", 30], 141 | ["Brand Y", "NT", "Modem", "Salaried", 15], 142 | ["Brand Y", "NT", "Modem", "Hourly", 6], 143 | ["Brand Y", "NT", "Modem", "Contr.", 60], 144 | ] 145 | 146 | 147 | class Test_pairewise_filter: 148 | def test_normal_example21(self): 149 | # example2.1.py 150 | 151 | parameters = [ 152 | ["Brand X", "Brand Y"], 153 | ["98", "NT", "2000", "XP"], 154 | ["Internal", "Modem"], 155 | ["Salaried", "Hourly", "Part-Time", "Contr."], 156 | [6, 10, 15, 30, 60], 157 | ] 158 | 159 | def is_valid_combination(row): 160 | """ 161 | Should return True if combination is valid and False otherwise. 162 | 163 | Test row that is passed here can be incomplete. 164 | To prevent search for unnecessary items filtering function 165 | is executed with found subset of data to validate it. 166 | """ 167 | 168 | n = len(row) 169 | if n > 1: 170 | # Brand Y does not support Windows 98 171 | if "98" == row[1] and "Brand Y" == row[0]: 172 | return False 173 | # Brand X does not work with XP 174 | if "XP" == row[1] and "Brand X" == row[0]: 175 | return False 176 | if n > 4: 177 | # Contractors are billed in 30 min increments 178 | if "Contr." == row[3] and row[4] < 30: 179 | return False 180 | 181 | return True 182 | 183 | assert list(AllPairs(parameters, filter_func=is_valid_combination)) == [ 184 | ["Brand X", "98", "Internal", "Salaried", 6], 185 | ["Brand Y", "NT", "Modem", "Hourly", 6], 186 | ["Brand Y", "2000", "Internal", "Part-Time", 10], 187 | ["Brand X", "2000", "Modem", "Contr.", 30], 188 | ["Brand X", "NT", "Internal", "Contr.", 60], 189 | ["Brand Y", "XP", "Modem", "Salaried", 60], 190 | ["Brand X", "98", "Modem", "Part-Time", 15], 191 | ["Brand Y", "XP", "Internal", "Hourly", 15], 192 | ["Brand Y", "NT", "Internal", "Part-Time", 30], 193 | ["Brand X", "2000", "Modem", "Hourly", 10], 194 | ["Brand Y", "XP", "Modem", "Contr.", 30], 195 | ["Brand Y", "2000", "Modem", "Salaried", 15], 196 | ["Brand Y", "NT", "Modem", "Salaried", 10], 197 | ["Brand Y", "XP", "Modem", "Part-Time", 6], 198 | ["Brand Y", "2000", "Modem", "Contr.", 60], 199 | ] 200 | 201 | def test_normal_example22(self): 202 | # example2.2.py 203 | 204 | parameters = [ 205 | ("brand", ["Brand X", "Brand Y"]), 206 | ("os", ["98", "NT", "2000", "XP"]), 207 | ("network", ["Internal", "Modem"]), 208 | ("employee", ["Salaried", "Hourly", "Part-Time", "Contr."]), 209 | ("increment", [6, 10, 15, 30, 60]), 210 | ] 211 | 212 | def is_valid_combination(values, names): 213 | dictionary = dict(zip(names, values)) 214 | 215 | """ 216 | Should return True if combination is valid and False otherwise. 217 | 218 | Dictionary that is passed here can be incomplete. 219 | To prevent search for unnecessary items filtering function 220 | is executed with found subset of data to validate it. 221 | """ 222 | 223 | rules = [ 224 | # Brand Y does not support Windows 98 225 | # Brand X does not work with XP 226 | # Contractors are billed in 30 min increments 227 | lambda d: "98" == d["os"] and "Brand Y" == d["brand"], 228 | lambda d: "XP" == d["os"] and "Brand X" == d["brand"], 229 | lambda d: "Contr." == d["employee"] and d["increment"] < 30, 230 | ] 231 | 232 | for rule in rules: 233 | try: 234 | if rule(dictionary): 235 | return False 236 | except KeyError: 237 | pass 238 | 239 | return True 240 | 241 | assert list( 242 | AllPairs( 243 | [x[1] for x in parameters], 244 | filter_func=lambda values: is_valid_combination(values, [x[0] for x in parameters]), 245 | ) 246 | ) == [ 247 | ["Brand X", "98", "Internal", "Salaried", 6], 248 | ["Brand Y", "NT", "Modem", "Hourly", 6], 249 | ["Brand Y", "2000", "Internal", "Part-Time", 10], 250 | ["Brand X", "2000", "Modem", "Contr.", 30], 251 | ["Brand X", "NT", "Internal", "Contr.", 60], 252 | ["Brand Y", "XP", "Modem", "Salaried", 60], 253 | ["Brand X", "98", "Modem", "Part-Time", 15], 254 | ["Brand Y", "XP", "Internal", "Hourly", 15], 255 | ["Brand Y", "NT", "Internal", "Part-Time", 30], 256 | ["Brand X", "2000", "Modem", "Hourly", 10], 257 | ["Brand Y", "XP", "Modem", "Contr.", 30], 258 | ["Brand Y", "2000", "Modem", "Salaried", 15], 259 | ["Brand Y", "NT", "Modem", "Salaried", 10], 260 | ["Brand Y", "XP", "Modem", "Part-Time", 6], 261 | ["Brand Y", "2000", "Modem", "Contr.", 60], 262 | ] 263 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | .. contents:: **allpairspy** forked from `bayandin/allpairs `__ 2 | :backlinks: top 3 | :depth: 2 4 | 5 | .. image:: https://badge.fury.io/py/allpairspy.svg 6 | :target: https://badge.fury.io/py/allpairspy 7 | :alt: PyPI package version 8 | 9 | .. image:: https://img.shields.io/pypi/pyversions/allpairspy.svg 10 | :target: https://pypi.org/project/allpairspy 11 | :alt: Supported Python versions 12 | 13 | .. image:: https://github.com/thombashi/allpairspy/workflows/Tests/badge.svg 14 | :target: https://github.com/thombashi/allpairspy/actions?query=workflow%3ATests 15 | :alt: Linux/macOS/Windows CI status 16 | 17 | .. image:: https://coveralls.io/repos/github/thombashi/allpairspy/badge.svg?branch=master 18 | :target: https://coveralls.io/github/thombashi/allpairspy?branch=master 19 | :alt: Test coverage 20 | 21 | 22 | AllPairs test combinations generator 23 | ------------------------------------------------ 24 | AllPairs is an open source test combinations generator written in 25 | Python, developed and maintained by MetaCommunications Engineering. 26 | The generator allows one to create a set of tests using "pairwise 27 | combinations" method, reducing a number of combinations of variables 28 | into a lesser set that covers most situations. 29 | 30 | For more info on pairwise testing see http://www.pairwise.org. 31 | 32 | 33 | Features 34 | -------- 35 | * Produces good enough dataset. 36 | * Pythonic, iterator-style enumeration interface. 37 | * Allows to filter out "invalid" combinations during search for the next combination. 38 | * Goes beyond pairs! If/when required can generate n-wise combinations. 39 | 40 | 41 | Get Started 42 | --------------- 43 | 44 | Basic Usage 45 | ================== 46 | :Sample Code: 47 | .. code:: python 48 | 49 | from allpairspy import AllPairs 50 | 51 | parameters = [ 52 | ["Brand X", "Brand Y"], 53 | ["98", "NT", "2000", "XP"], 54 | ["Internal", "Modem"], 55 | ["Salaried", "Hourly", "Part-Time", "Contr."], 56 | [6, 10, 15, 30, 60], 57 | ] 58 | 59 | print("PAIRWISE:") 60 | for i, pairs in enumerate(AllPairs(parameters)): 61 | print("{:2d}: {}".format(i, pairs)) 62 | 63 | :Output: 64 | .. code:: 65 | 66 | PAIRWISE: 67 | 0: ['Brand X', '98', 'Internal', 'Salaried', 6] 68 | 1: ['Brand Y', 'NT', 'Modem', 'Hourly', 6] 69 | 2: ['Brand Y', '2000', 'Internal', 'Part-Time', 10] 70 | 3: ['Brand X', 'XP', 'Modem', 'Contr.', 10] 71 | 4: ['Brand X', '2000', 'Modem', 'Part-Time', 15] 72 | 5: ['Brand Y', 'XP', 'Internal', 'Hourly', 15] 73 | 6: ['Brand Y', '98', 'Modem', 'Salaried', 30] 74 | 7: ['Brand X', 'NT', 'Internal', 'Contr.', 30] 75 | 8: ['Brand X', '98', 'Internal', 'Hourly', 60] 76 | 9: ['Brand Y', '2000', 'Modem', 'Contr.', 60] 77 | 10: ['Brand Y', 'NT', 'Modem', 'Salaried', 60] 78 | 11: ['Brand Y', 'XP', 'Modem', 'Part-Time', 60] 79 | 12: ['Brand Y', '2000', 'Modem', 'Hourly', 30] 80 | 13: ['Brand Y', '98', 'Modem', 'Contr.', 15] 81 | 14: ['Brand Y', 'XP', 'Modem', 'Salaried', 15] 82 | 15: ['Brand Y', 'NT', 'Modem', 'Part-Time', 15] 83 | 16: ['Brand Y', 'XP', 'Modem', 'Part-Time', 30] 84 | 17: ['Brand Y', '98', 'Modem', 'Part-Time', 6] 85 | 18: ['Brand Y', '2000', 'Modem', 'Salaried', 6] 86 | 19: ['Brand Y', '98', 'Modem', 'Salaried', 10] 87 | 20: ['Brand Y', 'XP', 'Modem', 'Contr.', 6] 88 | 21: ['Brand Y', 'NT', 'Modem', 'Hourly', 10] 89 | 90 | 91 | Filtering 92 | ================== 93 | You can restrict pairs by setting a filtering function to ``filter_func`` at 94 | ``AllPairs`` constructor. 95 | 96 | :Sample Code: 97 | .. code:: python 98 | 99 | from allpairspy import AllPairs 100 | 101 | def is_valid_combination(row): 102 | """ 103 | This is a filtering function. Filtering functions should return True 104 | if combination is valid and False otherwise. 105 | 106 | Test row that is passed here can be incomplete. 107 | To prevent search for unnecessary items filtering function 108 | is executed with found subset of data to validate it. 109 | """ 110 | 111 | n = len(row) 112 | 113 | if n > 1: 114 | # Brand Y does not support Windows 98 115 | if "98" == row[1] and "Brand Y" == row[0]: 116 | return False 117 | 118 | # Brand X does not work with XP 119 | if "XP" == row[1] and "Brand X" == row[0]: 120 | return False 121 | 122 | if n > 4: 123 | # Contractors are billed in 30 min increments 124 | if "Contr." == row[3] and row[4] < 30: 125 | return False 126 | 127 | return True 128 | 129 | parameters = [ 130 | ["Brand X", "Brand Y"], 131 | ["98", "NT", "2000", "XP"], 132 | ["Internal", "Modem"], 133 | ["Salaried", "Hourly", "Part-Time", "Contr."], 134 | [6, 10, 15, 30, 60] 135 | ] 136 | 137 | print("PAIRWISE:") 138 | for i, pairs in enumerate(AllPairs(parameters, filter_func=is_valid_combination)): 139 | print("{:2d}: {}".format(i, pairs)) 140 | 141 | :Output: 142 | .. code:: 143 | 144 | PAIRWISE: 145 | 0: ['Brand X', '98', 'Internal', 'Salaried', 6] 146 | 1: ['Brand Y', 'NT', 'Modem', 'Hourly', 6] 147 | 2: ['Brand Y', '2000', 'Internal', 'Part-Time', 10] 148 | 3: ['Brand X', '2000', 'Modem', 'Contr.', 30] 149 | 4: ['Brand X', 'NT', 'Internal', 'Contr.', 60] 150 | 5: ['Brand Y', 'XP', 'Modem', 'Salaried', 60] 151 | 6: ['Brand X', '98', 'Modem', 'Part-Time', 15] 152 | 7: ['Brand Y', 'XP', 'Internal', 'Hourly', 15] 153 | 8: ['Brand Y', 'NT', 'Internal', 'Part-Time', 30] 154 | 9: ['Brand X', '2000', 'Modem', 'Hourly', 10] 155 | 10: ['Brand Y', 'XP', 'Modem', 'Contr.', 30] 156 | 11: ['Brand Y', '2000', 'Modem', 'Salaried', 15] 157 | 12: ['Brand Y', 'NT', 'Modem', 'Salaried', 10] 158 | 13: ['Brand Y', 'XP', 'Modem', 'Part-Time', 6] 159 | 14: ['Brand Y', '2000', 'Modem', 'Contr.', 60] 160 | 161 | 162 | Data Source: OrderedDict 163 | ==================================== 164 | You can use ``collections.OrderedDict`` instance as an argument for ``AllPairs`` constructor. 165 | Pairs will be returned as ``collections.namedtuple`` instances. 166 | 167 | :Sample Code: 168 | .. code:: python 169 | 170 | from collections import OrderedDict 171 | from allpairspy import AllPairs 172 | 173 | parameters = OrderedDict({ 174 | "brand": ["Brand X", "Brand Y"], 175 | "os": ["98", "NT", "2000", "XP"], 176 | "minute": [15, 30, 60], 177 | }) 178 | 179 | print("PAIRWISE:") 180 | for i, pairs in enumerate(AllPairs(parameters)): 181 | print("{:2d}: {}".format(i, pairs)) 182 | 183 | :Sample Code: 184 | .. code:: 185 | 186 | PAIRWISE: 187 | 0: Pairs(brand='Brand X', os='98', minute=15) 188 | 1: Pairs(brand='Brand Y', os='NT', minute=15) 189 | 2: Pairs(brand='Brand Y', os='2000', minute=30) 190 | 3: Pairs(brand='Brand X', os='XP', minute=30) 191 | 4: Pairs(brand='Brand X', os='2000', minute=60) 192 | 5: Pairs(brand='Brand Y', os='XP', minute=60) 193 | 6: Pairs(brand='Brand Y', os='98', minute=60) 194 | 7: Pairs(brand='Brand X', os='NT', minute=60) 195 | 8: Pairs(brand='Brand X', os='NT', minute=30) 196 | 9: Pairs(brand='Brand X', os='98', minute=30) 197 | 10: Pairs(brand='Brand X', os='XP', minute=15) 198 | 11: Pairs(brand='Brand X', os='2000', minute=15) 199 | 200 | 201 | Parameterized testing pairwise by using pytest 202 | ==================================================================== 203 | 204 | Parameterized testing: value matrix 205 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 206 | :Sample Code: 207 | .. code:: python 208 | 209 | import pytest 210 | from allpairspy import AllPairs 211 | 212 | def function_to_be_tested(brand, operating_system, minute) -> bool: 213 | # do something 214 | return True 215 | 216 | class TestParameterized(object): 217 | @pytest.mark.parametrize(["brand", "operating_system", "minute"], [ 218 | values for values in AllPairs([ 219 | ["Brand X", "Brand Y"], 220 | ["98", "NT", "2000", "XP"], 221 | [10, 15, 30, 60] 222 | ]) 223 | ]) 224 | def test(self, brand, operating_system, minute): 225 | assert function_to_be_tested(brand, operating_system, minute) 226 | 227 | :Output: 228 | .. code:: 229 | 230 | $ py.test test_parameterize.py -v 231 | ============================= test session starts ============================== 232 | ... 233 | collected 16 items 234 | 235 | test_parameterize.py::TestParameterized::test[Brand X-98-10] PASSED [ 6%] 236 | test_parameterize.py::TestParameterized::test[Brand Y-NT-10] PASSED [ 12%] 237 | test_parameterize.py::TestParameterized::test[Brand Y-2000-15] PASSED [ 18%] 238 | test_parameterize.py::TestParameterized::test[Brand X-XP-15] PASSED [ 25%] 239 | test_parameterize.py::TestParameterized::test[Brand X-2000-30] PASSED [ 31%] 240 | test_parameterize.py::TestParameterized::test[Brand Y-XP-30] PASSED [ 37%] 241 | test_parameterize.py::TestParameterized::test[Brand Y-98-60] PASSED [ 43%] 242 | test_parameterize.py::TestParameterized::test[Brand X-NT-60] PASSED [ 50%] 243 | test_parameterize.py::TestParameterized::test[Brand X-NT-30] PASSED [ 56%] 244 | test_parameterize.py::TestParameterized::test[Brand X-98-30] PASSED [ 62%] 245 | test_parameterize.py::TestParameterized::test[Brand X-XP-60] PASSED [ 68%] 246 | test_parameterize.py::TestParameterized::test[Brand X-2000-60] PASSED [ 75%] 247 | test_parameterize.py::TestParameterized::test[Brand X-2000-10] PASSED [ 81%] 248 | test_parameterize.py::TestParameterized::test[Brand X-XP-10] PASSED [ 87%] 249 | test_parameterize.py::TestParameterized::test[Brand X-98-15] PASSED [ 93%] 250 | test_parameterize.py::TestParameterized::test[Brand X-NT-15] PASSED [100%] 251 | 252 | Parameterized testing: OrderedDict 253 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 254 | :Sample Code: 255 | .. code:: python 256 | 257 | import pytest 258 | from allpairspy import AllPairs 259 | 260 | def function_to_be_tested(brand, operating_system, minute) -> bool: 261 | # do something 262 | return True 263 | 264 | class TestParameterized(object): 265 | @pytest.mark.parametrize( 266 | ["pair"], 267 | [ 268 | [pair] 269 | for pair in AllPairs( 270 | OrderedDict( 271 | { 272 | "brand": ["Brand X", "Brand Y"], 273 | "operating_system": ["98", "NT", "2000", "XP"], 274 | "minute": [10, 15, 30, 60], 275 | } 276 | ) 277 | ) 278 | ], 279 | ) 280 | def test(self, pair): 281 | assert function_to_be_tested(pair.brand, pair.operating_system, pair.minute) 282 | 283 | 284 | Other Examples 285 | ================= 286 | Other examples could be found in `examples `__ directory. 287 | 288 | 289 | Installation 290 | ------------ 291 | 292 | Installation: pip 293 | ================================== 294 | :: 295 | 296 | pip install allpairspy 297 | 298 | Installation: apt 299 | ================================== 300 | You can install the package by ``apt`` via a Personal Package Archive (`PPA `__): 301 | 302 | :: 303 | 304 | sudo add-apt-repository ppa:thombashi/ppa 305 | sudo apt update 306 | sudo apt install python3-allpairspy 307 | 308 | 309 | Known issues 310 | ------------ 311 | * Not optimal - there are tools that can create smaller set covering 312 | all the pairs. However, they are missing some other important 313 | features and/or do not integrate well with Python. 314 | 315 | * Lousy written filtering function may lead to full permutation of parameters. 316 | 317 | * Version 2.0 has become slower (a side-effect of introducing ability to produce n-wise combinations). 318 | 319 | 320 | Dependencies 321 | ------------ 322 | Python 3.7+ 323 | no external dependencies. 324 | 325 | 326 | Sponsors 327 | ------------ 328 | .. image:: https://avatars.githubusercontent.com/u/3658062?s=48&v=4 329 | :target: https://github.com/b4tman 330 | :alt: Dmitry Belyaev (b4tman) 331 | .. image:: https://avatars.githubusercontent.com/u/44389260?s=48&u=6da7176e51ae2654bcfd22564772ef8a3bb22318&v=4 332 | :target: https://github.com/chasbecker 333 | :alt: Charles Becker (chasbecker) 334 | .. image:: https://avatars.githubusercontent.com/u/46711571?s=48&u=57687c0e02d5d6e8eeaf9177f7b7af4c9f275eb5&v=4 335 | :target: https://github.com/Arturi0 336 | :alt: Arturi0 337 | 338 | `Become a sponsor `__ 339 | --------------------------------------------------------------------------------