diff --git a/.bandit.yml b/.bandit.yml index 4275c12b..7744bc58 100644 --- a/.bandit.yml +++ b/.bandit.yml @@ -135,7 +135,7 @@ any_other_function_with_shell_equals_true: - subprocess.check_output - subprocess.run assert_used: - skips: ["**/test_*.py"] + skips: ["**/test_*.py", "tests/helpers/**"] hardcoded_tmp_directory: tmp_dirs: - /tmp diff --git a/.github/workflows/linting.yml b/.github/workflows/linting.yml index 99dd6570..a9515a56 100644 --- a/.github/workflows/linting.yml +++ b/.github/workflows/linting.yml @@ -14,7 +14,7 @@ jobs: strategy: matrix: os: [ubuntu-latest] - python-version: ["3.8", "3.10", "3.11"] + python-version: ["3.8", "3.9", "3.10", "3.11"] timeout-minutes: 30 @@ -35,7 +35,6 @@ jobs: tox -e black-check tox -e isort-check tox -e flake8 - tox -e darglint - name: Unused code check run: tox -e vulture - name: Static type check diff --git a/examples/pddl/domain.pddl b/examples/pddl/domain.pddl index ddf01a99..b48248a4 100644 --- a/examples/pddl/domain.pddl +++ b/examples/pddl/domain.pddl @@ -3,7 +3,7 @@ ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; (define (domain blocks) - (:requirements :strips :typing) + (:requirements :strips :typing :disjunctive-preconditions) (:types block) (:predicates (on ?x - block ?y - block) (ontable ?x - block) diff --git a/plan4past/helpers/utils.py b/plan4past/helpers/utils.py index 30723507..a1b47738 100644 --- a/plan4past/helpers/utils.py +++ b/plan4past/helpers/utils.py @@ -21,11 +21,7 @@ # """Miscellanea utilities.""" -import contextlib -import os -from os import PathLike -from pathlib import Path -from typing import Dict, Generator +from typing import Dict from pddl.logic import Predicate, constants from pylogics.syntax.base import Formula @@ -34,17 +30,6 @@ from plan4past.utils.atoms_visitor import find_atoms -@contextlib.contextmanager -def cd(path: PathLike) -> Generator: # pylint: disable=invalid-name - """Change working directory temporarily.""" - old_path = Path.cwd() - os.chdir(path) - try: - yield - finally: - os.chdir(str(old_path)) - - def add_val_prefix(name: str): """Add the 'prime' prefix.""" return "val-" + name.replace('"', "") @@ -52,12 +37,18 @@ def add_val_prefix(name: str): def remove_before_prefix(name: str): """Remove the 'Y' prefix.""" - return name.replace("Y-", "") if name[1] == "-" else name.replace("Y", "", 1) + return ( + name.replace("Y-", "") + if name.startswith("Y-") + else name.replace("Y", "", 1) + if name.startswith("Y") + else name + ) def remove_val_prefix(name: str): """Remove the 'prime' prefix.""" - return name.replace("val-", "") + return name.replace("val-", "") if name.startswith("val-") else name def replace_symbols(name: str): diff --git a/plan4past/utils/atoms_visitor.py b/plan4past/utils/atoms_visitor.py index 8c0aa358..2fe3d9d9 100644 --- a/plan4past/utils/atoms_visitor.py +++ b/plan4past/utils/atoms_visitor.py @@ -26,7 +26,6 @@ from typing import Set from pylogics.syntax.base import And as PLTLAnd -from pylogics.syntax.base import Formula from pylogics.syntax.base import Not as PLTLNot from pylogics.syntax.base import Or as PLTLOr from pylogics.syntax.base import _BinaryOp, _UnaryOp @@ -48,9 +47,9 @@ def find_atoms_unaryop(formula: _UnaryOp): @singledispatch -def find_atoms(_formula: Formula) -> Set[PLTLAtomic]: +def find_atoms(obj: object) -> Set[PLTLAtomic]: """Find atoms for a formula.""" - return set() + raise ValueError(f"object of type {type(obj)} is not supported by this function") @find_atoms.register diff --git a/plan4past/utils/derived_visitor.py b/plan4past/utils/derived_visitor.py index ec2c4463..7988d326 100644 --- a/plan4past/utils/derived_visitor.py +++ b/plan4past/utils/derived_visitor.py @@ -27,7 +27,6 @@ from pddl.logic.base import And, Not, Or from pddl.logic.predicates import DerivedPredicate, Predicate from pylogics.syntax.base import And as PLTLAnd -from pylogics.syntax.base import Formula from pylogics.syntax.base import Not as PLTLNot from pylogics.syntax.base import Or as PLTLOr from pylogics.syntax.pltl import Atomic as PLTLAtomic @@ -45,10 +44,10 @@ @singledispatch def derived_predicates( - formula: Formula, atoms_to_fluents: Dict[PLTLAtomic, Predicate] + formula: object, atoms_to_fluents: Dict[PLTLAtomic, Predicate] ) -> Set[DerivedPredicate]: """Compute the derived predicate for a formula.""" - raise NotImplementedError(f"handler not implemented for formula {type(formula)}") + raise NotImplementedError(f"handler not implemented for object {type(formula)}") @derived_predicates.register @@ -132,7 +131,7 @@ def derived_predicates_before( """Compute the derived predicate for a Before formula.""" formula_name = to_string(formula) val = Predicate(add_val_prefix(replace_symbols(formula_name))) - condition = And(Predicate(replace_symbols(to_string(formula)))) + condition = Predicate(replace_symbols(to_string(formula))) der_pred_arg = derived_predicates(formula.argument, atoms_to_fluents) return {DerivedPredicate(val, condition)}.union(der_pred_arg) @@ -167,7 +166,7 @@ def derived_predicates_once( val = Predicate(add_val_prefix(replace_symbols(formula_name))) condition = Or( Predicate(add_val_prefix(replace_symbols(to_string(formula.argument)))), - And(Predicate(f"Y-{replace_symbols(to_string(formula))}")), + Predicate(f"Y-{replace_symbols(to_string(formula))}"), ) der_pred_arg = derived_predicates(formula.argument, atoms_to_fluents) return {DerivedPredicate(val, condition)}.union(der_pred_arg) diff --git a/plan4past/utils/mapping_parser.py b/plan4past/utils/mapping_parser.py index ac7e50c0..f1e9f177 100644 --- a/plan4past/utils/mapping_parser.py +++ b/plan4past/utils/mapping_parser.py @@ -21,30 +21,166 @@ # """Mapping parser.""" +import re +from typing import Dict, Optional, Sequence, Set, Tuple -from typing import Dict - -from pddl.logic import Predicate, constants +from pddl.custom_types import name +from pddl.logic import Constant, Predicate, constants from pylogics.syntax.base import Formula from pylogics.syntax.pltl import Atomic as PLTLAtomic from plan4past.utils.atoms_visitor import find_atoms +class MappingParserError(Exception): + """Mapping parser error.""" + + def __init__(self, message: str, *args, row_id: Optional[int] = None) -> None: + """ + Initialize a mapping parser error. + + :param row_id: the row id + """ + self.row_id = row_id + + super().__init__(self._make_message_prefix() + message, *args) + + def _make_message_prefix(self) -> str: + """Make the message prefix.""" + if self.row_id is None: + return "invalid mapping: " + return f"invalid mapping at row {self.row_id}: " + + +SYMBOL_REGEX = re.compile("[a-z_]([a-zA-Z0-9_-]+[a-zA-Z0-9_])|[a-z_][a-zA-Z0-9_]*") +"""The following is a sub-regex of AtomName.REGEX in pylogics.syntax.base.""" + + +def _check_pddl_name(row_id: int, pddl_name: str, what: str) -> None: + """Check that a PDDL name is valid.""" + try: + name(pddl_name) + except ValueError as e: + raise MappingParserError( + f"got an invalid name for {what}: '{pddl_name}'. It must match the regex {name.REGEX}", + row_id=row_id, + ) from e + + +def _parse_constants(row_id: int, constants_str: str) -> Sequence[Constant]: + """ + Parse a string of constants. + + :param row_id: the row id + :param constants_str: the string of constants + :return: the sequence of constants + """ + try: + return constants(constants_str) + except ValueError as e: + raise MappingParserError(f"got an invalid constant: {e}", row_id=row_id) from e + + +def _parse_predicate(row_id: int, predicate_str: str) -> Predicate: + """ + Parse a predicate. + + :param predicate_str: the predicate string + :return: the predicate + """ + predicate_name, cons = predicate_str.split(" ", maxsplit=1) + + _check_pddl_name(row_id, predicate_name, "a predicate") + parsed_constants = _parse_constants(row_id, cons) + return Predicate(predicate_name, *parsed_constants) + + +def _process_row(row_id: int, row_str: str) -> Tuple[str, Predicate]: + """ + Process a row of the mapping file. + + :param row_id: the row id + :param row_str: the row string + :return: the pair (symbol_name, predicate_str) + """ + # check the row is a valid mapping + comma_separated_row_parts = row_str.split(",") + if len(comma_separated_row_parts) != 2: + raise MappingParserError( + "expected a mapping of the form ','", row_id=row_id + ) + + symbol_name, predicate = comma_separated_row_parts + + # strip leading and trailing whitespaces + symbol_name = symbol_name.strip() + predicate_str = predicate.strip() + + if symbol_name == "" or SYMBOL_REGEX.fullmatch(symbol_name) is None: + raise MappingParserError( + "symbol cannot be empty string and must match the regex {SYMBOL_REGEX}", + row_id=row_id, + ) + if predicate_str == "": + raise MappingParserError("predicate cannot be empty string", row_id=row_id) + + predicate = _parse_predicate(row_id, predicate_str) + return symbol_name, predicate + + +def _check_unmapped_symbols( + all_symbol_names: Set[str], mapped_symbol_names: Set[str] +) -> None: + """Check that all symbols are mapped.""" + # check if there are unmapped symbols (exclude 'true' and 'false') + unmapped_symbols = all_symbol_names - {"true", "false"} - mapped_symbol_names + # if some symbols are not mapped, raise an error + if unmapped_symbols: + raise MappingParserError( + f"the following symbols of the formula are not mapped: {unmapped_symbols}" + ) + + +def should_skip_row(row_str: str) -> bool: + """Check if a row should be skipped.""" + # skip empty lines + if row_str.strip() == "": + return True + + # skip comments + if row_str.strip().startswith(";"): + return True + + return False + + def mapping_parser(text: str, formula: Formula) -> Dict[PLTLAtomic, Predicate]: """Parse symbols to ground predicates mapping.""" symbols = find_atoms(formula) - maps = text.split("\n") + symbols_by_name = {symbol.name: symbol for symbol in symbols} + maps = text.splitlines(keepends=False) from_atoms_to_fluents = {} - for symbol in symbols: - for vmap in maps: - s, p = vmap.split(",") - if symbol.name == s: - if " " in p: - name, cons = p.split(" ", maxsplit=1) - from_atoms_to_fluents[symbol] = Predicate(name, *constants(cons)) - else: - from_atoms_to_fluents[symbol] = Predicate(p) - else: - continue + mapped_symbol_names = set() + + for row_id, vmap in enumerate(maps): + if should_skip_row(vmap): + continue + + symbol_name, predicate = _process_row(row_id, vmap) + + if symbol_name not in symbols_by_name: + # don't need to process this row, since the symbol does not occur in the formula + continue + + if symbol_name in mapped_symbol_names: + raise MappingParserError( + f"symbol '{symbol_name}' is mapped multiple times", row_id=row_id + ) + + symbol = symbols_by_name[symbol_name] + mapped_symbol_names.add(symbol_name) + + from_atoms_to_fluents[symbol] = predicate + + _check_unmapped_symbols(set(symbols_by_name.keys()), mapped_symbol_names) return from_atoms_to_fluents diff --git a/plan4past/utils/predicates_visitor.py b/plan4past/utils/predicates_visitor.py index 506be216..da81c05b 100644 --- a/plan4past/utils/predicates_visitor.py +++ b/plan4past/utils/predicates_visitor.py @@ -27,7 +27,6 @@ from pddl.logic.predicates import Predicate from pylogics.syntax.base import And as PLTLAnd -from pylogics.syntax.base import Formula from pylogics.syntax.base import Not as PLTLNot from pylogics.syntax.base import Or as PLTLOr from pylogics.syntax.base import _BinaryOp, _UnaryOp @@ -55,9 +54,11 @@ def predicates_unaryop(formula: _UnaryOp): @singledispatch -def predicates(formula: Formula) -> Set[Predicate]: +def predicates(formula: object) -> Set[Predicate]: """Compute predicate for a formula.""" - raise NotImplementedError(f"handler not implemented for formula {type(formula)}") + raise NotImplementedError( + f"handler not implemented for object of type {type(formula)}" + ) @predicates.register diff --git a/plan4past/utils/rewrite_formula_visitor.py b/plan4past/utils/rewrite_formula_visitor.py index 4fe95de6..d0981895 100644 --- a/plan4past/utils/rewrite_formula_visitor.py +++ b/plan4past/utils/rewrite_formula_visitor.py @@ -24,6 +24,7 @@ from functools import singledispatch from pylogics.syntax.base import And as PLTLAnd +from pylogics.syntax.base import Equivalence as PLTLEquivalence from pylogics.syntax.base import Formula from pylogics.syntax.base import Implies as PLTLImplies from pylogics.syntax.base import Not as PLTLNot @@ -46,9 +47,11 @@ def rewrite_unaryop(formula: _UnaryOp): @singledispatch -def rewrite(formula: Formula) -> Formula: +def rewrite(formula: object) -> Formula: """Rewrite a formula.""" - raise NotImplementedError(f"handler not implemented for formula {type(formula)}") + raise NotImplementedError( + f"handler not implemented for object of type {type(formula)}" + ) @rewrite.register @@ -98,6 +101,14 @@ def rewrite_implies(formula: PLTLImplies) -> Formula: return PLTLOr(*head, tail) +@rewrite.register +def rewrite_equivalence(formula: PLTLEquivalence) -> Formula: + """Compute the basic formula for an Equivalence formula.""" + positive = PLTLAnd(*[rewrite(f) for f in formula.operands]) + negative = PLTLAnd(*[PLTLNot(rewrite(f)) for f in formula.operands]) + return PLTLOr(positive, negative) + + @rewrite.register def rewrite_before(formula: Before) -> Formula: """Compute the basic formula for a Before (Yesterday) formula.""" diff --git a/plan4past/utils/val_predicates_visitor.py b/plan4past/utils/val_predicates_visitor.py index 4090f8c4..83ae37b0 100644 --- a/plan4past/utils/val_predicates_visitor.py +++ b/plan4past/utils/val_predicates_visitor.py @@ -20,14 +20,13 @@ # along with Plan4Past. If not, see . # -"""Value predicates for derived predicates visitor.""" +"""Value predicates for val predicates visitor.""" import functools from functools import singledispatch from typing import Set from pddl.logic.predicates import Predicate from pylogics.syntax.base import And as PLTLAnd -from pylogics.syntax.base import Formula from pylogics.syntax.base import Not as PLTLNot from pylogics.syntax.base import Or as PLTLOr from pylogics.syntax.base import _BinaryOp, _UnaryOp @@ -55,9 +54,11 @@ def val_predicates_unaryop(formula: _UnaryOp): @singledispatch -def val_predicates(formula: Formula) -> Set[Predicate]: +def val_predicates(formula: object) -> Set[Predicate]: """Compute the value predicate for a formula.""" - raise NotImplementedError(f"handler not implemented for formula {type(formula)}") + raise NotImplementedError( + f"handler not implemented for object of type {type(formula)}" + ) @val_predicates.register diff --git a/poetry.lock b/poetry.lock index 74901244..a0ea977d 100644 --- a/poetry.lock +++ b/poetry.lock @@ -739,6 +739,27 @@ files = [ {file = "distlib-0.3.6.tar.gz", hash = "sha256:14bad2d9b04d3a36127ac97f30b12a19268f211063d8f8ee4f47108896e11b46"}, ] +[[package]] +name = "docker" +version = "6.1.3" +description = "A Python library for the Docker Engine API." +optional = false +python-versions = ">=3.7" +files = [ + {file = "docker-6.1.3-py3-none-any.whl", hash = "sha256:aecd2277b8bf8e506e484f6ab7aec39abe0038e29fa4a6d3ba86c3fe01844ed9"}, + {file = "docker-6.1.3.tar.gz", hash = "sha256:aa6d17830045ba5ef0168d5eaa34d37beeb113948c413affe1d5991fc11f9a20"}, +] + +[package.dependencies] +packaging = ">=14.0" +pywin32 = {version = ">=304", markers = "sys_platform == \"win32\""} +requests = ">=2.26.0" +urllib3 = ">=1.26.0" +websocket-client = ">=0.32.0" + +[package.extras] +ssh = ["paramiko (>=2.4.3)"] + [[package]] name = "docutils" version = "0.20.1" @@ -1307,6 +1328,7 @@ optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, !=3.5.*, !=3.6.*" files = [ {file = "jsonpointer-2.4-py2.py3-none-any.whl", hash = "sha256:15d51bba20eea3165644553647711d150376234112651b4f1811022aecad7d7a"}, + {file = "jsonpointer-2.4.tar.gz", hash = "sha256:585cee82b70211fa9e6043b7bb89db6e1aa49524340dde8ad6b63206ea689d88"}, ] [[package]] @@ -3175,6 +3197,17 @@ files = [ {file = "rpds_py-0.8.8-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:78c5577f99d2edc9eed9ec39fae27b73d04d1b2462aff6f6b11207e0364fc40d"}, {file = "rpds_py-0.8.8-cp311-none-win32.whl", hash = "sha256:42eb3030665ee7a5c03fd4db6b8db1983aa91bcdffbed0f4687751deb2a94a7c"}, {file = "rpds_py-0.8.8-cp311-none-win_amd64.whl", hash = "sha256:7110854662ccf8db84b90e4624301ef5311cafff7e5f2a63f2d7cc0fc1a75b60"}, + {file = "rpds_py-0.8.8-cp312-cp312-macosx_10_7_x86_64.whl", hash = "sha256:d00b16de3c42bb3d26341b443e48d67d444bb1a4ce6b44dd5600def2da759599"}, + {file = "rpds_py-0.8.8-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4d7e540e4f85c04706ea798f47a86483f3d85c624704413bc701eb75684d35a5"}, + {file = "rpds_py-0.8.8-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:54a54c3c220e7c5038207912aab23443f829762503a4fcbc5c7bbffef7523b13"}, + {file = "rpds_py-0.8.8-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:22d5bef6f9942e46582610a60b8420f8e9af7e0c69e35c317cb508c30117f933"}, + {file = "rpds_py-0.8.8-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c4fccd5e138908ae6f2db5fbfc6769e65372993b0c4c047586de15b6c31a76e8"}, + {file = "rpds_py-0.8.8-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:502f0bee154fa1c13514dfddb402ef29b86aca11873a3316de4534cf0e13a1e8"}, + {file = "rpds_py-0.8.8-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d9cfd2c2dbb0446ec1ba132e62e1f4880163e43e131dd43f58f58fd46430649b"}, + {file = "rpds_py-0.8.8-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f12e679f29a6c2c0607b7037e7fce4f6430a0d304770768cf6d8036386918c29"}, + {file = "rpds_py-0.8.8-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8f9d619c66dc7c018a22a1795a14ab4dad3c76246c9059b681955254a0f58f7c"}, + {file = "rpds_py-0.8.8-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:87f9e70c04cc0402f8b14fec8ac91d1b825ac89a9aa015556a0af12a06b5f085"}, + {file = "rpds_py-0.8.8-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:d72757c0cb6423fe73ecaa2db3adf0077da513b7fe8cb19e102de6df4ccdad0c"}, {file = "rpds_py-0.8.8-cp38-cp38-macosx_10_7_x86_64.whl", hash = "sha256:d7e46f52272ceecc42c05ad869b068b2dbfb6eb5643bcccecd2327d3cded5a2e"}, {file = "rpds_py-0.8.8-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:7628b2080538faa4a1243b0002678cae7111af68ae7b5aa6cd8526762cace868"}, {file = "rpds_py-0.8.8-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:657348b35a4c2e7c2340bf0bc37597900037bd87e9db7e6282711aaa77256e16"}, @@ -3201,6 +3234,17 @@ files = [ {file = "rpds_py-0.8.8-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:1891903e567d728175c0475a1f0ffc1d1580013b0b265b9e2f1b8c93d58b2d05"}, {file = "rpds_py-0.8.8-cp39-none-win32.whl", hash = "sha256:ee42ce4ef46ea334ce8ab63d5a57c7fd78238c9c7293b3caa6dfedf11bd28773"}, {file = "rpds_py-0.8.8-cp39-none-win_amd64.whl", hash = "sha256:0e8da63b9baa154ec9ddd6dd397893830d17e5812ceb50edbae8122d8ecb9f2e"}, + {file = "rpds_py-0.8.8-pp310-pypy310_pp73-macosx_10_7_x86_64.whl", hash = "sha256:ecc79cd61c4c16f92521c7d34e0f534bc486fc5ed5d1fdf8d4e6e0c578dc7e07"}, + {file = "rpds_py-0.8.8-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:d43e4253469a6149f4dae91189ccbf832dcd870109b940fa6acb02769e57802b"}, + {file = "rpds_py-0.8.8-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9985927f001d98d38ad90e0829d3e3c162ce42060bafb833782a934bf1d1d39b"}, + {file = "rpds_py-0.8.8-pp310-pypy310_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7af604c6581da01fd5f89a917c903a324779fdfa7b3ae66204865d34b5f2c502"}, + {file = "rpds_py-0.8.8-pp310-pypy310_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:53ed81e3a9a0eb3dfd404ee097d4f50ec6985301ea6e03b4d57cd7ef239179f9"}, + {file = "rpds_py-0.8.8-pp310-pypy310_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7e6b93a8a17e84a53fa6636037955ba8e795f6645dab5ccbeb356c8dbc9cb371"}, + {file = "rpds_py-0.8.8-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:592b9de8d82e919ffbf069e586808f56118a7f522bb0d018c54fa3526e5f2bed"}, + {file = "rpds_py-0.8.8-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:af0920c26fe3421f9e113975c185f7c42a3f0a8ead72cee5b4e6648af5d8cecc"}, + {file = "rpds_py-0.8.8-pp310-pypy310_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:28ce85916d7377b9734b280872fb456aa048712901edff9d60836c7b2e177265"}, + {file = "rpds_py-0.8.8-pp310-pypy310_pp73-musllinux_1_2_i686.whl", hash = "sha256:cbbb26ac4dade6fdec22cb1155ca38d270b308f57cfd48a13a7a8ecc79369e82"}, + {file = "rpds_py-0.8.8-pp310-pypy310_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:8469755965ff2aa1da08e6e0afcde08950ebba84a4836cdc1672d097c62ffdbd"}, {file = "rpds_py-0.8.8-pp38-pypy38_pp73-macosx_10_7_x86_64.whl", hash = "sha256:f1c84912d77b01651488bbe392df593b4c5852e213477e268ebbb7c799059d78"}, {file = "rpds_py-0.8.8-pp38-pypy38_pp73-macosx_11_0_arm64.whl", hash = "sha256:180963bb3e1fcc6ed6313ece5e065f0df4021a7eb7016084d3cbc90cd2a8af3e"}, {file = "rpds_py-0.8.8-pp38-pypy38_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8e1251d6690f356a305192089017da83999cded1d7e2405660d14c1dff976af7"}, @@ -3296,10 +3340,8 @@ version = "2.4.0b1" description = "Checks installed dependencies for known vulnerabilities and licenses." optional = false python-versions = "*" -files = [ - {file = "safety-2.4.0b1-py3-none-any.whl", hash = "sha256:95570bfdb0ca17bb2acdf34963966ce8f8b26bffeb76722d98251cee2f81b215"}, - {file = "safety-2.4.0b1.tar.gz", hash = "sha256:26b3000eec09f64fdd323db29c44c0446607b0c9b4ce65c3f8f9570e2c640958"}, -] +files = [] +develop = false [package.dependencies] Click = ">=8.0.2" @@ -3316,6 +3358,12 @@ urllib3 = ">=1.26.5" github = ["pygithub (>=1.43.3)"] gitlab = ["python-gitlab (>=1.3.0)"] +[package.source] +type = "git" +url = "https://git@github.com/pyupio/safety.git" +reference = "8afa6de" +resolved_reference = "8afa6dec2a633a21e93ca19ff30d7b165f08eec7" + [[package]] name = "secretstorage" version = "3.3.3" @@ -3945,4 +3993,4 @@ testing = ["big-O", "flake8 (<5)", "jaraco.functools", "jaraco.itertools", "more [metadata] lock-version = "2.0" python-versions = ">=3.8.1,<4.0" -content-hash = "6fea45684db2d2ff5dbb0b93d88bcaf0b9a14e7fe747ff4c608d0dabe9518831" +content-hash = "f85f2cfc2e4f5cf12b8fb47e8ddab5e0ef10fb8541b6d3cf2d7bb381c3536070" diff --git a/pyproject.toml b/pyproject.toml index a14b34cd..d759b461 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -70,7 +70,7 @@ pytest = "^7.3.1" pytest-cov = "^4.1.0" pytest-lazy-fixture = "^0.6.3" pytest-randomly = "^3.12.0" -safety = "^2.3.5" +safety = {git = "https://git@github.com/pyupio/safety.git", rev = "8afa6de"} toml = "^0.10.2" tox = "^4.4.12" twine = "^4.0.2" @@ -78,6 +78,7 @@ types-requests = "^2.31.0.1" types-setuptools = "^67.8.0.0" types-toml = "^0.10.8.6" vulture = "^2.7" +docker = ">=6.1.3,<6.2.0" [build-system] requires = ["poetry-core"] diff --git a/scripts/whitelist.py b/scripts/whitelist.py index 7cee7bb1..1184e6c5 100644 --- a/scripts/whitelist.py +++ b/scripts/whitelist.py @@ -3,45 +3,46 @@ cls # unused variable (plan4past/compiler.py:86) cd # unused function (plan4past/helpers/utils.py:37) remove_val_prefix # unused function (plan4past/helpers/utils.py:58) -find_atoms_true # unused function (plan4past/utils/atoms_visitor.py:56) -find_atoms_false # unused function (plan4past/utils/atoms_visitor.py:62) -find_atoms_atom # unused function (plan4past/utils/atoms_visitor.py:68) -find_atoms_and # unused function (plan4past/utils/atoms_visitor.py:74) -find_atoms_or # unused function (plan4past/utils/atoms_visitor.py:80) -find_atoms_not # unused function (plan4past/utils/atoms_visitor.py:86) -find_atoms_before # unused function (plan4past/utils/atoms_visitor.py:92) -find_atoms_since # unused function (plan4past/utils/atoms_visitor.py:98) -find_atoms_once # unused function (plan4past/utils/atoms_visitor.py:104) -find_atoms_historically # unused function (plan4past/utils/atoms_visitor.py:110) -derived_predicates_true # unused function (plan4past/utils/derived_visitor.py:54) -derived_predicates_false # unused function (plan4past/utils/derived_visitor.py:63) -derived_predicates_atomic # unused function (plan4past/utils/derived_visitor.py:72) -derived_predicates_and # unused function (plan4past/utils/derived_visitor.py:82) -derived_predicates_or # unused function (plan4past/utils/derived_visitor.py:98) -derived_predicates_not # unused function (plan4past/utils/derived_visitor.py:114) -derived_predicates_before # unused function (plan4past/utils/derived_visitor.py:128) -derived_predicates_since # unused function (plan4past/utils/derived_visitor.py:140) -derived_predicates_once # unused function (plan4past/utils/derived_visitor.py:161) -predicates_true # unused function (plan4past/utils/predicates_visitor.py:63) -predicates_false # unused function (plan4past/utils/predicates_visitor.py:69) -predicates_atomic # unused function (plan4past/utils/predicates_visitor.py:75) -predicates_and # unused function (plan4past/utils/predicates_visitor.py:81) -predicates_or # unused function (plan4past/utils/predicates_visitor.py:87) -predicates_not # unused function (plan4past/utils/predicates_visitor.py:93) -predicates_before # unused function (plan4past/utils/predicates_visitor.py:99) -predicates_since # unused function (plan4past/utils/predicates_visitor.py:107) -predicates_once # unused function (plan4past/utils/predicates_visitor.py:120) -rewrite_true # unused function (plan4past/utils/rewrite_formula_visitor.py:54) -rewrite_false # unused function (plan4past/utils/rewrite_formula_visitor.py:60) -rewrite_atomic # unused function (plan4past/utils/rewrite_formula_visitor.py:66) -rewrite_and # unused function (plan4past/utils/rewrite_formula_visitor.py:72) -rewrite_or # unused function (plan4past/utils/rewrite_formula_visitor.py:79) -rewrite_not # unused function (plan4past/utils/rewrite_formula_visitor.py:86) -rewrite_implies # unused function (plan4past/utils/rewrite_formula_visitor.py:93) -rewrite_before # unused function (plan4past/utils/rewrite_formula_visitor.py:101) -rewrite_since # unused function (plan4past/utils/rewrite_formula_visitor.py:108) -rewrite_once # unused function (plan4past/utils/rewrite_formula_visitor.py:119) -rewrite_historically # unused function (plan4past/utils/rewrite_formula_visitor.py:126) +find_atoms_true # unused function (plan4past/utils/atoms_visitor.py:55) +find_atoms_false # unused function (plan4past/utils/atoms_visitor.py:61) +find_atoms_atom # unused function (plan4past/utils/atoms_visitor.py:67) +find_atoms_and # unused function (plan4past/utils/atoms_visitor.py:73) +find_atoms_or # unused function (plan4past/utils/atoms_visitor.py:79) +find_atoms_not # unused function (plan4past/utils/atoms_visitor.py:85) +find_atoms_before # unused function (plan4past/utils/atoms_visitor.py:91) +find_atoms_since # unused function (plan4past/utils/atoms_visitor.py:97) +find_atoms_once # unused function (plan4past/utils/atoms_visitor.py:103) +find_atoms_historically # unused function (plan4past/utils/atoms_visitor.py:109) +derived_predicates_true # unused function (plan4past/utils/derived_visitor.py:53) +derived_predicates_false # unused function (plan4past/utils/derived_visitor.py:62) +derived_predicates_atomic # unused function (plan4past/utils/derived_visitor.py:71) +derived_predicates_and # unused function (plan4past/utils/derived_visitor.py:81) +derived_predicates_or # unused function (plan4past/utils/derived_visitor.py:97) +derived_predicates_not # unused function (plan4past/utils/derived_visitor.py:113) +derived_predicates_before # unused function (plan4past/utils/derived_visitor.py:127) +derived_predicates_since # unused function (plan4past/utils/derived_visitor.py:139) +derived_predicates_once # unused function (plan4past/utils/derived_visitor.py:160) +predicates_true # unused function (plan4past/utils/predicates_visitor.py:64) +predicates_false # unused function (plan4past/utils/predicates_visitor.py:70) +predicates_atomic # unused function (plan4past/utils/predicates_visitor.py:76) +predicates_and # unused function (plan4past/utils/predicates_visitor.py:82) +predicates_or # unused function (plan4past/utils/predicates_visitor.py:88) +predicates_not # unused function (plan4past/utils/predicates_visitor.py:94) +predicates_before # unused function (plan4past/utils/predicates_visitor.py:100) +predicates_since # unused function (plan4past/utils/predicates_visitor.py:108) +predicates_once # unused function (plan4past/utils/predicates_visitor.py:121) +rewrite_true # unused function (plan4past/utils/rewrite_formula_visitor.py:57) +rewrite_false # unused function (plan4past/utils/rewrite_formula_visitor.py:63) +rewrite_atomic # unused function (plan4past/utils/rewrite_formula_visitor.py:69) +rewrite_and # unused function (plan4past/utils/rewrite_formula_visitor.py:75) +rewrite_or # unused function (plan4past/utils/rewrite_formula_visitor.py:82) +rewrite_not # unused function (plan4past/utils/rewrite_formula_visitor.py:89) +rewrite_implies # unused function (plan4past/utils/rewrite_formula_visitor.py:96) +rewrite_equivalence # unused function (plan4past/utils/rewrite_formula_visitor.py:104) +rewrite_before # unused function (plan4past/utils/rewrite_formula_visitor.py:112) +rewrite_since # unused function (plan4past/utils/rewrite_formula_visitor.py:119) +rewrite_once # unused function (plan4past/utils/rewrite_formula_visitor.py:130) +rewrite_historically # unused function (plan4past/utils/rewrite_formula_visitor.py:137) val_predicates_true # unused function (plan4past/utils/val_predicates_visitor.py:63) val_predicates_false # unused function (plan4past/utils/val_predicates_visitor.py:69) val_predicates_atomic # unused function (plan4past/utils/val_predicates_visitor.py:75) diff --git a/setup.cfg b/setup.cfg index c01421fe..a0ed0ab8 100644 --- a/setup.cfg +++ b/setup.cfg @@ -57,14 +57,18 @@ python_version = 3.10 strict_optional = True # Per-module options: +[mypy-docker.*] +ignore_missing_imports = True + +[mypy-mistune.*] +ignore_missing_imports = True + [mypy-pddl.*] ignore_missing_imports = True [mypy-pylogics.*] ignore_missing_imports = True -[mypy-mistune.*] -ignore_missing_imports = True # Per-module options for tests dir: [mypy-pytest] diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 00000000..56fe5f1c --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,50 @@ +# -*- coding: utf-8 -*- +# +# Copyright 2021 -- 2023 WhiteMech +# +# ------------------------------ +# +# This file is part of Plan4Past. +# +# Plan4Past is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Plan4Past is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with Plan4Past. If not, see . +# + +"""This module contains fixtures for the tests.""" +import pytest +from docker import DockerClient + +from tests.helpers.planutils.base import PlanutilsDockerImage +from tests.helpers.planutils.val import VALWrapper + + +@pytest.fixture(scope="session") +def docker_client() -> DockerClient: + """Return the docker client.""" + return DockerClient() + + +@pytest.fixture(scope="session") +def planutils_docker_image( + docker_client, # pylint: disable=redefined-outer-name +) -> PlanutilsDockerImage: + """Return the planutils docker image.""" + image = PlanutilsDockerImage(docker_client) + image.build() + return image + + +@pytest.fixture(scope="session") +def val(planutils_docker_image) -> VALWrapper: # pylint: disable=redefined-outer-name + """Return the val wrapper.""" + return VALWrapper(planutils_docker_image) diff --git a/tests/docker/Dockerfile-planutils b/tests/docker/Dockerfile-planutils new file mode 100644 index 00000000..5cf15544 --- /dev/null +++ b/tests/docker/Dockerfile-planutils @@ -0,0 +1,6 @@ +FROM aiplanning/planutils:latest + +# Install solvers and tools +RUN planutils install -y val +RUN planutils install -y planning.domains +RUN planutils install -y lama diff --git a/tests/helpers/__init__.py b/tests/helpers/__init__.py new file mode 100644 index 00000000..b1044ab8 --- /dev/null +++ b/tests/helpers/__init__.py @@ -0,0 +1,23 @@ +# -*- coding: utf-8 -*- +# +# Copyright 2021 -- 2023 WhiteMech +# +# ------------------------------ +# +# This file is part of Plan4Past. +# +# Plan4Past is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Plan4Past is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with Plan4Past. If not, see . +# + +"""This package contains helper functions for tests.""" diff --git a/tests/helpers/constants.py b/tests/helpers/constants.py new file mode 100644 index 00000000..3335a9e0 --- /dev/null +++ b/tests/helpers/constants.py @@ -0,0 +1,34 @@ +# -*- coding: utf-8 -*- +# +# Copyright 2021 -- 2023 WhiteMech +# +# ------------------------------ +# +# This file is part of Plan4Past. +# +# Plan4Past is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Plan4Past is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with Plan4Past. If not, see . +# + +"""This module contains constants for the tests.""" + +import inspect +from pathlib import Path + +ROOT_DIR = Path(inspect.getfile(inspect.currentframe())).resolve().parent.parent.parent # type: ignore +TEST_DIR = ROOT_DIR / "tests" +EXAMPLES_DIR = ROOT_DIR / "examples" +EXAMPLE_MAP_FILE = EXAMPLES_DIR / "pddl/p-0.map" + +DOCKER_DIR = TEST_DIR / "docker" +PLANUTILS_DOCKERFILE = DOCKER_DIR / "Dockerfile-planutils" diff --git a/tests/helpers/docker.py b/tests/helpers/docker.py new file mode 100644 index 00000000..57685655 --- /dev/null +++ b/tests/helpers/docker.py @@ -0,0 +1,161 @@ +# -*- coding: utf-8 -*- +# +# Copyright 2021 -- 2023 WhiteMech +# +# ------------------------------ +# +# This file is part of Plan4Past. +# +# Plan4Past is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Plan4Past is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with Plan4Past. If not, see . +# + + +"""This module contains testing utilities for using Docker images.""" +import logging +import re +import shutil +import subprocess # nosec +from abc import ABC +from pathlib import Path +from typing import Any, Mapping, Optional, Sequence + +import docker +import pytest +from docker import DockerClient + +logger = logging.getLogger(__name__) + + +class DockerImage(ABC): + """A class to wrap interatction with a Docker image.""" + + MINIMUM_DOCKER_VERSION = (19, 0, 0) + + def __init__( + self, + client: DockerClient, + tag: str, + dockerfile: Optional[Path] = None, + context: Optional[Path] = None, + ) -> None: + """Initialize.""" + self._client = client + + self._tag = tag + self._dockerfile = dockerfile + self._context = context + + @property + def tag(self) -> str: + """Return the tag of the image.""" + return self._tag + + @property + def dockerfile(self) -> Path: + """Return the path to the Dockerfile.""" + assert self._dockerfile is not None + return self._dockerfile + + @property + def context(self) -> Path: + """Return the path to the context.""" + assert self._context is not None + return self._context + + def check_skip(self): + """ + Check whether the test should be skipped. + + By default, nothing happens. + """ + self._check_docker_binary_available() + + def _check_docker_binary_available(self): + """Check the 'Docker' CLI tool is in the OS PATH.""" + result = shutil.which("docker") + if result is None: + pytest.skip("Docker not in the OS Path; skipping the test") + + result = subprocess.run( # nosec + ["docker", "--version"], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=False, + ) + if result.returncode != 0: + pytest.skip(f"'docker --version' failed with exit code {result.returncode}") + + match = re.search( + r"Docker version ([0-9]+)\.([0-9]+)\.([0-9]+)", + result.stdout.decode("utf-8"), + ) + if match is None: + pytest.skip("cannot read version from the output of 'docker --version'") + version = (int(match.group(1)), int(match.group(2)), int(match.group(3))) + if version < self.MINIMUM_DOCKER_VERSION: + pytest.skip( + f"expected Docker version to be at least {'.'.join(map(str, self.MINIMUM_DOCKER_VERSION))}, " + f"found {'.'.join(map(str,version))}" + ) + + def build(self, retries=3) -> None: + """Build the image.""" + assert self._dockerfile is not None, "Dockerfile not set" + for _ in range(retries): + try: + _image, build_logs = self._client.images.build( + path=str(self.context), + dockerfile=str(self.dockerfile), + tag=self.tag, + **self._get_build_kwargs(), + ) + for log_line in build_logs: + if "stream" in log_line: + line = log_line["stream"].strip() + if line: + logger.debug(line) + except Exception as e: # pylint: disable=broad-except + pytest.fail(f"failed to build image: {type(e)}: {e}") + + def _get_build_kwargs(self) -> Mapping[str, Any]: + """Return the keyword arguments to pass to the build command.""" + return {} + + def pull_image(self, retries=3) -> None: + """Pull image from remote repo.""" + for _ in range(retries): + try: + self._client.images.pull(self.tag) + return + except Exception as e: # pylint: disable=broad-except + pytest.fail(f"failed to pull image: {e}") + + def stop_if_already_running(self): + """Stop the running images with the same tag, if any.""" + client = docker.from_env() + for container in client.containers.list(): + if self.tag in container.image.tags: + logger.info("Stopping image %s ...", self.tag) + container.stop() + container.wait() + logger.info("Image %s stopped", self.tag) + + def run(self, cmd: Sequence[str], **kwargs) -> str: + """Create the container.""" + assert "command" not in kwargs + assert "remove" not in kwargs + stdout = self._client.containers.run( + self.tag, command=cmd, remove=True, **kwargs + ) + return stdout.decode("utf-8") diff --git a/tests/helpers/misc.py b/tests/helpers/misc.py new file mode 100644 index 00000000..9d656ec8 --- /dev/null +++ b/tests/helpers/misc.py @@ -0,0 +1,39 @@ +# -*- coding: utf-8 -*- +# +# Copyright 2021 -- 2023 WhiteMech +# +# ------------------------------ +# +# This file is part of Plan4Past. +# +# Plan4Past is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Plan4Past is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with Plan4Past. If not, see . +# + +"""This module contains miscellaneous helper functions for tests.""" +import contextlib +import os +from os import PathLike +from pathlib import Path +from typing import Generator + + +@contextlib.contextmanager +def cd(path: PathLike) -> Generator: # pylint: disable=invalid-name + """Change working directory temporarily.""" + old_path = Path.cwd() + os.chdir(path) + try: + yield + finally: + os.chdir(str(old_path)) diff --git a/tests/helpers/planutils/__init__.py b/tests/helpers/planutils/__init__.py new file mode 100644 index 00000000..b1753019 --- /dev/null +++ b/tests/helpers/planutils/__init__.py @@ -0,0 +1,23 @@ +# -*- coding: utf-8 -*- +# +# Copyright 2021 -- 2023 WhiteMech +# +# ------------------------------ +# +# This file is part of Plan4Past. +# +# Plan4Past is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Plan4Past is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with Plan4Past. If not, see . +# + +"""This package contains helper functions for using planutils tools.""" diff --git a/tests/helpers/planutils/base.py b/tests/helpers/planutils/base.py new file mode 100644 index 00000000..29771f60 --- /dev/null +++ b/tests/helpers/planutils/base.py @@ -0,0 +1,51 @@ +# -*- coding: utf-8 -*- +# +# Copyright 2021 -- 2023 WhiteMech +# +# ------------------------------ +# +# This file is part of Plan4Past. +# +# Plan4Past is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Plan4Past is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with Plan4Past. If not, see . +# + +"""This module contains base class for using planutils docker image.""" + + +from pathlib import Path + +from docker import DockerClient + +from tests.helpers.constants import PLANUTILS_DOCKERFILE +from tests.helpers.docker import DockerImage + + +class PlanutilsDockerImage(DockerImage): + """Wrapper to the Planutils Docker image.""" + + TAG = "plan4past-dev/planutils:latest" + + def __init__( + self, + client: DockerClient, + dockerfile: Path = PLANUTILS_DOCKERFILE, + ): + """ + Initialize the Planutils Docker image. + + :param client: the Docker client. + """ + super().__init__( + client, self.TAG, dockerfile=dockerfile, context=dockerfile.parent + ) diff --git a/tests/helpers/planutils/val.py b/tests/helpers/planutils/val.py new file mode 100644 index 00000000..e1b1338d --- /dev/null +++ b/tests/helpers/planutils/val.py @@ -0,0 +1,168 @@ +# -*- coding: utf-8 -*- +# +# Copyright 2021 -- 2023 WhiteMech +# +# ------------------------------ +# +# This file is part of Plan4Past. +# +# Plan4Past is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Plan4Past is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with Plan4Past. If not, see . +# + + +"""This module contains the VAL wrapper for the planutils docker image.""" +import logging +import re +import shutil +from dataclasses import dataclass +from pathlib import Path +from tempfile import TemporaryDirectory +from typing import Optional, Sequence + +from tests.helpers.docker import DockerImage + +logger = logging.getLogger(__name__) + + +@dataclass(frozen=True) +class VALResult: + """The result of the validation.""" + + errors: Sequence[str] + warnings: Sequence[str] + + @property + def nb_errors(self) -> int: + """Return the number of errors.""" + return len(self.errors) + + @property + def nb_warnings(self) -> int: + """Return the number of warnings.""" + return len(self.warnings) + + def is_valid(self, strict: bool = False) -> bool: + """Return whether the result is valid.""" + return self.nb_errors == 0 and (not strict or self.nb_warnings == 0) + + +class VALWrapper: + """A wrapper for the PDDL validator.""" + + def __init__(self, planutils_docker: DockerImage) -> None: + """Initialize the wrapper.""" + self._planutils_docker = planutils_docker + + def validate_domain(self, domain: Path) -> VALResult: + """Validate the domain.""" + return self._validate(domain, None) + + def validate_problem(self, domain: Path, problem: Path) -> VALResult: + """Validate the problem.""" + return self._validate(domain, problem) + + def _validate(self, domain: Path, problem: Optional[Path]) -> VALResult: + """Validate the domain and the problem.""" + assert domain.exists(), f"domain {domain} does not exist" + assert domain.is_file(), f"domain {domain} is not a file" + + if problem is not None: + assert problem.exists(), f"problem {problem} does not exist" + assert problem.is_file(), f"problem {problem} is not a file" + + # move files to a temporary directory + with TemporaryDirectory() as tmp_dir: + tmp_dir_path = Path(tmp_dir) + + domain_tmp = tmp_dir_path / domain.name + shutil.copy(domain, domain_tmp) + + if problem is not None: + problem_tmp = tmp_dir_path / problem.name + shutil.copy(problem, problem_tmp) + + bind_path = Path("/root/temp") + volumes = { + str(tmp_dir_path): { + "bind": str(bind_path), + "mode": "rw", + }, + } + + cmd = [ + "planutils", + "run", + "val", + "Parser", + str(bind_path / domain_tmp.name), + ] + if problem is not None: + cmd.append(str(bind_path / problem_tmp.name)) + + stdout = self._planutils_docker.run(cmd, volumes=volumes) + result = self._process_output(stdout) + return filter_val_result(result) + + def _process_output(self, output: str) -> VALResult: + """Process the output of the validator.""" + match = re.search("Errors: ([0-9]+), warnings: ([0-9]+)\n", output) + assert match is not None, f"output {output} does not match the expected format" + + start = match.end() + errors_and_warnings = output[start:] + errors_and_warnings_lines = errors_and_warnings.splitlines(keepends=False) + + errors = [] + warnings = [] + for line in errors_and_warnings_lines: + if "Error:" in line: + errors.append(line) + elif "Warning:" in line: + warnings.append(line) + else: + raise ValueError(f"line {line} does not match the expected format") + + return VALResult(errors=errors, warnings=warnings) + + +def filter_val_result(val_result: VALResult) -> VALResult: + """ + Check the validity of the result. + + This method filters out some warnings and errors that at the moment are not considered relevant for our library. + + For example, since the compilation makes the domain goal-dependent, it requires to define certain objects that are + then redefined in the problem. This causes the error 'Re-declaration of symbol in same scope'. + + Similarly, the presence of constants related to the goal in the domain file causes the warning message 'Undeclared + symbol'. + """ + ERRORS_TO_IGNORE = ["Re-declaration of symbol in same scope"] + WARNINGS_TO_IGNORE = ["Undeclared symbol"] + + filtered_errors = [] + for error in val_result.errors: + if not any(err in error for err in ERRORS_TO_IGNORE): + filtered_errors.append(error) + else: + logger.debug("ignoring VAL error: %s", error) + + filtered_warnings = [] + for warning in val_result.warnings: + if not any(warn in warning for warn in WARNINGS_TO_IGNORE): + filtered_warnings.append(warning) + else: + logger.debug("ignoring VAL warning: %s", warning) + + return VALResult(errors=filtered_errors, warnings=filtered_warnings) diff --git a/tests/test_compiler.py b/tests/test_compiler.py new file mode 100644 index 00000000..78864847 --- /dev/null +++ b/tests/test_compiler.py @@ -0,0 +1,79 @@ +# -*- coding: utf-8 -*- +# +# Copyright 2021 -- 2023 WhiteMech +# +# ------------------------------ +# +# This file is part of Plan4Past. +# +# Plan4Past is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Plan4Past is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with Plan4Past. If not, see . +# + +"""This module contain tests for the compiler module.""" + +from pathlib import Path +from tempfile import TemporaryDirectory + +import pytest +from pddl.formatter import domain_to_string, problem_to_string +from pddl.logic import Predicate, constants +from pddl.parser.domain import DomainParser +from pddl.parser.problem import ProblemParser +from pylogics.parsers import parse_pltl +from pylogics.syntax.pltl import Atomic as PLTLAtomic + +from plan4past.compiler import Compiler +from tests.helpers.constants import EXAMPLES_DIR + + +@pytest.mark.parametrize( + "from_atoms_to_fluent", + [ + None, + { + PLTLAtomic("on_b_a"): Predicate("on", *constants("b a")), + PLTLAtomic("ontable_c"): Predicate("ontable", *constants("c")), + }, + ], +) +def test_readme_example(val, from_atoms_to_fluent) -> None: + """Test the example from the README.""" + formula = "on_b_a & O(ontable_c)" + domain_parser = DomainParser() + problem_parser = ProblemParser() + + pddl_example_domain_path = EXAMPLES_DIR / Path("pddl/domain.pddl") + pddl_example_problem_path = EXAMPLES_DIR / Path("pddl/p-0.pddl") + + domain = domain_parser(pddl_example_domain_path.read_text(encoding="utf-8")) + problem = problem_parser(pddl_example_problem_path.read_text(encoding="utf-8")) + goal = parse_pltl(formula) + + compiler = Compiler( + domain, problem, goal, from_atoms_to_fluent=from_atoms_to_fluent + ) + compiler.compile() + compiled_domain, compiled_problem = compiler.result + + with TemporaryDirectory() as tmpdir: + tmpdir_path = Path(tmpdir) + new_domain_path = tmpdir_path / "new-domain.pddl" + new_problem_path = tmpdir_path / "new-problem.pddl" + with open(new_domain_path, "w+", encoding="utf-8") as d: + d.write(domain_to_string(compiled_domain)) + with open(new_problem_path, "w+", encoding="utf-8") as p: + p.write(problem_to_string(compiled_problem)) + + result = val.validate_problem(new_domain_path, new_problem_path) + assert result.is_valid(strict=True) diff --git a/tests/test_helpers/__init__.py b/tests/test_helpers/__init__.py new file mode 100644 index 00000000..cadf8bca --- /dev/null +++ b/tests/test_helpers/__init__.py @@ -0,0 +1,23 @@ +# -*- coding: utf-8 -*- +# +# Copyright 2021 -- 2023 WhiteMech +# +# ------------------------------ +# +# This file is part of Plan4Past. +# +# Plan4Past is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Plan4Past is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with Plan4Past. If not, see . +# + +"""This test package contains tests for the plan4past.helpers subpackage.""" diff --git a/tests/test_helpers/test_utils.py b/tests/test_helpers/test_utils.py new file mode 100644 index 00000000..18237ef1 --- /dev/null +++ b/tests/test_helpers/test_utils.py @@ -0,0 +1,83 @@ +# -*- coding: utf-8 -*- +# +# Copyright 2021 -- 2023 WhiteMech +# +# ------------------------------ +# +# This file is part of Plan4Past. +# +# Plan4Past is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Plan4Past is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with Plan4Past. If not, see . +# + +"""This module contains tests for the plan4past.helpers.utils module.""" +import pytest +from pddl.logic import Predicate, constants +from pylogics.syntax.pltl import Atomic as PLTLAtomic +from pylogics.syntax.pltl import Before, Since + +from plan4past.helpers.utils import ( + add_val_prefix, + check_, + default_mapping, + remove_before_prefix, + remove_val_prefix, +) + + +def test_default_mapping() -> None: + """Test the default mapping function.""" + p_a_b = PLTLAtomic("p_a_b") + p_b_c = PLTLAtomic("p_b_c") + p_c_d = PLTLAtomic("p_c_d") + p2 = PLTLAtomic("p2") + before_p_a_b = Before(p_a_b) + p_b_c_since_p_c_d = Since(p_b_c, p_c_d) + + result = default_mapping(before_p_a_b & p_b_c_since_p_c_d & p2) + assert result == { + p_a_b: Predicate("p", *constants("a b")), + p_b_c: Predicate("p", *constants("b c")), + p_c_d: Predicate("p", *constants("c d")), + p2: Predicate("p2"), + } + + +def test_val_prefix() -> None: + """Test the add_val_prefix function.""" + assert add_val_prefix("Y-foo") == "val-Y-foo" + assert add_val_prefix("Yfoo") == "val-Yfoo" + assert add_val_prefix("foo") == "val-foo" + assert add_val_prefix("Y-foo-bar") == "val-Y-foo-bar" + + +def test_remove_before_prefix() -> None: + """Test the remove_before_prefix function.""" + assert remove_before_prefix("Y-foo") == "foo" + assert remove_before_prefix("Yfoo") == "foo" + assert remove_before_prefix("foo") == "foo" + + +def test_remove_val_prefix() -> None: + """Test the remove_val_prefix function.""" + assert remove_val_prefix("val-foo") == "foo" + assert remove_val_prefix("foo") == "foo" + + +def test_check() -> None: + """Test the check_ function.""" + with pytest.raises(AssertionError, match=""): + check_(False) + + with pytest.raises(AssertionError, match="message"): + check_(False, message="message") diff --git a/tests/test_utils/__init__.py b/tests/test_utils/__init__.py new file mode 100644 index 00000000..4085d650 --- /dev/null +++ b/tests/test_utils/__init__.py @@ -0,0 +1,23 @@ +# -*- coding: utf-8 -*- +# +# Copyright 2021 -- 2023 WhiteMech +# +# ------------------------------ +# +# This file is part of Plan4Past. +# +# Plan4Past is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Plan4Past is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with Plan4Past. If not, see . +# + +"""This test package contains tests for the plan4past.utils subpackage.""" diff --git a/tests/test_utils/test_atoms_visitor.py b/tests/test_utils/test_atoms_visitor.py new file mode 100644 index 00000000..2cbc4283 --- /dev/null +++ b/tests/test_utils/test_atoms_visitor.py @@ -0,0 +1,104 @@ +# -*- coding: utf-8 -*- +# +# Copyright 2021 -- 2023 WhiteMech +# +# ------------------------------ +# +# This file is part of Plan4Past. +# +# Plan4Past is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Plan4Past is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with Plan4Past. If not, see . +# + +"""This module contains tests for the plan4past.utils.atoms_visitor module.""" +import pytest +from pylogics.syntax.pltl import Atomic as PLTLAtomic +from pylogics.syntax.pltl import Before +from pylogics.syntax.pltl import FalseFormula as PLTLFalse +from pylogics.syntax.pltl import Historically, Once, Since +from pylogics.syntax.pltl import TrueFormula as PLTLTrue + +from plan4past.utils.atoms_visitor import find_atoms + + +def test_find_atoms_true(): + """Test find_atoms for a true formula.""" + assert find_atoms(PLTLTrue()) == {PLTLAtomic("true")} + + +def test_find_atoms_false(): + """Test find_atoms for a false formula.""" + assert find_atoms(PLTLFalse()) == {PLTLAtomic("false")} + + +def test_find_atoms_atomic(): + """Test find_atoms for an atomic formula.""" + assert find_atoms(PLTLAtomic("a")) == {PLTLAtomic("a")} + + +def test_find_atoms_not(): + """Test find_atoms for a not formula.""" + a = PLTLAtomic("a") + assert find_atoms(~a) == {a} + + +def test_find_atoms_and(): + """Test find_atoms for an and formula.""" + a = PLTLAtomic("a") + b = PLTLAtomic("b") + assert find_atoms(a & b) == {a, b} + + +def test_find_atoms_or(): + """Test find_atoms for an or formula.""" + a = PLTLAtomic("a") + b = PLTLAtomic("b") + assert find_atoms(a | b) == {a, b} + + +def test_find_atoms_before(): + """Test find_atoms for a before formula.""" + a = PLTLAtomic("a") + before_a = Before(a) + assert find_atoms(before_a) == {a} + + +def test_find_atoms_since(): + """Test find_atoms for a since formula.""" + a = PLTLAtomic("a") + b = PLTLAtomic("b") + a_since_b = Since(a, b) + assert find_atoms(a_since_b) == {a, b} + + +def test_find_atoms_once(): + """Test find_atoms for a once formula.""" + a = PLTLAtomic("a") + once_a = Once(a) + assert find_atoms(once_a) == {a} + + +def test_find_atoms_historically(): + """Test find_atoms for a historically formula.""" + a = PLTLAtomic("a") + historically_a = Historically(a) + assert find_atoms(historically_a) == {a} + + +def test_find_atoms_called_with_not_supported_object_raises_error(): + """Test that passing an unsupported input for 'find_atoms' will raise an error.""" + with pytest.raises( + ValueError, + match="object of type is not supported by this function", + ): + find_atoms(1) diff --git a/tests/test_utils/test_derived_visitor.py b/tests/test_utils/test_derived_visitor.py new file mode 100644 index 00000000..aa299c92 --- /dev/null +++ b/tests/test_utils/test_derived_visitor.py @@ -0,0 +1,233 @@ +# -*- coding: utf-8 -*- +# +# Copyright 2021 -- 2023 WhiteMech +# +# ------------------------------ +# +# This file is part of Plan4Past. +# +# Plan4Past is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Plan4Past is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with Plan4Past. If not, see . +# + +"""This module contains tests for the plan4past.utils.derived_visitor module.""" +from typing import Set + +import pytest +from pddl.logic import Constant +from pddl.logic.base import And, Not, Or +from pddl.logic.predicates import DerivedPredicate, Predicate +from pylogics.syntax.pltl import Atomic as PLTLAtomic +from pylogics.syntax.pltl import ( + Before, + Once, + PropositionalFalse, + PropositionalTrue, + Since, +) + +from plan4past.helpers.utils import add_val_prefix +from plan4past.utils.derived_visitor import derived_predicates + + +def _eq_pred(a_pred: DerivedPredicate, b_pred: DerivedPredicate) -> bool: + """Check if two derived predicates are equal.""" + return a_pred.predicate == b_pred.predicate and a_pred.condition == b_pred.condition + + +def _eq(a: Set[DerivedPredicate], b: Set[DerivedPredicate]) -> bool: + """Check if two sets of derived predicates are equal.""" + if len(a) != len(b): + return False + + for a_pred in a: + if not any(_eq_pred(a_pred, b_pred) for b_pred in b): + return False + + return True + + +def test_derived_predicates_visitor_not_implemented_fail(): + """Test the derived predicates visitor when the input argument is not supported.""" + with pytest.raises( + NotImplementedError, match="handler not implemented for object " + ): + derived_predicates(1, {}) + + +def test_derived_predicates_visitor_true(): + """Test the derived predicates visitor for the propositional true.""" + val = Predicate(add_val_prefix("true")) + expected = {DerivedPredicate(val, And())} + + actual = derived_predicates(PropositionalTrue(), {}) + assert _eq(actual, expected) + + +def test_derived_predicates_visitor_false(): + """Test the derived predicates visitor for the propositional false.""" + val = Predicate(add_val_prefix("false")) + expected = {DerivedPredicate(val, Or())} + + actual = derived_predicates(PropositionalFalse(), {}) + assert _eq(actual, expected) + + +def test_derived_predicates_visitor_atomic(): + """Test the derived predicates visitor for the atomic formula.""" + a = PLTLAtomic("a") + val = Predicate(add_val_prefix("a")) + condition = Predicate("p", Constant("a")) + expected = {DerivedPredicate(val, condition)} + + actual = derived_predicates(a, {a: condition}) + assert _eq(actual, expected) + + +def test_derived_predicates_visitor_and(): + """Test the derived predicates visitor for the and formula.""" + a = PLTLAtomic("a") + b = PLTLAtomic("b") + + condition_a = Predicate("p", Constant("a")) + condition_b = Predicate("p", Constant("b")) + + val = Predicate(add_val_prefix("a-and-b")) + val_a = Predicate(add_val_prefix("a")) + val_b = Predicate(add_val_prefix("b")) + + condition = And(val_a, val_b) + + expected = { + DerivedPredicate(val, condition), + DerivedPredicate(val_a, condition_a), + DerivedPredicate(val_b, condition_b), + } + + actual = derived_predicates(a & b, {a: condition_a, b: condition_b}) + assert _eq(actual, expected) + + +def test_derived_predicates_visitor_or(): + """Test the derived predicates visitor for the or formula.""" + a = PLTLAtomic("a") + b = PLTLAtomic("b") + + condition_a = Predicate("p", Constant("a")) + condition_b = Predicate("p", Constant("b")) + + val = Predicate(add_val_prefix("a-or-b")) + val_a = Predicate(add_val_prefix("a")) + val_b = Predicate(add_val_prefix("b")) + + condition = Or(val_a, val_b) + + expected = { + DerivedPredicate(val, condition), + DerivedPredicate(val_a, condition_a), + DerivedPredicate(val_b, condition_b), + } + + actual = derived_predicates(a | b, {a: condition_a, b: condition_b}) + assert _eq(actual, expected) + + +def test_derived_predicates_visitor_not(): + """Test the derived predicates visitor for the not formula.""" + a = PLTLAtomic("a") + + condition_a = Predicate("p", Constant("a")) + + val = Predicate(add_val_prefix("not-a")) + val_a = Predicate(add_val_prefix("a")) + + condition = Not(val_a) + + expected = {DerivedPredicate(val, condition), DerivedPredicate(val_a, condition_a)} + + actual = derived_predicates(~a, {a: condition_a}) + assert _eq(actual, expected) + + +def test_derived_predicates_visitor_before(): + """Test the derived predicates visitor for the before formula.""" + a = PLTLAtomic("a") + Ya = Before(a) + + condition_a = Predicate("p", Constant("a")) + + val = Predicate(add_val_prefix("Ya")) + val_a = Predicate(add_val_prefix("a")) + + condition = Predicate("Ya") + + expected = {DerivedPredicate(val, condition), DerivedPredicate(val_a, condition_a)} + + actual = derived_predicates(Ya, {a: condition_a}) + assert _eq(actual, expected) + + +def test_derived_predicates_visitor_since(): + """Test the derived predicates visitor for the since formula.""" + a = PLTLAtomic("a") + b = PLTLAtomic("b") + c = PLTLAtomic("c") + a_since_b_since_c = Since(a, b, c) + + val_a = Predicate(add_val_prefix("a")) + val_b = Predicate(add_val_prefix("b")) + val_c = Predicate(add_val_prefix("c")) + val_a_since_b_since_c = Predicate(add_val_prefix("a-S-b-S-c")) + val_b_since_c = Predicate(add_val_prefix("b-S-c")) + Y_a_since_b_since_c = Predicate("Y-a-S-b-S-c") + Y_b_since_c = Predicate("Y-b-S-c") + + condition_a = Predicate("p", Constant("a")) + condition_b = Predicate("p", Constant("b")) + condition_c = Predicate("p", Constant("c")) + condition_b_since_c = Or(val_c, And(val_b, Y_b_since_c)) + condition_a_since_b_since_c = Or(val_b_since_c, And(val_a, Y_a_since_b_since_c)) + + expected = { + DerivedPredicate(val_a_since_b_since_c, condition_a_since_b_since_c), + DerivedPredicate(val_b_since_c, condition_b_since_c), + DerivedPredicate(val_a, condition_a), + DerivedPredicate(val_b, condition_b), + DerivedPredicate(val_c, condition_c), + } + + actual = derived_predicates( + a_since_b_since_c, {a: condition_a, b: condition_b, c: condition_c} + ) + assert _eq(actual, expected) + + +def test_derived_predicates_visitor_once(): + """Test the derived predicates visitor for the once formula.""" + a = PLTLAtomic("a") + once_a = Once(a) + + val_a = Predicate(add_val_prefix("a")) + val_once_a = Predicate(add_val_prefix("Oa")) + Y_once_a = Predicate("Y-Oa") + + condition_a = Predicate("p", Constant("a")) + condition_once_a = Or(val_a, Y_once_a) + + expected = { + DerivedPredicate(val_a, condition_a), + DerivedPredicate(val_once_a, condition_once_a), + } + + actual = derived_predicates(once_a, {a: condition_a}) + assert _eq(actual, expected) diff --git a/tests/test_utils/test_mapping_parser.py b/tests/test_utils/test_mapping_parser.py new file mode 100644 index 00000000..a9944f7a --- /dev/null +++ b/tests/test_utils/test_mapping_parser.py @@ -0,0 +1,168 @@ +# -*- coding: utf-8 -*- +# +# Copyright 2021 -- 2023 WhiteMech +# +# ------------------------------ +# +# This file is part of Plan4Past. +# +# Plan4Past is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Plan4Past is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with Plan4Past. If not, see . +# + +"""This module contains tests for the plan4past.utils.mapping_parser module.""" +from textwrap import dedent + +import pytest +from pddl.logic import Constant, Predicate, constants +from pylogics.syntax.pltl import Atomic as PLTLAtomic +from pylogics.syntax.pltl import FalseFormula as PLTLFalse +from pylogics.syntax.pltl import TrueFormula as PLTLTrue + +from plan4past.utils.mapping_parser import MappingParserError, mapping_parser +from tests.helpers.constants import EXAMPLE_MAP_FILE + +_on_b_a = PLTLAtomic("on_b_a") +_ontable_c = PLTLAtomic("ontable_c") + +_pddl_on_b_a = Predicate("on", *constants("b a")) +_pddl_ontable_c = Predicate("ontable", Constant("c")) + + +@pytest.mark.parametrize( + "formula,expected_mapping", + [ + (PLTLTrue(), {}), + (PLTLFalse(), {}), + (_on_b_a, {_on_b_a: _pddl_on_b_a}), + (_ontable_c, {_ontable_c: _pddl_ontable_c}), + (_on_b_a & _ontable_c, {_on_b_a: _pddl_on_b_a, _ontable_c: _pddl_ontable_c}), + ], +) +def test_mapping_parser_successful_case(formula, expected_mapping): + """Test the mapping parser with the example map file.""" + actual_mapping = mapping_parser(EXAMPLE_MAP_FILE.read_text(), formula) + assert actual_mapping == expected_mapping + + +def test_mapping_parser_empty_file(): + """Test the mapping parser when the file is empty.""" + assert not mapping_parser("", PLTLTrue()) + + +def test_mapping_parser_empty_lines(): + """Test the mapping parser with empty lines.""" + assert not mapping_parser("\n\n", PLTLTrue()) + + +def test_mapping_parser_with_comments(): + """Test the mapping parser with comments.""" + assert mapping_parser(";;;;\n;\non_b_a,on b a", _on_b_a) == {_on_b_a: _pddl_on_b_a} + + +def test_mapping_parser_when_row_has_no_comma(): + """Test the mapping parser when a row is invalid (has no comma).""" + with pytest.raises( + MappingParserError, + match="invalid mapping at row 0: expected a mapping of the form ','", + ): + mapping_parser( + "invalid_row\n", + PLTLTrue(), + ) + + +def test_mapping_parser_when_row_has_more_than_one_comma(): + """Test the mapping parser when a row is invalid (has more than two commas).""" + with pytest.raises( + MappingParserError, + match="invalid mapping at row 0: expected a mapping of the form ','", + ): + mapping_parser( + "invalid_row,invalid_row,\n", + PLTLTrue(), + ) + + +def test_mapping_parser_bad_symbol_format(): + """Test the mapping parser when a row is invalid (bad symbol format).""" + with pytest.raises( + MappingParserError, + match="invalid mapping at row 0: symbol cannot be empty string and must match the regex ", + ): + mapping_parser( + "not a valid symbol,some_predicate\n", + PLTLTrue(), + ) + + +def test_mapping_parser_empty_predicate_name(): + """Test the mapping parser when a row is invalid (empty predicate name).""" + with pytest.raises( + MappingParserError, + match="invalid mapping at row 0: predicate cannot be empty string", + ): + mapping_parser( + "some_symbol,\n", + PLTLTrue(), + ) + + +def test_mapping_parser_invalid_predicate_name(): + """Test the mapping parser when a row is invalid (invalid predicate name).""" + with pytest.raises( + MappingParserError, + match=r"invalid mapping at row 0: got an invalid name for a predicate: 'not!'\. It must match the regex .*", + ): + mapping_parser( + "some_symbol,not! a valid predicate\n", + PLTLTrue(), + ) + + +def test_mapping_parser_invalid_constant_name(): + """Test the mapping parser when a row is invalid (invalid constant name).""" + with pytest.raises( + MappingParserError, + match=r"invalid mapping at row 0: got an invalid constant: Value 'wrong-constant-name!' " + r"does not match the regular expression .*", + ): + mapping_parser( + "some_symbol,predicate wrong-constant-name!\n", + PLTLTrue(), + ) + + +def test_mapping_parser_in_case_of_unmapped_symbols(): + """Test the mapping parser when a symbol is not mapped.""" + with pytest.raises( + MappingParserError, + match=r"invalid mapping: the following symbols of the formula are not mapped: {'a|b', 'b|a'}", + ): + mapping_parser( + dedent( + """\ + some_symbol,predicate some_constant + """ + ), + PLTLAtomic("a") & PLTLAtomic("b") & PLTLTrue(), + ) + + +def test_mapping_parser_symbol_occurs_more_than_once(): + """Test the mapping parser when a symbol occurs more than once.""" + with pytest.raises( + MappingParserError, + match="invalid mapping at row 1: symbol 'on_b_a' is mapped multiple times", + ): + mapping_parser("on_b_a,on b a\non_b_a,on b a", _on_b_a) diff --git a/tests/test_utils/test_predicates_visitor.py b/tests/test_utils/test_predicates_visitor.py new file mode 100644 index 00000000..b398570d --- /dev/null +++ b/tests/test_utils/test_predicates_visitor.py @@ -0,0 +1,106 @@ +# -*- coding: utf-8 -*- +# +# Copyright 2021 -- 2023 WhiteMech +# +# ------------------------------ +# +# This file is part of Plan4Past. +# +# Plan4Past is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Plan4Past is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with Plan4Past. If not, see . +# + +"""This module contains tests for the plan4past.utils.predicates_visitor module.""" + +import pytest +from pddl.logic.predicates import Predicate +from pylogics.syntax.pltl import Atomic as PLTLAtomic +from pylogics.syntax.pltl import ( + Before, + Once, + PropositionalFalse, + PropositionalTrue, + Since, +) + +from plan4past.utils.predicates_visitor import predicates + + +def test_predicates_visitor_not_implemented_fail(): + """Test the predicates visitor when the input argument is not supported.""" + with pytest.raises( + NotImplementedError, + match="handler not implemented for object of type ", + ): + predicates(1) + + +def test_predicates_visitor_true(): + """Test the predicates visitor for the propositional true.""" + assert predicates(PropositionalTrue()) == set() + + +def test_predicates_visitor_false(): + """Test the predicates visitor for the propositional false.""" + assert predicates(PropositionalFalse()) == set() + + +def test_predicates_visitor_atomic(): + """Test the predicates visitor for the atomic formula.""" + assert predicates(PLTLAtomic("a")) == set() + + +def test_predicates_visitor_and(): + """Test the predicates visitor for the and formula.""" + a = PLTLAtomic("a") + b = PLTLAtomic("b") + assert predicates(a & b) == set() + + +def test_predicates_visitor_or(): + """Test the predicates visitor for the or formula.""" + a = PLTLAtomic("a") + b = PLTLAtomic("b") + assert predicates(a | b) == set() + + +def test_predicates_visitor_not(): + """Test the predicates visitor for the not formula.""" + a = PLTLAtomic("a") + assert predicates(~a) == set() + + +def test_predicates_visitor_before(): + """Test the predicates visitor for the before formula.""" + a = PLTLAtomic("a") + Ya = Before(a) + assert predicates(Ya) == {Predicate("Ya")} + + +def test_predicates_visitor_since(): + """Test the predicates visitor for the since formula.""" + a = PLTLAtomic("a") + b = PLTLAtomic("b") + c = PLTLAtomic("c") + a_since_b_since_c = Since(a, b, c) + assert predicates(a_since_b_since_c) == { + Predicate("Y-b-S-c"), + Predicate("Y-a-S-b-S-c"), + } + + +def test_predicates_visitor_once(): + """Test the predicates visitor for the once formula.""" + a = PLTLAtomic("a") + once_a = Once(a) + assert predicates(once_a) == {Predicate("Y-Oa")} diff --git a/tests/test_utils/test_rewrite_formula_visitor.py b/tests/test_utils/test_rewrite_formula_visitor.py new file mode 100644 index 00000000..2bd8a6fc --- /dev/null +++ b/tests/test_utils/test_rewrite_formula_visitor.py @@ -0,0 +1,132 @@ +# -*- coding: utf-8 -*- +# +# Copyright 2021 -- 2023 WhiteMech +# +# ------------------------------ +# +# This file is part of Plan4Past. +# +# Plan4Past is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Plan4Past is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with Plan4Past. If not, see . +# + +"""This module contains tests for the plan4past.utils.rewrite_formula_visitor module.""" +import pytest +from pylogics.syntax.base import Equivalence as PLTLEquivalence +from pylogics.syntax.base import Implies as PLTLImplies +from pylogics.syntax.pltl import Atomic as PLTLAtomic +from pylogics.syntax.pltl import ( + Before, + Historically, + Once, + PropositionalFalse, + PropositionalTrue, + Since, +) + +from plan4past.utils.rewrite_formula_visitor import rewrite + + +def test_rewrite_formula_visitor_not_implemented_fail(): + """Test the rewrite formula visitor when the input argument is not supported.""" + with pytest.raises( + NotImplementedError, + match="handler not implemented for object of type ", + ): + rewrite(1) + + +def test_rewrite_formula_visitor_true(): + """Test the rewrite formula visitor for the propositional true.""" + assert rewrite(PropositionalTrue()) == PropositionalTrue() + + +def test_rewrite_formula_visitor_false(): + """Test the rewrite formula visitor for the propositional false.""" + assert rewrite(PropositionalFalse()) == PropositionalFalse() + + +def test_rewrite_formula_visitor_atomic(): + """Test the rewrite formula visitor for the atomic formula.""" + a = PLTLAtomic("a") + assert rewrite(a) == a + + +def test_rewrite_formula_visitor_and(): + """Test the rewrite formula visitor for the and formula.""" + a = PLTLAtomic("a") + b = PLTLAtomic("b") + c = PLTLAtomic("c") + d = PLTLAtomic("d") + assert rewrite(PLTLImplies(a, b) & PLTLImplies(c, d)) == (~a | b) & (~c | d) + + +def test_rewrite_formula_visitor_or(): + """Test the rewrite formula visitor for the or formula.""" + a = PLTLAtomic("a") + b = PLTLAtomic("b") + c = PLTLAtomic("c") + d = PLTLAtomic("d") + assert rewrite(PLTLImplies(a, b) | PLTLImplies(c, d)) == (~a | b) | (~c | d) + + +def test_rewrite_formula_visitor_not(): + """Test the rewrite formula visitor for the not formula.""" + a = PLTLAtomic("a") + b = PLTLAtomic("b") + assert rewrite(~PLTLImplies(a, b)) == ~(~a | b) + + +def test_rewrite_formula_visitor_implies(): + """Test the rewrite formula visitor for the implies formula.""" + a = PLTLAtomic("a") + b = PLTLAtomic("b") + assert rewrite(PLTLImplies(a, b)) == (~a | b) + + +def test_rewrite_formula_visitor_equivalence(): + """Test the rewrite formula visitor for the equivalence formula.""" + a = PLTLAtomic("a") + b = PLTLAtomic("b") + assert rewrite(PLTLEquivalence(a, b)) == ((a & b) | (~a & ~b)) + + +def test_rewrite_formula_visitor_before(): + """Test the rewrite formula visitor for the before formula.""" + a = PLTLAtomic("a") + before_a = Before(a) + assert rewrite(before_a) == before_a + + +def test_rewrite_formula_visitor_since(): + """Test the rewrite formula visitor for the before formula.""" + a = PLTLAtomic("a") + b = PLTLAtomic("b") + c = PLTLAtomic("c") + d = PLTLAtomic("d") + a_since_b_since_c_implies_d = Since(a, b, PLTLImplies(c, d)) + assert rewrite(a_since_b_since_c_implies_d) == Since(a, Since(b, (~c | d))) + + +def test_rewrite_formula_visitor_once(): + """Test the rewrite formula visitor for the once formula.""" + a = PLTLAtomic("a") + once_a = Once(a) + assert rewrite(once_a) == once_a + + +def test_rewrite_formula_visitor_historically(): + """Test the rewrite formula visitor for the historically formula.""" + a = PLTLAtomic("a") + historically_a = Historically(a) + assert rewrite(historically_a) == ~Once(~a) diff --git a/tests/test_utils/test_val_predicates_visitor.py b/tests/test_utils/test_val_predicates_visitor.py new file mode 100644 index 00000000..c28f460c --- /dev/null +++ b/tests/test_utils/test_val_predicates_visitor.py @@ -0,0 +1,139 @@ +# -*- coding: utf-8 -*- +# +# Copyright 2021 -- 2023 WhiteMech +# +# ------------------------------ +# +# This file is part of Plan4Past. +# +# Plan4Past is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Plan4Past is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with Plan4Past. If not, see . +# + +"""This module contains tests for the plan4past.utils.val_predicates_visitor module.""" +import pytest +from pddl.logic import Predicate +from pylogics.syntax.pltl import Atomic as PLTLAtomic +from pylogics.syntax.pltl import ( + Before, + Once, + PropositionalFalse, + PropositionalTrue, + Since, +) + +from plan4past.helpers.utils import add_val_prefix +from plan4past.utils.val_predicates_visitor import val_predicates + + +def test_val_predicates_visitor_not_implemented_fail(): + """Test the val predicates visitor when the input argument is not supported.""" + with pytest.raises( + NotImplementedError, + match="handler not implemented for object of type ", + ): + val_predicates(1) + + +def test_val_predicates_visitor_true(): + """Test the val predicates visitor for the propositional true.""" + assert val_predicates(PropositionalTrue()) == {Predicate(add_val_prefix("true"))} + + +def test_val_predicates_visitor_false(): + """Test the val predicates visitor for the propositional false.""" + assert val_predicates(PropositionalFalse()) == {Predicate(add_val_prefix("false"))} + + +def test_val_predicates_visitor_atomic(): + """Test the val predicates visitor for the atomic formula.""" + a = PLTLAtomic("a") + assert val_predicates(a) == {Predicate(add_val_prefix(a.name))} + + +def test_val_predicates_visitor_and(): + """Test the val predicates visitor for the and formula.""" + a = PLTLAtomic("a") + b = PLTLAtomic("b") + + val_a_and_b = Predicate(add_val_prefix("a-and-b")) + val_a = Predicate(add_val_prefix(a.name)) + val_b = Predicate(add_val_prefix(b.name)) + + assert val_predicates(a & b) == {val_a_and_b, val_a, val_b} + + +def test_val_predicates_visitor_or(): + """Test the val predicates visitor for the or formula.""" + a = PLTLAtomic("a") + b = PLTLAtomic("b") + + val_a_or_b = Predicate(add_val_prefix("a-or-b")) + val_a = Predicate(add_val_prefix(a.name)) + val_b = Predicate(add_val_prefix(b.name)) + + assert val_predicates(a | b) == {val_a_or_b, val_a, val_b} + + +def test_val_predicates_visitor_not(): + """Test the val predicates visitor for the not formula.""" + a = PLTLAtomic("a") + + val_not_a = Predicate(add_val_prefix("not-a")) + val_a = Predicate(add_val_prefix(a.name)) + + assert val_predicates(~a) == {val_not_a, val_a} + + +def test_val_predicates_visitor_before(): + """Test the val predicates visitor for the before formula.""" + a = PLTLAtomic("a") + before_a = Before(a) + + val_before_a = Predicate(add_val_prefix("Ya")) + val_a = Predicate(add_val_prefix(a.name)) + + assert val_predicates(before_a) == {val_before_a, val_a} + + +def test_val_predicates_visitor_since(): + """Test the val predicates visitor for the before formula.""" + a = PLTLAtomic("a") + b = PLTLAtomic("b") + c = PLTLAtomic("c") + a_since_b_since_c = Since(a, b, c) + + val_a_since_b_since_c = Predicate(add_val_prefix("a-S-b-S-c")) + val_b_since_c = Predicate(add_val_prefix("b-S-c")) + val_a = Predicate(add_val_prefix(a.name)) + val_b = Predicate(add_val_prefix(b.name)) + val_c = Predicate(add_val_prefix(c.name)) + + assert val_predicates(a_since_b_since_c) == { + val_a_since_b_since_c, + val_b_since_c, + val_a, + val_b, + val_c, + } + + +def test_val_predicates_visitor_once(): + """Test the val predicates visitor for the once formula.""" + a = PLTLAtomic("a") + once_a = Once(a) + + val_once_a = Predicate(add_val_prefix("Oa")) + val_a = Predicate(add_val_prefix(a.name)) + + assert val_predicates(once_a) == {val_once_a, val_a} diff --git a/tox.ini b/tox.ini index e9a83068..58116c20 100644 --- a/tox.ini +++ b/tox.ini @@ -1,20 +1,21 @@ [tox] -envlist = bandit, black-check, check-copyright, darglint, docs, flake8, isort-check, mypy, safety, pylint, vulture, py3{8,10,11} +envlist = bandit, black-check, check-copyright, docs, flake8, isort-check, mypy, safety, pylint, vulture, py3{8,9,10,11} labels = - code = py3{8,10,11} - meta = bandit, black-check, check-copyright, darglint, docs, flake8, isort-check, mypy, safety, pylint, vulture + code = py3{8,9,10,11} + meta = bandit, black-check, check-copyright, docs, flake8, isort-check, mypy, safety, pylint, vulture [testenv] allowlist_externals = pytest setenv = PYTHONPATH = {toxinidir} -deps = +deps = + docker>=6.1.3,<6.2.0 + mistune>=2.0.5,<2.1.0 pytest>=7.3.1,<7.4.0 pytest-cov>=4.1.0,<4.2.0 - pytest-randomly>=3.12.0,<3.13.0 pytest-lazy-fixture>=0.6.3,<0.7.0 - mistune>=2.0.5,<2.1.0 -commands = + pytest-randomly>=3.12.0,<3.13.0 +commands = pytest --basetemp={envtmpdir} --doctest-modules \ {posargs:plan4past tests} \ --cov=plan4past \ @@ -22,7 +23,7 @@ commands = --cov-report=html \ --cov-report=term -[testenv:py{38,310,311}] +[testenv:py{38,39,310,311}] commands = {[testenv]commands} deps = @@ -46,21 +47,25 @@ commands = mypy plan4past tests [testenv:black] +skipdist = True skip_install = True deps = black>=23.3.0,<23.4.0 commands = black plan4past tests [testenv:black-check] +skipdist = True skip_install = True deps = black>=23.3.0,<23.4.0 commands = black plan4past tests --check --verbose [testenv:isort] +skipdist = True skip_install = True deps = isort>=5.12.0,<5.13.0 commands = isort plan4past tests [testenv:isort-check] +skipdist = True skip_install = True deps = isort>=5.12.0,<5.13.0 commands = isort --check-only plan4past tests @@ -73,7 +78,10 @@ commands = bandit --configfile .bandit.yml --recursive plan4past tests scripts [testenv:pylint] skipdist = True -deps = pylint>=2.17.4,<2.18.0 +deps = + pylint>=2.17.4,<2.18.0 + pytest>=7.3.1,<7.4.0 + docker>=6.1.3,<6.2.0 commands = pylint plan4past tests [testenv:safety]