├── .gitignore ├── BuildingAndTesting.md ├── CMakeLists.txt ├── README.md ├── mqi.doc ├── mqi.pl ├── mqi_app.pl ├── mqi_overview.md ├── python ├── LICENSE ├── README.md ├── pyproject.toml ├── setup.cfg ├── swiplserver │ ├── LICENSE │ ├── __init__.py │ └── prologmqi.py └── test_prologserver.py └── test_mqi.pl /.gitignore: -------------------------------------------------------------------------------- 1 | languageserveroverview.tex 2 | languageserverlib.tex 3 | __pycache__ 4 | *~ 5 | .idea/ 6 | env/ 7 | dist/ 8 | swiplserver.egg-info/ -------------------------------------------------------------------------------- /BuildingAndTesting.md: -------------------------------------------------------------------------------- 1 | # How to test the `swiplserver` Module in Python 2 | 1. Create a new directory 3 | 2. Copy test_prologserver.py into the directory 4 | 3. Open a command window 5 | 2. cd to the directory you created and: 6 | ~~~ 7 | python3 -m venv ./env 8 | source env/bin/activate 9 | pip install swiplserver 10 | python test_prologserver.py 11 | ~~~ 12 | 13 | # How to build the `swiplserver` module so that `pip install swiplserver` works 14 | First do a build. This will build into a directory called `/repository_root/python/dist` using the `pyproject.toml` and `setup.cfg` files (and the files they reference) in the `/repository_root/python/` subdirectory. 15 | 16 | Make sure to increase the version number in `setup.cfg` before you build: 17 | ~~~ 18 | cd /repository_root/python 19 | python3 -m venv ./env 20 | source env/bin/activate 21 | pip install build 22 | pip install twine 23 | python3 -m build 24 | ~~~ 25 | Then, upload for testing in the "test Python Package Index": https://test.pypi.org/. You'll need to create an account there and get an API token to do this test. 26 | 27 | You will be prompted for a username and password. For the username, use `__token__`. For the password, use the API token value, including the pypi- prefix. 28 | ~~~ 29 | python3 -m twine upload --repository testpypi dist/* 30 | ~~~ 31 | 32 | Or to upload for release (you'll need an account on http://www.pypi.org to do this): 33 | ~~~ 34 | python3 -m twine upload dist/* 35 | ~~~ 36 | 37 | # Building documentation 38 | 39 | ## How to build the Python documentation 40 | The Python documentation is hosted on https://www.swi-prolog.org/packages/mqi/prologmqi.html. It can be updated by updating the docs at: https://github.com/SWI-Prolog/plweb-www/tree/master/packages/mqi. 41 | 42 | HTML Docs produced with https://pdoc3.github.io like this: 43 | 44 | ~~~ 45 | cd /repository_root/python 46 | python3 -m venv ./env 47 | source env/bin/activate 48 | pip install pdoc3 49 | pdoc --html --force --output-dir docs --config show_source_code=False swiplserver.prologmqi 50 | ~~~ 51 | 52 | ## How to build the Prolog documentation 53 | The SWI Prolog documentation is automatically built from the sources and hosted at: https://www.swi-prolog.org/pldoc/doc_for?object=section(%27packages/mqi.html%27). To update the docs, simply change the sources in this respository. 54 | 55 | If you want to build them locally for some reason, run the following from the SWI Prolog top level: 56 | ~~~ 57 | consult("/.../swiplserver/mqi/mqi.pl"). 58 | doc_save("/.../swiplserver/mqi/mqi.pl", [doc_root("/.../swiplserver/docs/mqi")]). 59 | consult("/.../swiplserver/mqi/mqi_overview_doc.pl"). 60 | doc_save("/.../swiplserver/mqi/mqi_overview_doc.pl", [doc_root("/.../swiplserver/docs/mqi")]). 61 | ~~~ 62 | -------------------------------------------------------------------------------- /CMakeLists.txt: -------------------------------------------------------------------------------- 1 | cmake_minimum_required(VERSION 3.10) 2 | project(swipl-mqi) 3 | 4 | include("../cmake/PrologPackage.cmake") 5 | 6 | swipl_plugin(mqi 7 | PL_LIBS mqi.pl) 8 | 9 | pkg_doc(mqi 10 | SECTION 11 | mqi_overview.md 12 | SOURCE mqi.pl mqipl.tex) 13 | 14 | install(DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR}/python/swiplserver 15 | DESTINATION ${SWIPL_INSTALL_PREFIX}/lib) 16 | install_src(mqi_app 17 | FILES mqi_app.pl 18 | RENAME mqi.pl 19 | DESTINATION ${SWIPL_INSTALL_APP}) 20 | 21 | find_program(PROG_PYTHON NAMES python3) 22 | 23 | if(PROG_PYTHON) 24 | test_libs(mqi 25 | PACKAGES plunit) 26 | endif() 27 | 28 | if(INSTALL_TESTS) 29 | install(DIRECTORY python 30 | DESTINATION ${INSTALL_TESTS_DIR}/packages/mqi 31 | FILES_MATCHING PATTERN "*.py") 32 | endif() 33 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # SWI Prolog Machine Query Interface and Python Integration 2 | This package provides the library mqi.pl that enables embedding SWI Prolog into just about any programming language (Python, Go, C#, etc) in a straightforward way. It is designed for scenarios that need to use SWI Prolog as a local implementation detail of another language. Think of it as running SWI Prolog "like a library". It can support any programming language that can launch processes, read their STDOUT pipe, and send and receive JSON over TCP/IP. 3 | 4 | A Python 3.x library that uses the MQI to integrate Python with SWI Prolog is included with SWI Prolog. It is called `swiplserver` and is described in `./python/README.md`. 5 | 6 | Developers are encouraged to use the SWI Prolog MQI to integrate SWI Prolog with other languages, just as the swiplserver library does for Python. The MQI code is available in this repository at: `/mqi.pl` Read more in: 7 | - [Machine Query Interface Overview](https://www.swi-prolog.org/pldoc/doc_for?object=section(%27packages/mqi.html%27)) 8 | - [Machine Query Interface Predicates Reference](https://www.swi-prolog.org/pldoc/man?section=mqi) 9 | 10 | 11 | Questions or comments about the MQI or the `swiplserver` Python library should be posted to the [SWI Prolog Discourse Group](https://swi-prolog.discourse.group/). -------------------------------------------------------------------------------- /mqi.doc: -------------------------------------------------------------------------------- 1 | \documentclass[11pt]{article} 2 | \usepackage{times} 3 | \usepackage{pl} 4 | \usepackage{html} 5 | \sloppy 6 | \makeindex 7 | 8 | \onefile 9 | \htmloutput{.} % Output directory 10 | \htmlmainfile{mqi} % Main document file 11 | \bodycolor{white} % Page colour 12 | 13 | \begin{document} 14 | 15 | \title{mqi -- Python and Other Programming Languge Integration for SWI Prolog} 16 | \author{Eric Zinda \\ 17 | E-mail: \email{ericz@inductorsoftware.com}} 18 | 19 | \maketitle 20 | 21 | \begin{abstract} 22 | 23 | This package provides the library \file{mqi.pl} that enables embedding 24 | SWI Prolog into just about any programming language (Python, Go, C\#, 25 | etc) in a straightforward way. It is designed for scenarios that need to 26 | use SWI Prolog as a local implementation detail of another language. 27 | Think of it as running SWI Prolog ``like a library''. It can support any 28 | programming language that can launch processes, read their STDOUT pipe, 29 | and send and receive JSON over TCP/IP. 30 | 31 | A Python 3.x library is provided. 32 | 33 | \end{abstract} 34 | 35 | \pagebreak 36 | \tableofcontents 37 | 38 | \vfill 39 | \vfill 40 | 41 | \newpage 42 | 43 | \input{mqioverview.tex} 44 | \input{mqipl.tex} 45 | 46 | \printindex 47 | 48 | \end{document} 49 | 50 | -------------------------------------------------------------------------------- /mqi.pl: -------------------------------------------------------------------------------- 1 | /* Prolog Machine Query Interface 2 | Author: Eric Zinda 3 | E-mail: ericz@inductorsoftware.com 4 | WWW: http://www.inductorsoftware.com 5 | Copyright (c) 2021, Eric Zinda 6 | All rights reserved. 7 | 8 | Redistribution and use in source and binary forms, with or without 9 | modification, are permitted provided that the following conditions 10 | are met: 11 | 12 | 1. Redistributions of source code must retain the above copyright 13 | notice, this list of conditions and the following disclaimer. 14 | 15 | 2. Redistributions in binary form must reproduce the above copyright 16 | notice, this list of conditions and the following disclaimer in 17 | the documentation and/or other materials provided with the 18 | distribution. 19 | 20 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 21 | "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 22 | LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS 23 | FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE 24 | COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, 25 | INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, 26 | BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 27 | LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 28 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT 29 | LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN 30 | ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE 31 | POSSIBILITY OF SUCH DAMAGE. 32 | */ 33 | 34 | :- module(mqi, 35 | [ mqi_start/0, 36 | mqi_start/1, % +Options 37 | mqi_stop/1, % ?Thread 38 | mqi_version/2 % ?Major_Version, ?Minor_Version 39 | ]). 40 | 41 | /** 42 | mqi_start(+Options:list) is semidet. 43 | 44 | Starts a Prolog Machine Query Interface ('MQI') using Options. The MQI is normally started automatically by a library built for a particular programming language such as the [`swiplserver` Python library](#mqi-python-installation), but starting manually can be useful when debugging Prolog code in some scenarios. See the documentation on ["Standalone Mode"](#mqi-standalone-mode) for more information. 45 | 46 | Once started, the MQI listens for TCP/IP or Unix Domain Socket connections and authenticates them using the password provided (or created depending on options) before processing any messages. The messages processed by the MQI are described [below](#mqi-messages). 47 | 48 | For debugging, the server outputs traces using the `debug/3` predicate so that the server operation can be observed by using the `debug/1` predicate. Run the following commands to see them: 49 | 50 | - `debug(mqi(protocol))`: Traces protocol messages to show the flow of commands and connections. It is designed to avoid filling the screen with large queries and results to make it easier to read. 51 | - `debug(mqi(query))`: Traces messages that involve each query and its results. Therefore it can be quite verbose depending on the query. 52 | 53 | 54 | __Options__ 55 | 56 | Options is a list containing any combination of the following options. When used in the Prolog top level (i.e. in [Standalone Mode](#mqi-standalone-mode)), these are specified as normal Prolog options like this: 57 | ~~~ 58 | mqi_start([unix_domain_socket(Socket), password('a password')]) 59 | ~~~ 60 | When using ["Embedded Mode"](#mqi-embedded-mode) they are passed using the same name but as normal command line arguments like this: 61 | ~~~ 62 | swipl mqi --write_connection_values=true 63 | --password="a password" --create_unix_domain_socket=true 64 | ~~~ 65 | 66 | Note the use of quotes around values that could confuse command line 67 | processing like spaces (e.g. "a password") and that 68 | `unix_domain_socket(Variable)` is written as 69 | =|--create_unix_domain_socket=true|= on the command line. See below for 70 | more information. 71 | 72 | - port(?Port) 73 | The TCP/IP port to bind to on localhost. This option is ignored if the `unix_domain_socket/1` option is set. Port is either a legal TCP/IP port number (integer) or a variable term like `Port`. If it is a variable, it causes the system to select a free port and unify the variable with the selected port as in `tcp_bind/2`. If the option `write_connection_values(true)` is set, the selected port is output to STDOUT followed by `\n` on startup to allow the client language library to retrieve it in ["Embedded Mode"](#mqi-embedded-mode). 74 | 75 | - unix_domain_socket(?Unix_Domain_Socket_Path_And_File) 76 | If set, Unix Domain Sockets will be used as the way to communicate with the server. `Unix_Domain_Socket_Path_And_File` specifies the fully qualified path and filename to use for the socket. 77 | 78 | To have one generated instead (recommended), pass `Unix_Domain_Socket_Path_And_File` as a variable when calling from the Prolog top level and the variable will be unified with a created filename. If launching in ["Embedded Mode"](#mqi-embedded-mode), instead pass =|--create_unix_domain_socket=true|= since there isn't a way to specify variables from the command line. When generating the file, a temporary directory will be created using `tmp_file/2` and a socket file will be created within that directory following the below requirements. If the directory and file are unable to be created for some reason, mqi_start/1 fails. 79 | 80 | Regardless of whether the file is specified or generated, if the option `write_connection_values(true)` is set, the fully qualified path to the generated file is output to STDOUT followed by `\n` on startup to allow the client language library to retrieve it. 81 | 82 | Specifying a file to use should follow the same guidelines as the generated file: 83 | - If the file exists when the MQI is launched, it will be deleted. 84 | - The Prolog process will attempt to create and, if Prolog exits cleanly, delete this file (and directory if it was created) when the MQI closes. This means the directory from a specified file must have the appropriate permissions to allow the Prolog process to do so. 85 | - For security reasons, the filename should not be predictable and the directory it is contained in should have permissions set so that files created are only accessible to the current user. 86 | - The path must be below 92 *bytes* long (including null terminator) to be portable according to the Linux documentation. 87 | 88 | - password(?Password) 89 | The password required for a connection. If not specified (recommended), the MQI will generate one as a Prolog string type since Prolog atoms are globally visible (be sure not to convert to an atom for this reason). If `Password` is a variable it will be unified with the created password. Regardless of whether the password is specified or generated, if the option `write_connection_values(true)` is set, the password is output to STDOUT followed by `\n` on startup to allow the client language library to retrieve it. This is the recommended way to integrate the MQI with a language as it avoids including the password as source code. This option is only included so that a known password can be supplied for when the MQI is running in Standalone Mode. 90 | 91 | - query_timeout(+Seconds) 92 | Sets the default time in seconds that a query is allowed to run before it is cancelled. This can be overridden on a query by query basis. If not set, the default is no timeout (`-1`). 93 | 94 | - pending_connections(+Count) 95 | Sets the number of pending connections allowed for the MQI as in `tcp_listen/2`. If not provided, the default is `5`. 96 | 97 | - run_server_on_thread(+Run_Server_On_Thread) 98 | Determines whether `mqi_start/1` runs in the background on its own thread or blocks until the MQI shuts down. Must be missing or set to `true` when running in ["Embedded Mode"](#mqi-embedded-mode) so that the SWI Prolog process can exit properly. If not set, the default is `true`. 99 | 100 | - server_thread(?Server_Thread) 101 | Specifies or retrieves the name of the thread the MQI will run on if `run_server_on_thread(true)`. Passing in an atom for Server_Thread will only set the server thread name if run_server_on_thread(true). If `Server_Thread` is a variable, it is unified with a generated name. 102 | 103 | - write_connection_values(+Write_Connection_Values) 104 | Determines whether the server writes the port (or generated Unix Domain Socket) and password to STDOUT as it initializes. Used by language libraries to retrieve this information for connecting. If not set, the default is `false`. 105 | 106 | - write_output_to_file(+File) 107 | Redirects STDOUT and STDERR to the file path specified. Useful for debugging the MQI when it is being used in ["Embedded Mode"](#mqi-embedded-mode). If using multiple MQI instances in one SWI Prolog instance, only set this on the first one. Each time it is set the output will be redirected. 108 | 109 | */ 110 | :- use_module(library(socket)). 111 | :- use_module(library(http/json)). 112 | :- use_module(library(http/json_convert)). 113 | :- use_module(library(http/http_stream)). 114 | :- use_module(library(option)). 115 | :- use_module(library(term_to_json)). 116 | :- use_module(library(debug)). 117 | :- use_module(library(filesex)). 118 | :- use_module(library(gensym)). 119 | :- use_module(library(lists)). 120 | :- use_module(library(main)). 121 | :- use_module(library(make)). 122 | :- use_module(library(prolog_source)). 123 | :- use_module(library(time)). 124 | :- use_module(library(uuid)). 125 | 126 | % One for every Machine Query Interface running 127 | :- dynamic(mqi_thread/3). 128 | 129 | % One for every active connection 130 | :- dynamic(mqi_worker_threads/3). 131 | :- dynamic(mqi_socket/5). 132 | 133 | % Indicates that a query is in progress on the goal thread or hasn't had its results drained 134 | % Deleted once the last result from the queue has been drained 135 | % Only deleted by the communication thread to avoid race conditions 136 | :- dynamic(query_in_progress/1). 137 | 138 | % Indicates to the communication thread that we are in a place 139 | % that can be cancelled 140 | :- dynamic(safe_to_cancel/1). 141 | 142 | %! mqi_version(?Major_Version, ?Minor_Version) is det. 143 | % 144 | % Provides the major and minor version number of the protocol used by the MQI. 145 | % The protocol includes the message format and the messages that can 146 | % be sent and received from the MQI. 147 | % 148 | % Note that the initial version of the MQI did not have a version predicate so 149 | % The proper way for callers to check the version is: 150 | % 151 | % use_module(library(mqi)), 152 | % ( current_predicate(mqi_version/2) 153 | % -> mqi_version(Major_Version, Minor_Version) 154 | % ; Major_Version = 0, Minor_Version = 0 155 | % ) 156 | % 157 | % Major versions are increased when there is a change to the protocol that will 158 | % likely break clients written to the previous version. Minor versions are increased 159 | % when there is new functionality that will *not* break clients written to the old version 160 | % 161 | % This allows a client written to MQI version 'Client_Major_Version.Client_Minor_Version' 162 | % to check for non-breaking compatibility like this: 163 | % 164 | % Client_Major_Version = MQI_Major_Version and Client_Minor_Version <= MQI_Minor_Version 165 | % 166 | % Breaking changes (i.e. Major version increments) should be very rare as the goal is to 167 | % have the broadest adoption possible. 168 | % 169 | % Protocol Version History: 170 | % - 0.0 First published version. Had a protocol bug that required messages sent to MQI to 171 | % count Unicode code points instead of bytes for the message header. 172 | % 173 | % - 1.0 Breaking change: Fixed protocol bug so that it properly accepted byte count instead of Unicode code point 174 | % count in the message header for messages sent to MQI. 175 | mqi_version(1, 0). 176 | 177 | 178 | % Password is carefully constructed to be a string (not an atom) so that it is not 179 | % globally visible 180 | % Add ".\n" to the password since it will be added by the message when received 181 | mqi_start(Options) :- 182 | Encoding = utf8, 183 | option(pending_connections(Connection_Count), Options, 5), 184 | option(query_timeout(Query_Timeout), Options, -1), 185 | option(port(Port), Options, _), 186 | option(run_server_on_thread(Run_Server_On_Thread), Options, true), 187 | option(exit_main_on_failure(Exit_Main_On_Failure), Options, false), 188 | option(write_connection_values(Write_Connection_Values), Options, false), 189 | option(unix_domain_socket(Unix_Domain_Socket_Path_And_File), Options, _), 190 | ( ( memberchk(unix_domain_socket(_), Options), 191 | var(Unix_Domain_Socket_Path_And_File) 192 | ) 193 | -> unix_domain_socket_path(Unix_Domain_Socket_Path, Unix_Domain_Socket_Path_And_File) 194 | ; true 195 | ), 196 | option(server_thread(Server_Thread_ID), Options, _), 197 | ( var(Server_Thread_ID) 198 | -> gensym(mqi, Server_Thread_ID) 199 | ; true 200 | ), 201 | option(password(Password), Options, _), 202 | ( var(Password) 203 | -> ( current_prolog_flag(bounded, false) 204 | -> uuid(UUID, [format(integer)]) 205 | ; UUID is random(1<<62) 206 | ), 207 | format(string(Password), '~d', [UUID]) 208 | ; true 209 | ), 210 | string_concat(Password, '.\n', Final_Password), 211 | bind_socket(Server_Thread_ID, Unix_Domain_Socket_Path_And_File, Port, Socket, Client_Address), 212 | send_client_startup_data(Write_Connection_Values, user_output, Unix_Domain_Socket_Path_And_File, Client_Address, Password), 213 | option(write_output_to_file(File), Options, _), 214 | ( var(File) 215 | -> true 216 | ; write_output_to_file(File) 217 | ), 218 | Server_Goal = ( 219 | catch(server_thread(Server_Thread_ID, Socket, Client_Address, Final_Password, Connection_Count, Encoding, Query_Timeout, Exit_Main_On_Failure), error(E1, E2), true), 220 | debug(mqi(protocol), "Stopped MQI on thread: ~w due to exception: ~w", [Server_Thread_ID, error(E1, E2)]) 221 | ), 222 | start_server_thread(Run_Server_On_Thread, Server_Thread_ID, Server_Goal, Unix_Domain_Socket_Path, Unix_Domain_Socket_Path_And_File). 223 | 224 | opt_type(port, port, natural). 225 | opt_type(create_unix_domain_socket, create_unix_domain_socket, boolean). 226 | opt_type(unix_domain_socket, unix_domain_socket, file(write)). 227 | opt_type(password, password, string). 228 | opt_type(pending_connections, pending_connections, nonneg). 229 | opt_type(query_timeout, query_timeout, float). 230 | opt_type(run_server_on_thread, run_server_on_thread, boolean). 231 | opt_type(exit_main_on_failure, exit_main_on_failure, boolean). 232 | opt_type(write_connection_values, write_connection_values, boolean). 233 | opt_type(write_output_to_file, write_output_to_file, file(write)). 234 | 235 | opt_help(port, "TCP/IP port for clients to connect to"). 236 | opt_help(create_unix_domain_socket, "Create a Unix domain socket for clients to connect to"). 237 | opt_help(unix_domain_socket, "File path for the Unix domain socket"). 238 | opt_help(password, "Connection password"). 239 | opt_help(pending_connections, "Max number of queued connections (5)"). 240 | opt_help(query_timeout, "Max query runtime in seconds (default infinite)"). 241 | opt_help(run_server_on_thread, "Run server in a background thread (true)"). 242 | opt_help(exit_main_on_failure, "Exit the process on a failure"). 243 | opt_help(write_connection_values, "Print info for clients to connect"). 244 | opt_help(write_output_to_file, "Write stdout and stderr to file"). 245 | 246 | %! mqi_start is semidet. 247 | % 248 | % Main entry point for running the Machine Query Interface in 249 | % ["Embedded Mode"](#mqi-embedded-mode) and designed to be called from 250 | % the command line. Embedded Mode is used when launching the Machine 251 | % Query Interface as an embedded part of another language (e.g. 252 | % Python). Calling mqi_start/0 from Prolog interactively is not 253 | % recommended as it depends on Prolog exiting to stop the MQI, instead 254 | % use mqi_start/1 for interactive use. 255 | % 256 | % To launch embedded mode: 257 | % 258 | % ~~~ 259 | % swipl mqi --write_connection_values=true 260 | % ~~~ 261 | % 262 | % This will start SWI Prolog and invoke the mqi_start/0 predicate and 263 | % exit the process when that predicate stops. Any command line 264 | % arguments after the standalone `--` will be passed as Options. These 265 | % are the same Options that mqi_start/1 accepts and are passed to it 266 | % directly. Some options are expressed differently due to command line 267 | % limitations, see mqi_start/1 Options for more information. 268 | % 269 | % Any Option values that cause issues during command line parsing (such 270 | % as spaces) should be passed with =|""|= like this: 271 | % 272 | % ~~~ 273 | % swipl mqi --write_connection_values=true --password="HGJ SOWLWW" 274 | % ~~~ 275 | % 276 | % For help on commandline options run 277 | % 278 | % ~~~ 279 | % swipl mqi --help 280 | % ~~~ 281 | 282 | 283 | % Turn off int signal when running in embedded mode so the client language 284 | % debugger signal doesn't put Prolog into debug mode 285 | % run_server_on_thread must be missing or true (the default) so we can exit 286 | % properly 287 | % create_unix_domain_socket=true/false is only used as a command line argument 288 | % since it doesn't seem possible to pass create_unix_domain_socket=_ on the command line 289 | % and have it interpreted as a variable. 290 | mqi_start :- 291 | current_prolog_flag(argv, Argv), 292 | argv_options(Argv, _Args, Options), 293 | merge_options(Options, [exit_main_on_failure(true)], Options1), 294 | select_option(create_unix_domain_socket(Create_Unix_Domain_Socket), Options1, Options2, false), 295 | ( Create_Unix_Domain_Socket == true 296 | -> merge_options(Options2, [unix_domain_socket(_)], FinalOptions) 297 | ; FinalOptions = Options2 298 | ), 299 | option(run_server_on_thread(Run_Server_On_Thread), FinalOptions, true), 300 | ( Run_Server_On_Thread == true 301 | -> true 302 | ; throw(domain_error(cannot_be_set_in_embedded_mode, run_server_on_thread)) 303 | ), 304 | mqi_start(FinalOptions), 305 | on_signal(int, _, quit), 306 | thread_get_message(quit_mqi). 307 | 308 | 309 | quit(_) :- 310 | thread_send_message(main, quit_mqi). 311 | 312 | 313 | %! mqi_stop(?Server_Thread_ID:atom) is det. 314 | % 315 | % If `Server_Thread_ID` is a variable, stops all Machine Query Interfaces and associated threads. If `Server_Thread_ID` is an atom, then only the MQI with that `Server_Thread_ID` is stopped. `Server_Thread_ID` can be provided or retrieved using `Options` in `mqi_start/1`. 316 | % 317 | % Always succeeds. 318 | 319 | % tcp_close_socket(Socket) will shut down the server thread cleanly so the socket is released and can be used again in the same session 320 | % Closes down any pending connections using abort even if there were no matching server threads since the server thread could have died. 321 | % At this point only threads associated with live connections (or potentially a goal thread that hasn't detected its missing communication thread) 322 | % should be left so seeing abort warning messages in the console seems OK 323 | mqi_stop(Server_Thread_ID) :- 324 | % First shut down any matching servers to stop new connections 325 | forall(retract(mqi_thread(Server_Thread_ID, _, Socket)), 326 | ( debug(mqi(protocol), "Found server: ~w", [Server_Thread_ID]), 327 | catch(tcp_close_socket(Socket), _0Socket_Exception, true), 328 | abortSilentExit(Server_Thread_ID, _0Server_Thread_Exception), 329 | debug(mqi(protocol), "Stopped server thread: ~w, \c 330 | socket_close_exception(~w), stop_thread_exception(~w)", 331 | [Server_Thread_ID, _0Socket_Exception, _0Server_Thread_Exception]) 332 | )), 333 | forall(retract(mqi_worker_threads(Server_Thread_ID, Communication_Thread_ID, Goal_Thread_ID)), 334 | ( abortSilentExit(Communication_Thread_ID, _0CommunicationException), 335 | debug(mqi(protocol), "Stopped server: ~w communication thread: ~w, exception(~w)", 336 | [Server_Thread_ID, Communication_Thread_ID, _0CommunicationException]), 337 | abortSilentExit(Goal_Thread_ID, _0Goal_Exception), 338 | debug(mqi(protocol), "Stopped server: ~w goal thread: ~w, exception(~w)", 339 | [Server_Thread_ID, Goal_Thread_ID, _0Goal_Exception]) 340 | )). 341 | 342 | 343 | start_server_thread(Run_Server_On_Thread, Server_Thread_ID, Server_Goal, Unix_Domain_Socket_Path, Unix_Domain_Socket_Path_And_File) :- 344 | ( Run_Server_On_Thread 345 | -> ( thread_create(Server_Goal, _, [ alias(Server_Thread_ID), 346 | at_exit((delete_unix_domain_socket_file(Unix_Domain_Socket_Path, Unix_Domain_Socket_Path_And_File), 347 | detach_if_expected(Server_Thread_ID) 348 | )) 349 | ]), 350 | debug(mqi(protocol), "Started server on thread: ~w", [Server_Thread_ID]) 351 | ) 352 | ; ( Server_Goal, 353 | delete_unix_domain_socket_file(Unix_Domain_Socket_Path, Unix_Domain_Socket_Path_And_File), 354 | debug(mqi(protocol), "Halting.", []) 355 | ) 356 | ). 357 | 358 | 359 | % Unix domain sockets create a file that needs to be cleaned up 360 | % If mqi generated it, there is also a directory that needs to be cleaned up 361 | % that will only contain that file 362 | delete_unix_domain_socket_file(Unix_Domain_Socket_Path, Unix_Domain_Socket_Path_And_File) :- 363 | ( nonvar(Unix_Domain_Socket_Path) 364 | -> catch(delete_directory_and_contents(Unix_Domain_Socket_Path), error(_, _), true) 365 | ; ( nonvar(Unix_Domain_Socket_Path_And_File) 366 | -> catch(delete_file(Unix_Domain_Socket_Path_And_File), error(_, _), true) 367 | ; true 368 | ) 369 | ). 370 | 371 | :- if(current_predicate(unix_domain_socket/1)). 372 | optional_unix_domain_socket(Socket) :- 373 | unix_domain_socket(Socket). 374 | :- else. 375 | optional_unix_domain_socket(_). 376 | :- endif. 377 | 378 | % Always bind only to localhost for security reasons 379 | % Delete the socket file in case it is already around so that the same name can be reused 380 | bind_socket(Server_Thread_ID, Unix_Domain_Socket_Path_And_File, Port, Socket, Client_Address) :- 381 | ( nonvar(Unix_Domain_Socket_Path_And_File) 382 | -> debug(mqi(protocol), "Using Unix domain socket name: ~w", [Unix_Domain_Socket_Path_And_File]), 383 | optional_unix_domain_socket(Socket), 384 | catch(delete_file(Unix_Domain_Socket_Path_And_File), error(_, _), true), 385 | tcp_bind(Socket, Unix_Domain_Socket_Path_And_File), 386 | Client_Address = Unix_Domain_Socket_Path_And_File 387 | ; ( tcp_socket(Socket), 388 | tcp_setopt(Socket, reuseaddr), 389 | tcp_bind(Socket, '127.0.0.1':Port), 390 | debug(mqi(protocol), "Using TCP/IP port: ~w", ['127.0.0.1':Port]), 391 | Client_Address = Port 392 | ) 393 | ), 394 | assert(mqi_thread(Server_Thread_ID, Unix_Domain_Socket_Path_And_File, Socket)). 395 | 396 | % Communicates the used port and password to the client via STDOUT so the client 397 | % language library can use them to connect 398 | send_client_startup_data(Write_Connection_Values, Stream, Unix_Domain_Socket_Path_And_File, Port, Password) :- 399 | ( Write_Connection_Values 400 | -> ( ( var(Unix_Domain_Socket_Path_And_File) 401 | -> format(Stream, "~d\n", [Port]) 402 | ; format(Stream, "~w\n", [Unix_Domain_Socket_Path_And_File]) 403 | ), 404 | format(Stream, "~w\n", [Password]), 405 | flush_output(Stream) 406 | ) 407 | ; true 408 | ). 409 | 410 | 411 | % Server thread worker predicate 412 | % Listen for connections and create a connection for each in its own communication thread 413 | % Uses tail recursion to ensure the stack doesn't grow 414 | server_thread(Server_Thread_ID, Socket, Address, Password, Connection_Count, Encoding, Query_Timeout, Exit_Main_On_Failure) :- 415 | debug(mqi(protocol), "Listening on address: ~w", [Address]), 416 | tcp_listen(Socket, Connection_Count), 417 | tcp_open_socket(Socket, AcceptFd, _), 418 | create_connection(Server_Thread_ID, AcceptFd, Password, Encoding, Query_Timeout, Exit_Main_On_Failure), 419 | server_thread(Server_Thread_ID, Socket, Address, Password, Connection_Count, Encoding, Query_Timeout, Exit_Main_On_Failure). 420 | 421 | 422 | % Wait for the next connection and create communication and goal threads to support it 423 | % Create known IDs for the threads so we can pass them along before the threads are created 424 | % First create the goal thread to avoid a race condition where the communication 425 | % thread tries to queue a goal before it is created 426 | create_connection(Server_Thread_ID, AcceptFd, Password, Encoding, Query_Timeout, Exit_Main_On_Failure) :- 427 | debug(mqi(protocol), "Waiting for client connection...", []), 428 | tcp_accept(AcceptFd, Socket, _Peer), 429 | debug(mqi(protocol), "Client connected", []), 430 | gensym('conn', Connection_Base), 431 | atomic_list_concat([Server_Thread_ID, "_", Connection_Base, '_comm'], Thread_Alias), 432 | atomic_list_concat([Server_Thread_ID, "_", Connection_Base, '_goal'], Goal_Alias), 433 | mutex_create(Goal_Alias, [alias(Goal_Alias)]), 434 | assert(mqi_worker_threads(Server_Thread_ID, Thread_Alias, Goal_Alias)), 435 | thread_create(goal_thread(Thread_Alias), 436 | _, 437 | [alias(Goal_Alias), at_exit(detach_if_expected(Goal_Alias))]), 438 | thread_create(communication_thread(Password, Socket, Encoding, Server_Thread_ID, Goal_Alias, Query_Timeout, Exit_Main_On_Failure), 439 | _, 440 | [alias(Thread_Alias), at_exit(detach_if_expected(Thread_Alias))]). 441 | 442 | 443 | % The worker predicate for the Goal thread. 444 | % Looks for a message from the connection thread, processes it, then recurses. 445 | % 446 | % Goals always run in the same thread in case the user is setting thread local information. 447 | % For each answer to the user's query (including an exception), the goal thread will queue a message 448 | % to the communication thread of the form result(Answer, Find_All), where Find_All == true if the user wants all answers at once 449 | % Tail recurse to avoid growing the stack 450 | goal_thread(Respond_To_Thread_ID) :- 451 | thread_self(Self_ID), 452 | throw_if_testing(Self_ID), 453 | thread_get_message(Self_ID, goal(Unexpanded_Goal, Binding_List, Query_Timeout, Find_All)), 454 | expand_goal(Unexpanded_Goal, Goal), 455 | debug(mqi(query), "Received Findall = ~w, Query_Timeout = ~w, binding list: ~w, unexpanded: ~w, goal: ~w", [Find_All, Query_Timeout, Binding_List, Unexpanded_Goal, Goal]), 456 | ( Find_All 457 | -> One_Answer_Goal = findall(Binding_List, @(user:Goal, user), Answers) 458 | ; One_Answer_Goal = ( findall( One_Answer, 459 | ( @(user:Goal, user), 460 | One_Answer = [Binding_List], 461 | send_next_result(Respond_To_Thread_ID, One_Answer, _, Find_All) 462 | ), 463 | Answers 464 | ), 465 | ( Answers == [] 466 | -> send_next_result(Respond_To_Thread_ID, [], _, Find_All) 467 | ; true 468 | ) 469 | ) 470 | ), 471 | Cancellable_Goal = run_cancellable_goal(Self_ID, One_Answer_Goal), 472 | ( Query_Timeout == -1 473 | -> catch(Cancellable_Goal, Top_Exception, true) 474 | ; catch(call_with_time_limit(Query_Timeout, Cancellable_Goal), Top_Exception, true) 475 | ), 476 | ( var(Top_Exception) 477 | -> ( Find_All 478 | -> send_next_result(Respond_To_Thread_ID, Answers, _, Find_All) 479 | ; send_next_result(Respond_To_Thread_ID, [], no_more_results, Find_All) 480 | ) 481 | ; send_next_result(Respond_To_Thread_ID, [], Top_Exception, true) 482 | ), 483 | goal_thread(Respond_To_Thread_ID). 484 | 485 | 486 | % Used only for testing unhandled exceptions outside of the "safe zone" 487 | throw_if_testing(Self_ID) :- 488 | ( thread_peek_message(Self_ID, testThrow(Test_Exception)) 489 | -> ( debug(mqi(query), "TESTING: Throwing test exception: ~w", [Test_Exception]), 490 | throw(Test_Exception) 491 | ) 492 | ; true 493 | ). 494 | 495 | 496 | % run_cancellable_goal handles the communication 497 | % to ensure the cancel exception from the communication thread 498 | % is injected at a place we are prepared to handle in the goal_thread 499 | % Before the goal is run, sets a fact to indicate we are in the "safe to cancel" 500 | % zone for the communication thread. 501 | % Then it doesn't exit this "safe to cancel" zone if the 502 | % communication thread is about to cancel 503 | run_cancellable_goal(Mutex_ID, Goal) :- 504 | thread_self(Self_ID), 505 | setup_call_cleanup( 506 | assert(safe_to_cancel(Self_ID), Assertion), 507 | Goal, 508 | with_mutex(Mutex_ID, erase(Assertion)) 509 | ). 510 | 511 | 512 | % Worker predicate for the communication thread. 513 | % Processes messages and sends goals to the goal thread. 514 | % Continues processing messages until communication_thread_listen() throws or ends with true/false 515 | % 516 | % Catches all exceptions from communication_thread_listen so that it can do an orderly shutdown of the goal 517 | % thread if there is a communication failure. 518 | % 519 | % True means user explicitly called close or there was an exception 520 | % only exit the main thread if there was an exception and we are supposed to Exit_Main_On_Failure 521 | % otherwise just exit the session 522 | communication_thread(Password, Socket, Encoding, Server_Thread_ID, Goal_Thread_ID, Query_Timeout, Exit_Main_On_Failure) :- 523 | thread_self(Self_ID), 524 | ( catch(communication_thread_listen(Password, Socket, Encoding, Server_Thread_ID, Goal_Thread_ID, Query_Timeout), 525 | error(Serve_Exception1, _0Serve_Exception2), 526 | true), 527 | debug(mqi(protocol), "Session finished. Communication thread exception: ~w", 528 | [error(Serve_Exception1, _0Serve_Exception2)]), 529 | abortSilentExit(Goal_Thread_ID, _), 530 | retractall(mqi_worker_threads(Server_Thread_ID, Self_ID, Goal_Thread_ID)) 531 | -> Halt = (nonvar(Serve_Exception1), Exit_Main_On_Failure) 532 | ; Halt = true 533 | ), 534 | ( Halt 535 | -> debug(mqi(protocol), "Ending session and halting Prolog server due to thread ~w: exception(~w)", 536 | [Self_ID, error(Serve_Exception1, _0Serve_Exception2)]), 537 | quit(_) 538 | ; debug(mqi(protocol), "Ending session ~w", [Self_ID]), 539 | catch(tcp_close_socket(Socket), error(_, _), true) 540 | ). 541 | 542 | 543 | % Open socket and begin processing the streams for a connection using the Encoding if the password matches 544 | % true: session ended 545 | % exception: communication failure or an internal failure (like a thread threw or shutdown unexpectedly) 546 | % false: halt 547 | communication_thread_listen(Password, Socket, Encoding, Server_Thread_ID, Goal_Thread_ID, Query_Timeout) :- 548 | tcp_open_socket(Socket, Read_Stream, Write_Stream), 549 | thread_self(Communication_Thread_ID), 550 | assert(mqi_socket(Server_Thread_ID, Communication_Thread_ID, Socket, Read_Stream, Write_Stream)), 551 | set_stream(Read_Stream, encoding(Encoding)), 552 | set_stream(Write_Stream, encoding(Encoding)), 553 | read_message(Read_Stream, Sent_Password), 554 | ( Password == Sent_Password 555 | -> ( debug(mqi(protocol), "Password matched.", []), 556 | thread_self(Self_ID), 557 | mqi_version(Major, Minor), 558 | reply(Write_Stream, true([[threads(Self_ID, Goal_Thread_ID), version(Major, Minor)]])) 559 | ) 560 | ; ( debug(mqi(protocol), "Password mismatch, failing. ~w", [Sent_Password]), 561 | reply_error(Write_Stream, password_mismatch), 562 | throw(password_mismatch) 563 | ) 564 | ), 565 | process_mqi_messages(Read_Stream, Write_Stream, Goal_Thread_ID, Query_Timeout), 566 | debug(mqi(protocol), "Session finished.", []). 567 | 568 | 569 | % process_mqi_messages implements the main interface to the Machine Query Interface. 570 | % Continuously reads a Machine Query Interface message from Read_Stream and writes a response to Write_Stream, 571 | % until the connection fails or a `quit` or `close` message is sent. 572 | % 573 | % Read_Stream and Write_Stream can be any valid stream using any encoding. 574 | % 575 | % Goal_Thread_ID must be the threadID of a thread started on the goal_thread predicate 576 | % 577 | % uses tail recursion to ensure the stack doesn't grow 578 | % 579 | % true: indicates we should terminate the session (clean termination) 580 | % false: indicates we should exit the process if running in embedded mode 581 | % exception: indicates we should terminate the session (communication failure termination) or 582 | % thread was asked to halt 583 | process_mqi_messages(Read_Stream, Write_Stream, Goal_Thread_ID, Query_Timeout) :- 584 | process_mqi_message(Read_Stream, Write_Stream, Goal_Thread_ID, Query_Timeout, Command), 585 | ( Command == close 586 | -> ( debug(mqi(protocol), "Command: close. Client closed the connection cleanly.", []), 587 | true 588 | ) 589 | ; ( Command == quit 590 | -> ( debug(mqi(protocol), "Command: quit.", []), 591 | false 592 | ) 593 | ; 594 | process_mqi_messages(Read_Stream, Write_Stream, Goal_Thread_ID, Query_Timeout) 595 | ) 596 | ). 597 | 598 | % process_mqi_message manages the protocol for the connection: receive message, parse it, process it. 599 | % - Reads a single message from Read_Stream. 600 | % - Processes it and issues a response on Write_Stream. 601 | % - The message will be unified with Command to allow the caller to handle it. 602 | % 603 | % Read_Stream and Write_Stream can be any valid stream using any encoding. 604 | % 605 | % True if the message understood. A response will always be sent. 606 | % False if the message was malformed. 607 | % Exceptions will be thrown by the underlying stream if there are communication failures writing to Write_Stream or the thread was asked to exit. 608 | % 609 | % state_* predicates manage the state transitions of the protocol 610 | % They only bubble up exceptions if there is a communication failure 611 | % 612 | % state_process_command will never return false 613 | % since errors should be sent to the client 614 | % It can throw if there are communication failures, though. 615 | process_mqi_message(Read_Stream, Write_Stream, Goal_Thread_ID, Query_Timeout, Command) :- 616 | debug(mqi(protocol), "Waiting for next message ...", []), 617 | ( state_receive_raw_message(Read_Stream, Message_String) 618 | -> ( state_parse_command(Write_Stream, Message_String, Command, Binding_List) 619 | -> state_process_command(Write_Stream, Goal_Thread_ID, Query_Timeout, Command, Binding_List) 620 | ; true 621 | ) 622 | ; false 623 | ). 624 | 625 | 626 | % state_receive_raw_message: receive a raw message, which is simply a string 627 | % true: valid message received 628 | % false: invalid message format 629 | % exception: communication failure OR thread asked to exit 630 | state_receive_raw_message(Read, Command_String) :- 631 | read_message(Read, Command_String), 632 | debug(mqi(protocol), "Valid message: ~w", [Command_String]). 633 | 634 | 635 | % state_parse_command: attempt to parse the message string into a valid command 636 | % 637 | % Use read_term_from_atom instead of read_term(stream) so that we don't hang 638 | % indefinitely if the caller didn't properly finish the term 639 | % parse in the context of module 'user' to properly bind operators, do term expansion, etc 640 | % 641 | % true: command could be parsed 642 | % false: command cannot be parsed. An error is sent to the client in this case 643 | % exception: communication failure on sending a reply 644 | state_parse_command(Write_Stream, Command_String, Parsed_Command, Binding_List) :- 645 | ( catch(read_term_from_atom(Command_String, Parsed_Command, [variable_names(Binding_List), module(user)]), Parse_Exception, true) 646 | -> ( var(Parse_Exception) 647 | -> debug(mqi(protocol), "Parse Success: ~w", [Parsed_Command]) 648 | ; ( reply_error(Write_Stream, Parse_Exception), 649 | fail 650 | ) 651 | ) 652 | ; ( reply_error(Write_Stream, error(couldNotParseCommand, _)), 653 | fail 654 | ) 655 | ). 656 | 657 | 658 | % state_process_command(): execute the requested Command 659 | % 660 | % First wait until we have removed all results from any previous query. 661 | % If query_in_progress(Goal_Thread_ID) exists then there is at least one 662 | % more result to drain, by definition. Because the predicate is 663 | % deleted by get_next_result in the communication thread when the last result is drained 664 | % 665 | % true: if the command itself succeeded, failed or threw an exception. 666 | % In that case, the outcome is sent to the client 667 | % exception: only communication or thread failures are allowed to bubble up 668 | % See mqi(Options) documentation 669 | state_process_command(Stream, Goal_Thread_ID, Query_Timeout, run(Goal, Timeout), Binding_List) :- 670 | !, 671 | debug(mqi(protocol), "Command: run/1. Timeout: ~w", [Timeout]), 672 | repeat_until_false(( 673 | query_in_progress(Goal_Thread_ID), 674 | debug(mqi(protocol), "Draining unretrieved result for ~w", [Goal_Thread_ID]), 675 | heartbeat_until_result(Goal_Thread_ID, Stream, Unused_Answer), 676 | debug(mqi(protocol), "Drained result for ~w", [Goal_Thread_ID]), 677 | debug(mqi(query), " Discarded answer: ~w", [Unused_Answer]) 678 | )), 679 | debug(mqi(protocol), "All previous results drained", []), 680 | send_goal_to_thread(Stream, Goal_Thread_ID, Query_Timeout, Timeout, Goal, Binding_List, true), 681 | heartbeat_until_result(Goal_Thread_ID, Stream, Answers), 682 | reply_with_result(Goal_Thread_ID, Stream, Answers). 683 | 684 | 685 | % See mqi(Options) documentation for documentation 686 | % See notes in run(Goal, Timeout) re: draining previous query 687 | state_process_command(Stream, Goal_Thread_ID, Query_Timeout, run_async(Goal, Timeout, Find_All), Binding_List) :- 688 | !, 689 | debug(mqi(protocol), "Command: run_async/1.", []), 690 | debug(mqi(query), " Goal: ~w", [Goal]), 691 | repeat_until_false(( 692 | query_in_progress(Goal_Thread_ID), 693 | debug(mqi(protocol), "Draining unretrieved result for ~w", [Goal_Thread_ID]), 694 | heartbeat_until_result(Goal_Thread_ID, Stream, Unused_Answer), 695 | debug(mqi(protocol), "Drained result for ~w", [Goal_Thread_ID]), 696 | debug(mqi(query), " Discarded answer: ~w", [Unused_Answer]) 697 | )), 698 | debug(mqi(protocol), "All previous results drained", []), 699 | send_goal_to_thread(Stream, Goal_Thread_ID, Query_Timeout, Timeout, Goal, Binding_List, Find_All), 700 | reply(Stream, true([[]])). 701 | 702 | 703 | % See mqi(Options) documentation for documentation 704 | state_process_command(Stream, Goal_Thread_ID, _, async_result(Timeout), _) :- 705 | !, 706 | debug(mqi(protocol), "Command: async_result, timeout: ~w.", [Timeout]), 707 | ( once((var(Timeout) ; Timeout == -1)) 708 | -> Options = [] 709 | ; Options = [timeout(Timeout)] 710 | ), 711 | ( query_in_progress(Goal_Thread_ID) 712 | -> ( ( debug(mqi(protocol), "Pending query results exist for ~w", [Goal_Thread_ID]), 713 | get_next_result(Goal_Thread_ID, Stream, Options, Result) 714 | ) 715 | -> reply_with_result(Goal_Thread_ID, Stream, Result) 716 | ; reply_error(Stream, result_not_available) 717 | ) 718 | ; ( debug(mqi(protocol), "No pending query results for ~w", [Goal_Thread_ID]), 719 | reply_error(Stream, no_query) 720 | ) 721 | ). 722 | 723 | 724 | % See mqi(Options) documentation for documentation 725 | % To ensure the goal thread is in a place it is safe to cancel, 726 | % we lock a mutex first that the goal thread checks before exiting 727 | % the "safe to cancel" zone. 728 | % It is not in the safe zone: it either finished 729 | % or was never running. 730 | state_process_command(Stream, Goal_Thread_ID, _, cancel_async, _) :- 731 | !, 732 | debug(mqi(protocol), "Command: cancel_async/0.", []), 733 | with_mutex(Goal_Thread_ID, ( 734 | ( safe_to_cancel(Goal_Thread_ID) 735 | -> ( thread_signal(Goal_Thread_ID, throw(cancel_goal)), 736 | reply(Stream, true([[]])) 737 | ) 738 | ; ( query_in_progress(Goal_Thread_ID) 739 | -> ( debug(mqi(protocol), "Pending query results exist for ~w", [Goal_Thread_ID]), 740 | reply(Stream, true([[]])) 741 | ) 742 | ; ( debug(mqi(protocol), "No pending query results for ~w", [Goal_Thread_ID]), 743 | reply_error(Stream, no_query) 744 | ) 745 | ) 746 | ) 747 | )). 748 | 749 | 750 | % Used for testing how the system behaves when the goal thread is killed unexpectedly 751 | % Needs to run a bogus command `run(true, -1)` to 752 | % get the goal thread to process the exception 753 | state_process_command(Stream, Goal_Thread_ID, Query_Timeout, testThrowGoalThread(Test_Exception), Binding_List) :- 754 | !, 755 | debug(mqi(protocol), "TESTING: requested goal thread unhandled exception", []), 756 | thread_send_message(Goal_Thread_ID, testThrow(Test_Exception)), 757 | state_process_command(Stream, Goal_Thread_ID, Query_Timeout, run(true, -1), Binding_List). 758 | 759 | 760 | state_process_command(Stream, _, _, close, _) :- 761 | !, 762 | reply(Stream, true([[]])). 763 | 764 | 765 | state_process_command(Stream, _, _, quit, _) :- 766 | !, 767 | reply(Stream, true([[]])). 768 | 769 | 770 | % Send an exception if the command is not known 771 | state_process_command(Stream, _, _, Command, _) :- 772 | debug(mqi(protocol), "Unknown command ~w", [Command]), 773 | reply_error(Stream, unknownCommand). 774 | 775 | 776 | % Wait for a result (and put in Answers) from the goal thread, but send a heartbeat message 777 | % every so often until it arrives to detect if the socket is broken. 778 | % Throws if If the heartbeat failed which will 779 | % and then shutdown the communication thread 780 | % Tail recurse to not grow the stack 781 | heartbeat_until_result(Goal_Thread_ID, Stream, Answers) :- 782 | ( get_next_result(Goal_Thread_ID, Stream, [timeout(2)], Answers) 783 | -> debug(mqi(query), "Received answer from goal thread: ~w", [Answers]) 784 | ; ( debug(mqi(protocol), "heartbeat...", []), 785 | write_heartbeat(Stream), 786 | heartbeat_until_result(Goal_Thread_ID, Stream, Answers) 787 | ) 788 | ). 789 | 790 | 791 | % True if write succeeded, otherwise throws as that 792 | % indicates that heartbeat failed because the other 793 | % end of the pipe terminated 794 | write_heartbeat(Stream) :- 795 | put_char(Stream, '.'), 796 | flush_output(Stream). 797 | 798 | 799 | % Send a goal to the goal thread in its queue 800 | % 801 | % Remember that we are now running a query using assert. 802 | % This will be retracted once all the answers have been drained. 803 | % 804 | % If Goal_Thread_ID died, thread_send_message throws and, if we don't respond, 805 | % the client could hang so catch and give them a good message before propagating 806 | % the exception 807 | send_goal_to_thread(Stream, Goal_Thread_ID, Default_Timeout, Timeout, Goal, Binding_List, Find_All) :- 808 | ( var(Timeout) 809 | -> Timeout = Default_Timeout 810 | ; true 811 | ), 812 | ( var(Binding_List) 813 | -> Binding_List = [] 814 | ; true 815 | ), 816 | debug(mqi(query), "Sending to goal thread with timeout = ~w: ~w", [Timeout, Goal]), 817 | assert(query_in_progress(Goal_Thread_ID)), 818 | catch(thread_send_message(Goal_Thread_ID, goal(Goal, Binding_List, Timeout, Find_All)), Send_Message_Exception, true), 819 | ( var(Send_Message_Exception) 820 | -> true 821 | ; ( reply_error(Stream, connection_failed), 822 | throw(Send_Message_Exception) 823 | ) 824 | ). 825 | 826 | 827 | % Send a result from the goal thread to the communication thread in its queue 828 | send_next_result(Respond_To_Thread_ID, Answer, Exception_In_Goal, Find_All) :- 829 | ( var(Exception_In_Goal) 830 | -> ( ( debug(mqi(query), "Sending result of goal to communication thread, Result: ~w", [Answer]), 831 | Answer == [] 832 | ) 833 | -> thread_send_message(Respond_To_Thread_ID, result(false, Find_All)) 834 | ; handle_constraints(Answer, Final_Answer), 835 | thread_send_message(Respond_To_Thread_ID, result(true(Final_Answer), Find_All)) 836 | ) 837 | ; ( debug(mqi(query), "Sending result of goal to communication thread, Exception: ~w", [Exception_In_Goal]), 838 | thread_send_message(Respond_To_Thread_ID, result(error(Exception_In_Goal), Find_All)) 839 | ) 840 | ). 841 | 842 | 843 | handle_constraints(Answer, Final_Answer) :- 844 | ( term_attvars(Answer, []) 845 | -> Final_Answer = Answer 846 | ; findall( Single_Answer_With_Attributes, 847 | ( member(Single_Answer, Answer), 848 | copy_term(Single_Answer, Single_Answer_Copy, Attributes), 849 | append(['$residuals' = Attributes], Single_Answer_Copy, Single_Answer_With_Attributes) 850 | ), 851 | Final_Answer 852 | ), 853 | debug(mqi(query), "Constraints detected, converted: ~w to ~w", [Answer, Final_Answer]) 854 | ). 855 | 856 | 857 | % Gets the next result from the goal thread in the communication thread queue, 858 | % and retracts query_in_progress/1 when the last result has been sent. 859 | % Find_All == true only returns one message, so delete query_in_progress 860 | % no matter what it is 861 | % \+ Find_All: There may be more than one result. The first one we hit with any exception 862 | % (note that no_more_results is also returned as an exception) means we are done 863 | get_next_result(Goal_Thread_ID, Stream, Options, Answers) :- 864 | ( thread_property(Goal_Thread_ID, status(running)) 865 | -> true 866 | ; ( reply_error(Stream, connection_failed), 867 | throw(connection_failed) 868 | ) 869 | ), 870 | thread_self(Self_ID), 871 | thread_get_message(Self_ID, result(Answers, Find_All), Options), 872 | ( Find_All 873 | -> ( debug(mqi(protocol), "Query completed and answers drained for findall ~w", [Goal_Thread_ID]), 874 | retractall(query_in_progress(Goal_Thread_ID)) 875 | ) 876 | ; ( Answers = error(_) 877 | -> ( debug(mqi(protocol), "Query completed and answers drained for non-findall ~w", [Goal_Thread_ID]), 878 | retractall(query_in_progress(Goal_Thread_ID)) 879 | ) 880 | ; true 881 | ) 882 | ). 883 | 884 | 885 | % reply_with_result predicates are used to consistently return 886 | % answers for a query from either run() or run_async() 887 | reply_with_result(_, Stream, error(Error)) :- 888 | !, 889 | reply_error(Stream, Error). 890 | 891 | % Gracefully handle exceptions that can occur during conversion to JSON 892 | reply_with_result(_, Stream, Result) :- 893 | !, 894 | catch(reply(Stream, Result), error(Exception, _), reply_with_result(_, Stream, error(Exception))). 895 | 896 | 897 | % Reply with a normal term 898 | % Convert term to an actual JSON string 899 | reply(Stream, Term) :- 900 | debug(mqi(query), "Responding with Term: ~w", [Term]), 901 | term_to_json_string(Term, Json_String), 902 | write_message(Stream, Json_String). 903 | 904 | 905 | % Special handling for exceptions since they can have parts that are not 906 | % "serializable". Ensures they they are always returned in an exception/1 term 907 | reply_error(Stream, Error_Term) :- 908 | debug(mqi(query), "Responding with exception: ~w", [Error_Term]), 909 | ( error(Error_Value, _) = Error_Term 910 | -> Response = exception(Error_Value) 911 | ; ( atom(Error_Term) 912 | -> 913 | Response = exception(Error_Term) 914 | ; ( compound_name_arity(Error_Term, Name, _), 915 | Response = exception(Name) 916 | ) 917 | ) 918 | ), 919 | reply(Stream, Response). 920 | 921 | 922 | % Send and receive messages are simply strings preceded by their length + ".\n" 923 | % i.e. ".\n" 924 | % The desired encoding must be set on the Stream before calling this predicate 925 | 926 | 927 | % Writes the next message. 928 | % Throws if there is an unexpected exception 929 | write_message(Stream, String) :- 930 | write_string_length(Stream, String), 931 | write(Stream, String), 932 | flush_output(Stream). 933 | 934 | 935 | % Reads the next message. 936 | % Throws if there is an unexpected exception or thread has been requested to quit 937 | % the length passed must match the actual number of bytes in the stream 938 | % in whatever encoding is being used 939 | read_message(Stream, String) :- 940 | read_string_length(Stream, Length), 941 | stream_property(Stream, encoding(Encoding)), 942 | setup_call_cleanup( 943 | stream_range_open(Stream, Tmp, [size(Length)]), 944 | ( set_stream(Tmp, encoding(Encoding)), 945 | read_string(Tmp, _, String) 946 | ), 947 | close(Tmp)). 948 | 949 | 950 | % Terminate with '.\n' so we know that's the end of the count 951 | write_string_length(Stream, String) :- 952 | stream_property(Stream, encoding(Encoding)), 953 | string_encoding_length(String, Encoding, Length), 954 | format(Stream, "~d.\n", [Length]). 955 | 956 | 957 | % Note: read_term requires ".\n" after the length 958 | % ... but does not consume the "\n" 959 | read_string_length(Stream, Length) :- 960 | read_term(Stream, Length, []), 961 | get_char(Stream, _). 962 | 963 | 964 | % converts a string to Codes using Encoding 965 | string_encoding_length(String, Encoding, Length) :- 966 | setup_call_cleanup( 967 | open_null_stream(Out), 968 | ( set_stream(Out, encoding(Encoding)), 969 | write(Out, String), 970 | byte_count(Out, Length) 971 | ), 972 | close(Out)). 973 | 974 | 975 | % Convert Prolog Term to a Prolog JSON term 976 | % Add a final \n so that using netcat to debug works well 977 | term_to_json_string(Term, Json_String) :- 978 | term_to_json(Term, Json), 979 | with_output_to(string(Json_String), 980 | ( current_output(Stream), 981 | json_write(Stream, Json), 982 | put(Stream, '\n') 983 | )). 984 | 985 | 986 | % Execute the goal as once() without binding any variables 987 | % and keep executing it until it returns false (or throws) 988 | repeat_until_false(Goal) :- 989 | (\+ (\+ Goal)), !, repeat_until_false(Goal). 990 | repeat_until_false(_). 991 | 992 | 993 | % Used to kill a thread in an "expected" way so it doesn't leave around traces in thread_property/2 afterwards 994 | % 995 | % If the thread is alive OR it was already aborted (expected cases) then attempt to join 996 | % the thread so that no warnings are sent to the console. Other cases leave the thread for debugging. 997 | % There are some fringe cases (like calling external code) 998 | % where the call might not return for a long time. Do a timeout for those cases. 999 | abortSilentExit(Thread_ID, Exception) :- 1000 | catch(thread_signal(Thread_ID, abort), error(Exception, _), true), 1001 | debug(mqi(protocol), "Attempting to abort thread: ~w. thread_signal_exception: ~w", [Thread_ID, Exception]). 1002 | 1003 | 1004 | % Detach a thread that exits with true or false so that it doesn't leave around a record in thread_property/2 afterwards 1005 | % Don't detach a thread if it exits because of an exception so we can debug using thread_property/2 afterwards 1006 | % 1007 | % However, `abort` is an expected exception but detaching a thread that aborts will leave an unwanted 1008 | % thread_property/2 record *and* print a message to the console. To work around this, 1009 | % the goal thread is always aborted by the communication thread using abortSilentExit. 1010 | detach_if_expected(Thread_ID) :- 1011 | thread_property(Thread_ID, status(Status)), 1012 | debug(mqi(protocol), "Thread ~w exited with status ~w", [Thread_ID, Status]), 1013 | ( once((Status = true ; Status = false)) 1014 | -> ( debug(mqi(protocol), "Expected thread status, detaching thread ~w", [Thread_ID]), 1015 | thread_detach(Thread_ID) 1016 | ) 1017 | ; true 1018 | ). 1019 | 1020 | 1021 | write_output_to_file(File) :- 1022 | debug(mqi(protocol), "Writing all STDOUT and STDERR to file:~w", [File]), 1023 | open(File, write, Stream, [buffer(false)]), 1024 | set_prolog_IO(user_input, Stream, Stream). 1025 | 1026 | 1027 | % Creates a Unix Domain Socket file in a secured directory. 1028 | % Throws if the directory or file cannot be created in /tmp for any reason 1029 | % Requirements for this file are: 1030 | % - The Prolog process will attempt to create and, if Prolog exits cleanly, 1031 | % delete this file when the server closes. This means the directory 1032 | % must have the appropriate permissions to allow the Prolog process 1033 | % to do so. 1034 | % - For security reasons, the filename should not be predictable and the 1035 | % directory it is contained in should have permissions set so that files 1036 | % created are only accessible to the current user. 1037 | % - The path must be below 92 *bytes* long (including null terminator) to 1038 | % be portable according to the Linux documentation 1039 | % 1040 | % tmp_file finds the right /tmp directory, even on Mac OS, so the path is small 1041 | % Set 700 (rwx------) permission so it is only accessible by current user 1042 | % Create a secure tmp file in the new directory 1043 | % {set,current}_prolog_flag is copied to a thread, so no need to use a mutex. 1044 | % Close the stream so sockets can use it 1045 | unix_domain_socket_path(Created_Directory, File_Path) :- 1046 | tmp_file(udsock, Created_Directory), 1047 | make_directory(Created_Directory), 1048 | catch( chmod(Created_Directory, urwx), 1049 | Exception, 1050 | ( catch(delete_directory(Created_Directory), error(_, _), true), 1051 | throw(Exception) 1052 | ) 1053 | ), 1054 | setup_call_cleanup( ( current_prolog_flag(tmp_dir, Save_Tmp_Dir), 1055 | set_prolog_flag(tmp_dir, Created_Directory) 1056 | ), 1057 | tmp_file_stream(File_Path, Stream, []), 1058 | set_prolog_flag(tmp_dir, Save_Tmp_Dir) 1059 | ), 1060 | close(Stream). 1061 | -------------------------------------------------------------------------------- /mqi_app.pl: -------------------------------------------------------------------------------- 1 | /* Part of SWI-Prolog 2 | 3 | Author: Jan Wielemaker 4 | E-mail: jan@swi-prolog.org 5 | WWW: http://www.swi-prolog.org 6 | Copyright (c) 2023, SWI-Prolog Solutions b.v. 7 | All rights reserved. 8 | 9 | Redistribution and use in source and binary forms, with or without 10 | modification, are permitted provided that the following conditions 11 | are met: 12 | 13 | 1. Redistributions of source code must retain the above copyright 14 | notice, this list of conditions and the following disclaimer. 15 | 16 | 2. Redistributions in binary form must reproduce the above copyright 17 | notice, this list of conditions and the following disclaimer in 18 | the documentation and/or other materials provided with the 19 | distribution. 20 | 21 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 22 | "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 23 | LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS 24 | FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE 25 | COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, 26 | INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, 27 | BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 28 | LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 29 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT 30 | LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN 31 | ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE 32 | POSSIBILITY OF SUCH DAMAGE. 33 | */ 34 | 35 | :- use_module(library(mqi)). 36 | 37 | :- initialization(mqi_start, main). 38 | 39 | -------------------------------------------------------------------------------- /mqi_overview.md: -------------------------------------------------------------------------------- 1 | # Machine Query Interface Overview {#mqi-overview} 2 | The SWI Prolog Machine Query Interface ('MQI') is designed to enable embedding SWI Prolog into just about any programming language (Python, Go, C#, etc) in a straightforward way. It is designed for scenarios that need to use SWI Prolog as a local implementation detail of another language. Think of it as running SWI Prolog "like a library". It can support any programming language that can launch processes, read their STDOUT pipe, and send and receive JSON over TCP/IP. A Python 3 library is included as a part of SWI Prolog, see [Installation Steps for Python](#mqi-python-installation). 3 | 4 | Key features of the MQI: 5 | 6 | - Simulates the familiar Prolog "top level" (i.e. the interactive prompt you get when running Prolog: "?-"). 7 | - Always runs queries from a connection on a consistent, single thread for that connection. The application itself can still be multi-threaded by running queries that use the multi-threading Prolog predicates or by opening more than one connection. 8 | - Runs as a separate dedicated *local* Prolog process to simplify integration (vs. using the C-level SWI Prolog interface). The process is launched and managed by a specific running client (e.g. Python or other language) program. 9 | - Communicates using sockets and [JSON](https://www.json.org/) encoded as UTF-8 to allow it to work on any platform supported by SWI Prolog. For security reasons, only listens on TCP/IP localhost or Unix Domain Sockets and requires (or generates depending on the options) a password to open a connection. 10 | - Has a lightweight text-based message format with only 6 commands: run synchronous query, run asynchronous query, retrieve asynchronous results, cancel asynchronous query, close connection and terminate the session. 11 | - Communicates answers using [JSON](https://www.json.org/), a well-known data format supported by most languages natively or with generally available libraries. 12 | 13 | 14 | The server can be used in two different modes: 15 | 16 | - *Embedded mode*: This is the main use case for the MQI. The user uses a library (just like any other library in their language of choice). That library integrates the MQI by launching the SWI Prolog process, connecting to it, and wrapping the MQI protocol with a language specific interface. 17 | - *Standalone mode*: The user still uses a library as above, but launches SWI Prolog independently of the language. The client language library connects to that process. This allows the user to see, interact with, and debug the Prolog process while the library interacts with it. 18 | 19 | Note that the MQI is related to the [Pengines library](https://www.swi-prolog.org/pldoc/man?section=pengine-references), but where the Pengines library is focused on a client/server, multi-tenet, sandboxed environment, the MQI is local, single tenet and unconstrained. Thus, when the requirement is to embed Prolog within another programming language "like a library", it can be a good solution for exposing the full power of Prolog with low integration overhead. 20 | 21 | ## Installation Steps for Python {#mqi-python-installation} 22 | A Python 3.x library that integrates Python with SWI Prolog using the Machine Query Interface is included within the `libs` directory of the SWI Prolog installation. It is also available using =|pip install swiplserver|=. See the [Python swiplserver library documentation](https://www.swi-prolog.org/packages/mqi/prologmqi.html) for more information on how to use and install it from either location. 23 | 24 | ## Installation Steps for Other Languages {#mqi-language-installation} 25 | 26 | In general, to use the Machine Query Interface with any programming language: 27 | 28 | 1. Install SWI Prolog itself on the machine the application will run on. 29 | 2. Ensure that the system path includes a path to the `swipl` executable from that installation. 30 | 3. Check if your SWI Prolog version includes the MQI by running 31 | `swipl mqi --help` 32 | 4. Make sure the application (really the user that launches the application) has permission to launch the SWI Prolog process. Unless your system is unusually locked down, this should be allowed by default. If not, you'll need to set the appropriate permissions to allow this. 33 | 5. Install (or write!) the library you'll be using to access the MQI in your language of choice. 34 | 35 | ## Prolog Language Differences from the Top Level {#mqi-toplevel-differences} 36 | 37 | The Machine Query Interface is designed to act like using the ["top level"](https://www.swi-prolog.org/pldoc/man?section=quickstart) prompt of SWI Prolog itself (i.e. the "?-" prompt). If you've built the Prolog part of your application by loading code, running it and debugging it using the normal SWI Prolog top level, integrating it with your native language should be straightforward: simply run the commands you'd normally run on the top level, but now run them using the query APIs provided by the library built for your target language. Those APIs will allow you to send the exact same text to Prolog and they should execute the same way. Here's an example using the Python `swiplserver` library: 38 | 39 | ~~~ 40 | % Prolog Top Level 41 | ?- member(X, [first, second, third]). 42 | X = first ; 43 | X = second ; 44 | X = third. 45 | ~~~ 46 | ~~~ 47 | # Python using the swiplserver library 48 | from swiplserver import PrologMQI, PrologThread 49 | 50 | with PrologMQI() as mqi: 51 | with mqi.create_thread() as prolog_thread: 52 | result = prolog_thread.query("member(X, [first, second, third]).") 53 | print(result) 54 | 55 | first 56 | second 57 | third 58 | ~~~ 59 | 60 | While the query functionality of the MQI does run on a thread, it will always be the *same* thread, and, if you use a single connection, it will only allow queries to be run one at a time, just like the top level. Of course, the queries you send can launch threads, just like the top level, so you are not limited to a single threaded application. There are a few differences from the top level, however: 61 | 62 | - Normally, the SWI Prolog top level runs all user code in the context of a built-in module called "user", as does the MQI. However, the top level allows this to be changed using the module/1 predicate. This predicate has no effect when sent to the MQI. 63 | - Predefined streams like `user_input` are initially bound to the standard operating system I/O streams (like STDIN) and, since the Prolog process is running invisibly, will obviously not work as expected. Those streams can be changed, however, by issuing commands using system predicates as defined in the SWI Prolog documentation. 64 | - Every connection to the MQI runs in its own thread, so opening two connections from an application means you are running multithreaded code. 65 | - The SWI Prolog top level does special handling to make residual [attributes on variables](https://www.swi-prolog.org/pldoc/man?section=attvar) print out in a human-friendly way. MQI instead returns any residuals in a special variable called `$residuals` that is added to the results. 66 | - Some Prolog extensions do not provide the full answer to a query by means of the variable bindings. The top level does extra work to find where the answers are stored and print them out. All of these extensions have an interface to get to this data as a Prolog term and users of MQI need to do this work themselves. Examples include: [Constraint Handling Rules (CHR)](https://www.swi-prolog.org/pldoc/man?section=chr) stores constraints in global variables, [Well Founded Semantics](https://www.swi-prolog.org/pldoc/man?section=wfs-toplevel) has the notion of delayed, which is translated into a program that carries the inconsistency if there are conflicting negations, [s(CASP)](https://www.swi-prolog.org/pack/list?p=scasp) has a model and justification. 67 | 68 | A basic rule to remember is: any predicates designed to interact with or change the default behavior of the top level itself probably won't have any effect. 69 | 70 | 71 | ## Embedded Mode: Integrating the Machine Query Interface Into a New Programming Language {#mqi-embedded-mode} 72 | The most common way to use the Machine Query Interface is to find a library that wraps and exposes it as a native part of another programming language such as the [Python =|swiplserver|= library](#mqi-python-installation). This section describes how to build one if there isn't yet a library for your language. To do this, you'll need to familiarize yourself with the MQI protocol as described in the `mqi_start/1` documentation. However, to give an idea of the scope of work required, below is a typical interaction done (invisibly to the user) in the implementation of any programming language library: 73 | 74 | 75 | 1. Launch the SWI Prolog process using (along with any other options the user requests): =|swipl mqi --write_connection_values=true|=. To work, the `swipl` Prolog executable will need to be on the path or the path needs to be specified in the command. This launches SWI Prolog, starts the MQI, and writes the chosen port and password to STDOUT. This way of launching invokes the mqi_start/0 predicate that turns off the `int` (i.e. Interrupt/SIGINT) signal to Prolog. This is because some languages (such as Python) use that signal during debugging and it would be otherwise passed to the client Prolog process and switch it into the debugger. See the mqi_start/0 predicate for more information on other command line options. 76 | 2. Read the SWI Prolog STDOUT to retrieve the TCP/IP port and password. They are sent in that order, delimited by '\n'. 77 | 78 | ~~~ 79 | $ swipl mqi --write_connection_values=true 80 | 54501 81 | 185786669688147744015809740744888120144 82 | ~~~ 83 | 84 | Now the server is started. To create a connection: 85 | 86 | 3. Use the language's TCP/IP sockets library to open a socket on the specified port of localhost and send the password as a message. Messages to and from the MQI are in the form =|.\n.\n |= where `stringByteLength` includes the =|.\n|= from the string. For example: =|7.\nhello.\n|= More information on the [message format](#mqi-message-format) is below. 87 | 4. Listen on the socket for a response message of `true([[threads(Comm_Thread_ID, Goal_Thread_ID), version(Major, Minor)]])` (which will be in JSON form) indicating successful creation of the connection. `Comm_Thread_ID` and `Goal_Thread_ID` are the internal Prolog IDs of the two threads that are used for the connection. They are sent solely for monitoring and debugging purposes. `version` was introduced in version 1.0 of the protocol to allow for detecting the protocol version and should be checked to ensure the protocol version is supported by your library. See `mqi_version/2` for more information and a version history. 88 | 89 | We can try all of this using the Unix tool `nc` (netcat) (also available for Windows) to interactively connect to the MQI. In `nc` hitting `enter` sends =|\n|= which is what the message format requires. The server responses are show indented inline. 90 | 91 | We'll use the port and password that were sent to STDOUT above: 92 | ~~~ 93 | $ nc 127.0.0.1 54501 94 | 41. 95 | 185786669688147744015809740744888120144. 96 | 173. 97 | { 98 | "args": [ 99 | [ 100 | [ 101 | { 102 | "args": ["mqi1_conn2_comm", "mqi1_conn2_goal" ], 103 | "functor":"threads" 104 | }, 105 | { 106 | "args": ["1", "0" ], 107 | "functor":"version" 108 | } 109 | ] 110 | ] 111 | ], 112 | "functor":"true" 113 | } 114 | 115 | ~~~ 116 | 117 | Now the connection is established. To run queries and shutdown: 118 | 119 | 5. Any of the messages described in the [Machine Query Interface messages documentation](#mqi-messages) can now be sent to run queries and retrieve their answers. For example, send the message `run(atom(a), -1)` to run the synchronous query `atom(a)` with no timeout and wait for the response message. It will be `true([[]])` (in JSON form). 120 | 6. Shutting down the connection is accomplished by sending the message `close`, waiting for the response message of `true([[]])` (in JSON form), and then closing the socket using the socket API of the language. If the socket is closed (or fails) before the `close` message is sent, the default behavior of the MQI is to exit the SWI Prolog process to avoid leaving the process around. This is to support scenarios where the user is running and halting their language debugger without cleanly exiting. 121 | 7. Shutting down the launched SWI Prolog process is accomplished by sending the `quit` message and waiting for the response message of `true([[]])` (in JSON form). This will cause an orderly shutdown and exit of the process. 122 | 123 | Continuing with the `nc` session (the `quit` message isn't shown since the `close` message closes the connection): 124 | ~~~ 125 | 18. 126 | run(atom(a), -1). 127 | 39. 128 | {"args": [ [ [] ] ], "functor":"true"} 129 | 7. 130 | close. 131 | 39. 132 | {"args": [ [ [] ] ], "functor":"true"} 133 | ~~~ 134 | 135 | Note that Unix Domain Sockets can be used instead of a TCP/IP port. How 136 | to do this is described with mqi_start/1. 137 | 138 | Here's the same example running in the R language. Note that this is *not* an example of how to use the MQI from R, it just shows the first code a developer would write as they begin to build a nice library to connect R to Prolog using the MQI: 139 | ~~~ 140 | # Server run with: swipl mqi.pl --port=40001 --password=123 141 | # R Source 142 | print("# Establish connection") 143 | 144 | sck = make.socket('localhost', 40001) 145 | 146 | print("# Send password") 147 | 148 | write.socket(sck, '5.\n') # message length 149 | 150 | write.socket(sck, '123.\n') # password 151 | 152 | print(read.socket(sck)) 153 | 154 | print("# Run query") 155 | 156 | query = 'run(member(X, [1, 2, 3]), -1).\n' 157 | 158 | write.socket(sck, paste(nchar(query), '.\n', sep='')) # message length 159 | 160 | write.socket(sck, query) # query 161 | 162 | print(read.socket(sck)) 163 | 164 | print("# Close session") 165 | 166 | close.socket(sck) 167 | ~~~ 168 | And here's the output: 169 | ~~~ 170 | [1] "# Establish connection" 171 | 172 | [1] "# Send password" 173 | 174 | [1] "172.\n{\n "args": [\n [\n [\n\t{\n\t "args": ["mqi1_conn1_comm", "mqi1_conn1_goal" ],\n\t "functor":"threads"\n\t}\n ]\n ]\n ],\n "functor":"true"\n}" 175 | 176 | [1] "# Run query" 177 | 178 | [1] "188.\n{\n "args": [\n [\n [ {"args": ["X", 1 ], "functor":"="} ],\n [ {"args": ["X", 2 ], "functor":"="} ],\n [ {"args": ["X", 3 ], "functor":"="} ]\n ]\n ],\n "functor":"true"\n}" 179 | 180 | [1] "# Close session" 181 | ~~~ 182 | 183 | Other notes about creating a new library to communicate with the MQI: 184 | - Where appropriate, use similar names and approaches to the [Python library](https://github.com/SWI-Prolog/packages-mqi/tree/master/python) when designing your language library. This will give familiarity and faster learning for users that use more than one language. 185 | - Use the `debug/1` predicate described in the `mqi_start/1` documentation to turn on debug tracing. It can really speed up debugging. 186 | - Read the STDOUT and STDERR output of the SWI Prolog process and output them to the debugging console of the native language to help users debug their Prolog application. 187 | 188 | ## Standalone Mode: Debugging Prolog Code Used in an Application {#mqi-standalone-mode} 189 | When using the Machine Query Interface from another language, debugging the Prolog code itself can often be done by viewing traces from the Prolog native `writeln/1` or `debug/3` predicates. Their output will be shown in the debugger of the native language used. Sometimes an issue surfaces deep in an application. When this happens, running the application in the native language while setting breakpoints and viewing traces in Prolog itself is often the best debugging approach. Standalone mode is designed for this scenario. 190 | 191 | As the MQI is a multithreaded application, debugging the running code requires using the multithreaded debugging features of SWI Prolog as described in the section on ["Debugging Threads"](https://www.swi-prolog.org/pldoc/man?section=threaddebug) in the SWI Prolog documentation. A typical flow for Standalone Mode is: 192 | 193 | 1. Launch SWI Prolog and call the `mqi_start/1` predicate specifying a port and password. Use the `tdebug/0` predicate to set all threads to debugging mode like this: `tdebug, mqi_start([port(4242), password(debugnow)])`. 194 | 2. Set the port and password in the initialization API in the native language being used. 195 | 3. Launch the application and go through the steps to reproduce the issue. 196 | 197 | In Python this would look like: 198 | ~~~ 199 | % From the SWI Prolog top level 200 | ?- tdebug, mqi_start([port(4242), password(debugnow)]). 201 | % The graphical front-end will be used for subsequent tracing 202 | true. 203 | ~~~ 204 | ~~~ 205 | # Python using the swiplserver library {#mqi-library} 206 | from swiplserver import PrologMQI, PrologThread 207 | 208 | with PrologMQI(launch_mqi=False, port=4242, password="debugnow") as mqi: 209 | with mqi.create_thread() as prolog_thread: 210 | # Your code to be debugged here 211 | ~~~ 212 | 213 | At this point, all of the multi-threaded debugging tools in SWI Prolog are available for debugging the problem. If the issue is an unexpected exception, the exception debugging features of SWI Prolog can be used to break on the exception and examine the state of the application. If it is a logic error, breakpoints can be set to halt at the point where the problem appears, etc. 214 | 215 | Note that, while using an MQI library to access Prolog will normally end and restart the process between runs of the code, running the server in standalone mode doesn't clear state between launches of the application. You'll either need to relaunch between runs or build your application so that it does the initialization at startup. 216 | 217 | ## Machine Query Interface Messages {#mqi-messages} 218 | The messages the Machine Query Interface responds to are described below. A few things are true for all of them: 219 | 220 | - Every connection is in its own separate thread. Opening more than one connection means the code is running concurrently. 221 | - Closing the socket without sending `close` and waiting for a response will halt the process if running in ["Embedded Mode"](#mqi-embedded-mode). This is so that stopping a debugger doesn't leave the process orphaned. 222 | - All messages are request/response messages. After sending, there will be exactly one response from the MQI. 223 | - Timeout in all of the commands is in seconds. Sending a variable (e.g. `_`) will use the default timeout passed to the initial `mqi_start/1` predicate and `-1` means no timeout. 224 | - All queries are run in the default module context of `user`. `module/1` has no effect. 225 | 226 | ### Machine Query Interface Message Format {#mqi-message-format} 227 | Every Machine Query Interface message is a single valid Prolog term. Those that run queries have an argument which represents the query as a single term. To run several goals at once use `(goal1, goal2, ...)` as the goal term. 228 | 229 | The format of sent and received messages is identical (`\n` stands for the ASCII newline character which is a single byte): 230 | ~~~ 231 | .\n.\n. 232 | ~~~ 233 | For example, to send `hello` as a message you would send this: 234 | ~~~ 235 | 7.\nhello.\n 236 | ~~~ 237 | - =||= is the number of bytes of the string to follow (including the =|.\n|=), in human readable numbers, such as `15` for a 15 byte string. It must be followed by =|.\n|=. 238 | - =||= is the actual message string being sent, such as =|run(atom(a), -1).\n|=. It must always end with =|.\n|=. The character encoding used to decode and encode the string is UTF-8. 239 | 240 | > Important: The very first version of MQI (version 0.0) had a bug that required messages sent to (but not received from) MQI to use the count of Unicode code points (*not* bytes). This was fixed to properly require byte count in the next version, version 1.0. 241 | 242 | To send a message to the MQI, send a message using the message format above to the localhost port or Unix Domain Socket that the MQI is listening on. For example, to run the synchronous goal `atom(a)`, send the following message: 243 | ~~~ 244 | 18.\nrun(atom(a), -1).\n 245 | ~~~ 246 | You will receive the response below on the receive stream of the same connection you sent on. Note that the answer is in JSON format. If a message takes longer than 2 seconds, there will be "heartbeat" characters (".") at the beginning of the response message, approximately 1 every 2 seconds. So, if the query takes 6 seconds for some reason, there will be three "." characters first: 247 | ~~~ 248 | ...12\ntrue([[]]).\n 249 | ~~~ 250 | 251 | ### Machine Query Interface Messages Reference {#mqi-messages} 252 | 253 | The full list of Machine Query Interface messages is described below: 254 | 255 | 256 | - run(Goal, Timeout) 257 | 258 | Runs `Goal` on the connection's designated query thread. Stops accepting new commands until the query is finished and it has responded with the results. If a previous query is still in progress, waits until the previous query finishes (discarding that query's results) before beginning the new query. 259 | 260 | Timeout is in seconds and indicates a timeout for generating all results for the query. Sending a variable (e.g. `_`) will use the default timeout passed to the initial `mqi_start/1` predicate and `-1` means no timeout. 261 | 262 | While it is waiting for the query to complete, sends a "." character *not* in message format, just as a single character, once every two seconds to proactively ensure that the client is alive. Those should be read and discarded by the client. 263 | 264 | If a communication failure happens (during a heartbeat or otherwise), the connection is terminated, the query is aborted and (if running in ["Embedded Mode"](#mqi-embedded-mode)) the SWI Prolog process shuts down. 265 | 266 | When completed, sends a response message using the normal message format indicating the result. 267 | 268 | Response: 269 | 270 | |`true([Answer1, Answer2, ... ])` | The goal succeeded at least once. The response always includes all answers as if run with findall() (see run_async/3 below to get individual results back iteratively). Each `Answer` is a list of the assignments of free variables in the answer. A special variable called `$residuals` will be added to each answer that has residual [variable constraints on it](https://www.swi-prolog.org/pldoc/man?section=attvar). This will contain a list of all the constraints on all the variables for that answer. If there are no free variables, `Answer` is an empty list. | 271 | |`false` | The goal failed. | 272 | |`exception(time_limit_exceeded)` | The query timed out. | 273 | |`exception(Exception)` | An arbitrary exception was not caught while running the goal. | 274 | |`exception(connection_failed)` | The query thread unexpectedly exited. The MQI will no longer be listening after this exception. | 275 | 276 | - run_async(Goal, Timeout, Find_All) 277 | 278 | Starts a Prolog query specified by `Goal` on the connection's designated query thread. Answers to the query, including exceptions, are retrieved afterwards by sending the `async_result` message (described below). The query can be cancelled by sending the `cancel_async` message. If a previous query is still in progress, waits until that query finishes (discarding that query's results) before responding. 279 | 280 | Timeout is in seconds and indicates a timeout for generating all results for the query. Sending a variable (e.g. `_`) will use the default timeout passed to the initial `mqi_start/1` predicate and `-1` means no timeout. 281 | 282 | If the socket closes before a response is sent, the connection is terminated, the query is aborted and (if running in ["Embedded Mode"](#mqi-embedded-mode)) the SWI Prolog process shuts down. 283 | 284 | If it needs to wait for the previous query to complete, it will send heartbeat messages (see ["Machine Query Interface Message Format"](#mqi-message-format)) while it waits. After it responds, however, it does not send more heartbeats. This is so that it can begin accepting new commands immediately after responding so the client. 285 | 286 | `Find_All == true` means generate one response to an `async_result` message with all of the answers to the query (as in the `run` message above). `Find_All == false` generates a single response to an `async_result` message per answer. 287 | 288 | Response: 289 | 290 | |`true([[]])` | The goal was successfully parsed. | 291 | |`exception(Exception)` | An error occurred parsing the goal. | 292 | |`exception(connection_failed)` | The goal thread unexpectedly shut down. The MQI will no longer be listening after this exception. | 293 | 294 | 295 | - cancel_async 296 | Attempt to cancel a query started by the `run_async` message in a way that allows further queries to be run on this Prolog thread afterwards. 297 | 298 | If there is a goal running, injects a `throw(cancel_goal)` into the executing goal to attempt to stop the goal's execution. Begins accepting new commands immediately after responding. Does not inject `abort/0` because this would kill the connection's designated thread and the system is designed to maintain thread local data for the client. This does mean it is a "best effort" cancel since the exception can be caught. 299 | 300 | `cancel_async` is guaranteed to either respond with an exception (if there is no query or pending results from the last query), or safely attempt to stop the last executed query even if it has already finished. 301 | 302 | To guarantee that a query is cancelled, send `close` and close the socket. 303 | 304 | It is not necessary to determine the outcome of `cancel_async` after sending it and receiving a response. Further queries can be immediately run. They will start after the current query stops. 305 | 306 | However, if you do need to determine the outcome or determine when the query stops, send `async_result`. Using `Timeout = 0` is recommended since the query might have caught the exception or still be running. Sending `async_result` will find out the "natural" result of the goal's execution. The "natural" result depends on the particulars of what the code actually did. The response could be: 307 | 308 | |`exception(cancel_goal)` | The query was running and did not catch the exception. I.e. the goal was successfully cancelled. | 309 | |`exception(time_limit_exceeded)` | The query timed out before getting cancelled. | 310 | |`exception(Exception)` | They query hits another exception before it has a chance to be cancelled. | 311 | | A valid answer | The query finished before being cancelled. | 312 | 313 | Note that you will need to continue sending `async_result` until you receive an `exception(Exception)` message if you want to be sure the query is finished (see documentation for `async_result`). 314 | 315 | Response: 316 | 317 | | `true([[]])` | There is a query running or there are pending results for the last query. | 318 | | `exception(no_query)` | There is no query or pending results from a query to cancel. | 319 | | `exception(connection_failed)` | The connection has been unexpectedly shut down. The MQI will no longer be listening after this exception. | 320 | 321 | 322 | - async_result(Timeout) 323 | Get results from a query that was started via a `run_async` message. Used to get results for all cases: if the query terminates normally, is cancelled by sending a `cancel_async` message, or times out. 324 | 325 | Each response to an `async_result` message responds with one result and, when there are no more results, responds with `exception(no_more_results)` or whatever exception stopped the query. Receiving any `exception` response except `exception(result_not_available)` means there are no more results. If `run_async` was run with `Find_All == false`, multiple `async_result` messages may be required before receiving the final exception. 326 | 327 | Waits `Timeout` seconds for a result. `Timeout == -1` or sending a variable for Timeout indicates no timeout. If the timeout is exceeded and no results are ready, sends `exception(result_not_available)`. 328 | 329 | Some examples: 330 | 331 | |If the query succeeds with N answers... | `async_result` messages 1 to N will receive each answer, in order, and `async_result` message N+1 will receive `exception(no_more_results)` | 332 | |If the query fails (i.e. has no answers)... | `async_result` message 1 will receive `false` and `async_result` message 2 will receive `exception(no_more_results)` | 333 | |If the query times out after one answer... | `async_result` message 1 will receive the first answer and `async_result` message 2 will receive `exception(time_limit_exceeded)` | 334 | |If the query is cancelled after it had a chance to get 3 answers... | `async_result` messages 1 to 3 will receive each answer, in order, and `async_result` message 4 will receive `exception(cancel_goal)` | 335 | |If the query throws an exception before returning any results... | `async_result` message 1 will receive `exception(Exception)`| 336 | 337 | Note that, after sending `cancel_async`, calling `async_result` will return the "natural" result of the goal's execution. The "natural" result depends on the particulars of what the code actually did since this is multi-threaded and there are race conditions. This is described more below in the response section and above in `cancel_async`. 338 | 339 | Response: 340 | 341 | |`true([Answer1, Answer2, ... ])` | The next answer from the query is a successful answer. Whether there are more than one `Answer` in the response depends on the `findall` setting. Each `Answer` is a list of the assignments of free variables in the answer. A special variable called `$residuals` will be added to each answer that has residual [variable constraints on it](https://www.swi-prolog.org/pldoc/man?section=attvar). This will contain a list of all the constraints on all the variables for that answer. If there are no free variables, `Answer` is an empty list.| 342 | |`false`| The query failed with no answers.| 343 | |`exception(no_query)` | There is no query in progress.| 344 | |`exception(result_not_available)` | There is a running query and no results were available in `Timeout` seconds.| 345 | |`exception(no_more_results)` | There are no more answers and no other exception occurred. | 346 | |`exception(cancel_goal)`| The next answer is an exception caused by `cancel_async`. Indicates no more answers. | 347 | |`exception(time_limit_exceeded)`| The query timed out generating the next answer (possibly in a race condition before getting cancelled). Indicates no more answers. | 348 | |`exception(Exception)`| The next answer is an arbitrary exception. This can happen after `cancel_async` if the `cancel_async` exception is caught or the code hits another exception first. Indicates no more answers. | 349 | |`exception(connection_failed)`| The goal thread unexpectedly exited. The MQI will no longer be listening after this exception.| 350 | 351 | 352 | - close 353 | Closes a connection cleanly, indicating that the subsequent socket close is not a connection failure. Thus it doesn't shutdown the MQI in ["Embedded Mode"](#mqi-embedded-mode). The response must be processed by the client before closing the socket or it will be interpreted as a connection failure. 354 | 355 | Any asynchronous query that is still running will be halted by using `abort/0` in the connection's query thread. 356 | 357 | Response: 358 | `true([[]])` 359 | 360 | 361 | - quit 362 | Stops the MQI and ends the SWI Prolog process. This allows client language libraries to ask for an orderly shutdown of the Prolog process. 363 | 364 | Response: 365 | `true([[]])` 366 | -------------------------------------------------------------------------------- /python/LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 EricZinda 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. 22 | -------------------------------------------------------------------------------- /python/README.md: -------------------------------------------------------------------------------- 1 | 2 | # swiplserver 3 | 4 | > Note that swiplserver 1.0 changes the names of classes from previous versions in an incompatible way -- the terminology changed from 'language server' to 'machine query interface (MQI)'. You'll need to update your code as you transition. The names should be stable from version 1.0 on, however. 5 | 6 | The `swiplserver` module provides a set of classes to call SWI Prolog from Python. It allows running any query from Python that could be executed from the SWI Prolog console (i.e. the "top level"). Answers to Prolog queries are returned as JSON. 7 | 8 | The library uses a SWI Prolog interface called the [Machine Query Interface ('MQI')](https://www.swi-prolog.org/pldoc/doc_for?object=section(%27packages/mqi.html%27)) that allows Prolog queries to be executed. It also manages launching and shutting down SWI Prolog automatically, making the process management invisible to the developer. The whole experience should feel just like using any other library. 9 | 10 | ~~~ 11 | from swiplserver import PrologMQI, PrologThread 12 | 13 | with PrologMQI() as mqi: 14 | with mqi.create_thread() as prolog_thread: 15 | result = prolog_thread.query("member(X, [color(blue), color(red)])") 16 | print(result) 17 | 18 | [{'X': {'functor': 'color', 'args': ['blue']}}, 19 | {'X': {'functor': 'color', 'args': ['red']}}] 20 | ~~~ 21 | 22 | To install and learn how to use the swiplserver Python library, see [the docs](https://www.swi-prolog.org/packages/mqi/prologmqi.html). 23 | 24 | 25 | ### Supported Configurations 26 | Should work on: 27 | - MQI protocol version 1.x or prior 28 | - SWI Prolog 8.2.2 or greater (may work on older builds, untested) 29 | - Any Mac, Linux Variants or Windows that are supported by SWI Prolog 30 | - Python 3.7 or later (may work on older builds, untested) 31 | 32 | Has been tested with: 33 | - Ubuntu 20.04.2 + SWI Prolog 8.3.22 + Python 3.7.8 34 | - Windows 10 Pro 64 bit + SWI Prolog 8.3.27 + Python 3.7.0 35 | - Windows 8.1 Pro 64 bit + SWI Prolog 8.2.4 + Python 3.8.1 36 | - MacOS Catalina/Big Sur + SWI Prolog 8.3.24 + Python 3.7.4 37 | 38 | ### Performance 39 | If you're interested in rough performance overhead of the approach this library takes. On a late 2013 macbook pro the per call overhead of the library for running a Prolog query is about: 40 | - 170 uSec per call using TCP/IP localhost 41 | - 145 uSec per call using Unix Domain Sockets 42 | -------------------------------------------------------------------------------- /python/pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = [ 3 | "setuptools>=42", 4 | "wheel" 5 | ] 6 | build-backend = "setuptools.build_meta" -------------------------------------------------------------------------------- /python/setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | name = swiplserver 3 | version = 1.0.2 4 | author = Eric Zinda 5 | author_email = ericz@inductorsoftware.com 6 | description = swiplserver integrates SWI Prolog with Python. 7 | long_description = file: README.md 8 | long_description_content_type = text/markdown 9 | url = https://github.com/SWI-Prolog/packages-mqi 10 | project_urls = 11 | Bug Tracker = https://github.com/SWI-Prolog/packages-mqi/issues 12 | Documentation = https://www.swi-prolog.org/packages/mqi/prologmqi.html 13 | classifiers = 14 | Programming Language :: Python :: 3.7 15 | License :: OSI Approved :: MIT License 16 | Operating System :: OS Independent 17 | 18 | [options] 19 | packages = swiplserver 20 | python_requires = >=3.7 21 | -------------------------------------------------------------------------------- /python/swiplserver/LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Eric Zinda 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. -------------------------------------------------------------------------------- /python/swiplserver/__init__.py: -------------------------------------------------------------------------------- 1 | from swiplserver.prologmqi import ( 2 | PrologMQI, 3 | PrologThread, 4 | PrologError, 5 | PrologLaunchError, 6 | PrologQueryTimeoutError, 7 | PrologQueryCancelledError, 8 | PrologConnectionFailedError, 9 | PrologResultNotAvailableError, 10 | PrologNoQueryError, 11 | is_prolog_variable, 12 | is_prolog_list, 13 | is_prolog_functor, 14 | is_prolog_atom, 15 | create_posix_path, 16 | prolog_name, 17 | prolog_args, 18 | quote_prolog_identifier, 19 | json_to_prolog, 20 | ) 21 | 22 | # make "from swiplserver import *" work 23 | __all__ = [ 24 | "PrologLaunchError", 25 | "PrologQueryTimeoutError", 26 | "PrologQueryCancelledError", 27 | "PrologConnectionFailedError", 28 | "PrologResultNotAvailableError", 29 | "PrologNoQueryError", 30 | "PrologError", 31 | "PrologMQI", 32 | "PrologThread", 33 | "create_posix_path", 34 | "is_prolog_functor", 35 | "is_prolog_list", 36 | "is_prolog_variable", 37 | "is_prolog_atom", 38 | "prolog_name", 39 | "prolog_args", 40 | "quote_prolog_identifier", 41 | "json_to_prolog", 42 | ] 43 | -------------------------------------------------------------------------------- /python/swiplserver/prologmqi.py: -------------------------------------------------------------------------------- 1 | # swiplserver SWI Prolog integration library 2 | # Author: Eric Zinda 3 | # E-mail: ericz@inductorsoftware.com 4 | # WWW: http://www.inductorsoftware.com 5 | # Copyright (c) 2021, Eric Zinda 6 | 7 | # HTML Docs produced with https://pdoc3.github.io 8 | # pip install pdoc3 9 | # pdoc --html --force --output-dir docs --config show_source_code=False swiplserver.prologmqi 10 | 11 | """ 12 | Allows using SWI Prolog as an embedded part of an application, "like a library". 13 | 14 | `swiplserver` enables SWI Prolog queries to be executed from within your Python application as if Python had a Prolog engine running inside of it. Queries are sent as strings like "atom(foo)" and the response is JSON. 15 | 16 | `swiplserver` provides: 17 | 18 | - The `PrologMQI` class that automatically manages starting and stopping a SWI Prolog instance and starts the Machine Query Interface ('MQI') using the `mqi_start/1` predicate to allow running Prolog queries. 19 | - The `PrologThread` class is used to run queries on the created process. Queries are run exactly as they would be if you were interacting with the SWI Prolog "top level" (i.e. the Prolog command line). 20 | 21 | Installation: 22 | 23 | 1. Install SWI Prolog (www.swi-prolog.org) and ensure that "swipl" is on the system path. 24 | 2. Either "pip install swiplserver" or copy the "swiplserver" library (the whole directory) from the "libs" directory of your SWI Prolog installation to be a subdirectory of your Python project. 25 | 3. Check if your SWI Prolog version includes the Machine Query Interface by running ``swipl mqi --help``. 26 | 27 | Usage: 28 | 29 | `PrologThread` represents a thread in *Prolog* (it is not a Python thread!). A given `PrologThread` instance will always run queries on the same Prolog thread (i.e. it is single threaded within Prolog). 30 | 31 | To run a query and wait until all results are returned: 32 | 33 | from swiplserver import PrologMQI, PrologThread 34 | 35 | with PrologMQI() as mqi: 36 | with mqi.create_thread() as prolog_thread: 37 | result = prolog_thread.query("atom(a)") 38 | print(result) 39 | 40 | True 41 | 42 | To run a query that returns multiple results and retrieve them as they are available: 43 | 44 | from swiplserver import PrologMQI, PrologThread 45 | 46 | with PrologMQI() as mqi: 47 | with mqi.create_thread() as prolog_thread: 48 | prolog_thread.query_async("member(X, [first, second, third])", 49 | find_all=False) 50 | while True: 51 | result = prolog_thread.query_async_result() 52 | if result is None: 53 | break 54 | else: 55 | print(result) 56 | first 57 | second 58 | third 59 | 60 | Creating two `PrologThread` instances allows queries to be run on multiple threads in Prolog: 61 | 62 | from swiplserver import PrologMQI, PrologThread 63 | 64 | with PrologMQI() as mqi: 65 | with mqi.create_thread() as prolog_thread1: 66 | with mqi.create_thread() as prolog_thread2: 67 | prolog_thread1.query_async("sleep(2), writeln(first_thread(true))") 68 | prolog_thread2.query_async("sleep(1), writeln(second_thread(true))") 69 | thread1_answer = prolog_thread1.query_async_result() 70 | thread2_answer = prolog_thread2.query_async_result() 71 | 72 | Prolog: second_thread(true) 73 | Prolog: first_thread(true) 74 | 75 | Output printed in Prolog using `writeln/1` or errors output by Prolog itself are written to Python's logging facility using the `swiplserver` log and shown prefixed with "Prolog:" as above. 76 | 77 | Answers to Prolog queries that are not simply `True` or `False` are converted to JSON using the [json_to_prolog/2](https://www.swi-prolog.org/pldoc/doc_for?object=json_to_prolog/2) predicate in Prolog. They are returned as a Python `dict` with query variables as the keys and standard JSON as the values. If there is more than one answer, it is returned as a list: 78 | 79 | from swiplserver import PrologMQI, PrologThread 80 | 81 | with PrologMQI() as mqi: 82 | with mqi.create_thread() as prolog_thread: 83 | result = prolog_thread.query("member(X, [color(blue), color(red)])") 84 | print(result) 85 | 86 | [{'X': {'functor': 'color', 'args': ['blue']}}, 87 | {'X': {'functor': 'color', 'args': ['red']}}] 88 | 89 | Exceptions in Prolog code are raised using Python's native exception facilities. 90 | 91 | Debugging: 92 | When using `swiplserver`, debugging the Prolog code itself can often be done by viewing traces from the Prolog native `writeln/1` or `debug/3` predicates and viewing their output in the debugger output window. Sometimes an issue occurs deep in an application and it would be easier to set breakpoints and view traces in Prolog itself. Running SWI Prolog manually and launching the Machine Query Interface in "Standalone mode" is designed for this scenario. 93 | 94 | `swiplserver` normally launches SWI Prolog and starts the Machine Query Interface so that it can connect and run queries. To debug your code using Prolog itself, you can do this manually and connect your application to it. A typical flow for standalone mode is: 95 | 96 | 1. Launch SWI Prolog and call the `mqi_start/1` predicate, specifying a port and password (documentation is [here](https://www.swi-prolog.org/pldoc/man?predicate=mqi_start/1)). Use the `tdebug/0` predicate to set all threads to debugging mode like this: `tdebug, mqi_start([port(4242), password(debugnow)])`. 97 | 2. Optionally run the predicate `debug(mqi(_)).` in Prolog to turn on tracing for the Machine Query Interface. 98 | 3. Set the selected port and password when you call `PrologMQI.__init__()`. 99 | 4. Launch the application and go through the steps to reproduce the issue. 100 | 101 | (As the Machine Query Interface is multithreaded, debugging the running code requires using the multithreaded debugging features of SWI Prolog as described in the section on ["Debugging Threads"](https://www.swi-prolog.org/pldoc/man?section=threaddebug) in the SWI Prolog documentation.) 102 | 103 | At this point, all of the multi-threaded debugging tools in SWI Prolog are available for debugging the problem. If the issue is an unhandled or unexpected exception, the exception debugging features of SWI Prolog can be used to break on the exception and examine the state of the application. If it is a logic error, breakpoints can be set to halt at the point where the problem appears, etc. 104 | 105 | Note that, while using a library to access Prolog will normally end and restart the process between runs of the code, running the Machine Query Interface standalone won't. You'll either need to relaunch between runs or build your application so that it does the initialization at startup. 106 | """ 107 | 108 | import json 109 | import logging 110 | import os 111 | import socket 112 | import subprocess 113 | import unittest 114 | import uuid 115 | from contextlib import suppress 116 | from os.path import join 117 | from threading import Thread 118 | from pathlib import PurePath, PurePosixPath, PureWindowsPath 119 | from tempfile import gettempdir 120 | from time import sleep 121 | 122 | 123 | class PrologError(Exception): 124 | """ 125 | Base class used for all exceptions raised by `swiplserver.PrologMQI` except for PrologLaunchError. Used directly when an exception is thrown by Prolog code itself, otherwise the subclass exceptions are used. 126 | """ 127 | 128 | def __init__(self, exception_json): 129 | assert ( 130 | prolog_name(exception_json) == "exception" 131 | and len(prolog_args(exception_json)) == 1 132 | ) 133 | self._exception_json = prolog_args(exception_json)[0] 134 | super().__init__(self.prolog()) 135 | 136 | def json(self): 137 | """ 138 | Returns: 139 | A string that represents the Prolog exception in Prolog json form. See `PrologThread.query` for documentation on the Prolog json format. 140 | """ 141 | return self._exception_json 142 | 143 | def prolog(self): 144 | """ 145 | Returns: 146 | A string that represents the Prolog exception in the Prolog native form. 147 | """ 148 | return json_to_prolog(self._exception_json) 149 | 150 | def is_prolog_exception(self, term_name): 151 | """ 152 | True if the exception thrown by Prolog code has the term name specified by term_name. 153 | 154 | Args: 155 | term_name: The name of the Prolog term to test for. 156 | """ 157 | return prolog_name(self._exception_json) == term_name 158 | 159 | 160 | class PrologLaunchError(Exception): 161 | """ 162 | Raised when the SWI Prolog process was unable to be launched for any reason. This can include a version mismatch between the library and the server. 163 | """ 164 | 165 | pass 166 | 167 | 168 | class PrologQueryTimeoutError(PrologError): 169 | """ 170 | Raised when a Prolog query times out when calling `PrologThread.query()` or `PrologThread.query_async()` with a timeout. 171 | """ 172 | 173 | pass 174 | 175 | 176 | class PrologConnectionFailedError(PrologError): 177 | """ 178 | Raised when the connection used by a `PrologThread` fails. Indicates that the Machine Query Interface will no longer respond. 179 | """ 180 | 181 | pass 182 | 183 | 184 | class PrologNoQueryError(PrologError): 185 | """ 186 | Raised by `PrologThread.cancel_query_async()` and `PrologThread.query_async_result()` if there is no query running and no results to retrieve. 187 | """ 188 | 189 | pass 190 | 191 | 192 | class PrologQueryCancelledError(PrologError): 193 | """ 194 | Raised by `PrologThread.query_async_result()` when the query has been cancelled. 195 | """ 196 | 197 | pass 198 | 199 | 200 | class PrologResultNotAvailableError(PrologError): 201 | """ 202 | Raised by `PrologThread.query_async_result()` when the next result to a query is not yet available. 203 | """ 204 | 205 | pass 206 | 207 | 208 | class PrologMQI: 209 | def __init__( 210 | self, 211 | launch_mqi: bool = True, 212 | port: int = None, 213 | password: str = None, 214 | unix_domain_socket: str = None, 215 | query_timeout_seconds: float = None, 216 | pending_connection_count: int = None, 217 | output_file_name: str = None, 218 | mqi_traces: str = None, 219 | prolog_path: str = None, 220 | prolog_path_args: list = None, 221 | ): 222 | """ 223 | Initialize a PrologMQI class that manages a SWI Prolog process associated with your application process. `PrologMQI.start()` actually launches the process if launch_mqi is True. 224 | 225 | This class is designed to allow Prolog to be used "like a normal Python library" using the Machine Query Interface of SWI Prolog. All communication is done using protocols that only work on the same machine as your application (localhost TCP/IP or Unix Domain Sockets), and the implementation is designed to make sure the process doesn't hang around even if the application is terminated unexpectedly (as with halting a debugger). 226 | 227 | All arguments are optional and the defaults are set to the recommended settings that work best on all platforms during development. In production on Unix systems, consider using unix_domain_socket to further decrease security attack surface area. 228 | 229 | For debugging scenarios, SWI Prolog can be launched manually and this class can be configured to (locally) connect to it using launch_mqi = False. This allows for inspection of the Prolog state and usage of the SWI Prolog debugging tools while your application is running. See the documentation for the Prolog [`mqi_start/1`](https://www.swi-prolog.org/pldoc/man?predicate=mqi_start/1) predicate for more information on how to run the Machine Query Interface in "Standalone Mode". 230 | 231 | Examples: 232 | To automatically launch a SWI Prolog process using TCP/IP localhost and an automatically chosen port and password (the default): 233 | 234 | with PrologMQI() as mqi: 235 | # your code here 236 | 237 | To connect to an existing SWI Prolog process that has already started the `mqi_start/1` predicate and is using an automatically generated Unix Domain Socket (this value will be different for every launch) and a password of '8UIDSSDXLPOI': 238 | 239 | with PrologMQI(launch_mqi = False, 240 | unix_domain_socket = '/tmp/swipl_udsock_15609_1/swipl_15609_2', 241 | password = '8UIDSSDXLPOI') as mqi: 242 | # your code here 243 | 244 | Args: 245 | launch_mqi: True (default) launch a SWI Prolog process on `PrologMQI.start()` and shut it down automatically on `PrologMQI.stop()` (or after a resource manager like the Python "with" statement exits). False connects to an existing SWI Prolog process that is running the `mqi_start/1` predicate (i.e. "Standalone Mode"). When False, `password` and one of `port` or `unix_domain_socket` must be specified to match the options provided to `mqi_start/1` in the separate SWI Prolog process. 246 | 247 | port: The TCP/IP localhost port to use for communication with the SWI Prolog process. Ignored if `unix_domain_socket` is not None. 248 | 249 | - When `launch_mqi` is True, None (default) automatically picks an open port that the Machine Query Interface and this class both use. 250 | - When `launch_mqi` is False, must be set to match the port specified in `mqi_start/1` of the running SWI Prolog process. 251 | 252 | password: The password to use for connecting to the SWI Prolog process. This is to prevent malicious users from connecting to the Machine Query Interface since it can run arbitrary code. Allowing the MQI to generate a strong password by using None is recommended. 253 | 254 | - When `launch_mqi` is True, None (default) automatically generates a strong password using a uuid. Other values specify the password to use. 255 | - When `launch_mqi` is False, must be set to match the password specified in `mqi_start/1` of the running SWI Prolog process. 256 | 257 | unix_domain_socket: None (default) use localhost TCP/IP for communication with the SWI Prolog process. Otherwise (only on Unix) is either a fully qualified path and filename of the Unix Domain Socket to use or an empty string (recommended). An empty string will cause a temporary directory to be created using Prolog's `tmp_file/2` and a socket file will be created within that directory following the below requirements. If the directory and file are unable to be created for some reason, `PrologMQI.start()` with raise an exception. Specifying a file to use should follow the same guidelines as the generated file: 258 | 259 | - If the file exists when the Machine Query Interface is launched, it will be deleted. 260 | - The Prolog process will attempt to create and, if Prolog exits cleanly, delete this file when the Machine Query Interface closes. This means the directory must have the appropriate permissions to allow the Prolog process to do so. 261 | - For security reasons, the filename should not be predictable and the directory it is contained in should have permissions set so that files created are only accessible to the current user. 262 | - The path must be below 92 *bytes* long (including null terminator) to be portable according to the Linux documentation. 263 | 264 | query_timeout_seconds: None (default) set the default timeout for all queries to be infinite (this can be changed on a per query basis). Other values set the default timeout in seconds. 265 | 266 | pending_connection_count: Set the default number of pending connections allowed on the Machine Query Interface. Since the MQI is only connected to by your application and is not a server, this value should probably never be changed unless your application is creating new `PrologThread` objects at a very high rate. 267 | 268 | - When `launch_mqi` is True, None uses the default (5) and other values set the count. 269 | - When `launch_mqi` is False, ignored. 270 | 271 | output_file_name: Provide the file name for a file to redirect all Prolog output (STDOUT and STDERR) to. Used for debugging or gathering a log of Prolog output. None outputs all Prolog output to the Python logging infrastructure using the 'swiplserver' log. If using multiple Machine Query Interfaces in one SWI Prolog instance, only set this on the first one. Each time it is set the output will be deleted and redirected. 272 | 273 | mqi_traces: (Only used in unusual debugging circumstances) Since these are Prolog traces, where they go is determined by `output_file_name`. 274 | 275 | - None (the default) does not turn on mqi tracing 276 | - "_" turns on all tracing output from the Prolog Machine Query Interface (i.e. runs `debug(mqi(_)).` in Prolog). 277 | - "protocol" turns on only protocol level messages (which results in much less data in the trace for large queries) 278 | - "query" turns on only messages about the query. 279 | 280 | prolog_path: (Only used for unusual testing situations) Set the path to where the swipl executable can be found. 281 | 282 | prolog_path_args: (Only used for unusual testing situations) Set extra command line arguments to be sent to swipl when it is launched. 283 | 284 | Raises: 285 | ValueError if the arguments don't make sense. For example: choosing Unix Domain Sockets on Windows or setting output_file with launch_mqi = False 286 | """ 287 | self._port = port 288 | self._password = password 289 | self._process = None 290 | self._stderr_reader = None 291 | self._stdout_reader = None 292 | self._query_timeout = query_timeout_seconds 293 | self.pending_connections = pending_connection_count 294 | self._output_file = output_file_name 295 | self._unix_domain_socket = unix_domain_socket 296 | self._mqi_traces = mqi_traces 297 | self._launch_mqi = launch_mqi 298 | self._prolog_path = prolog_path 299 | self._prolog_path_args = prolog_path_args 300 | 301 | # Becomes true if a PrologThread class encounters a situation 302 | # where the mqi is clearly shutdown and thus more communication 303 | # will not work and probably hang. 304 | self.connection_failed = False 305 | 306 | # Ensure arguments are valid 307 | if self._unix_domain_socket is not None: 308 | if os.name == "nt": 309 | raise ValueError("Unix Domain Sockets are not supported on windows") 310 | elif self._port is not None: 311 | raise ValueError("Must only provide one of: port or unix_domain_socket") 312 | 313 | if self._launch_mqi is False and self._output_file is not None: 314 | raise ValueError("output_file only works when launch_mqi is True.") 315 | 316 | def __enter__(self): 317 | self.start() 318 | return self 319 | 320 | def __exit__(self, exception_type, exception_value, exception_traceback): 321 | self.stop() 322 | 323 | def __del__(self): 324 | self.stop(kill=True) 325 | 326 | def stop(self, kill=False): 327 | """ 328 | Stop the SWI Prolog process and wait for it to exit if it has been launched by using `launch_mqi = True` on `PrologMQI` creation. 329 | 330 | Does nothing if `launch_mqi` is False. 331 | 332 | Args: 333 | kill: False (default) connect to the Machine Query Interface and ask it to perform an orderly shutdown of Prolog and exit the process. True uses the Python subprocess.kill() command which will terminate it immediately. Note that if PrologMQI.connection_failed is set to true (due to a failure that indicates the MQI will not respond), subprocess.kill() will be used regardless of this setting. 334 | """ 335 | if self._process: 336 | with suppress(Exception): 337 | if kill is True or self.connection_failed: 338 | _log.debug("Killing Prolog process...") 339 | self._process.kill() 340 | _log.debug("Killed Prolog process.") 341 | else: 342 | with self.create_thread() as prologThread: 343 | prologThread.halt_server() 344 | 345 | # Use __exit__ instead of wait() as it will close stderr/out handles too 346 | self._process.__exit__(None, None, None) 347 | 348 | # Need to get rid of the unix domain socket file 349 | if self._unix_domain_socket: 350 | with suppress(Exception): 351 | os.remove(self._unix_domain_socket) 352 | 353 | self._process = None 354 | 355 | def start(self): 356 | """ 357 | Start a new SWI Prolog process associated with this class using the settings from `PrologMQI.__init__()` and start the Machine Query Interface using the `mqi_start` Prolog predicate. If `launch_mqi` is False, does nothing. 358 | 359 | To create the SWI Prolog process, 'swipl' must be on the system path. Manages the lifetime of the process it creates, ending it on `PrologMQI.stop()`. 360 | 361 | Raises: 362 | PrologLaunchError: The SWI Prolog process was unable to be launched. Often indicates that `swipl` is not in the system path. 363 | """ 364 | if self._launch_mqi: 365 | swiplPath = ( 366 | os.path.join(self._prolog_path, "swipl") 367 | if self._prolog_path is not None 368 | else "swipl" 369 | ) 370 | launchArgs = ( 371 | [swiplPath] 372 | + (self._prolog_path_args if self._prolog_path_args is not None else []) 373 | + [ 374 | "mqi", 375 | "--write_connection_values=true", 376 | ] 377 | ) 378 | 379 | if self.pending_connections is not None: 380 | launchArgs += [f"--pending_connections={str(self.pending_connections)}"] 381 | if self._query_timeout is not None: 382 | launchArgs += [f"--query_timeout={str(self._query_timeout)}"] 383 | if self._password is not None: 384 | launchArgs += [f"--password={str(self._password)}"] 385 | if self._output_file is not None: 386 | finalPath = create_posix_path(self._output_file) 387 | launchArgs += [f"--write_output_to_file={finalPath}"] 388 | _log.debug("Writing all Prolog output to file: %s", finalPath) 389 | if self._port is not None: 390 | launchArgs += [f"--port={str(self._port)}"] 391 | if self._unix_domain_socket is not None: 392 | if len(self._unix_domain_socket) > 0: 393 | launchArgs += [f"--unix_domain_socket={self._unix_domain_socket}"] 394 | else: 395 | launchArgs += ["--create_unix_domain_socket=true"] 396 | 397 | _log.debug("PrologMQI launching swipl: %s", launchArgs) 398 | try: 399 | self._process = subprocess.Popen( 400 | launchArgs, stdout=subprocess.PIPE, stderr=subprocess.PIPE 401 | ) 402 | except FileNotFoundError: 403 | raise PrologLaunchError( 404 | "The SWI Prolog executable 'swipl' could not be found on the system path, please add it." 405 | ) 406 | 407 | # Add STDERR reader immediately so we can see errors printed out 408 | self._stderr_reader = _NonBlockingStreamReader(self._process.stderr) 409 | 410 | # Now read the data that Prolog sends about how to connect 411 | if self._unix_domain_socket is None: 412 | portString = self._process.stdout.readline().decode() 413 | if portString == "": 414 | raise PrologLaunchError("no port found in stdout") 415 | else: 416 | serverPortString = portString.rstrip("\n") 417 | self._port = int(serverPortString) 418 | _log.debug("Prolog MQI port: %s", self._port) 419 | else: 420 | domain_socket = self._process.stdout.readline().decode() 421 | if domain_socket == "": 422 | raise PrologLaunchError("no Unix Domain Socket found in stdout") 423 | self._unix_domain_socket = domain_socket.rstrip("\n") 424 | 425 | passwordString = self._process.stdout.readline().decode() 426 | if passwordString == "": 427 | raise PrologLaunchError("no password found in stdout") 428 | else: 429 | self._password = passwordString.rstrip("\n") 430 | 431 | # Now that we are done reading, we can add the STDOUT Reader 432 | self._stdout_reader = _NonBlockingStreamReader(self._process.stdout) 433 | 434 | if self._mqi_traces is not None: 435 | with self.create_thread() as prologThread: 436 | prologThread.query(f"debug(mqi({self._mqi_traces}))") 437 | 438 | def create_thread(self): 439 | """ 440 | Create a new `PrologThread` instance for this `PrologMQI`. 441 | 442 | 443 | Examples: 444 | Using with the Python `with` statement is recommended: 445 | 446 | with PrologMQI() as mqi: 447 | with mqi.create_thread() as prolog_thread: 448 | # Your code here 449 | 450 | Returns: 451 | A `PrologThread` instance. 452 | """ 453 | return PrologThread(self) 454 | 455 | def process_id(self): 456 | """Retrieve the operating system process id of the SWI Prolog process that was launched by this class. 457 | 458 | Returns: 459 | None if the value of `launch_mqi` passed to `PrologMQI` is False or if `PrologMQI.start()` has not yet been called. Otherwise return the operating system process ID. 460 | """ 461 | if self._process is not None: 462 | return self._process.pid 463 | else: 464 | return None 465 | 466 | @staticmethod 467 | def unix_domain_socket_file(directory: str): 468 | """ 469 | Creates a non-predictable Filename 36 bytes long suitable for using in the unix_domain_socket argument of the `PrologMQI` constructor. Appends it to directory. 470 | 471 | Note that Python's gettempdir() function generates paths which are often quite large on some platforms and thus (at the time of this writing) is not suitable for use as the directory. The recommendation is to create a custom directory in a suitably short path (see notes below on length) in the filesystem and use that as directory. Ensure that the permissions for this folder are set as described below. 472 | Args: 473 | directory: The fully qualified directory the file name will be appended to. Note that: 474 | 475 | - The directory containing the file must grant the user running the application (and ideally only that user) the ability to create and delete files created within it. 476 | - The total path (including the 36 bytes used by the file) must be below 92 *bytes* long (including null terminator) to be portable according to the Linux documentation. 477 | Returns: 478 | A fully qualified path to a file in directory. 479 | """ 480 | filename = "sock" + str(uuid.uuid4().hex) 481 | return os.path.join(directory, filename) 482 | 483 | 484 | class PrologThread: 485 | def __init__(self, prolog_mqi: PrologMQI): 486 | """ 487 | Initialize a PrologThread instance for running Prolog queries on a single, consistent thread in the Machine Query Interface managed by `prolog_mqi` (does not create a thread in Python). 488 | 489 | Each `PrologThread` class represents a single, consistent thread in `prolog_mqi` that can run queries using `PrologThread.query()` or `PrologThread.query_async()`. Queries on a single `PrologThread` will never run concurrently. 490 | 491 | However, running queries on more than one `PrologThread` instance will run concurrent Prolog queries and all the multithreading considerations that that implies. 492 | 493 | Usage: 494 | All of these are equivalent and automatically start SWI Prolog and the Machine Query Interface: 495 | 496 | PrologThread instances can be created and started manually: 497 | 498 | mqi = PrologMQI() 499 | prolog_thread = PrologThread(mqi) 500 | prolog_thread.start() 501 | # Your code here 502 | 503 | Or (recommended) started automatically using the Python `with` statement: 504 | 505 | with PrologMQI() as mqi: 506 | with PrologThread(mqi) as prolog_thread: 507 | # Your code here 508 | 509 | Or using the handy helper function: 510 | 511 | with PrologMQI() as mqi: 512 | with mqi.create_thread() as prolog_thread: 513 | # Your code here 514 | """ 515 | self._prolog_server = prolog_mqi 516 | self._socket = None 517 | self.communication_thread_id = None 518 | self.goal_thread_id = None 519 | self._heartbeat_count = 0 520 | self._server_protocol_major = None 521 | self._server_protocol_minor = None 522 | 523 | def __enter__(self): 524 | self.start() 525 | return self 526 | 527 | def __exit__(self, exception_type, exception_value, exception_traceback): 528 | self.stop() 529 | 530 | def __del__(self): 531 | self.stop() 532 | 533 | def start(self): 534 | """ 535 | Connect to the `prolog_mqi` specified in `PrologThread` and start a new thread in it. Launch SWI Prolog and start the Machine Query Interface using the `mqi/1` predicate if `launch_mqi` is `True` on that object. Does not start a Python thread. 536 | 537 | Does nothing if the thread is already started. 538 | 539 | Raises: 540 | `PrologLaunchError` if `launch_mqi` is `False` and the password does not match the server. 541 | 542 | Various socket errors if the server is not running or responding. 543 | """ 544 | if self._socket is not None: 545 | return 546 | 547 | if self._prolog_server.process_id() is None: 548 | self._prolog_server.start() 549 | 550 | # create an ipv4 (AF_INET) socket object using the tcp protocol (SOCK_STREAM) 551 | if self._prolog_server._unix_domain_socket: 552 | prologAddress = self._prolog_server._unix_domain_socket 553 | self._socket = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) 554 | else: 555 | prologAddress = ("127.0.0.1", self._prolog_server._port) 556 | self._socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) 557 | 558 | _log.debug("PrologMQI connecting to Prolog at: %s", prologAddress) 559 | 560 | # There is a race condition where the MQI has not yet started enough 561 | # to connect, give it 3 seconds and then fail since it is a really small 562 | # window for the race condition 563 | connect_count = 0 564 | while connect_count < 3: 565 | try: 566 | self._socket.connect(prologAddress) 567 | break 568 | except ConnectionRefusedError as error: 569 | _log.debug("Server not responding %s", prologAddress) 570 | connect_exception = error 571 | connect_count += 1 572 | sleep(1) 573 | continue 574 | if connect_count == 3: 575 | raise connect_exception 576 | 577 | # Send the password as the first message 578 | self._send(f"{self._prolog_server._password}") 579 | result = self._receive() 580 | jsonResult = json.loads(result) 581 | if prolog_name(jsonResult) != "true": 582 | raise PrologLaunchError( 583 | f"Failed to accept password: {json_to_prolog(jsonResult)}" 584 | ) 585 | else: 586 | threadTerm = prolog_args(jsonResult)[0][0][0] 587 | self.communication_thread_id, self.goal_thread_id = prolog_args(threadTerm) 588 | if len(prolog_args(jsonResult)[0][0]) > 1: 589 | versionTerm = prolog_args(jsonResult)[0][0][1] 590 | self._server_protocol_major = prolog_args(versionTerm)[0] 591 | self._server_protocol_minor = prolog_args(versionTerm)[1] 592 | else: 593 | self._server_protocol_major = 0 594 | self._server_protocol_minor = 0 595 | 596 | self._check_protocol_version() 597 | 598 | # Major versions get incremented when there is a change that will break clients written to the old version 599 | # Minor versions get incremented if there are changes but it will not break clients written to the previous version 600 | def _check_protocol_version(self): 601 | # This version of the library works around a protocol bug in MQI 0.0, so it supports that *and* MQI 1.x 602 | required_server_major = 1 603 | required_server_minor = 0 604 | 605 | if self._server_protocol_major == 0 and self._server_protocol_minor == 0: 606 | return 607 | 608 | if self._server_protocol_major == required_server_major and self._server_protocol_minor >= required_server_minor: 609 | return 610 | 611 | raise PrologLaunchError( 612 | f"This version of swiplserver requires MQI major version {required_server_major} and minor version >= {required_server_minor}. The server is running MQI '{self._server_protocol_major}.{self._server_protocol_minor}'." 613 | ) 614 | 615 | def stop(self): 616 | """ 617 | Do an orderly stop of the thread running in the Prolog process associated with this object and close the connection to the `prolog_mqi` specified in `PrologThread`. 618 | 619 | If an asynchronous query is running on that thread, it is halted using Prolog's `abort`. 620 | """ 621 | if self._socket: 622 | if not self._prolog_server.connection_failed: 623 | with suppress(OSError): 624 | # attempt a clean exit so SWI Prolog doesn't shutdown 625 | self._send("close.\n") 626 | self._return_prolog_response() 627 | 628 | with suppress(Exception): 629 | self._socket.close() 630 | 631 | self._socket = None 632 | 633 | def query(self, value: str, query_timeout_seconds: float = None): 634 | """ 635 | Run a Prolog query and wait to return all results (as if run using Prolog `findall/3`) or optionally time out. 636 | 637 | Calls `PrologMQI.start()` and `PrologThread.start()` if either is not already started. 638 | 639 | The query is run on the same Prolog thread every time, emulating the Prolog top level. There is no way to cancel the goal using this method, so using a timeout is recommended. To run a cancellable goal, use `PrologThread.query_async()`. 640 | 641 | Args: 642 | value: A Prolog query to execute as a string, just like you would run on the Prolog top level. e.g. `"member(X, [1, 2]), X = 2"`. 643 | query_timeout_seconds: `None` uses the query_timeout_seconds set in the prolog_mqi object passed to `PrologThread`. 644 | 645 | Raises: 646 | `PrologQueryTimeoutError` if the query timed out. 647 | 648 | `PrologError` for all other exceptions that occurred when running the query in Prolog. 649 | 650 | `PrologConnectionFailedError` if the query thread has unexpectedly exited. The MQI will no longer be listening after this exception. 651 | 652 | Returns: 653 | False: The query failed. 654 | True: The query succeeded once with no free variables. 655 | list: The query succeeded once with free variables or more than once with no free variables. There will be an item in the list for every answer. Each item will be: 656 | 657 | - `True` if there were no free variables 658 | - A `dict` if there were free variables. Each key will be the name of a variable, each value will be the JSON representing the term it was unified with. Note that a special variable called `$residuals` will be added to each answer that has residual [variable constraints on it](https://www.swi-prolog.org/pldoc/man?section=attvar). This will contain a list of all the constraints on all the variables for that answer. 659 | """ 660 | if self._socket is None: 661 | self.start() 662 | value = value.strip() 663 | value = value.rstrip("\n.") 664 | timeoutString = ( 665 | "_" if query_timeout_seconds is None else str(query_timeout_seconds) 666 | ) 667 | self._send(f"run(({value}), {timeoutString}).\n") 668 | return self._return_prolog_response() 669 | 670 | def query_async( 671 | self, value: str, find_all: bool = True, query_timeout_seconds: float = None 672 | ): 673 | """Start a Prolog query and return immediately unless a previous query is still running. In that case, wait until the previous query finishes before returning. 674 | 675 | Calls `PrologMQI.start()` and `PrologThread.start()` if either is not already started. 676 | 677 | Answers are retrieved using `PrologThread.query_async_result()`. The query can be cancelled by calling `PrologThread.cancel_query_async()`. The query is run on the same Prolog thread every time, emulating the Prolog top level. 678 | 679 | Args: 680 | value: A Prolog query to execute as a string, just like you would run on the Prolog top level. e.g. `"member(X, [1, 2]), X = 2"`. 681 | find_all: `True` (default) will run the query using Prolog's `findall/3` to return all answers with one call to `PrologThread.query_async_result()`. `False` will return one answer per `PrologThread.query_async_result()` call. 682 | query_timeout_seconds: `None` uses the `query_timeout_seconds` set in the `prolog_mqi` object passed to `PrologThread`. 683 | 684 | Raises: 685 | `PrologError` if an exception occurs in Prolog when parsing the goal. 686 | 687 | `PrologConnectionFailedError` if the query thread has unexpectedly exited. The MQI will no longer be listening after this exception. 688 | 689 | Any exception that happens when running the query is raised when calling `PrologThread.query_async_result()` 690 | 691 | Returns: 692 | `True` 693 | """ 694 | if self._socket is None: 695 | self.start() 696 | 697 | value = value.strip() 698 | value = value.rstrip("\n.") 699 | timeoutString = ( 700 | "_" if query_timeout_seconds is None else str(query_timeout_seconds) 701 | ) 702 | findallResultsString = "true" if find_all else "false" 703 | self._send(f"run_async(({value}), {timeoutString}, {findallResultsString}).\n") 704 | self._return_prolog_response() 705 | 706 | def cancel_query_async(self): 707 | """ 708 | Attempt to cancel a query started with `PrologThread.query_async()` in a way that allows further queries to be run on this `PrologThread` afterwards. 709 | 710 | If there is a query running, injects a Prolog `throw(cancel_goal)` into the query's thread. Does not inject Prolog `abort/0` because this would kill the thread and we want to keep the thread alive for future queries. This means it is a "best effort" cancel since the exception can be caught by your Prolog code. `cancel_query_async()` is guaranteed to either raise an exception (if there is no query or pending results from the last query), or safely attempt to stop the last executed query. 711 | 712 | To guaranteed that a query is cancelled, call `PrologThread.stop()` instead. 713 | 714 | It is not necessary to determine the outcome of `cancel_query_async()` after calling it. Further queries can be immediately run after calling `cancel_query_async()`. They will be run after the current query stops for whatever reason. 715 | 716 | If you do need to determine the outcome or determine when the query stops, call `PrologThread.query_async_result(wait_timeout_seconds = 0)`. Using `wait_timeout_seconds = 0` is recommended since the query might have caught the exception or still be running. Calling `PrologThread.query_async_result()` will return the "natural" result of the goal's execution. The "natural" result depends on the particulars of what the code actually did. The return value could be one of: 717 | 718 | - Raise `PrologQueryCancelledError` if the goal was running and did not catch the exception. I.e. the goal was successfully cancelled. 719 | - Raise `PrologQueryTimeoutError` if the query timed out before getting cancelled. 720 | - Raise `PrologError` (i.e. an arbitrary exception) if query hits another exception before it has a chance to be cancelled. 721 | - A valid answer if the query finished before being cancelled. 722 | 723 | Note that you will need to continue calling `PrologThread.query_async_result()` until you receive `None` or an exception to be sure the query is finished (see documentation for `PrologThread.query_async_result()`). 724 | 725 | Raises: 726 | `PrologNoQueryError` if there was no query running and no results that haven't been retrieved yet from the last query. 727 | 728 | `PrologConnectionFailedError` if the query thread has unexpectedly exited. The MQI will no longer be listening after this exception. 729 | 730 | Returns: 731 | `True`. Note that this does not mean the query was successfully cancelled (see notes above). 732 | """ 733 | self._send("cancel_async.\n") 734 | self._return_prolog_response() 735 | 736 | def query_async_result(self, wait_timeout_seconds: float = None): 737 | """Get results from a query that was run using `PrologThread.query_async()`. 738 | 739 | Used to get results for all cases: if the query terminates normally, is cancelled by `PrologThread.cancel_query_async()`, or times out. Each call to `query_async_result()` returns one result and either `None` or raises an exception when there are no more results. Any raised exception except for `PrologResultNotAvailableError` indicates there are no more results. If `PrologThread.query_async()` was run with `find_all == False`, multiple `query_async_result()` calls may be required before receiving the final None or raised exception. 740 | 741 | Examples: 742 | - If the query succeeds with N answers: `query_async_result()` calls 1 to N will receive each answer, in order, and `query_async_result()` call N+1 will return `None`. 743 | - If the query fails (i.e. has no answers): `query_async_result()` call 1 will return False and `query_async_result()` ` call 2 will return `None`. 744 | - If the query times out after one answer, `query_async_result()` call 1 will return the first answer and `query_async_result()` call 2 will raise `PrologQueryTimeoutError`. 745 | - If the query is cancelled after it had a chance to get 3 answers: `query_async_result()` calls 1 to 3 will receive each answer, in order, and `query_async_result()` call 4 will raise `PrologQueryCancelledError`. 746 | - If the query throws an exception before returning any results, `query_async_result()` call 1 will raise `PrologError`. 747 | 748 | Note that, after calling `PrologThread.cancel_query_async()`, calling `query_async_result()` will return the "natural" result of the goal's execution. See documentation for `PrologThread.cancel_query_async()` for more information. 749 | 750 | Args: 751 | wait_timeout_seconds: Wait `wait_timeout_seconds` seconds for a result, or forever if `None`. If the wait timeout is exceeded before a result is available, raises `PrologResultNotAvailableError`. 752 | 753 | Raises: 754 | `PrologNoQueryError` if there is no query in progress. 755 | 756 | `PrologResultNotAvailableError` if there is a running query and no results were available in `wait_timeout_seconds`. 757 | 758 | `PrologQueryCancelledError` if the next answer was the exception caused by `PrologThread.cancel_query_async()`. Indicates no more answers. 759 | 760 | `PrologQueryTimeoutError` if the query timed out generating the next answer (possibly in a race condition before getting cancelled). Indicates no more answers. 761 | 762 | `PrologError` if the next answer is an arbitrary exception thrown when the query was generating the next answer. This can happen after `PrologThread.cancel_query_async()` is called if the exception for cancelling the query is caught or the code hits another exception first. Indicates no more answers. 763 | 764 | `PrologConnectionFailedError` if the query thread unexpectedly exited. The MQI will no longer be listening after this exception. 765 | 766 | Returns: 767 | False: The query failed. 768 | True: The next answer is success with no free variables. 769 | list: The query succeeded once with free variables or more than once with no free variables. There will be an item in the list for every answer. Each item will be: 770 | 771 | - `True` if there were no free variables 772 | - A `dict` if there were free variables. Each key will be the name of a variable, each value will be the JSON representing the term it was unified with. Note that a special variable called `$residuals` will be added to each answer that has residual [variable constraints on it](https://www.swi-prolog.org/pldoc/man?section=attvar). This will contain a list of all the constraints on all the variables for that answer. 773 | 774 | """ 775 | timeoutString = ( 776 | "-1" if wait_timeout_seconds is None else str(wait_timeout_seconds) 777 | ) 778 | self._send(f"async_result({timeoutString}).\n") 779 | return self._return_prolog_response() 780 | 781 | def halt_server(self): 782 | """ 783 | Perform an orderly shutdown of the Machine Query Interface and end the Prolog process. 784 | 785 | This is called automatically by `PrologMQI.stop()` and when a `PrologMQI` instance is used in a Python `with` statement. 786 | """ 787 | self._send("quit.\n") 788 | # wait for the answer to make sure it was processed 789 | result = self._return_prolog_response() 790 | # Set this so the thread doesn't try to close down cleanly since the MQI is gone 791 | self._prolog_server.connection_failed = True 792 | 793 | # Returns: 794 | # false/0: False 795 | # true[[]]: True 796 | # true[[], [], ...]: [True, True, ...] 797 | # true[[...], [...], ...]: [{"var1": }, {"var1": }, {"var1": }, {"var1": }, ...] 798 | # exception(no_more_results): None 799 | # 800 | # Raises: 801 | # PrologConnectionFailedError if the connection failed 802 | # PrologQueryTimeoutError if the query timed out 803 | # PrologError if an exception not above is returned 804 | # PrologNoQueryError if the user attempted to cancel and there was no query 805 | # PrologQueryCancelledError if a query was cancelled and goals are trying to be retrieved 806 | # PrologResultNotAvailableError if query_async_result is called with a timeout and the result is not available 807 | def _return_prolog_response(self): 808 | result = self._receive() 809 | jsonResult = json.loads(result) 810 | if prolog_name(jsonResult) == "exception": 811 | if jsonResult["args"][0] == "no_more_results": 812 | return None 813 | elif jsonResult["args"][0] == "connection_failed": 814 | self._prolog_server.connection_failed = True 815 | elif not isinstance(jsonResult["args"][0], str): 816 | raise PrologError(jsonResult) 817 | 818 | raise { 819 | "connection_failed": PrologConnectionFailedError(jsonResult), 820 | "time_limit_exceeded": PrologQueryTimeoutError(jsonResult), 821 | "no_query": PrologNoQueryError(jsonResult), 822 | "cancel_goal": PrologQueryCancelledError(jsonResult), 823 | "result_not_available": PrologResultNotAvailableError(jsonResult), 824 | }.get(jsonResult["args"][0], PrologError(jsonResult)) 825 | else: 826 | if prolog_name(jsonResult) == "false": 827 | return False 828 | elif prolog_name(jsonResult) == "true": 829 | answerList = [] 830 | for answer in prolog_args(jsonResult)[0]: 831 | if len(answer) == 0: 832 | answerList.append(True) 833 | else: 834 | answerDict = {} 835 | for answerAssignment in answer: 836 | # These will all be =(Variable, Term) terms 837 | answerDict[prolog_args(answerAssignment)[0]] = prolog_args( 838 | answerAssignment 839 | )[1] 840 | answerList.append(answerDict) 841 | if answerList == [True]: 842 | return True 843 | else: 844 | return answerList 845 | 846 | return jsonResult 847 | 848 | def _send(self, value): 849 | value = value.strip() 850 | value = value.rstrip("\n.") 851 | value += ".\n" 852 | _log.debug("PrologMQI send: %s", value) 853 | utf8Value = value.encode("utf-8") 854 | if self._server_protocol_major == 0: 855 | # Work around bug in version 0 of MQI that required 856 | # count of Unicode code points (not bytes) 857 | messageLen = len(value) 858 | else: 859 | messageLen = len(utf8Value) 860 | 861 | msgHeader = f"{str(messageLen)}.\n".encode("utf-8") 862 | self._socket.sendall(msgHeader) 863 | self._socket.sendall(utf8Value) 864 | 865 | # The format of sent and received messages is identical: `.\n.\n`. For example: `7.\nhello.\n`: 866 | # - `` is the number of bytes of the string to follow (including the `.\n`), in human readable numbers, such as `15` for a 15 byte string. It must be followed by `.\n`. 867 | # - `` is the actual message string being sent, such as `run(atom(a), -1).\n`. It must always end with `.\n`. The character encoding used to decode and encode the string is UTF-8. 868 | # heartbeats (the "." character) can be sent by some commands to ensure the client is still listening. These are discarded. 869 | def _receive(self): 870 | # Look for the response 871 | amount_received = 0 872 | amount_expected = None 873 | bytesReceived = bytearray() 874 | sizeBytes = bytearray() 875 | self._heartbeat_count = 0 876 | 877 | data = None 878 | while amount_expected is None or amount_received < amount_expected: 879 | headerData = self._socket.recv(4096) 880 | if amount_expected is None: 881 | # Start / continue reading the string length 882 | # Ignore any leading "." characters because those are heartbeats 883 | for index, item in enumerate(headerData): 884 | # String length ends with '.\n' characters 885 | if chr(item) == ".": 886 | # ignore "." 887 | if len(sizeBytes) == 0: 888 | # Count heartbeats for testing only 889 | self._heartbeat_count += 1 890 | continue 891 | if chr(item) == "\n": 892 | # convert all the characters we've received so far to a number 893 | stringLength = "".join(chr(code) for code in sizeBytes) 894 | amount_expected = int(stringLength) 895 | # And consume the rest of the stream 896 | data = bytearray(headerData[index + 1 :]) 897 | break 898 | else: 899 | sizeBytes.append(item) 900 | if data is None: 901 | continue 902 | else: 903 | data = headerData 904 | 905 | amount_received += len(data) 906 | bytesReceived += data 907 | 908 | finalValue = bytesReceived.decode("utf-8") 909 | _log.debug("PrologMQI receive: %s", finalValue) 910 | return finalValue 911 | 912 | 913 | def create_posix_path(os_path): 914 | """ 915 | Convert a file path in whatever the current OS path format is to be a posix path so Prolog can understand it. 916 | 917 | This is useful for Prolog predicates like `consult` which need a Posix path to be passed in on any platform. 918 | """ 919 | # Attempt to convert the local file system format to Posix. Need to handle 920 | # "C:\" on windows with a workaround since PurePath doesn't really handle it right 921 | convertedPath = str(PurePosixPath(PurePath(os_path))) 922 | if convertedPath[0] != "/" and convertedPath[1] == ":" and convertedPath[2] == "\\": 923 | finalPath = convertedPath[0] + convertedPath[1] + convertedPath[3:] 924 | else: 925 | finalPath = convertedPath 926 | return finalPath 927 | 928 | 929 | def is_prolog_functor(json_term): 930 | """ 931 | True if json_term is Prolog JSON representing a Prolog functor (i.e. a term with zero or more arguments). See `PrologThread.query` for documentation on the Prolog JSON format. 932 | """ 933 | return ( 934 | isinstance(json_term, dict) and "functor" in json_term and "args" in json_term 935 | ) 936 | 937 | 938 | def is_prolog_list(json_term): 939 | """ 940 | True if json_term is Prolog JSON representing a Prolog list. See `PrologThread.query` for documentation on the Prolog JSON format. 941 | """ 942 | return isinstance(json_term, list) 943 | 944 | 945 | def is_prolog_variable(json_term): 946 | """ 947 | True if json_term is Prolog JSON representing a Prolog variable. See `PrologThread.query` for documentation on the Prolog JSON format. 948 | """ 949 | return isinstance(json_term, str) and ( 950 | json_term[0].isupper() or json_term[0] == "_" 951 | ) 952 | 953 | 954 | def is_prolog_atom(json_term): 955 | """ 956 | True if json_term is Prolog JSON representing a Prolog atom. See `PrologThread.query` for documentation on the Prolog JSON format. 957 | """ 958 | return isinstance(json_term, str) and not is_prolog_variable(json_term) 959 | 960 | 961 | def prolog_name(json_term): 962 | """ 963 | Return the atom (if json_term is an atom), variable (if a variable) or functor name of json_term. json_term must be in the Prolog JSON format. See `PrologThread.query` for documentation on the Prolog JSON format. 964 | """ 965 | if is_prolog_atom(json_term) or is_prolog_variable(json_term): 966 | return json_term 967 | else: 968 | return json_term["functor"] 969 | 970 | 971 | def prolog_args(json_term): 972 | """ 973 | Return the arguments from json_term if json_term is in the Prolog JSON format. See `PrologThread.query` for documentation on the Prolog JSON format. 974 | """ 975 | return json_term["args"] 976 | 977 | 978 | def quote_prolog_identifier(identifier: str): 979 | """ 980 | Surround a Prolog identifier with '' if Prolog rules require it. 981 | """ 982 | if not is_prolog_atom(identifier): 983 | return identifier 984 | else: 985 | mustQuote = is_prolog_atom(identifier) and ( 986 | len(identifier) == 0 987 | or not identifier[0].isalpha() 988 | or 989 | # characters like _ are allowed without quoting 990 | not identifier.translate({ord(c): "" for c in "_"}).isalnum() 991 | ) 992 | 993 | if mustQuote: 994 | return f"'{identifier}'" 995 | else: 996 | return identifier 997 | 998 | 999 | def json_to_prolog(json_term): 1000 | """ 1001 | Convert json_term from the Prolog JSON format to a string that represents the term in the Prolog language. See `PrologThread.query` for documentation on the Prolog JSON format. 1002 | """ 1003 | if is_prolog_functor(json_term): 1004 | argsString = [json_to_prolog(item) for item in prolog_args(json_term)] 1005 | return f"{quote_prolog_identifier(prolog_name(json_term))}({', '.join(argsString)})" 1006 | elif is_prolog_list(json_term): 1007 | listString = [json_to_prolog(item) for item in json_term] 1008 | return f"[{', '.join(listString)}]" 1009 | else: 1010 | # must be an atom, number or variable 1011 | return str(quote_prolog_identifier(json_term)) 1012 | 1013 | 1014 | # Helper to print all data from STDERR and STDOUT as it gets printed 1015 | # Code inspired by http://eyalarubas.com/python-subproc-nonblock.html 1016 | class _NonBlockingStreamReader: 1017 | def __init__(self, stream): 1018 | def _print_output(stream): 1019 | while True: 1020 | with suppress(Exception): 1021 | line = stream.readline() 1022 | 1023 | # When the process exits the stream will return EOF 1024 | # and allow us to exit the thread cleanly 1025 | if line: 1026 | _log.critical(f"Prolog: {line.decode().rstrip()}") 1027 | else: 1028 | break 1029 | 1030 | self._stream = stream 1031 | self._thread = Thread(target=_print_output, args=(self._stream,)) 1032 | # Don't run the thread as a daemon as it causes errors on exit like these: 1033 | # https://bugs.python.org/issue42717 1034 | # https://bugs.python.org/issue23309 1035 | # https: // bugs.python.org / issue43308 1036 | 1037 | # self._thread.daemon = True 1038 | self._thread.start() 1039 | 1040 | 1041 | _log = logging.getLogger("swiplserver") 1042 | 1043 | 1044 | if __name__ == "__main__": 1045 | # This is not guaranteed to create a socket that is portable, but since 1046 | # we are testing we are OK with it. 1047 | socketPath = os.path.dirname(os.path.realpath(__file__)) 1048 | -------------------------------------------------------------------------------- /test_mqi.pl: -------------------------------------------------------------------------------- 1 | /* Prolog Language Server 2 | Author: Eric Zinda 3 | E-mail: ericz@inductorsoftware.com 4 | WWW: http://www.inductorsoftware.com 5 | Copyright (c) 2021-2023, Eric Zinda 6 | SWI-Prolog Solutions b.v. 7 | All rights reserved. 8 | 9 | Redistribution and use in source and binary forms, with or without 10 | modification, are permitted provided that the following conditions 11 | are met: 12 | 13 | 1. Redistributions of source code must retain the above copyright 14 | notice, this list of conditions and the following disclaimer. 15 | 16 | 2. Redistributions in binary form must reproduce the above copyright 17 | notice, this list of conditions and the following disclaimer in 18 | the documentation and/or other materials provided with the 19 | distribution. 20 | 21 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 22 | "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 23 | LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS 24 | FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE 25 | COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, 26 | INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, 27 | BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 28 | LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 29 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT 30 | LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN 31 | ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE 32 | POSSIBILITY OF SUCH DAMAGE. 33 | */ 34 | 35 | :- module(test_mqi, 36 | [ test_mqi/0, 37 | test_mqi_all/0 38 | ]). 39 | :- use_module(library(plunit)). 40 | :- use_module(library(process)). 41 | :- use_module(library(debug)). 42 | :- use_module(library(mqi)). 43 | 44 | :- debug(test). 45 | 46 | :- dynamic 47 | python_exe/1. 48 | 49 | has_python :- 50 | python_exe(_), 51 | !. 52 | has_python :- 53 | has_python(Prog), 54 | asserta(python_exe(Prog)). 55 | 56 | has_python(Prog) :- 57 | exe_options(Options), 58 | absolute_file_name(path(python3), Prog, Options). 59 | 60 | exe_options(Options) :- 61 | current_prolog_flag(windows, true), 62 | !, 63 | ( Options = [ extensions(['',exe,com]), access(read), file_errors(fail) ] 64 | ; Options = [ extensions(['',exe,com]), access(exist), file_errors(fail) ] 65 | ). 66 | exe_options(Options) :- 67 | Options = [ access(execute) ]. 68 | 69 | 70 | test_mqi :- 71 | ( has_python 72 | -> run_tests([py_mqi_fast]) 73 | ; print_message(informational, test_no_python) 74 | ). 75 | test_mqi_all :- 76 | ( has_python 77 | -> run_tests([py_mqi]) 78 | ; print_message(informational, test_no_python) 79 | ). 80 | 81 | % Launch the python script with command line arguments so it can, in turn, 82 | % launch the proper development build of prolog, passing all the same command 83 | % line arguments to it 84 | run_test_script(Script, Status, EssentialOnly) :- 85 | source_file(test_mqi, ThisFile), 86 | file_directory_name(ThisFile, ThisDir), 87 | current_prolog_flag(os_argv, [_|Args]), 88 | current_prolog_flag(executable, Swipl_exe), 89 | absolute_file_name(Swipl_exe, Swipl), 90 | file_directory_name(Swipl, Swipl_Path), 91 | atomic_list_concat(Args, '~|~', Args_String), 92 | debug(test, 'swipl in dir ~p; Packed args: ~p', [Swipl_Path, Args_String]), 93 | % Python for Windows wants this 94 | ( current_prolog_flag(windows, true) 95 | -> getenv('SYSTEMROOT', SR), 96 | System_Root = ['SYSTEMROOT'=SR] 97 | ; System_Root = [] 98 | ), 99 | python_exe(Python), 100 | process_create(Python, [Script], 101 | [ stdin(std), 102 | stdout(pipe(Out)), 103 | stderr(pipe(Out)), 104 | process(PID), 105 | cwd(ThisDir), 106 | environment([ 'PROLOG_PATH'=Swipl_Path, 107 | 'PROLOG_ARGS'=Args_String, 108 | 'ESSENTIAL_TESTS_ONLY'=EssentialOnly 109 | | System_Root 110 | ])]), 111 | ( debugging(test) 112 | -> call_cleanup(copy_stream_data(Out, current_output), 113 | close(Out)) 114 | ; setup_call_cleanup( 115 | open_null_stream(Null), 116 | copy_stream_data(Out, Null), 117 | close(Null)) 118 | ), 119 | process_wait(PID, Status). 120 | 121 | :- begin_tests(py_mqi_fast, [sto(rational_trees)]). 122 | 123 | test(mqi, Status == exit(0)):- 124 | run_test_script('python/test_prologserver.py', Status, 'True'). 125 | 126 | :- end_tests(py_mqi_fast). 127 | 128 | :- begin_tests(py_mqi, [sto(rational_trees)]). 129 | 130 | test(mqi, Status == exit(0)):- 131 | run_test_script('python/test_prologserver.py', Status, 'False'). 132 | 133 | :- end_tests(py_mqi). 134 | 135 | 136 | /******************************* 137 | * MESSAGES * 138 | *******************************/ 139 | 140 | :- multifile prolog:message//1. 141 | 142 | prolog:message(test_no_python) --> 143 | [ 'Could not find Python. Skipping MQI tests.' ]. 144 | --------------------------------------------------------------------------------