asp-studio 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 yakupbulbul
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,110 @@
1
+ Metadata-Version: 2.4
2
+ Name: asp-studio
3
+ Version: 0.1.0
4
+ Summary: Answer sets, a graph and a step-by-step animator for Answer Set Programming, in Jupyter, VS Code notebooks and the command line.
5
+ Author: yakupbulbul
6
+ License: MIT
7
+ Project-URL: Homepage, https://yakupbulbul.github.io/asp-vscode/
8
+ Project-URL: Documentation, https://yakupbulbul.github.io/asp-vscode/docs.html
9
+ Project-URL: Source, https://github.com/yakupbulbul/asp-vscode
10
+ Project-URL: Issues, https://github.com/yakupbulbul/asp-vscode/issues
11
+ Project-URL: Changelog, https://github.com/yakupbulbul/asp-vscode/blob/main/CHANGELOG.md
12
+ Keywords: asp,answer set programming,clingo,jupyter,visualization,potassco
13
+ Classifier: Development Status :: 4 - Beta
14
+ Classifier: Framework :: Jupyter
15
+ Classifier: Intended Audience :: Education
16
+ Classifier: License :: OSI Approved :: MIT License
17
+ Classifier: Programming Language :: Python :: 3
18
+ Classifier: Topic :: Scientific/Engineering :: Visualization
19
+ Requires-Python: >=3.9
20
+ Description-Content-Type: text/markdown
21
+ License-File: LICENSE
22
+ Requires-Dist: clingo>=5.5
23
+ Provides-Extra: notebook
24
+ Requires-Dist: ipython; extra == "notebook"
25
+ Provides-Extra: test
26
+ Requires-Dist: pytest; extra == "test"
27
+ Requires-Dist: ipython; extra == "test"
28
+ Dynamic: license-file
29
+
30
+ # ASP Studio for Python
31
+
32
+ ASP Studio shows the answer sets of an [Answer Set Programming](https://potassco.org/) program, draws them as a graph, and steps through how a recursive program reaches its result. It works in Jupyter, in VS Code notebooks and from the command line. It uses the clingo Python API and shares its visualizer with the [ASP Studio VS Code extension](https://github.com/yakupbulbul/asp-vscode).
33
+
34
+ Website and live demo: <https://yakupbulbul.github.io/asp-vscode/>
35
+
36
+ ```bash
37
+ pip install "asp-studio[notebook]" # Python 3.9 or newer
38
+ ```
39
+
40
+ The `notebook` extra adds IPython for the `%%asp` magic. Without it you still get `solve()` and the command line.
41
+
42
+ ## In a notebook
43
+
44
+ ```python
45
+ %load_ext asp_studio
46
+ ```
47
+
48
+ ```text
49
+ %%asp
50
+ parent(anna,bob). parent(bob,carl). parent(carl,eve).
51
+ ancestor(X,Y) :- parent(X,Y).
52
+ ancestor(X,Z) :- parent(X,Y), ancestor(Y,Z).
53
+ ```
54
+
55
+ The output has four tabs. Animator steps through the rounds and shows which rule fired with which variable bindings. Answer Sets lists every model. Graph draws the atoms. Console shows clingo's messages and the position of any error.
56
+
57
+ Cell options:
58
+
59
+ | Option | Meaning |
60
+ |---|---|
61
+ | `-n 5`, `--models 5` | At most 5 answer sets (0 means all). |
62
+ | `-o "--opt-mode=optN -c n=6"` | Extra clingo options. |
63
+ | `-t 20` | Timeout in seconds. The default is 10. |
64
+ | `--tab answers` | First tab to show: `animator`, `answers`, `graph` or `console`. |
65
+ | `--theme dark` | `auto` (default), `light` or `dark`. Inside VS Code the editor theme is used. |
66
+ | `--height 500` | Starting height in pixels. The output grows with its content. |
67
+ | `--out res` | Keep the result in a variable called `res`. |
68
+
69
+ ## From Python
70
+
71
+ ```python
72
+ import asp_studio as asp
73
+
74
+ res = asp.solve("a :- not b. b :- not a.", models=0, timeout=10)
75
+ res.result # 'SATISFIABLE'
76
+ list(res) # [['b'], ['a']]
77
+ res.diagnostics # positions of errors and warnings from clingo
78
+ res # as the last line of a cell: shows the visualizer
79
+ asp.show(res, tab="graph")
80
+ ```
81
+
82
+ `solve` takes ASP text, a `pathlib.Path`, or a list of those, which are joined in order. A broken program does not raise: check `res.ok` and `res.error`. With `#minimize` or `#maximize` and a finished search, only the optimal models are kept, and their costs are in `res.costs`.
83
+
84
+ ## Command line
85
+
86
+ ```bash
87
+ asp-studio program.lp --open # writes program.html and opens it
88
+ asp-studio instance.lp encoding.lp -n 3 # several files are joined in order
89
+ python -m asp_studio program.lp # the same, without the script on PATH
90
+ ```
91
+
92
+ The exit code is 0 when the program ran, 1 when it has an error (positions print as `file:line:col`) and 2 for bad arguments or unreadable input.
93
+
94
+ ## Notes
95
+
96
+ - The animator follows positive programs only: no `not`, choice rules, constraints or ranges. For other programs it names the unsupported line, and the other three tabs still work.
97
+ - The visualizer runs in an isolated `<iframe>`. Jupyter shows such output only in trusted notebooks.
98
+ - Outside notebooks and VS Code, the color theme follows the operating system.
99
+
100
+ ## Development
101
+
102
+ ```bash
103
+ pip install -e ".[test]"
104
+ pytest
105
+ python sync_static.py # copy ../media/{engine.js,webview.js,webview.css,themes.css} into the package
106
+ ```
107
+
108
+ The visualizer files live in `../media` and are copied into `src/asp_studio/static`. A test fails when the copies drift apart.
109
+
110
+ MIT license. Not affiliated with the Potassco team.
@@ -0,0 +1,81 @@
1
+ # ASP Studio for Python
2
+
3
+ ASP Studio shows the answer sets of an [Answer Set Programming](https://potassco.org/) program, draws them as a graph, and steps through how a recursive program reaches its result. It works in Jupyter, in VS Code notebooks and from the command line. It uses the clingo Python API and shares its visualizer with the [ASP Studio VS Code extension](https://github.com/yakupbulbul/asp-vscode).
4
+
5
+ Website and live demo: <https://yakupbulbul.github.io/asp-vscode/>
6
+
7
+ ```bash
8
+ pip install "asp-studio[notebook]" # Python 3.9 or newer
9
+ ```
10
+
11
+ The `notebook` extra adds IPython for the `%%asp` magic. Without it you still get `solve()` and the command line.
12
+
13
+ ## In a notebook
14
+
15
+ ```python
16
+ %load_ext asp_studio
17
+ ```
18
+
19
+ ```text
20
+ %%asp
21
+ parent(anna,bob). parent(bob,carl). parent(carl,eve).
22
+ ancestor(X,Y) :- parent(X,Y).
23
+ ancestor(X,Z) :- parent(X,Y), ancestor(Y,Z).
24
+ ```
25
+
26
+ The output has four tabs. Animator steps through the rounds and shows which rule fired with which variable bindings. Answer Sets lists every model. Graph draws the atoms. Console shows clingo's messages and the position of any error.
27
+
28
+ Cell options:
29
+
30
+ | Option | Meaning |
31
+ |---|---|
32
+ | `-n 5`, `--models 5` | At most 5 answer sets (0 means all). |
33
+ | `-o "--opt-mode=optN -c n=6"` | Extra clingo options. |
34
+ | `-t 20` | Timeout in seconds. The default is 10. |
35
+ | `--tab answers` | First tab to show: `animator`, `answers`, `graph` or `console`. |
36
+ | `--theme dark` | `auto` (default), `light` or `dark`. Inside VS Code the editor theme is used. |
37
+ | `--height 500` | Starting height in pixels. The output grows with its content. |
38
+ | `--out res` | Keep the result in a variable called `res`. |
39
+
40
+ ## From Python
41
+
42
+ ```python
43
+ import asp_studio as asp
44
+
45
+ res = asp.solve("a :- not b. b :- not a.", models=0, timeout=10)
46
+ res.result # 'SATISFIABLE'
47
+ list(res) # [['b'], ['a']]
48
+ res.diagnostics # positions of errors and warnings from clingo
49
+ res # as the last line of a cell: shows the visualizer
50
+ asp.show(res, tab="graph")
51
+ ```
52
+
53
+ `solve` takes ASP text, a `pathlib.Path`, or a list of those, which are joined in order. A broken program does not raise: check `res.ok` and `res.error`. With `#minimize` or `#maximize` and a finished search, only the optimal models are kept, and their costs are in `res.costs`.
54
+
55
+ ## Command line
56
+
57
+ ```bash
58
+ asp-studio program.lp --open # writes program.html and opens it
59
+ asp-studio instance.lp encoding.lp -n 3 # several files are joined in order
60
+ python -m asp_studio program.lp # the same, without the script on PATH
61
+ ```
62
+
63
+ The exit code is 0 when the program ran, 1 when it has an error (positions print as `file:line:col`) and 2 for bad arguments or unreadable input.
64
+
65
+ ## Notes
66
+
67
+ - The animator follows positive programs only: no `not`, choice rules, constraints or ranges. For other programs it names the unsupported line, and the other three tabs still work.
68
+ - The visualizer runs in an isolated `<iframe>`. Jupyter shows such output only in trusted notebooks.
69
+ - Outside notebooks and VS Code, the color theme follows the operating system.
70
+
71
+ ## Development
72
+
73
+ ```bash
74
+ pip install -e ".[test]"
75
+ pytest
76
+ python sync_static.py # copy ../media/{engine.js,webview.js,webview.css,themes.css} into the package
77
+ ```
78
+
79
+ The visualizer files live in `../media` and are copied into `src/asp_studio/static`. A test fails when the copies drift apart.
80
+
81
+ MIT license. Not affiliated with the Potassco team.
@@ -0,0 +1,45 @@
1
+ [build-system]
2
+ requires = ["setuptools>=64"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "asp-studio"
7
+ version = "0.1.0"
8
+ description = "Answer sets, a graph and a step-by-step animator for Answer Set Programming, in Jupyter, VS Code notebooks and the command line."
9
+ readme = "README.md"
10
+ license = { text = "MIT" }
11
+ requires-python = ">=3.9"
12
+ authors = [{ name = "yakupbulbul" }]
13
+ keywords = ["asp", "answer set programming", "clingo", "jupyter", "visualization", "potassco"]
14
+ classifiers = [
15
+ "Development Status :: 4 - Beta",
16
+ "Framework :: Jupyter",
17
+ "Intended Audience :: Education",
18
+ "License :: OSI Approved :: MIT License",
19
+ "Programming Language :: Python :: 3",
20
+ "Topic :: Scientific/Engineering :: Visualization",
21
+ ]
22
+ dependencies = ["clingo>=5.5"]
23
+
24
+ [project.optional-dependencies]
25
+ notebook = ["ipython"]
26
+ test = ["pytest", "ipython"]
27
+
28
+ [project.scripts]
29
+ asp-studio = "asp_studio.__main__:main"
30
+
31
+ [project.urls]
32
+ Homepage = "https://yakupbulbul.github.io/asp-vscode/"
33
+ Documentation = "https://yakupbulbul.github.io/asp-vscode/docs.html"
34
+ Source = "https://github.com/yakupbulbul/asp-vscode"
35
+ Issues = "https://github.com/yakupbulbul/asp-vscode/issues"
36
+ Changelog = "https://github.com/yakupbulbul/asp-vscode/blob/main/CHANGELOG.md"
37
+
38
+ [tool.setuptools.packages.find]
39
+ where = ["src"]
40
+
41
+ [tool.setuptools.package-data]
42
+ asp_studio = ["static/*"]
43
+
44
+ [tool.pytest.ini_options]
45
+ testpaths = ["tests"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,18 @@
1
+ """ASP Studio: answer sets, graph and a step-by-step fixpoint animator for Answer Set Programming in Python.
2
+
3
+ import asp_studio as asp
4
+ res = asp.solve("a :- not b. b :- not a.") # clingo Python API under the hood
5
+ asp.show(res) # visualizer in a Jupyter / VS Code notebook
6
+
7
+ In a notebook: ``%load_ext asp_studio`` and start a cell with ``%%asp``.
8
+ """
9
+ from .solver import Diagnostic, Result, solve
10
+ from .render import iframe, page, show
11
+
12
+ __version__ = "0.1.0"
13
+ __all__ = ["solve", "show", "Result", "Diagnostic", "page", "iframe", "load_ipython_extension", "__version__"]
14
+
15
+
16
+ def load_ipython_extension(ipython): # `%load_ext asp_studio`
17
+ from .magic import load_ipython_extension as _load
18
+ _load(ipython)
@@ -0,0 +1,65 @@
1
+ """``python -m asp_studio program.lp`` (or ``asp-studio program.lp``): solve files and write a standalone HTML report."""
2
+ from __future__ import annotations
3
+
4
+ import argparse
5
+ import pathlib
6
+ import sys
7
+ import webbrowser
8
+ from typing import List, Optional
9
+
10
+ from .render import page
11
+ from .solver import solve
12
+
13
+
14
+ def _fail(message: str, code: int = 2) -> int:
15
+ print(f"error: {message}", file=sys.stderr)
16
+ return code
17
+
18
+
19
+ def main(argv: Optional[List[str]] = None) -> int:
20
+ ap = argparse.ArgumentParser(prog="asp-studio", description="Solve ASP files and write an interactive HTML report.")
21
+ ap.add_argument("files", nargs="+", type=pathlib.Path, help=".lp files (joined in order)")
22
+ ap.add_argument("-n", "--models", type=int, default=0, help="max answer sets (0 = all)")
23
+ ap.add_argument("-o", "--options", default="", help='extra clingo options, e.g. "--opt-mode=optN"')
24
+ ap.add_argument("-t", "--timeout", type=float, default=10.0, help="seconds (default 10)")
25
+ ap.add_argument("--out", type=pathlib.Path, help="output file (default: <first file>.html)")
26
+ ap.add_argument("--tab", default="animator", choices=["animator", "answers", "graph", "console"])
27
+ ap.add_argument("--theme", default="auto", choices=["auto", "light", "dark"])
28
+ ap.add_argument("--open", action="store_true", help="open the report in the browser")
29
+ a = ap.parse_args(argv)
30
+
31
+ if a.models < 0:
32
+ return _fail("--models must be 0 or greater")
33
+ if a.timeout <= 0:
34
+ return _fail("--timeout must be greater than 0")
35
+ for f in a.files:
36
+ if f.is_dir():
37
+ return _fail(f"{f} is a directory, not a file")
38
+ if not f.is_file():
39
+ return _fail(f"{f} does not exist")
40
+ try:
41
+ f.read_text(encoding="utf-8")
42
+ except UnicodeDecodeError as e:
43
+ return _fail(f"{f} is not UTF-8 text ({e.reason} at byte {e.start}); is it an ASP file?")
44
+ except OSError as e:
45
+ return _fail(f"cannot read {f}: {e.strerror or e}")
46
+ res = solve(list(a.files), models=a.models, options=a.options, timeout=a.timeout)
47
+
48
+ out = a.out or a.files[0].with_suffix(".html")
49
+ try:
50
+ out.parent.mkdir(parents=True, exist_ok=True)
51
+ out.write_text(page(res, tab=a.tab, theme=a.theme, max_height=100000, title=a.files[0].name), encoding="utf-8")
52
+ except OSError as e:
53
+ return _fail(f"cannot write {out}: {e.strerror or e}", 1)
54
+
55
+ for d in res.diagnostics:
56
+ print(f"{d.file}:{d.line}:{d.col}: {d.severity}: {d.message.splitlines()[0]}", file=sys.stderr)
57
+ status = res.error or f"{res.result}, {len(res)} model(s), {res.ms} ms"
58
+ print(f"{status} -> {out}")
59
+ if a.open:
60
+ webbrowser.open(out.resolve().as_uri())
61
+ return 1 if res.error else 0
62
+
63
+
64
+ if __name__ == "__main__":
65
+ raise SystemExit(main())
@@ -0,0 +1,48 @@
1
+ """IPython cell magic: ``%%asp`` solves the cell and shows the visualizer."""
2
+ from __future__ import annotations
3
+
4
+ import argparse
5
+ import shlex
6
+ from typing import Any
7
+
8
+ from IPython.core.magic import Magics, cell_magic, magics_class
9
+
10
+ from .render import show
11
+ from .solver import solve
12
+
13
+
14
+ def _parser() -> argparse.ArgumentParser:
15
+ p = argparse.ArgumentParser(prog="%%asp", add_help=False)
16
+ p.add_argument("-n", "--models", type=int, default=0, help="max answer sets (0 = all)")
17
+ p.add_argument("-o", "--options", default="", help="extra clingo options, quoted")
18
+ p.add_argument("-t", "--timeout", type=float, default=10.0)
19
+ p.add_argument("--tab", default="animator", choices=["animator", "answers", "graph", "console"])
20
+ p.add_argument("--theme", default="auto", choices=["auto", "light", "dark"])
21
+ p.add_argument("--height", type=int, default=None)
22
+ p.add_argument("--out", default=None, help="store the Result in this variable")
23
+ return p
24
+
25
+
26
+ @magics_class
27
+ class AspMagics(Magics):
28
+ @cell_magic
29
+ def asp(self, line: str, cell: str) -> Any:
30
+ """Solve the cell with clingo and show answer sets, graph and animator.
31
+
32
+ %%asp [-n N] [-o "--opt-mode=optN"] [-t SECONDS] [--tab animator|answers|graph|console]
33
+ [--theme auto|light|dark] [--height PX] [--out NAME]
34
+ """
35
+ try:
36
+ a = _parser().parse_args(shlex.split(line))
37
+ except SystemExit:
38
+ print(self.asp.__doc__)
39
+ return None
40
+ res = solve(cell, models=a.models, options=a.options, timeout=a.timeout)
41
+ show(res, tab=a.tab, theme=a.theme, height=a.height)
42
+ if a.out:
43
+ self.shell.user_ns[a.out] = res
44
+ return None
45
+
46
+
47
+ def load_ipython_extension(ipython: Any) -> None:
48
+ ipython.register_magics(AspMagics)
@@ -0,0 +1,61 @@
1
+ """Build the visualizer page (HTML) for a :class:`~asp_studio.solver.Result`."""
2
+ from __future__ import annotations
3
+
4
+ import html as _html
5
+ import json
6
+ from importlib import resources
7
+ from typing import Any, Optional
8
+
9
+ _STATIC = ("themes.css", "webview.css")
10
+ _cache: dict = {}
11
+
12
+
13
+ def _asset(name: str) -> str:
14
+ if name not in _cache:
15
+ _cache[name] = resources.files("asp_studio").joinpath("static", name).read_text(encoding="utf-8")
16
+ return _cache[name]
17
+
18
+
19
+ def _js_json(value: Any) -> str:
20
+ """JSON that is safe to place inside a <script> element."""
21
+ return json.dumps(value).replace("</", "<\\/").replace("<!--", "<\\!--")
22
+
23
+
24
+ def page(result, *, tab: str = "animator", theme: str = "auto", max_height: int = 760, title: str = "program") -> str:
25
+ """A complete, self-contained HTML document showing the visualizer."""
26
+ init = {"state": result.to_state(), "program": result.program, "tab": tab, "theme": theme,
27
+ "maxHeight": max_height, "title": title}
28
+ css = "\n".join(_asset(n) for n in _STATIC)
29
+ return (
30
+ '<!DOCTYPE html><html lang="en"><head><meta charset="utf-8">'
31
+ '<meta name="viewport" content="width=device-width, initial-scale=1">'
32
+ f"<title>ASP Studio: {_html.escape(title)}</title><style>{css}</style></head>"
33
+ '<body><div id="app"></div>'
34
+ f"<script>window.__ASP_INIT__ = {_js_json(init)};</script>"
35
+ f"<script>{_asset('engine.js')}</script>"
36
+ f"<script>{_asset('bootstrap.js')}</script>"
37
+ f"<script>{_asset('webview.js')}</script>"
38
+ "</body></html>"
39
+ )
40
+
41
+
42
+ def iframe(result, *, tab: str = "animator", theme: str = "auto", height: Optional[int] = None, max_height: int = 760) -> str:
43
+ """The page wrapped in an isolated <iframe>, ready to display in a notebook output."""
44
+ doc = page(result, tab=tab, theme=theme, max_height=max_height)
45
+ start = height if height else 420
46
+ return (
47
+ '<iframe class="asp-studio" sandbox="allow-scripts allow-same-origin" '
48
+ f'style="width:100%;height:{start}px;border:0;display:block" srcdoc="{_html.escape(doc, quote=True)}"></iframe>'
49
+ )
50
+
51
+
52
+ def show(program_or_result, *, tab: str = "animator", theme: str = "auto", height: Optional[int] = None,
53
+ max_height: int = 760, **solve_kwargs: Any) -> None:
54
+ """Display the visualizer in the current notebook cell. ``program_or_result`` may be ASP text or a Result."""
55
+ try:
56
+ from IPython.display import display
57
+ except ImportError as e: # pragma: no cover
58
+ raise ImportError("asp_studio.show needs IPython: pip install 'asp-studio[notebook]'") from e
59
+ from .solver import Result, solve
60
+ result = program_or_result if isinstance(program_or_result, Result) else solve(program_or_result, **solve_kwargs)
61
+ display({"text/html": iframe(result, tab=tab, theme=theme, height=height, max_height=max_height)}, raw=True)
@@ -0,0 +1,214 @@
1
+ """Solve an ASP program with the clingo Python API and keep everything the visualizer needs."""
2
+ from __future__ import annotations
3
+
4
+ import re
5
+ import time
6
+ from dataclasses import dataclass, field
7
+ from pathlib import Path
8
+ from typing import Any, Dict, Iterable, List, Optional, Sequence, Tuple, Union
9
+
10
+ import clingo
11
+
12
+ Program = Union[str, Path]
13
+
14
+ _DIAG = re.compile(
15
+ r"^(?:<block>|<string>|-):(\d+):(\d+)(?:-(\d+))?(?::(\d+)(?:-(\d+))?)?: (error|warning|info): (.*)$"
16
+ )
17
+
18
+
19
+ @dataclass
20
+ class Diagnostic:
21
+ line: int
22
+ col: int
23
+ end_col: int
24
+ severity: str
25
+ message: str
26
+ file: str = "program"
27
+
28
+
29
+ @dataclass(repr=False)
30
+ class Result:
31
+ """Outcome of :func:`solve`. Iterating yields the answer sets (lists of atom strings)."""
32
+
33
+ program: str
34
+ version: str = clingo.__version__
35
+ command: str = ""
36
+ result: str = "UNKNOWN"
37
+ models: List[List[str]] = field(default_factory=list)
38
+ costs: List[Optional[List[int]]] = field(default_factory=list)
39
+ more: bool = False
40
+ ms: int = 0
41
+ time: Optional[float] = None
42
+ stderr: str = ""
43
+ diagnostics: List[Diagnostic] = field(default_factory=list)
44
+ error: Optional[str] = None
45
+ timed_out: bool = False
46
+
47
+ def __repr__(self) -> str:
48
+ if self.error:
49
+ return f"Result(error={self.error!r})"
50
+ return f"Result({self.result}, {len(self.models)} model{'s' if len(self.models) != 1 else ''}, {self.ms} ms)"
51
+
52
+ @property
53
+ def ok(self) -> bool:
54
+ """True if the program ran without error (it may still be unsatisfiable)."""
55
+ return self.error is None
56
+
57
+ @property
58
+ def satisfiable(self) -> bool:
59
+ return self.ok and bool(self.models)
60
+
61
+ def __iter__(self):
62
+ return iter(self.models)
63
+
64
+ def __len__(self) -> int:
65
+ return len(self.models)
66
+
67
+ def __getitem__(self, i: int) -> List[str]:
68
+ return self.models[i]
69
+
70
+ def to_state(self) -> Dict[str, Any]:
71
+ """The ``solve`` object the visualizer front end renders."""
72
+ state: Dict[str, Any] = {
73
+ "engine": "python",
74
+ "version": self.version,
75
+ "command": self.command,
76
+ "result": self.result,
77
+ "models": [{"atoms": a, "costs": c} for a, c in zip(self.models, self.costs)],
78
+ "totalModels": len(self.models),
79
+ "more": self.more,
80
+ "ms": self.ms,
81
+ "time": self.time,
82
+ "stderr": self.stderr,
83
+ "capped": 0,
84
+ "diagnostics": [
85
+ {"sourceIndex": 0, "file": d.file, "line": d.line, "col": d.col, "endCol": d.end_col,
86
+ "severity": d.severity, "message": d.message}
87
+ for d in self.diagnostics
88
+ ],
89
+ }
90
+ if self.error:
91
+ state["error"] = self.error
92
+ state["timedOut"] = self.timed_out
93
+ return state
94
+
95
+ def _repr_html_(self) -> str: # picked up by Jupyter when a Result is the last expression of a cell
96
+ from .render import iframe
97
+ return iframe(self)
98
+
99
+ def show(self, **kwargs: Any) -> None:
100
+ from .render import show
101
+ show(self, **kwargs)
102
+
103
+
104
+ def _read(programs: Union[Program, Sequence[Program]]) -> Tuple[str, List[Tuple[str, int]]]:
105
+ """Join the programs; also return (name, line count) per part so messages can be mapped back to their file."""
106
+ items: Iterable[Program] = [programs] if isinstance(programs, (str, Path)) else programs
107
+ parts: List[str] = []
108
+ sources: List[Tuple[str, int]] = []
109
+ for p in items:
110
+ text = p.read_text(encoding="utf-8") if isinstance(p, Path) else p
111
+ parts.append(text)
112
+ sources.append((p.name if isinstance(p, Path) else "program", len(text.split("\n"))))
113
+ return "\n".join(parts) + "\n", sources
114
+
115
+
116
+ def _locate(sources: Sequence[Tuple[str, int]], line: int) -> Tuple[str, int]:
117
+ """Map a 1-based line of the joined program to (file name, line in that file)."""
118
+ start = 0
119
+ for name, count in sources:
120
+ if line - 1 < start + count:
121
+ return name, line - start
122
+ start += count
123
+ return (sources[-1][0], 1) if sources else ("program", line)
124
+
125
+
126
+ def _parse_messages(messages: Sequence[str], sources: Sequence[Tuple[str, int]]) -> List[Diagnostic]:
127
+ out: List[Diagnostic] = []
128
+ for msg in messages:
129
+ lines = msg.split("\n")
130
+ m = _DIAG.match(lines[0])
131
+ if not m:
132
+ continue
133
+ col = int(m.group(2))
134
+ # `L:C1-C2` marks a range on one line; `L:C1-L2:C2` spans lines, then only the start is used
135
+ end = int(m.group(3)) if m.group(3) and m.group(4) is None else col + 1
136
+ text = m.group(7)
137
+ extra = [l.strip() for l in lines[1:] if l.strip() and not l.startswith("***")]
138
+ if extra:
139
+ text += "\n" + "\n".join(extra)
140
+ sev = {"error": "error", "info": "info"}.get(m.group(6), "warning")
141
+ name, line = _locate(sources, int(m.group(1)))
142
+ out.append(Diagnostic(line, col, end, sev, text, name))
143
+ return out
144
+
145
+
146
+ def solve(
147
+ program: Union[Program, Sequence[Program]],
148
+ *,
149
+ models: int = 0,
150
+ options: Union[str, Sequence[str]] = (),
151
+ timeout: float = 10.0,
152
+ ) -> Result:
153
+ """Solve ``program`` and return a :class:`Result`.
154
+
155
+ ``program`` is ASP text, a :class:`pathlib.Path`, or a list of those (joined in order).
156
+ ``models`` limits the number of answer sets (0 = all). ``options`` are extra clingo options,
157
+ either a list or one string such as ``"--opt-mode=optN -c n=6"``. ``timeout`` is in seconds.
158
+ With optimisation statements and a finished search only the optimal models are kept.
159
+ """
160
+ text, sources = _read(program)
161
+ opts = options.split() if isinstance(options, str) else list(options)
162
+ args = [str(max(0, int(models)))] + opts
163
+ res = Result(program=text, command="clingo.Control(" + repr(args) + ")")
164
+ messages: List[str] = []
165
+ found: List[Any] = []
166
+ exhausted = False
167
+ t0 = time.perf_counter()
168
+
169
+ def on_model(m: clingo.Model) -> None:
170
+ found.append(([str(s) for s in m.symbols(shown=True)], list(m.cost) if m.cost else None, m.optimality_proven))
171
+
172
+ try:
173
+ ctl = clingo.Control(args, lambda code, msg: messages.append(msg), 50)
174
+ ctl.add("base", [], text)
175
+ ctl.ground([("base", [])])
176
+ handle = ctl.solve(on_model=on_model, async_=True)
177
+ if not handle.wait(timeout):
178
+ handle.cancel()
179
+ handle.get()
180
+ res.timed_out = True
181
+ res.error = f"Timeout after {timeout:g} s."
182
+ else:
183
+ sr = handle.get()
184
+ res.result = "SATISFIABLE" if sr.satisfiable else "UNSATISFIABLE" if sr.unsatisfiable else "UNKNOWN"
185
+ exhausted = bool(sr.exhausted)
186
+ res.more = bool(models) and len(found) >= models and not exhausted
187
+ try:
188
+ res.time = float(ctl.statistics["summary"]["times"]["total"])
189
+ except Exception: # statistics are optional
190
+ res.time = None
191
+ except RuntimeError as e:
192
+ res.error = str(e)
193
+ res.diagnostics = _parse_messages(messages, sources)
194
+ if res.error and not res.timed_out and any(d.severity == "error" for d in res.diagnostics):
195
+ res.error = "The program has a syntax or semantic error."
196
+ res.stderr = "".join(m if m.endswith("\n") else m + "\n" for m in messages)
197
+ res.ms = round((time.perf_counter() - t0) * 1000)
198
+
199
+ if res.error is None:
200
+ optimising = any(f[1] for f in found)
201
+ keep = found
202
+ if optimising and exhausted: # search finished: only the best cost is optimal
203
+ best = min(tuple(f[1] or ()) for f in found)
204
+ keep = [f for f in found if tuple(f[1] or ()) == best]
205
+ res.result = "OPTIMUM FOUND"
206
+ seen = set()
207
+ for atoms, cost, _ in keep:
208
+ key = tuple(atoms)
209
+ if key in seen:
210
+ continue
211
+ seen.add(key)
212
+ res.models.append(atoms)
213
+ res.costs.append(cost)
214
+ return res