diff --git a/.readthedocs.yaml b/.readthedocs.yaml index 1b2e907..b862a3f 100644 --- a/.readthedocs.yaml +++ b/.readthedocs.yaml @@ -7,9 +7,11 @@ version: 2 # Set the OS, Python version and other tools you might need build: - os: ubuntu-22.04 + os: ubuntu-24.04 tools: python: "3.12" + apt_packages: + - swi-prolog-nox # Build documentation in the "docs/" directory with Sphinx sphinx: diff --git a/Makefile b/Makefile index f34c2e0..4ab0c31 100644 --- a/Makefile +++ b/Makefile @@ -1,7 +1,7 @@ .PHONY: build clean coverage upload-coverage test upload build: - build + pyproject-build clean: rm -rf dist build pyswip.egg-info diff --git a/dev-requirements.txt b/dev-requirements.txt index 23f9a6a..44aba86 100644 --- a/dev-requirements.txt +++ b/dev-requirements.txt @@ -2,4 +2,5 @@ ruff==0.6.2 build pytest-cov mypy>=1.0.0 -Sphinx \ No newline at end of file +Sphinx +sphinx-autodoc-typehints \ No newline at end of file diff --git a/docs/source/api/examples.rst b/docs/source/api/examples.rst new file mode 100644 index 0000000..e9a2768 --- /dev/null +++ b/docs/source/api/examples.rst @@ -0,0 +1,11 @@ +Examples +-------- + +.. automodule:: pyswip.examples + :members: + +Sudoku +^^^^^^ + +.. automodule:: pyswip.examples.sudoku + :members: diff --git a/docs/source/api/modules.rst b/docs/source/api/modules.rst new file mode 100644 index 0000000..295b605 --- /dev/null +++ b/docs/source/api/modules.rst @@ -0,0 +1,10 @@ +API Documentation +----------------- + +.. toctree:: + + examples + prolog + + + diff --git a/docs/source/api/prolog.rst b/docs/source/api/prolog.rst new file mode 100644 index 0000000..50e8e04 --- /dev/null +++ b/docs/source/api/prolog.rst @@ -0,0 +1,5 @@ +Prolog +------ + +.. automodule:: pyswip.prolog + :members: diff --git a/docs/source/conf.py b/docs/source/conf.py index 4754a28..d4823f6 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -6,9 +6,18 @@ # -- Project information ----------------------------------------------------- # https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information +import sys +from pathlib import Path + +sys.path.insert(0, str(Path("..", "..", "src").resolve())) + +from pyswip import __VERSION__ + project = "PySwip" copyright = "2024, Yüce Tekol and PySwip Contributors" author = "Yüce Tekol and PySwip Contributors" +version = __VERSION__ +release = version # -- General configuration --------------------------------------------------- # https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration @@ -17,6 +26,7 @@ "sphinx.ext.duration", "sphinx.ext.doctest", "sphinx.ext.autodoc", + "sphinx_autodoc_typehints", ] templates_path = ["_templates"] @@ -26,7 +36,7 @@ # -- Options for HTML output ------------------------------------------------- # https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output -html_theme = "alabaster" +html_theme = "bizstyle" html_static_path = ["_static"] source_suffix = { @@ -34,3 +44,8 @@ ".txt": "markdown", ".md": "markdown", } + +autodoc_member_order = "bysource" +autoclass_content = "both" + +html_logo = "https://pyswip.org/images/pyswip_logo_sm_256colors.gif" diff --git a/docs/source/index.rst b/docs/source/index.rst index 482c9d7..671c1a5 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -14,4 +14,13 @@ It features an SWI-Prolog foreign language interface, a utility class that makes :caption: Contents: get_started + api/modules + +Indices and Tables +================== + +.. toctree:: + + genindex + modindex diff --git a/pyproject.toml b/pyproject.toml index cba1dc8..d0fad86 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -19,7 +19,7 @@ keywords = [ "prolog", ] classifiers = [ - "Development Status :: 3 - Alpha", + "Development Status :: 4 - Beta", "Intended Audience :: Developers", "Intended Audience :: Science/Research", "License :: OSI Approved :: MIT License", @@ -45,4 +45,10 @@ ignore = ["F403", "F405", "E721"] [tool.pytest.ini_options] markers = [ "slow: marks tests as slow (deselect with '-m \"not slow\"')", -] \ No newline at end of file +] + +[tool.setuptools.package-data] +"pyswip" = ["py.typed"] + +[tool.setuptools.packages.find] +where = ["src"] diff --git a/src/pyswip/examples/sudoku.py b/src/pyswip/examples/sudoku.py index 955b8dc..68af449 100644 --- a/src/pyswip/examples/sudoku.py +++ b/src/pyswip/examples/sudoku.py @@ -21,14 +21,20 @@ # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE # SOFTWARE. -import sys -from typing import List, Union, Literal +""" +Sudoku example + +You can run this module using:: + + $ python3 -m pyswip.examples.sudoku +""" + +from typing import List, Union, Literal, Optional, IO from io import StringIO from pyswip.prolog import Prolog - -__all__ = "Matrix", "solve", "prolog_source" +__all__ = "Matrix", "prolog_source", "sample_puzzle", "solve" _DIMENSION = 9 _SOURCE_PATH = "sudoku.pl" @@ -38,6 +44,8 @@ class Matrix: + """Represents a 9x9 Sudoku puzzle""" + def __init__(self, matrix: List[List[int]]) -> None: if not matrix: raise ValueError("matrix must be given") @@ -49,7 +57,33 @@ def __init__(self, matrix: List[List[int]]) -> None: @classmethod def from_text(cls, text: str) -> "Matrix": - lines = text.strip().split("\n") + """ + Create a Matrix from the given string + + The following are valid characters in the string: + + * `.`: Blank column + * `1-9`: Numbers + + The text must contain exactly 9 rows and 9 columns. + Each row ends with a newline character. + You can use blank lines and spaces/tabs between columns. + + :param text: The text to use for creating the Matrix + + >>> puzzle = Matrix.from_text(''' + ... . . 5 . 7 . 2 6 8 + ... . . 4 . . 2 . . . + ... . . 1 . 9 . . . . + ... . 8 . . . . 1 . . + ... . 2 . 9 . . . 7 . + ... . . 6 . . . . 3 . + ... . . 2 . 4 . 7 . . + ... . . . 5 . . 9 . . + ... 9 5 7 . 3 . . . . + ... ''') + """ + lines = [row for line in text.strip().split("\n") if (row := line.strip())] dimension = len(lines) rows = [] for i, line in enumerate(lines): @@ -82,7 +116,25 @@ def __str__(self) -> str: def __repr__(self) -> str: return str(self.matrix) - def pretty_print(self, *, file=sys.stdout) -> None: + def pretty_print(self, *, file: Optional[IO] = None) -> None: + """ + Prints the matrix as a grid + + :param file: The file to use for printing + + >>> import sys + >>> puzzle = sample_puzzle() + >>> puzzle.pretty_print(file=sys.stdout) + . . 5 . 7 . 2 6 8 + . . 4 . . 2 . . . + . . 1 . 9 . . . . + . 8 . . . . 1 . . + . 2 . 9 . . . 7 . + . . 6 . . . . 3 . + . . 2 . 4 . 7 . . + . . . 5 . . 9 . . + 9 5 7 . 3 . . . . + """ for row in self.matrix: row = " ".join(str(x or ".") for x in row) print(row, file=file) @@ -92,12 +144,29 @@ def solve(matrix: Matrix) -> Union[Matrix, Literal[False]]: """ Solves the given Sudoku puzzle - Parameters: - matrix (Matrix): The matrix that contains the Sudoku puzzle - - Returns: - Matrix: Solution matrix - False: If no solutions was found + :param matrix: The matrix that contains the Sudoku puzzle + + >>> puzzle = sample_puzzle() + >>> print(puzzle) + . . 5 . 7 . 2 6 8 + . . 4 . . 2 . . . + . . 1 . 9 . . . . + . 8 . . . . 1 . . + . 2 . 9 . . . 7 . + . . 6 . . . . 3 . + . . 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 + 2 6 1 3 9 8 5 4 7 + 5 8 9 7 6 3 1 2 4 + 1 2 3 9 8 4 6 7 5 + 7 4 6 2 1 5 8 3 9 + 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)) @@ -110,6 +179,7 @@ def solve(matrix: Matrix) -> Union[Matrix, Literal[False]]: def prolog_source() -> str: + """Returns the Prolog source file that solves Sudoku puzzles""" from pathlib import Path path = Path(__file__).parent / _SOURCE_PATH @@ -117,8 +187,9 @@ def prolog_source() -> str: return f.read() -def main(): - puzzle = Matrix.from_text(""" +def sample_puzzle() -> Matrix: + """Returns the sample Sudoku puzzle""" + matrix = Matrix.from_text(""" . . 5 . 7 . 2 6 8 . . 4 . . 2 . . . . . 1 . 9 . . . . @@ -129,6 +200,11 @@ def main(): . . . 5 . . 9 . . 9 5 7 . 3 . . . . """) + return matrix + + +def main(): + puzzle = sample_puzzle() print("\n-- PUZZLE --") puzzle.pretty_print() print("\n-- SOLUTION --") diff --git a/src/pyswip/prolog.py b/src/pyswip/prolog.py index 192854a..73a69a9 100644 --- a/src/pyswip/prolog.py +++ b/src/pyswip/prolog.py @@ -18,7 +18,11 @@ # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE # SOFTWARE. -from typing import Union +""" +Provides the basic Prolog interface. +""" + +from typing import Union, Generator from pathlib import Path from pyswip.utils import resolve_path @@ -48,17 +52,17 @@ ) +__all__ = "PrologError", "NestedQueryError", "Prolog" + + class PrologError(Exception): pass class NestedQueryError(PrologError): """ - SWI-Prolog does not accept nested queries, that is, opening a query while - the previous one was not closed. - - As this error may be somewhat difficult to debug in foreign code, it is - automatically treated inside pySWIP + SWI-Prolog does not accept nested queries, that is, opening a query while the previous one was not closed. + As this error may be somewhat difficult to debug in foreign code, it is automatically treated inside PySwip """ pass @@ -103,9 +107,7 @@ def _initialize(): class Prolog: - """Easily query SWI-Prolog. - This is a singleton class - """ + """Provides the entry point for the Prolog interface""" # We keep track of open queries to avoid nested queries. _queryIsOpen = False @@ -129,7 +131,7 @@ def __call__(self, query, maxresult, catcherrors, normalize): swipl_predicate = PL_predicate("pyrun", 2, None) - plq = catcherrors and (PL_Q_NODEBUG | PL_Q_CATCH_EXCEPTION) or PL_Q_NORMAL + plq = PL_Q_NODEBUG | PL_Q_CATCH_EXCEPTION if catcherrors else PL_Q_NORMAL swipl_qid = PL_open_query(None, plq, swipl_predicate, swipl_args) Prolog._queryIsOpen = True # From now on, the query will be considered open @@ -182,49 +184,177 @@ def _init_prolog_thread(cls): print("{WARN} Single-threaded swipl build, beware!") @classmethod - def asserta(cls, assertion, catcherrors=False): + def asserta(cls, assertion: str, *, catcherrors: bool = False) -> None: + """ + Assert a clause (fact or rule) into the database. + + ``asserta`` asserts the clause as the first clause of the predicate. + + 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 + + >>> Prolog.asserta("big(airplane)") + >>> Prolog.asserta("small(mouse)") + >>> Prolog.asserta('''bigger(A, B) :- + ... big(A), + ... small(B)''') + """ next(cls.query(assertion.join(["asserta((", "))."]), catcherrors=catcherrors)) @classmethod - def assertz(cls, assertion, catcherrors=False): + def assertz(cls, assertion: str, *, catcherrors: bool = False) -> None: + """ + Assert a clause (fact or rule) into the database. + + ``assertz`` asserts the clause as the last clause of the predicate. + + 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 + + >>> Prolog.assertz("big(airplane)") + >>> Prolog.assertz("small(mouse)") + >>> Prolog.assertz('''bigger(A, B) :- + ... big(A), + ... small(B)''') + """ next(cls.query(assertion.join(["assertz((", "))."]), catcherrors=catcherrors)) @classmethod - def dynamic(cls, term, catcherrors=False): - next(cls.query(term.join(["dynamic((", "))."]), catcherrors=catcherrors)) + def dynamic(cls, *terms: str, catcherrors: bool = False) -> None: + """Informs the interpreter that the definition of the predicate(s) may change during execution + + See `dynamic/1 `_ in SWI-Prolog documentation. + + :param terms: One or more predicate indicators + :param catcherrors: Catches the exception raised during goal execution + + :raises ValueError: if no terms was given. + + >>> Prolog.dynamic("person/1") + >>> Prolog.asserta("person(jane)") + >>> list(Prolog.query("person(X)")) + [{'X': 'jane'}] + >>> Prolog.retractall("person(_)") + >>> list(Prolog.query("person(X)")) + [] + """ + if len(terms) < 1: + raise ValueError("One or more terms must be given") + params = ", ".join(terms) + next(cls.query(f"dynamic(({params}))", catcherrors=catcherrors)) @classmethod - def retract(cls, term, catcherrors=False): + def retract(cls, term: str, *, 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 + + >>> Prolog.dynamic("person/1") + >>> Prolog.asserta("person(jane)") + >>> list(Prolog.query("person(X)")) + [{'X': 'jane'}] + >>> Prolog.retract("person(jane)") + >>> list(Prolog.query("person(X)")) + [] + """ next(cls.query(term.join(["retract((", "))."]), catcherrors=catcherrors)) @classmethod - def retractall(cls, term, catcherrors=False): - next(cls.query(term.join(["retractall((", "))."]), catcherrors=catcherrors)) + def retractall(cls, head: str, *, 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 catcherrors: Catches the exception raised during goal execution + + >>> Prolog.dynamic("person/1") + >>> Prolog.asserta("person(jane)") + >>> Prolog.asserta("person(joe)") + >>> list(Prolog.query("person(X)")) + [{'X': 'joe'}, {'X': 'jane'}] + >>> Prolog.retractall("person(_)") + >>> list(Prolog.query("person(X)")) + [] + """ + next(cls.query(head.join(["retractall((", "))."]), catcherrors=catcherrors)) @classmethod def consult( cls, path: Union[str, Path], *, - catcherrors=False, + catcherrors: bool = False, relative_to: Union[str, Path] = "", - ): + ) -> None: + """ + Reads the given Prolog source file + + The file is always reloaded when called. + + See `consult/1 `_ in SWI-Prolog documentation. + + Tilde character (``~``) in paths are expanded to the user home directory + + >>> Prolog.consult("~/my_files/hanoi.pl") + >>> # consults file /home/me/my_files/hanoi.pl + + ``relative_to`` keyword argument makes it easier to construct the consult path. + This keyword is no-op, if the consult path is absolute. + If the given ``relative_to`` path is a file, then the consult path is updated to become a sibling of that path. + + Assume you have the ``/home/me/project/facts.pl`` that you want to consult from the ``run.py`` file which exists in the same directory ``/home/me/project``. + Using the built-in ``__file__`` constant which contains the path of the current Python file , it becomes very easy to do that: + + >>> Prolog.consult("facts.pl", relative_to=__file__) + + If the given `relative_path` is a directory, then the consult path is updated to become a child of that path. + + >>> project_dir = "~/projects" + >>> Prolog.consult("facts1.pl", relative_to=project_dir) + + :param path: The path to the Prolog source file + :param catcherrors: Catches the exception raised during goal execution + :param relative_to: The path where the consulted file is relative to + """ path = resolve_path(path, relative_to) next(cls.query(str(path).join(["consult('", "')"]), catcherrors=catcherrors)) @classmethod - def query(cls, query, maxresult=-1, catcherrors=True, normalize=True): - """Run a prolog query and return a generator. + def query( + cls, + query: str, + *, + maxresult: int = -1, + catcherrors: bool = True, + normalize: bool = True, + ) -> Generator: + """Run a prolog query and return a generator + 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 + >>> Prolog.assertz("father(michael,john)") >>> Prolog.assertz("father(michael,gina)") >>> bool(list(Prolog.query("father(michael,john)"))) True >>> bool(list(Prolog.query("father(michael,olivia)"))) False - >>> print sorted(Prolog.query("father(michael,X)")) + >>> print(sorted(Prolog.query("father(michael,X)"))) [{'X': 'gina'}, {'X': 'john'}] """ return cls._QueryWrapper()(query, maxresult, catcherrors, normalize) diff --git a/src/pyswip/py.typed b/src/pyswip/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_issues.py b/tests/test_issues.py index 01a7c6a..411360e 100644 --- a/tests/test_issues.py +++ b/tests/test_issues.py @@ -159,9 +159,9 @@ def test_issue_5(self): varSet.add(B) varSet.add(C) # This is equal to A self.assertEqual(len(varSet), 2) - self.assertEqual(varSet, set([A, B])) + self.assertEqual(varSet, {A, B}) - def test_issue_4(self): + def test_dynamic(self): """ Patch for a dynamic method diff --git a/tests/test_prolog.py b/tests/test_prolog.py index a84b870..757f48a 100644 --- a/tests/test_prolog.py +++ b/tests/test_prolog.py @@ -29,7 +29,7 @@ import os.path import unittest -import pyswip.prolog as pl # This implicitly tests library loading code +from pyswip.prolog import Prolog, NestedQueryError class TestProlog(unittest.TestCase): @@ -47,64 +47,60 @@ def test_nested_queries(self): message is thrown. """ - p = pl.Prolog() - # Add something to the base - p.assertz("father(john,mich)") - p.assertz("father(john,gina)") - p.assertz("mother(jane,mich)") + Prolog.assertz("father(john,mich)") + Prolog.assertz("father(john,gina)") + Prolog.assertz("mother(jane,mich)") somequery = "father(john, Y)" otherquery = "mother(jane, X)" # This should not throw an exception - for _ in p.query(somequery): + for _ in Prolog.query(somequery): pass - for _ in p.query(otherquery): + for _ in Prolog.query(otherquery): pass - with self.assertRaises(pl.NestedQueryError): - for q in p.query(somequery): - for j in p.query(otherquery): + with self.assertRaises(NestedQueryError): + for q in Prolog.query(somequery): + for j in Prolog.query(otherquery): # This should throw an error, because I opened the second # query pass def test_prolog_functor_in_list(self): - p = pl.Prolog() - p.assertz("f([g(a,b),h(a,b,c)])") - self.assertEqual([{"L": ["g(a, b)", "h(a, b, c)"]}], list(p.query("f(L)"))) - p.retract("f([g(a,b),h(a,b,c)])") + Prolog.assertz("f([g(a,b),h(a,b,c)])") + self.assertEqual([{"L": ["g(a, b)", "h(a, b, c)"]}], list(Prolog.query("f(L)"))) + Prolog.retract("f([g(a,b),h(a,b,c)])") def test_prolog_functor_in_functor(self): - p = pl.Prolog() - p.assertz("f([g([h(a,1), h(b,1)])])") - self.assertEqual([{"G": ["g(['h(a, 1)', 'h(b, 1)'])"]}], list(p.query("f(G)"))) - p.assertz("a([b(c(x), d([y, z, w]))])") + Prolog.assertz("f([g([h(a,1), h(b,1)])])") + self.assertEqual( + [{"G": ["g(['h(a, 1)', 'h(b, 1)'])"]}], list(Prolog.query("f(G)")) + ) + Prolog.assertz("a([b(c(x), d([y, z, w]))])") self.assertEqual( - [{"B": ["b(c(x), d(['y', 'z', 'w']))"]}], list(p.query("a(B)")) + [{"B": ["b(c(x), d(['y', 'z', 'w']))"]}], list(Prolog.query("a(B)")) ) - p.retract("f([g([h(a,1), h(b,1)])])") - p.retract("a([b(c(x), d([y, z, w]))])") + Prolog.retract("f([g([h(a,1), h(b,1)])])") + Prolog.retract("a([b(c(x), d([y, z, w]))])") def test_prolog_strings(self): """ See: https://github.com/yuce/pyswip/issues/9 """ - p = pl.Prolog() - p.assertz('some_string_fact("abc")') - self.assertEqual([{"S": b"abc"}], list(p.query("some_string_fact(S)"))) + Prolog.assertz('some_string_fact("abc")') + self.assertEqual([{"S": b"abc"}], list(Prolog.query("some_string_fact(S)"))) def test_quoted_strings(self): """ See: https://github.com/yuce/pyswip/issues/90 """ - p = pl.Prolog() - self.assertEqual([{"X": b"a"}], list(p.query('X = "a"'))) - - p.assertz('test_quoted_strings("hello","world")') + self.assertEqual([{"X": b"a"}], list(Prolog.query('X = "a"'))) + Prolog.assertz('test_quoted_strings("hello","world")') self.assertEqual( - [{"A": b"hello", "B": b"world"}], list(p.query("test_quoted_strings(A,B)")) + [{"A": b"hello", "B": b"world"}], + list(Prolog.query("test_quoted_strings(A,B)")), ) def test_prolog_read_file(self): @@ -113,5 +109,14 @@ def test_prolog_read_file(self): """ current_dir = os.path.dirname(os.path.abspath(__file__)) path = os.path.join(current_dir, "test_read.pl") - pl.Prolog.consult(path) - list(pl.Prolog.query(f'read_file("{path}", S)')) + Prolog.consult("test_read.pl", relative_to=__file__) + list(Prolog.query(f'read_file("{path}", S)')) + + def test_retract(self): + Prolog.dynamic("person/1") + Prolog.asserta("person(jane)") + result = list(Prolog.query("person(X)")) + self.assertEqual([{"X": "jane"}], result) + Prolog.retract("person(jane)") + result = list(Prolog.query("person(X)")) + self.assertEqual([], result)