From cc850b28a8fa7ded04fc88f846808221787c3f4f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Y=C3=BCce=20Tekol?= Date: Sun, 27 Oct 2024 14:38:15 +0300 Subject: [PATCH] Updates (#191) Python to Prolog value exchange using string interpolation --- README.md | 2 +- docs/source/api/examples.rst | 14 ++ docs/source/get_started.rst | 5 +- docs/source/index.rst | 1 + docs/source/value_exchange.rst | 65 ++++++++ examples/README.md | 4 +- examples/coins/coins.pl | 8 +- examples/coins/coins.py | 41 ----- examples/coins/coins_new.py | 2 +- examples/create_term.py | 2 +- examples/draughts/puzzle1.py | 2 +- examples/hanoi/hanoi.py | 87 ---------- examples/hanoi/hanoi_simple.py | 40 ----- examples/knowledgebase.py | 4 +- examples/register_foreign_simple.py | 7 +- src/pyswip/easy.py | 8 +- src/pyswip/examples/coins.pl | 38 +++++ src/pyswip/examples/coins.py | 86 ++++++++++ .../hanoi => src/pyswip/examples}/hanoi.pl | 7 +- src/pyswip/examples/hanoi.py | 145 ++++++++++++++++ src/pyswip/examples/sudoku.py | 12 +- src/pyswip/prolog.py | 157 ++++++++++++++---- tests/examples/hanoi_fixture.txt | 64 +++++++ tests/examples/hanoi_simple_fixture.txt | 7 + tests/examples/sudoku.txt | 9 + tests/examples/test_coins.py | 14 ++ tests/examples/test_hanoi.py | 23 +++ tests/examples/test_sudoku.py | 15 +- tests/examples/utils.py | 7 + tests/test_examples.py | 1 - tests/test_prolog.py | 35 +++- 31 files changed, 672 insertions(+), 240 deletions(-) create mode 100644 docs/source/value_exchange.rst delete mode 100644 examples/coins/coins.py delete mode 100644 examples/hanoi/hanoi.py delete mode 100644 examples/hanoi/hanoi_simple.py create mode 100644 src/pyswip/examples/coins.pl create mode 100644 src/pyswip/examples/coins.py rename {examples/hanoi => src/pyswip/examples}/hanoi.pl (68%) create mode 100644 src/pyswip/examples/hanoi.py create mode 100644 tests/examples/hanoi_fixture.txt create mode 100644 tests/examples/hanoi_simple_fixture.txt create mode 100644 tests/examples/sudoku.txt create mode 100644 tests/examples/test_coins.py create mode 100644 tests/examples/test_hanoi.py create mode 100644 tests/examples/utils.py diff --git a/README.md b/README.md index 4144491..a2781b0 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ See the [Change Log](https://pyswip.org/change-log.html). If you have SWI-Prolog installed, it's just: ``` -pip install pyswip +pip install -U pyswip ``` See [Get Started](https://pyswip.readthedocs.io/en/latest/get_started.html) for detailed instructions. diff --git a/docs/source/api/examples.rst b/docs/source/api/examples.rst index e9a2768..079a2a5 100644 --- a/docs/source/api/examples.rst +++ b/docs/source/api/examples.rst @@ -9,3 +9,17 @@ Sudoku .. automodule:: pyswip.examples.sudoku :members: + +Hanoi +^^^^^ + +.. automodule:: pyswip.examples.hanoi + :members: + + +Coins +^^^^^ + +.. automodule:: pyswip.examples.coins + :members: + diff --git a/docs/source/get_started.rst b/docs/source/get_started.rst index 5afc3a6..66134b3 100644 --- a/docs/source/get_started.rst +++ b/docs/source/get_started.rst @@ -29,7 +29,7 @@ PySwip is available to install from `Python Package Index 0: - print(disks[pole][n], end=" ") - else: - print(" ", end=" ") - print() - print("-" * 9) - print(" ", "L", "C", "R") - if self.interactive: - cont = input("Press 'n' to finish: ") - return cont.lower() == "n" - - -def main(): - n = 3 - tower = Tower(n, True) - notifier = Notifier(tower.move) - registerForeign(notifier.notify) - Prolog.consult("hanoi.pl", relative_to=__file__) - list(Prolog.query("hanoi(%d)" % n)) - - -if __name__ == "__main__": - main() diff --git a/examples/hanoi/hanoi_simple.py b/examples/hanoi/hanoi_simple.py deleted file mode 100644 index b7d9014..0000000 --- a/examples/hanoi/hanoi_simple.py +++ /dev/null @@ -1,40 +0,0 @@ -# pyswip -- Python SWI-Prolog bridge -# Copyright (c) 2007-2018 Yüce Tekol -# -# Permission is hereby granted, free of charge, to any person obtaining a copy -# of this software and associated documentation files (the "Software"), to deal -# in the Software without restriction, including without limitation the rights -# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -# copies of the Software, and to permit persons to whom the Software is -# furnished to do so, subject to the following conditions: -# -# The above copyright notice and this permission notice shall be included in all -# copies or substantial portions of the Software. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -# SOFTWARE. - -from pyswip.prolog import Prolog -from pyswip.easy import registerForeign - -N = 3 # Number of disks - - -def main(): - def notify(t): - print("move disk from %s pole to %s pole." % tuple(t)) - - notify.arity = 1 - - registerForeign(notify) - Prolog.consult("hanoi.pl", relative_to=__file__) - list(Prolog.query(f"hanoi({N})")) - - -if __name__ == "__main__": - main() diff --git a/examples/knowledgebase.py b/examples/knowledgebase.py index 120e3d7..8990dd4 100644 --- a/examples/knowledgebase.py +++ b/examples/knowledgebase.py @@ -21,11 +21,11 @@ # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE # SOFTWARE. -from pyswip import * +from pyswip import Prolog, Functor, Variable, Query, newModule, call def main(): - _ = Prolog() + _ = Prolog() # not strictly required, but helps to silence the linter assertz = Functor("assertz") parent = Functor("parent", 2) diff --git a/examples/register_foreign_simple.py b/examples/register_foreign_simple.py index 1c5c8ad..1918149 100644 --- a/examples/register_foreign_simple.py +++ b/examples/register_foreign_simple.py @@ -27,11 +27,8 @@ from pyswip.easy import registerForeign -def hello(t): - print("Hello,", t) - - -hello.arity = 1 +def hello(who): + print("Hello,", who) def main(): diff --git a/src/pyswip/easy.py b/src/pyswip/easy.py index 6acbfe8..a1470ae 100644 --- a/src/pyswip/easy.py +++ b/src/pyswip/easy.py @@ -106,7 +106,7 @@ class InvalidTypeError(TypeError): def __init__(self, *args): type_ = args and args[0] or "Unknown" - msg = "Term is expected to be of type: '%s'" % type_ + msg = f"Term is expected to be of type: '{type_}'" Exception.__init__(self, msg, *args) @@ -116,7 +116,7 @@ class ArgumentTypeError(Exception): """ def __init__(self, expected, got): - msg = "Expected an argument of type '%s' but got '%s'" % (expected, got) + msg = f"Expected an argument of type '{expected}' but got '{got}'" Exception.__init__(self, msg) @@ -290,7 +290,7 @@ def __str__(self): return self.__repr__() def __repr__(self): - return "Variable(%s)" % self.handle + return f"Variable({self.handle})" def put(self, term): # PL_put_variable(term) @@ -703,7 +703,7 @@ class Query(object): def __init__(self, *terms, **kwargs): for key in kwargs: if key not in ["flags", "module"]: - raise Exception("Invalid kwarg: %s" % key, key) + raise Exception(f"Invalid kwarg: {key}", key) flags = kwargs.get("flags", PL_Q_NODEBUG | PL_Q_CATCH_EXCEPTION) module = kwargs.get("module", None) diff --git a/src/pyswip/examples/coins.pl b/src/pyswip/examples/coins.pl new file mode 100644 index 0000000..e7be435 --- /dev/null +++ b/src/pyswip/examples/coins.pl @@ -0,0 +1,38 @@ + +% Coins -- 2007 by Yuce Tekol + +:- use_module(library('bounds')). + +coins(Count, Total, Solution) :- + % A=1, B=5, C=10, D=50, E=100 + Solution = [A, B, C, D, E], + + Av is 1, + Bv is 5, + Cv is 10, + Dv is 50, + Ev is 100, + + Aup is Total // Av, + Bup is Total // Bv, + Cup is Total // Cv, + Dup is Total // Dv, + Eup is Total // Ev, + + A in 0..Aup, + B in 0..Bup, + C in 0..Cup, + D in 0..Dup, + E in 0..Eup, + + VA #= A*Av, + VB #= B*Bv, + VC #= C*Cv, + VD #= D*Dv, + VE #= E*Ev, + + sum(Solution, #=, Count), + VA + VB + VC + VD + VE #= Total, + + label(Solution). + diff --git a/src/pyswip/examples/coins.py b/src/pyswip/examples/coins.py new file mode 100644 index 0000000..2a6a46d --- /dev/null +++ b/src/pyswip/examples/coins.py @@ -0,0 +1,86 @@ +# pyswip -- Python SWI-Prolog bridge +# Copyright (c) 2007-2018 Yüce Tekol +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. +from typing import List, Dict + +# 100 coins must sum to $5.00 + +from pyswip.prolog import Prolog + + +__all__ = "solve", "prolog_source" + +_PROLOG_FILE = "coins.pl" + +Prolog.consult(_PROLOG_FILE, relative_to=__file__) + + +def solve( + *, coin_count: int = 100, total_cents: int = 500, max_solutions: int = 1 +) -> List[Dict[int, int]]: + """ + Solves the coins problem. + + Finds and returns combinations of ``coin_count`` coins that makes ``total`` cents. + + :param coin_count: Number of coins + :param total_cents: Total cent value of coins + """ + cents = [1, 5, 10, 50, 100] + query = Prolog.query( + "coins(%p, %p, Solution)", coin_count, total_cents, maxresult=max_solutions + ) + return [ + {cent: count for cent, count in zip(cents, soln["Solution"])} for soln in query + ] + + +def prolog_source() -> str: + """ + Returns the Prolog source file that solves the coins problem. + """ + from pathlib import Path + + path = Path(__file__).parent / _PROLOG_FILE + with open(path) as f: + return f.read() + + +def main(): + import argparse + + parser = argparse.ArgumentParser() + parser.add_argument("-c", "--count", type=int, default=100) + parser.add_argument("-t", "--total", type=int, default=500) + parser.add_argument("-s", "--solutions", type=int, default=1) + args = parser.parse_args() + print(f"{args.count} coins must sum to ${args.total/100}:\n") + solns = solve( + coin_count=args.count, total_cents=args.total, max_solutions=args.solutions + ) + for i, soln in enumerate(solns, start=1): + text = " + ".join( + f"{count}x{cent} cent(s)" for cent, count in soln.items() if count + ) + print(f"{i}. {text}") + + +if __name__ == "__main__": + main() diff --git a/examples/hanoi/hanoi.pl b/src/pyswip/examples/hanoi.pl similarity index 68% rename from examples/hanoi/hanoi.pl rename to src/pyswip/examples/hanoi.pl index 3530c0d..5900a6c 100644 --- a/examples/hanoi/hanoi.pl +++ b/src/pyswip/examples/hanoi.pl @@ -2,8 +2,11 @@ % Towers of Hanoi % Based on: http://en.wikipedia.org/wiki/Prolog -hanoi(N) :- move(N, left, right, center). -move(0, _, _, _) :- !. +hanoi(N) :- + move(N, left, right, center). + +move(0, _, _, _) :- + !. move(N, A, B, C) :- M is N-1, move(M, A, C, B), diff --git a/src/pyswip/examples/hanoi.py b/src/pyswip/examples/hanoi.py new file mode 100644 index 0000000..707476b --- /dev/null +++ b/src/pyswip/examples/hanoi.py @@ -0,0 +1,145 @@ +# pyswip -- Python SWI-Prolog bridge +# Copyright (c) 2007-2018 Yüce Tekol +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +from collections import deque +from typing import IO + +from pyswip.prolog import Prolog + + +__all__ = "solve", "prolog_source" + +_PROLOG_FILE = "hanoi.pl" + +Prolog.consult(_PROLOG_FILE, relative_to=__file__) + + +def make_notify_function(file): + state = {"step": 1} + + def f(from_to): + frm, to = from_to + print(f"{state['step']}. Move disk from {frm} pole to {to} pole.", file=file) + state["step"] += 1 + + return f + + +class Notifier: + def __init__(self, fun): + self.fun = fun + + def notify(self, t): + return not self.fun(t) + + +class Tower: + def __init__(self, disk_count=3, file=None): + if disk_count < 1 or disk_count > 9: + raise ValueError("disk_count must be between 1 and 9") + self.disk_count = disk_count + self.file = file + self.disks = dict( + left=deque(range(disk_count, 0, -1)), + center=deque(), + right=deque(), + ) + self.started = False + self.step = 0 + + def draw(self) -> None: + print("\n Step", self.step, file=self.file) + print(file=self.file) + for i in range(self.disk_count): + n = self.disk_count - i - 1 + print(" ", end=" ", file=self.file) + for pole in ["left", "center", "right"]: + if len(self.disks[pole]) - n > 0: + print(self.disks[pole][n], end=" ", file=self.file) + else: + print(" ", end=" ", file=self.file) + print(file=self.file) + print("-" * 9, file=self.file) + print(" ", "L", "C", "R", file=self.file) + + def move(self, r) -> None: + if not self.started: + self.draw() + self.started = True + self.disks[str(r[1])].append(self.disks[str(r[0])].pop()) + self.step += 1 + self.draw() + + +def solve(disk_count: int = 3, simple: bool = False, file: IO = None) -> None: + """ + Solves the Towers of Hanoi problem. + + :param disk_count: + Number of disks to use + :param simple: + If set to ``True``, only the moves are printed. + Otherwise all states are drawn. + :param file: + The file-like object to output the steps of the solution. + By default stdout is used. + + >>> solve(3, simple=True) + 1. Move disk from left pole to right pole. + 2. Move disk from left pole to center pole. + 3. Move disk from right pole to center pole. + 4. Move disk from left pole to right pole. + 5. Move disk from center pole to left pole. + 6. Move disk from center pole to right pole. + 7. Move disk from left pole to right pole. + """ + if simple: + Prolog.register_foreign(make_notify_function(file), name="notify") + else: + tower = Tower(disk_count, file=file) + notifier = Notifier(tower.move) + Prolog.register_foreign(notifier.notify) + list(Prolog.query("hanoi(%p)", disk_count)) + + +def prolog_source() -> str: + """ + Returns the Prolog source file that solves the Towers of Hanoi problem. + """ + from pathlib import Path + + path = Path(__file__).parent / _PROLOG_FILE + with open(path) as f: + return f.read() + + +def main(): + import argparse + + parser = argparse.ArgumentParser() + parser.add_argument("disk_count", type=int, choices=list(range(1, 10))) + parser.add_argument("-s", "--simple", action="store_true") + args = parser.parse_args() + solve(args.disk_count, simple=args.simple) + + +if __name__ == "__main__": + main() diff --git a/src/pyswip/examples/sudoku.py b/src/pyswip/examples/sudoku.py index 68af449..fcf964a 100644 --- a/src/pyswip/examples/sudoku.py +++ b/src/pyswip/examples/sudoku.py @@ -37,10 +37,10 @@ __all__ = "Matrix", "prolog_source", "sample_puzzle", "solve" _DIMENSION = 9 -_SOURCE_PATH = "sudoku.pl" +_PROLOG_FILE = "sudoku.pl" -Prolog.consult(_SOURCE_PATH, relative_to=__file__) +Prolog.consult(_PROLOG_FILE, relative_to=__file__) class Matrix: @@ -157,6 +157,7 @@ def solve(matrix: Matrix) -> Union[Matrix, Literal[False]]: . . 2 . 4 . 7 . . . . . 5 . . 9 . . 9 5 7 . 3 . . . . + >>> print(solve(puzzle)) 3 9 5 4 7 1 2 6 8 8 7 4 6 5 2 3 9 1 @@ -167,6 +168,7 @@ def solve(matrix: Matrix) -> Union[Matrix, Literal[False]]: 6 1 2 8 4 9 7 5 3 4 3 8 5 2 7 9 1 6 9 5 7 1 3 6 4 8 2 + """ p = repr(matrix).replace("0", "_") result = list(Prolog.query(f"L={p},sudoku(L)", maxresult=1)) @@ -179,10 +181,10 @@ def solve(matrix: Matrix) -> Union[Matrix, Literal[False]]: def prolog_source() -> str: - """Returns the Prolog source file that solves Sudoku puzzles""" + """Returns the Prolog source file that solves Sudoku puzzles.""" from pathlib import Path - path = Path(__file__).parent / _SOURCE_PATH + path = Path(__file__).parent / _PROLOG_FILE with open(path) as f: return f.read() @@ -199,7 +201,7 @@ def sample_puzzle() -> Matrix: . . 2 . 4 . 7 . . . . . 5 . . 9 . . 9 5 7 . 3 . . . . - """) +""") return matrix diff --git a/src/pyswip/prolog.py b/src/pyswip/prolog.py index ee302a8..0c87c03 100644 --- a/src/pyswip/prolog.py +++ b/src/pyswip/prolog.py @@ -24,7 +24,8 @@ import functools import inspect -from typing import Union, Generator, Callable, Optional +import re +from typing import Union, Generator, Callable, Optional, Tuple from pathlib import Path from pyswip.utils import resolve_path @@ -63,6 +64,9 @@ __all__ = "PrologError", "NestedQueryError", "Prolog" +RE_PLACEHOLDER = re.compile(r"%p") + + class PrologError(Exception): pass @@ -76,13 +80,13 @@ class NestedQueryError(PrologError): pass -def _initialize(): +def __initialize(): args = [] args.append("./") args.append("-q") # --quiet args.append("--nosignals") # "Inhibit any signal handling by Prolog" if SWI_HOME_DIR: - args.append("--home=%s" % SWI_HOME_DIR) + args.append(f"--home={SWI_HOME_DIR}") result = PL_initialise(len(args), args) # result is a boolean variable (i.e. 0 or 1) indicating whether the @@ -107,11 +111,11 @@ def _initialize(): PL_discard_foreign_frame(swipl_fid) -_initialize() +__initialize() -# NOTE: This import MUST be after _initialize is called!! -from pyswip.easy import getTerm # noqa: E402 +# NOTE: These imports MUST come after _initialize is called!! +from pyswip.easy import getTerm, Atom, Variable # noqa: E402 class Prolog: @@ -193,7 +197,7 @@ def _init_prolog_thread(cls): print("{WARN} Single-threaded swipl build, beware!") @classmethod - def asserta(cls, assertion: str, *, catcherrors: bool = False) -> None: + def asserta(cls, format: str, *args, catcherrors: bool = False) -> None: """ Assert a clause (fact or rule) into the database. @@ -201,19 +205,33 @@ def asserta(cls, assertion: str, *, catcherrors: bool = False) -> None: See `asserta/1 `_ in SWI-Prolog documentation. - :param assertion: Clause to insert into the head of the database - :param catcherrors: Catches the exception raised during goal execution + :param format: + The format to be used to generate the clause. + The placeholders (``%p``) are replaced by the ``args`` if one ore more arguments are given. + :param args: + Arguments to replace the placeholders in the ``format`` string + :param catcherrors: + Catches the exception raised during goal execution + + .. Note:: + Currently, If no arguments given, the format string is used as the raw clause, even if it contains a placeholder. + This behavior is kept for for compatibility reasons. + It may be removed in future versions. >>> Prolog.asserta("big(airplane)") >>> Prolog.asserta("small(mouse)") >>> Prolog.asserta('''bigger(A, B) :- ... big(A), ... small(B)''') + >>> nums = list(range(5)) + >>> Prolog.asserta("numbers(%p)", nums) """ - next(cls.query(assertion.join(["asserta((", "))."]), catcherrors=catcherrors)) + next( + cls.query(format.join(["asserta((", "))."]), *args, catcherrors=catcherrors) + ) @classmethod - def assertz(cls, assertion: str, *, catcherrors: bool = False) -> None: + def assertz(cls, format: str, *args, catcherrors: bool = False) -> None: """ Assert a clause (fact or rule) into the database. @@ -221,16 +239,28 @@ def assertz(cls, assertion: str, *, catcherrors: bool = False) -> None: See `assertz/1 `_ in SWI-Prolog documentation. - :param assertion: Clause to insert into the tail of the database - :param catcherrors: Catches the exception raised during goal execution + :param format: + The format to be used to generate the clause. + The placeholders (``%p``) are replaced by the ``args`` if one ore more arguments are given. + :param catcherrors: + Catches the exception raised during goal execution + + .. Note:: + Currently, If no arguments given, the format string is used as the raw clause, even if it contains a placeholder. + This behavior is kept for for compatibility reasons. + It may be removed in future versions. >>> Prolog.assertz("big(airplane)") >>> Prolog.assertz("small(mouse)") >>> Prolog.assertz('''bigger(A, B) :- ... big(A), ... small(B)''') + >>> nums = list(range(5)) + >>> Prolog.assertz("numbers(%p)", nums) """ - next(cls.query(assertion.join(["assertz((", "))."]), catcherrors=catcherrors)) + next( + cls.query(format.join(["assertz((", "))."]), *args, catcherrors=catcherrors) + ) @classmethod def dynamic(cls, *terms: str, catcherrors: bool = False) -> None: @@ -253,18 +283,27 @@ def dynamic(cls, *terms: str, catcherrors: bool = False) -> None: """ if len(terms) < 1: raise ValueError("One or more terms must be given") - params = ", ".join(terms) + params = ",".join(terms) next(cls.query(f"dynamic(({params}))", catcherrors=catcherrors)) @classmethod - def retract(cls, term: str, *, catcherrors: bool = False) -> None: + def retract(cls, format: str, *args, catcherrors: bool = False) -> None: """ Removes the fact or clause from the database See `retract/1 `_ in SWI-Prolog documentation. - :param term: The term to remove from the database - :param catcherrors: Catches the exception raised during goal execution + :param format: + The format to be used to generate the term. + The placeholders (``%p``) are replaced by the ``args`` if one ore more arguments are given. + :param catcherrors: + Catches the exception raised during goal execution + + .. Note:: + Currently, If no arguments given, the format string is used as the raw term, even if it contains a placeholder. + This behavior is kept for for compatibility reasons. + It may be removed in future versions. + >>> Prolog.dynamic("person/1") >>> Prolog.asserta("person(jane)") @@ -273,17 +312,28 @@ def retract(cls, term: str, *, catcherrors: bool = False) -> None: >>> Prolog.retract("person(jane)") >>> list(Prolog.query("person(X)")) [] + >>> Prolog.dynamic("numbers/1") + >>> nums = list(range(5)) + >>> Prolog.asserta("numbers(10)") + >>> Prolog.asserta("numbers(%p)", nums) + >>> list(Prolog.query("numbers(X)")) + [{'X': [0, 1, 2, 3, 4]}, {'X': 10}] + >>> Prolog.retract("numbers(%p)", nums) + >>> list(Prolog.query("numbers(X)")) + [{'X': 10}] """ - next(cls.query(term.join(["retract((", "))."]), catcherrors=catcherrors)) + next( + cls.query(format.join(["retract((", "))."]), *args, catcherrors=catcherrors) + ) @classmethod - def retractall(cls, head: str, *, catcherrors: bool = False) -> None: + def retractall(cls, format: str, *args, catcherrors: bool = False) -> None: """ Removes all facts or clauses in the database where the ``head`` unifies. See `retractall/1 `_ in SWI-Prolog documentation. - :param head: The term to unify with the facts or clauses in the database + :param format: The term to unify with the facts or clauses in the database :param catcherrors: Catches the exception raised during goal execution >>> Prolog.dynamic("person/1") @@ -295,7 +345,11 @@ def retractall(cls, head: str, *, catcherrors: bool = False) -> None: >>> list(Prolog.query("person(X)")) [] """ - next(cls.query(head.join(["retractall((", "))."]), catcherrors=catcherrors)) + next( + cls.query( + format.join(["retractall((", "))."]), *args, catcherrors=catcherrors + ) + ) @classmethod def consult( @@ -341,8 +395,8 @@ def consult( @classmethod def query( cls, - query: str, - *, + format: str, + *args, maxresult: int = -1, catcherrors: bool = True, normalize: bool = True, @@ -352,10 +406,22 @@ def query( If the query is a yes/no question, returns {} for yes, and nothing for no. Otherwise returns a generator of dicts with variables as keys. - :param query: The query to execute in the Prolog engine - :param maxresult: Maximum number of results to return - :param catcherrors: Catches the exception raised during goal execution - :param normalize: Return normalized values + :param format: + The format to be used to generate the query. + The placeholders (``%p``) are replaced by the ``args`` if one ore more arguments are given. + :param args: + Arguments to replace the placeholders in the ``format`` string + :param maxresult: + Maximum number of results to return + :param catcherrors: + Catches the exception raised during goal execution + :param normalize: + Return normalized values + + .. Note:: + Currently, If no arguments given, the format string is used as the raw query, even if it contains a placeholder. + This behavior is kept for for compatibility reasons. + It may be removed in future versions. >>> Prolog.assertz("father(michael,john)") >>> Prolog.assertz("father(michael,gina)") @@ -366,6 +432,10 @@ def query( >>> print(sorted(Prolog.query("father(michael,X)"))) [{'X': 'gina'}, {'X': 'john'}] """ + if args: + query = format_prolog(format, args) + else: + query = format return cls._QueryWrapper()(query, maxresult, catcherrors, normalize) @classmethod @@ -450,3 +520,34 @@ def normalize_values(values): elif isinstance(values, (list, tuple)): return [normalize_values(v) for v in values] return values + + +def make_prolog_str(value) -> str: + if isinstance(value, str): + return f'"{value}"' + elif isinstance(value, list): + inner = ",".join(make_prolog_str(v) for v in value) + return f"[{inner}]" + elif isinstance(value, Atom): + # TODO: escape atom nome + return f"'{value.chars}'" + elif isinstance(value, Variable): + # TODO: escape variable name + return value.chars + elif value is True: + return "1" + elif value is False: + return "0" + return str(value) + + +def format_prolog(fmt: str, args: Tuple) -> str: + frags = RE_PLACEHOLDER.split(fmt) + if len(args) != len(frags) - 1: + raise ValueError("Number of arguments must match the number of placeholders") + fs = [] + for i in range(len(args)): + fs.append(frags[i]) + fs.append(make_prolog_str(args[i])) + fs.append(frags[-1]) + return "".join(fs) diff --git a/tests/examples/hanoi_fixture.txt b/tests/examples/hanoi_fixture.txt new file mode 100644 index 0000000..25dfa97 --- /dev/null +++ b/tests/examples/hanoi_fixture.txt @@ -0,0 +1,64 @@ + + Step 0 + + 1 + 2 + 3 +--------- + L C R + + Step 1 + + + 2 + 3 1 +--------- + L C R + + Step 2 + + + + 3 2 1 +--------- + L C R + + Step 3 + + + 1 + 3 2 +--------- + L C R + + Step 4 + + + 1 + 2 3 +--------- + L C R + + Step 5 + + + + 1 2 3 +--------- + L C R + + Step 6 + + + 2 + 1 3 +--------- + L C R + + Step 7 + + 1 + 2 + 3 +--------- + L C R diff --git a/tests/examples/hanoi_simple_fixture.txt b/tests/examples/hanoi_simple_fixture.txt new file mode 100644 index 0000000..9fafce1 --- /dev/null +++ b/tests/examples/hanoi_simple_fixture.txt @@ -0,0 +1,7 @@ +1. Move disk from left pole to right pole. +2. Move disk from left pole to center pole. +3. Move disk from right pole to center pole. +4. Move disk from left pole to right pole. +5. Move disk from center pole to left pole. +6. Move disk from center pole to right pole. +7. Move disk from left pole to right pole. diff --git a/tests/examples/sudoku.txt b/tests/examples/sudoku.txt new file mode 100644 index 0000000..f086d63 --- /dev/null +++ b/tests/examples/sudoku.txt @@ -0,0 +1,9 @@ +. 6 . 1 . 4 . 5 . +. . 8 3 . 5 6 . . +2 . . . . . . . 1 +8 . . 4 . 7 . . 6 +. . 6 . . . 3 . . +7 . . 9 . 1 . . 4 +5 . . . . . . . 2 +. . 7 2 . 6 9 . . +. 4 . 5 . 8 . 7 . \ No newline at end of file diff --git a/tests/examples/test_coins.py b/tests/examples/test_coins.py new file mode 100644 index 0000000..e4358c4 --- /dev/null +++ b/tests/examples/test_coins.py @@ -0,0 +1,14 @@ +import unittest + +from pyswip.examples.coins import solve, prolog_source + + +class CoinsTestCase(unittest.TestCase): + def test_solve(self): + fixture = [{1: 3, 5: 0, 10: 30, 50: 0, 100: 0}] + soln = solve(coin_count=33, total_cents=303, max_solutions=1) + self.assertEqual(fixture, soln) + + def test_prolog_source(self): + source = prolog_source() + self.assertIn("label(Solution)", source) diff --git a/tests/examples/test_hanoi.py b/tests/examples/test_hanoi.py new file mode 100644 index 0000000..b8a4514 --- /dev/null +++ b/tests/examples/test_hanoi.py @@ -0,0 +1,23 @@ +import unittest +from io import StringIO + +from pyswip.examples.hanoi import solve, prolog_source +from .utils import load_fixture + + +class HanoiTestCase(unittest.TestCase): + def test_solve(self): + fixture = load_fixture("hanoi_fixture.txt") + sio = StringIO() + solve(3, file=sio) + self.assertEqual(fixture, sio.getvalue()) + + def test_solve_simple(self): + fixture = load_fixture("hanoi_simple_fixture.txt") + sio = StringIO() + solve(3, simple=True, file=sio) + self.assertEqual(fixture, sio.getvalue()) + + def test_prolog_source(self): + source = prolog_source() + self.assertIn("move(N, A, B, C)", source) diff --git a/tests/examples/test_sudoku.py b/tests/examples/test_sudoku.py index 2cc35be..1e79c6f 100644 --- a/tests/examples/test_sudoku.py +++ b/tests/examples/test_sudoku.py @@ -1,20 +1,11 @@ import unittest from pyswip.examples.sudoku import Matrix, solve, prolog_source +from .utils import load_fixture class MatrixTestCase(unittest.TestCase): - FIXTURE = """ -. 6 . 1 . 4 . 5 . -. . 8 3 . 5 6 . . -2 . . . . . . . 1 -8 . . 4 . 7 . . 6 -. . 6 . . . 3 . . -7 . . 9 . 1 . . 4 -5 . . . . . . . 2 -. . 7 2 . 6 9 . . -. 4 . 5 . 8 . 7 . - """ + FIXTURE = load_fixture("sudoku.txt") def test_matrix_from_text(self): got = Matrix.from_text(self.FIXTURE) @@ -48,7 +39,7 @@ def test_solve_success(self): self.assertEqual(target, solution.matrix) def test_solve_failure(self): - fixture = " 8" + self.FIXTURE[2:] + fixture = "8 " + self.FIXTURE[2:] puzzle = Matrix.from_text(fixture) solution = solve(puzzle) self.assertFalse(solution) diff --git a/tests/examples/utils.py b/tests/examples/utils.py new file mode 100644 index 0000000..9901b3d --- /dev/null +++ b/tests/examples/utils.py @@ -0,0 +1,7 @@ +from pathlib import Path + + +def load_fixture(filename: str) -> str: + path = Path(__file__).parent / filename + with open(path) as f: + return f.read() diff --git a/tests/test_examples.py b/tests/test_examples.py index 28d1903..7b86fea 100644 --- a/tests/test_examples.py +++ b/tests/test_examples.py @@ -34,7 +34,6 @@ "register_foreign.py", "register_foreign_simple.py", "knowledgebase.py", - "hanoi/hanoi_simple.py", "sendmoremoney/money.py", "sendmoremoney/money_new.py", ] diff --git a/tests/test_prolog.py b/tests/test_prolog.py index 757f48a..a565419 100644 --- a/tests/test_prolog.py +++ b/tests/test_prolog.py @@ -29,7 +29,10 @@ import os.path import unittest -from pyswip.prolog import Prolog, NestedQueryError +import pytest + +from pyswip import Atom, Variable +from pyswip.prolog import Prolog, NestedQueryError, format_prolog class TestProlog(unittest.TestCase): @@ -120,3 +123,33 @@ def test_retract(self): Prolog.retract("person(jane)") result = list(Prolog.query("person(X)")) self.assertEqual([], result) + + def test_placeholder_2(self): + joe = Atom("joe") + ids = [1, 2, 3] + Prolog.assertz("user(%p,%p)", joe, ids) + result = list(Prolog.query("user(%p,IDs)", joe)) + self.assertEqual([{"IDs": [1, 2, 3]}], result) + + +format_prolog_fixture = [ + ("", (), ""), + ("no-args", (), "no-args"), + ("before%pafter", ("text",), 'before"text"after'), + ("before%pafter", (123,), "before123after"), + ("before%pafter", (123.45,), "before123.45after"), + ("before%pafter", (Atom("foo"),), "before'foo'after"), + ("before%pafter", (Variable(name="Foo"),), "beforeFooafter"), + ("before%pafter", (False,), "before0after"), + ("before%pafter", (True,), "before1after"), + ( + "before%pafter", + (["foo", 38, 45.897, [1, 2, 3]],), + 'before["foo",38,45.897,[1,2,3]]after', + ), +] + + +@pytest.mark.parametrize("format, args, target", format_prolog_fixture) +def test_convert_to_prolog(format, args, target): + assert format_prolog(format, args) == target