/*  Part of SWI-Prolog

    Author:        Jan Wielemaker
    E-mail:        jan@swi-prolog.org
    WWW:           http://www.swi-prolog.org
    Copyright (c)  2023-2024, SWI-Prolog Solutions b.v.
    All rights reserved.

    Redistribution and use in source and binary forms, with or without
    modification, are permitted provided that the following conditions
    are met:

    1. Redistributions of source code must retain the above copyright
       notice, this list of conditions and the following disclaimer.

    2. Redistributions in binary form must reproduce the above copyright
       notice, this list of conditions and the following disclaimer in
       the documentation and/or other materials provided with the
       distribution.

    THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
    "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
    LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS
    FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE
    COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT,
    INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING,
    BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
    LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
    CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT
    LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN
    ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
    POSSIBILITY OF SUCH DAMAGE.
*/

:- module(janus,
          [ py_version/0,

            py_call/1,                  % +Call
            py_call/2,                  % +Call, -Return
            py_call/3,                  % +Call, -Return, +Options
	    py_iter/2,			% +Call, -Return
	    py_iter/3,			% +Call, -Return, +Options
            py_setattr/3,               % +On, +Name, +Value
            py_free/1,			% +Obj
	    py_is_object/1,		% @Term
	    py_is_dict/1,		% @Term
	    py_with_gil/1,		% :Goal
	    py_gil_owner/1,		% -ThreadID

            py_func/3,                  % +Module, +Func, -Return
            py_func/4,                  % +Module, +Func, -Return, +Options
            py_dot/3,                   % +ObjRef, +Meth, ?Ret
            py_dot/4,                   % +ObjRef, +Meth, -Ret, +Options

            values/3,                   % +Dict, +Path, ?Val
            keys/2,                     % +Dict, ?Keys
            key/2,                      % +Dict, ?Key
            items/2,                    % +Dict, ?Items

            py_shell/0,

	    py_pp/1,                    % +Term
            py_pp/2,                    % +Stream, +Term
            py_pp/3,                    % +Stream, +Term, +Options

            py_object_dir/2,            % +ObjRef, -List
            py_object_dict/2,           % +ObjRef, -Dict
            py_obj_dir/2,               % +ObjRef, -List (deprecated)
            py_obj_dict/2,              % +ObjRef, -Dict (deprecated)
            py_type/2,			% +ObjRef, -Type:atom
            py_isinstance/2,            % +ObjRef, +Type
            py_module_exists/1,         % +Module
            py_hasattr/2,               % +Module, ?Symbol

            py_module/2,                % +Module:atom, +Source:string

            py_initialize/3,            % +Program, +Argv, +Options
            py_lib_dirs/1,              % -Dirs
            py_add_lib_dir/1,           % +Dir
            py_add_lib_dir/2,           % +Dir,+Where

            op(200, fy, @),             % @constant
            op(50,  fx, #)              % #Value
          ]).
:- meta_predicate py_with_gil(0).

:- use_module(library(apply_macros), []).
:- autoload(library(lists), [append/3, member/2, append/2]).
:- autoload(library(apply),
            [maplist/2, exclude/3, maplist/3, convlist/3, partition/4]).
:- autoload(library(error), [must_be/2, domain_error/2]).
:- autoload(library(dicts), [dict_keys/2]).
:- autoload(library(option), [dict_options/2, select_option/4, option/2]).
:- autoload(library(prolog_code), [comma_list/2]).
:- autoload(library(readutil), [read_line_to_string/2, read_file_to_string/3]).
:- autoload(library(wfs), [call_delays/2, delays_residual_program/2]).
:- autoload(library(dcg/high_order), [sequence//2, sequence//3]).

:- if(\+current_predicate(py_call/1)).
:- if(current_prolog_flag(windows, true)).
:- use_module(library(shlib), [win_add_dll_directory/1]).

% Just having the Python dir in PATH seems insufficient. We also need to
% add the directory to the DLL search path.
add_python_dll_dir :-
    (   current_prolog_flag(msys2, true)
    ->  absolute_file_name(path('libpython3.dll'), DLL, [access(read)])
    ;   absolute_file_name(path('python3.dll'), DLL, [access(read)])
    ),
    file_directory_name(DLL, Dir),
    win_add_dll_directory(Dir).
:- initialization(add_python_dll_dir, now).
:- endif.

:- use_foreign_library(foreign(janus), [visibility(global)]).
:- endif.

:- predicate_options(py_call/3, 3,
                     [ py_object(boolean),
                       py_string_as(oneof([string,atom]))
                     ]).
:- predicate_options(py_func/4, 4,
                     [ pass_to(py_call/3, 3)
                     ]).
:- predicate_options(py_dot/5, 5,
                     [ pass_to(py_call/3, 3)
                     ]).

:- public
    py_initialize/0,
    py_call_string/3,
    py_write/2,
    py_readline/4.

:- create_prolog_flag(py_backtrace,       true, [type(boolean), keep(true)]).
:- create_prolog_flag(py_backtrace_depth, 4,    [type(integer), keep(true)]).
:- create_prolog_flag(py_argv,		  [],   [type(term), keep(true)]).

/** <module> Call Python from Prolog

This library implements calling Python  from   Prolog.  It  is available
directly from Prolog if  the  janus   package  is  bundled.  The library
provides access to an  _embedded_  Python   instance.  If  SWI-Prolog is
embedded into Python  using  the   Python  package  ``janus-swi``,  this
library is provided either from Prolog or from the Python package.

Normally,  the  Prolog  user  can  simply  start  calling  Python  using
py_call/2 or friends. In special cases it   may  be needed to initialize
Python with options using  py_initialize/3   and  optionally  the Python
search path may be extended using py_add_lib_dir/1.
*/

%!  py_version is det.
%
%   Print version  info on the  embedded Python installation  based on
%   Python `sys.version`.  If a Python _virtual environment_ (venv) is
%   active, indicate this with the location of this environment found.

py_version :-
    py_call(sys:version, PythonVersion),
    py_call(janus_swi:version_str(), JanusVersion),
    print_message(information, janus(version(JanusVersion, PythonVersion))),
    (   py_venv(VEnvDir, EnvSiteDir)
    ->  print_message(information, janus(venv(VEnvDir, EnvSiteDir)))
    ;   true
    ).


%!  py_call(+Call) is det.
%!  py_call(+Call, -Return) is det.
%!  py_call(+Call, -Return, +Options) is det.
%
%   Call Python and return the result of   the called function. Call has
%   the shape `[Target][:Action]*`, where `Target`   is  either a Python
%   module name or a Python object reference. Each `Action` is either an
%   atom to get the denoted attribute from   current `Target` or it is a
%   compound term where the first  argument   is  the function or method
%   name  and  the  arguments  provide  the  parameters  to  the  Python
%   function. On success, the returned Python   object  is translated to
%   Prolog.  `Action` without a `Target` denotes a buit-in function.
%
%   Arguments to Python  functions  use   the  Python  conventions. Both
%   _positional_  and  _keyword_  arguments    are   supported.  Keyword
%   arguments are written as `Name = Value`   and  must appear after the
%   positional arguments.
%
%   Below are some examples.
%
%       % call a built-in
%	?- py_call(print("Hello World!\n")).
%	true.
%
%       % call a built-in (alternative)
%	?- py_call(builtins:print("Hello World!\n")).
%	true.
%
%	% call function in a module
%	?- py_call(sys:getsizeof([1,2,3]), Size).
%	Size = 80.
%
%	% call function on an attribute of a module
%       ?- py_call(sys:path:append("/home/bob/janus")).
%       true
%
%       % get attribute from a module
%       ?- py_call(sys:path, Path)
%       Path = ["dir1", "dir2", ...]
%
%   Given a class in a file `dog.py`  such as the following example from
%   the Python documentation
%
%   ```
%   class Dog:
%       tricks = []
%
%       def __init__(self, name):
%           self.name = name
%
%       def add_trick(self, trick):
%           self.tricks.append(trick)
%   ```
%
%   We can interact with this class as  below. Note that ``$Doc`` in the
%   SWI-Prolog toplevel refers to the  last   toplevel  binding  for the
%   variable `Dog`.
%
%       ?- py_call(dog:'Dog'("Fido"), Dog).
%       Dog = <py_Dog>(0x7f095c9d02e0).
%
%       ?- py_call($Dog:add_trick("roll_over")).
%       Dog = <py_Dog>(0x7f095c9d02e0).
%
%       ?- py_call($Dog:tricks, Tricks).
%       Dog = <py_Dog>(0x7f095c9d02e0),
%       Tricks = ["roll_over"]
%
%   If the principal term of the   first  argument is not `Target:Func`,
%   The argument is evaluated as the initial target, i.e., it must be an
%   object reference or a module.   For example:
%
%       ?- py_call(dog:'Dog'("Fido"), Dog),
%          py_call(Dog, X).
%          Dog = X, X = <py_Dog>(0x7fa8cbd12050).
%       ?- py_call(sys, S).
%          S = <py_module>(0x7fa8cd582390).
%
%   Options processed:
%
%     - py_object(Boolean)
%       If `true` (default `false`), translate the return as a Python
%       object reference. Some objects are _always_ translated to
%       Prolog, regardless of this flag.  These are the Python constants
%       ``None``, ``True`` and ``False`` as well as instances of the
%       Python base classes `int`, `float`, `str` or `tuple`. Instances
%       of sub classes of these base classes are controlled by this
%       option.
%     - py_string_as(+Type)
%       If Type is `atom` (default), translate a Python String into a
%       Prolog atom.  If Type is `string`, translate into a Prolog string.
%	Strings are more efficient if they are short lived.
%     - py_dict_as(+Type)
%       One of `dict` (default) to map a Python dict to a SWI-Prolog
%       dict if all keys can be represented.  If `{}` or not all keys
%       can be represented, Return is unified to a term `{k:v, ...}`
%       or `py({})` if the Python dict is empty.
%
%   @compat  PIP.  The  options  `py_string_as`   and  `py_dict_as`  are
%   SWI-Prolog  specific,  where  SWI-Prolog   Janus  represents  Python
%   strings as atoms as required by  the   PIP  and it represents Python
%   dicts by default  as  SWI-Prolog   dicts.  The  predicates values/3,
%   keys/2, etc. provide portable access to the data in the dict.

%!  py_iter(+Iterator, -Value) is nondet.
%!  py_iter(+Iterator, -Value, +Options) is nondet.
%
%   True when Value is returned by the Python Iterator. Python iterators
%   may be used to implement   non-deterministic foreign predicates. The
%   implementation uses these steps:
%
%     1. Evaluate Iterator as py_call/2 evaluates its first argument,
%        except the ``Obj:Attr = Value`` construct is not accepted.
%     2. Call ``__iter__`` on the result to get the iterator itself.
%     3. Get the ``__next__`` function of the iterator.
%     4. Loop over the return values of the _next_ function.  If
%        the Python return value unifies with Value, succeed with
%        a choicepoint.  Abort on Python or unification exceptions.
%     5. Re-satisfaction continues at (4).
%
%   The example below uses the built-in iterator range():
%
%       ?- py_iter(range(1,3), X).
%       X = 1 ;
%       X = 2.
%
%   Note that the implementation performs a   _look  ahead_, i.e., after
%   successful unification it calls `__next__()`   again. On failure the
%   Prolog predicate succeeds deterministically. On   success,  the next
%   candidate is stored.
%
%   Note that a Python _generator_ is   a  Python _iterator_. Therefore,
%   given  the  Python  generator   expression    below,   we   can  use
%   py_iter(squares(1,5),X) to generate the squares on backtracking.
%
%   ```
%   def squares(start, stop):
%        for i in range(start, stop):
%            yield i * i
%   ```
%
%   @arg Options is processed as with py_call/3.
%   @bug Iterator may not depend on janus.query(), i.e., it is not
%   possible to iterate over a Python iterator that under the hoods
%   relies on a Prolog non-deterministic predicate.
%   @compat PIP.  The same remarks as for py_call/2 apply.

%!  py_setattr(+Target, +Name, +Value) is det.
%
%   Set a Python attribute on an object.  If   Target  is an atom, it is
%   interpreted  as  a  module.  Otherwise  it  is  normally  an  object
%   reference. py_setattr/3 allows for  _chaining_   and  behaves  as if
%   defined as
%
%       py_setattr(Target, Name, Value) :-
%           py_call(Target, Obj, [py_object(true)]),
%           py_call(setattr(Obj, Name, Value)).
%
%   @compat PIP

%!  py_run(+String, +Globals, +Locals, -Result, +Options) is det.
%
%   Interface  to  Py_CompileString()  followed   by  PyEval_EvalCode().
%   Options:
%
%       - file_name(String)
%         Errors are reported against this pseudo file name
%       - start(Token)
%         One of `eval`, `file` (default) or `single`.
%
%   @arg Globals is a dict
%   @arg Locals is a dict

%!  py_is_object(@Term) is semidet.
%
%   True when Term is a Python object reference. Fails silently if Term
%   is any other Prolog term.
%
%   @error existence_error(py_object, Term) is raised of Term is a
%   Python object, but it has been freed using py_free/1.
%
%   @compat PIP. The SWI-Prolog implementation is safe in the sense that
%   an arbitrary term cannot be confused  with   a  Python  object and a
%   reliable error is generated  if  the   references  has  been  freed.
%   Portable applications can not rely on this.

%!  py_is_dict(@Term) is semidet.
%
%   True if Term is a Prolog term that represents a Python dict.
%
%   @compat PIP. The SWI-Prolog version accepts   both a SWI-Prolog dict
%   and the `{k:v,...}`  representation.  See   `py_dict_as`  option  of
%   py_call/2.

py_is_dict(Dict), is_dict(Dict) => true.
py_is_dict(py({})) => true.
py_is_dict(py({KV})) => is_kv(KV).
py_is_dict({KV}) => is_kv(KV).

is_kv((K:V,T)) => ground(K), ground(V), is_kv(T).
is_kv(K:V) => ground(K), ground(V).


%!  py_free(+Obj) is det.
%
%   Immediately free (decrement the  reference   count)  for  the Python
%   object Obj. Further reference  to  Obj   using  e.g.,  py_call/2  or
%   py_free/1 raises an `existence_error`. Note that by decrementing the
%   reference count, we make the reference invalid from Prolog. This may
%   not  actually  delete  the  object  because   the  object  may  have
%   references inside Python.
%
%   Prolog references to Python objects  are   subject  to  atom garbage
%   collection and thus normally do not need to be freed explicitly.
%
%   @compat PIP. The SWI-Prolog  implementation   is  safe  and normally
%   reclaiming Python object can  be  left   to  the  garbage collector.
%   Portable applications may not assume   garbage  collection of Python
%   objects and must ensure to call py_free/1 exactly once on any Python
%   object reference. Not calling  py_free/1   leaks  the Python object.
%   Calling it twice may lead to undefined behavior.

%!  py_with_gil(:Goal) is semidet.
%
%   Run Goal as  once(Goal)  while  holding   the  Phyton  GIL  (_Global
%   Interpreter Lock_). Note that  all   predicates  that  interact with
%   Python lock the GIL. This predicate is   only required if we wish to
%   make multiple calls to Python while keeping   the  GIL. The GIL is a
%   _recursive_ lock and thus calling py_call/1,2  while holding the GIL
%   does not _deadlock_.

%!  py_gil_owner(-Thread) is semidet.
%
%   True when  the Python GIL is  owned by Thread.  Note  that, unless
%   Thread  is the  calling thread,  this merely  samples the  current
%   state and may thus no longer  be true when the predicate succeeds.
%   This predicate is intended to help diagnose _deadlock_ problems.
%
%   Note that  this predicate returns  the Prolog threads  that locked
%   the GIL.  It is however possible that Python releases the GIL, for
%   example if  it performs a  blocking call.  In this  scenario, some
%   other thread or no thread may hold the gil.


		 /*******************************
		 *         COMPATIBILIY		*
		 *******************************/

%!  py_func(+Module, +Function, -Return) is det.
%!  py_func(+Module, +Function, -Return, +Options) is det.
%
%   Call Python Function in  Module.   The  SWI-Prolog implementation is
%   equivalent to py_call(Module:Function, Return).   See  py_call/2 for
%   details.
%
%   @compat  PIP.  See  py_call/2  for  notes.    Note   that,  as  this
%   implementation is based on py_call/2,   Function can use _chaining_,
%   e.g., py_func(sys, path:append(dir), Return)  is   accepted  by this
%   implementation, but not portable.

py_func(Module, Function, Return) :-
    py_call(Module:Function, Return).
py_func(Module, Function, Return, Options) :-
    py_call(Module:Function, Return, Options).

%!  py_dot(+ObjRef, +MethAttr, -Ret) is det.
%!  py_dot(+ObjRef, +MethAttr, -Ret, +Options) is det.
%
%   Call a method or access  an  attribute   on  the  object ObjRef. The
%   SWI-Prolog implementation is equivalent  to py_call(ObjRef:MethAttr,
%   Return). See py_call/2 for details.
%
%   @compat PIP.  See py_func/3 for details.

py_dot(ObjRef, MethAttr, Ret) :-
    py_call(ObjRef:MethAttr, Ret).
py_dot(ObjRef, MethAttr, Ret, Options) :-
    py_call(ObjRef:MethAttr, Ret, Options).


		 /*******************************
		 *   PORTABLE ACCESS TO DICTS	*
		 *******************************/

%!  values(+Dict, +Path, ?Val) is semidet.
%
%   Get the value associated with Dict at  Path. Path is either a single
%   key or a list of keys.
%
%   @compat PIP. Note that this predicate   handle  a SWI-Prolog dict, a
%   {k:v, ...} term as well as py({k:v, ...}.

values(Dict, Key, Val), is_dict(Dict), atom(Key) =>
    get_dict(Key, Dict, Val).
values(Dict, Keys, Val), is_dict(Dict), is_list(Keys) =>
    get_dict_path(Keys, Dict, Val).
values(py({CommaDict}), Key, Val) =>
    comma_values(CommaDict, Key, Val).
values({CommaDict}, Key, Val) =>
    comma_values(CommaDict, Key, Val).

get_dict_path([], Val, Val).
get_dict_path([H|T], Dict, Val) :-
    get_dict(H, Dict, Val0),
    get_dict_path(T, Val0, Val).

comma_values(CommaDict, Key, Val), atom(Key) =>
    comma_value(Key, CommaDict, Val).
comma_values(CommaDict, Keys, Val), is_list(Keys) =>
    comma_value_path(Keys, CommaDict, Val).

comma_value(Key, Key:Val0, Val) =>
    Val = Val0.
comma_value(Key, (_,Tail), Val) =>
    comma_value(Key, Tail, Val).

comma_value_path([], Val, Val).
comma_value_path([H|T], Dict, Val) :-
    comma_value(H, Dict, Val0),
    comma_value_path(T, Val0, Val).

%!  keys(+Dict, ?Keys) is det.
%
%   True when Keys is a list of keys that appear in Dict.
%
%   @compat PIP. Note that this predicate   handle  a SWI-Prolog dict, a
%   {k:v, ...} term as well as py({k:v, ...}.

keys(Dict, Keys), is_dict(Dict) =>
    dict_keys(Dict, Keys).
keys(py({CommaDict}), Keys) =>
    comma_dict_keys(CommaDict, Keys).
keys({CommaDict}, Keys) =>
    comma_dict_keys(CommaDict, Keys).

comma_dict_keys((Key:_,T), Keys) =>
    Keys = [Key|KT],
    comma_dict_keys(T, KT).
comma_dict_keys(Key:_, Keys) =>
    Keys = [Key].

%!  key(+Dict, ?Key) is nondet.
%
%   True when Key is a key in   Dict.  Backtracking enumerates all known
%   keys.
%
%   @compat PIP. Note that this predicate   handle  a SWI-Prolog dict, a
%   {k:v, ...} term as well as py({k:v, ...}.

key(Dict, Key), is_dict(Dict) =>
    dict_pairs(Dict, _Tag, Pairs),
    member(Key-_, Pairs).
key(py({CommaDict}), Keys) =>
    comma_dict_key(CommaDict, Keys).
key({CommaDict}, Keys) =>
    comma_dict_key(CommaDict, Keys).

comma_dict_key((Key:_,_), Key).
comma_dict_key((_,T), Key) :-
    comma_dict_key(T, Key).

%!  items(+Dict, ?Items) is det.
%
%   True when Items is a list of Key:Value that appear in Dict.
%
%   @compat PIP. Note that this predicate   handle  a SWI-Prolog dict, a
%   {k:v, ...} term as well as py({k:v, ...}.

items(Dict, Items), is_dict(Dict) =>
    dict_pairs(Dict, _, Pairs),
    maplist(pair_item, Pairs, Items).
items(py({CommaDict}), Keys) =>
    comma_dict_items(CommaDict, Keys).
items({CommaDict}, Keys) =>
    comma_dict_items(CommaDict, Keys).

pair_item(K-V, K:V).

comma_dict_items((Key:Value,T), Keys) =>
    Keys = [Key:Value|KT],
    comma_dict_items(T, KT).
comma_dict_items(Key:Value, Keys) =>
    Keys = [Key:Value].


		 /*******************************
		 *             SHELL		*
		 *******************************/

%!  py_shell
%
%   Start an interactive Python REPL  loop   using  the  embedded Python
%   interpreter. The interpreter first imports `janus` as below.
%
%       from janus import *
%
%   So, we can do
%
%       ?- py_shell.
%       ...
%       >>> query_once("writeln(X)", {"X":"Hello world"})
%       Hello world
%       {'truth': True}
%
%   If possible, we enable command line   editing using the GNU readline
%   library.
%
%   When used in an environment  where  Prolog   does  not  use the file
%   handles 0,1,2 for  the  standard   streams,  e.g.,  in  `swipl-win`,
%   Python's I/O is rebound to use  Prolog's I/O. This includes Prolog's
%   command line editor, resulting in  a   mixed  history  of Prolog and
%   Pythin commands.

py_shell :-
    import_janus,
    py_call(janus_swi:interact(), _).

import_janus :-
    py_call(sys:hexversion, V),
    V >= 0x030A0000,                    % >= 3.10
    !,
    py_run("from janus_swi import *", py{}, py{}, _, []).
import_janus :-
    print_message(warning, janus(py_shell(no_janus))).


		 /*******************************
		 *          UTILITIES           *
		 *******************************/

%!  py_pp(+Term) is det.
%!  py_pp(+Term, +Options) is det.
%!  py_pp(+Stream, +Term, +Options) is det.
%
%   Pretty prints the Prolog translation of a Python data structure in
%   Python  syntax. This  exploits  pformat() from  the Python  module
%   `pprint` to do the actual  formatting.  Options is translated into
%   keyword arguments  passed to  pprint.pformat().  In  addition, the
%   option  nl(Bool)  is processed.   When  `true`  (default), we  use
%   pprint.pp(), which  makes the output  followed by a  newline.  For
%   example:
%
%   ```
%   ?- py_pp(py{a:1, l:[1,2,3], size:1000000},
%            [underscore_numbers(true)]).
%   {'a': 1, 'l': [1, 2, 3], 'size': 1_000_000}
%   ```
%
%   @compat PIP

py_pp(Term) :-
    py_pp(current_output, Term, []).

py_pp(Term, Options) :-
    py_pp(current_output, Term, Options).

py_pp(Stream, Term, Options) :-
    select_option(nl(NL), Options, Options1, true),
    (   NL == true
    ->  Method = pp
    ;   Method = pformat
    ),
    opts_kws(Options1, Kws),
    PFormat =.. [Method, Term|Kws],
    py_call(pprint:PFormat, String),
    write(Stream, String).

opts_kws(Options, Kws) :-
    dict_options(Dict, Options),
    dict_pairs(Dict, _, Pairs),
    maplist(pair_kws, Pairs, Kws).

pair_kws(Name-Value, Name=Value).


%!  py_object_dir(+ObjRef, -List) is det.
%!  py_object_dict(+ObjRef, -Dict) is det.
%
%   Examine attributes of  an  object.   The  predicate  py_object_dir/2
%   fetches the names of all attributes,   while  py_object_dir/2 gets a
%   dict with all attributes and their values.
%
%   @compat PIP

py_object_dir(ObjRef, List) :-
    py_call(ObjRef:'__dir__'(), List).

py_object_dict(ObjRef, Dict) :-
    py_call(ObjRef:'__dict__', Dict).

%!  py_obj_dir(+ObjRef, -List) is det.
%!  py_obj_dict(+ObjRef, -Dict) is det.
%
%   @deprecated Use py_object_dir/2 or py_object_dict/2.

py_obj_dir(ObjRef, List) :-
    py_object_dir(ObjRef, List).

py_obj_dict(ObjRef, Dict) :-
    py_object_dict(ObjRef, Dict).


%!  py_type(+ObjRef, -Type:atom) is det.
%
%   True when Type is the name of the   type of ObjRef. This is the same
%   as ``type(ObjRef).__name__`` in Python.
%
%   @compat PIP

py_type(ObjRef, Type) :-
    py_call(type(ObjRef):'__name__', Type).

%!  py_isinstance(+ObjRef, +Type) is semidet.
%
%   True if ObjRef is an instance of Type   or an instance of one of the
%   sub types of Type. This  is   the  same as ``isinstance(ObjRef)`` in
%   Python.
%
%   @arg Type is either a term `Module:Type` or a plain atom to refer to
%   a built-in type.
%
%   @compat PIP

py_isinstance(Obj, Module:Type) =>
    py_call(isinstance(Obj, eval(Module:Type)), @true).
py_isinstance(Obj, Type) =>
    py_call(isinstance(Obj, eval(sys:modules:'__getitem__'(builtins):Type)), @true).

%!  py_module_exists(+Module) is semidet.
%
%   True if Module is a currently  loaded   Python  module  or it can be
%   loaded.
%
%   @compat PIP

py_module_exists(Module) :-
    must_be(atom, Module),
    py_call(sys:modules:'__contains__'(Module), @true),
    !.
py_module_exists(Module) :-
    py_call(importlib:util:find_spec(Module), R),
    R \== @none,
    py_free(R).

%!  py_hasattr(+ModuleOrObj, ?Name) is nondet.
%
%   True when Name is an attribute of   Module. The name is derived from
%   the Python built-in hasattr(). If Name   is unbound, this enumerates
%   the members of py_object_dir/2.
%
%   @arg ModuleOrObj If this is an atom it refers to a module, otherwise
%   it must be a Python object reference.
%
%   @compat PIP

py_hasattr(ModuleOrObj, Name) :-
    var(Name),
    !,
    py_object_dir(ModuleOrObj, Names),
    member(Name, Names).
py_hasattr(ModuleOrObj, Name) :-
    must_be(atom, Name),
    (   atom(ModuleOrObj)
    ->  py_call(ModuleOrObj:'__name__'), % force loading
        py_call(hasattr(eval(sys:modules:'__getitem__'(ModuleOrObj)), Name), @true)
    ;   py_call(hasattr(ModuleOrObj, Name), @true)
    ).


%!  py_module(+Module:atom, +Source:string) is det.
%
%   Load Source into the Python module Module.   This  is intended to be
%   used together with the `string` _quasi quotation_ that supports long
%   strings in SWI-Prolog.   For example:
%
%   ```
%   :- use_module(library(strings)).
%   :- py_module(hello,
%                {|string||
%                 | def say_hello_to(s):
%                 |     print(f"hello {s}")
%                 |}).
%   ```
%
%   Calling this predicate multiple  times  with   the  same  Module and
%   Source is a no-op. Called with  a   different  source  creates a new
%   Python module that replaces the old in the global namespace.
%
%   @error python_error(Type, Data) is raised if Python raises an error.

:- dynamic py_dyn_module/2 as volatile.

py_module(Module, Source) :-
    variant_sha1(Source, Hash),
    (   py_dyn_module(Module, Hash)
    ->  true
    ;   py_call(janus:import_module_from_string(Module, Source)),
        (   retract(py_dyn_module(Module, _))
        ->  py_update_module_cache(Module)
        ;   true
        ),
        asserta(py_dyn_module(Module, Hash))
    ).


		 /*******************************
		 *            INIT		*
		 *******************************/

:- dynamic py_venv/2 as volatile.
:- dynamic py_is_initialized/0 as volatile.

%   py_initialize is det.
%
%   Used as a callback from C for lazy initialization of Python.

py_initialize :-
    getenv('VIRTUAL_ENV', VEnv),
    prolog_to_os_filename(VEnvDir, VEnv),
    atom_concat(VEnvDir, '/pyvenv.cfg', Cfg),
    venv_config(Cfg, Config),
    !,
    current_prolog_flag(executable, Program),
    current_prolog_flag(py_argv, Argv),
    py_initialize(Program, ['-I'|Argv], []),
    py_setattr(sys, prefix, VEnv),
    venv_update_path(VEnvDir, Config).
py_initialize :-
    current_prolog_flag(executable, Program),
    current_prolog_flag(py_argv, Argv),
    py_initialize(Program, Argv, []).

venv_config(File, Config) :-
    access_file(File, read),
    read_file_to_string(File, String, []),
    split_string(String, "\n", "\n\r", Lines),
    convlist(venv_config_line, Lines, Config).

venv_config_line(Line, Config) :-
    sub_string(Line, B, _, A, "="),
    !,
    sub_string(Line, 0, B, _, NameS),
    split_string(NameS, "", "\t\s", [NameS2]),
    atom_string(Name, NameS2),
    sub_string(Line, _, A, 0, ValueS),
    split_string(ValueS, "", "\t\s", [ValueS2]),
    (   number_string(Value, ValueS2)
    ->  true
    ;   atom_string(Value, ValueS2)
    ),
    Config =.. [Name,Value].

venv_update_path(VEnvDir, Options) :-
    py_call(sys:version_info, Info),    % Tuple
    Info =.. [_,Major,Minor|_],
    format(string(EnvSiteDir),
           '~w/lib/python~w.~w/site-packages',
           [VEnvDir, Major, Minor]),
    prolog_to_os_filename(EnvSiteDir, PyEnvSiteDir),
    (   exists_directory(EnvSiteDir)
    ->  true
    ;   print_message(warning,
                      janus(venv(no_site_package_dir(VEnvDir, EnvSiteDir))))
    ),
    py_call(sys:path, Path0),
    (   option('include-system-site-packages'(true), Options)
    ->  partition(is_site_dir, Path0, PkgPath, SysPath),
        append([SysPath,[PyEnvSiteDir], PkgPath], Path)
    ;   exclude(is_site_dir, Path0, Path1),
        append(Path1, [PyEnvSiteDir], Path)
    ),
    py_setattr(sys, path, Path),
    print_message(silent, janus(venv(VEnvDir, EnvSiteDir))),
    asserta(py_venv(VEnvDir, EnvSiteDir)).

is_site_dir(OsDir) :-
    prolog_to_os_filename(PlDir, OsDir),
    file_base_name(PlDir, Dir0),
    downcase_atom(Dir0, Dir),
    no_env_dir(Dir).

no_env_dir('site-packages').
no_env_dir('dist-packages').

%!  py_initialize(+Program, +Argv, +Options) is det.
%
%   Initialize  and configure  the  embedded Python  system.  If  this
%   predicate is  not called before any  other call to Python  such as
%   py_call/2, it is called _lazily_, passing the Prolog executable as
%   Program, passing Argv from the  Prolog flag `py_argv` and an empty
%   Options list.
%
%   Calling this predicate while the  Python is already initialized is
%   a  no-op.  This  predicate is  thread-safe, where  the first  call
%   initializes Python.
%
%   In addition to initializing the Python system, it
%
%     - Adds the directory holding `janus.py` to the Python module
%       search path.
%     - If Prolog I/O is not connected to the file handles 0,1,2,
%       it rebinds Python I/O to use the Prolog I/O.
%
%   @arg Options is currently ignored.  It will be used to provide
%   additional configuration options.

py_initialize(Program, Argv, Options) :-
    (   py_initialize_(Program, Argv, Options)
    ->  absolute_file_name(library('python/janus.py'), Janus,
			   [ access(read) ]),
	file_directory_name(Janus, PythonDir),
	py_add_lib_dir(PythonDir, first),
	py_connect_io,
        repl_add_cwd,
        asserta(py_is_initialized)
    ;   true
    ).

%!  py_connect_io is det.
%
%   If SWI-Prolog console streams are bound to something non-standard,
%   bind the Python console I/O to our streans.

py_connect_io :-
    maplist(non_file_stream,
	    [0-user_input, 1-user_output, 2-user_error],
	    NonFiles),
    Call =.. [connect_io|NonFiles],
    py_call(janus_swi:Call).

non_file_stream(Expect-Stream, Bool) :-
    (   stream_property(Stream, file_no(Expect))
    ->  Bool = @false
    ;   Bool = @true
    ).

		 /*******************************
		 *            PATHS		*
		 *******************************/

%!  py_lib_dirs(-Dirs) is det.
%
%   True when Dirs is a list of directories searched for Python modules.
%   The elements of Dirs are in Prolog canonical notation.
%
%   @compat PIP

py_lib_dirs(Dirs) :-
    py_call(sys:path, Dirs0),
    maplist(prolog_to_os_filename, Dirs, Dirs0).

%!  py_add_lib_dir(+Dir) is det.
%!  py_add_lib_dir(+Dir, +Where) is det.
%
%   Add a directory to the Python  module   search  path.  In the second
%   form, Where is one of `first`   or `last`. py_add_lib_dir/1 adds the
%   directory as `last`. The property `sys:path`   is not modified if it
%   already contains Dir.
%
%   Dir is in Prolog notation. The added   directory  is converted to an
%   absolute path using the OS notation using prolog_to_os_filename/2.
%
%   If Dir is a _relative_ path, it   is taken relative to Prolog source
%   file when used as a _directive_ and  relative to the process working
%   directory when called as a predicate.
%
%   @compat PIP. Note  that  SWI-Prolog   uses  POSIX  file  conventions
%   internally, mapping to OS  conventions   inside  the predicates that
%   deal with files or explicitly   using prolog_to_os_filename/2. Other
%   systems may use the native file conventions in Prolog.

:- multifile system:term_expansion/2.

system:term_expansion((:- py_add_lib_dir(Dir0)),
                      (:- initialization(py_add_lib_dir(Dir, first), now))) :-
    \+ is_absolute_file_name(Dir0),
    prolog_load_context(directory, CWD),
    absolute_file_name(Dir0, Dir, [relative_to(CWD)]).
system:term_expansion((:- py_add_lib_dir(Dir0, Where)),
                      (:- initialization(py_add_lib_dir(Dir, Where), now))) :-
    \+ is_absolute_file_name(Dir0),
    prolog_load_context(directory, CWD),
    absolute_file_name(Dir0, Dir, [relative_to(CWD)]),
    absolute_file_name(Dir0, Dir).

py_add_lib_dir(Dir) :-
    py_add_lib_dir(Dir, last).

py_add_lib_dir(Dir, Where) :-
    absolute_file_name(Dir, AbsDir),
    prolog_to_os_filename(AbsDir, OSDir),
    py_add_lib_dir_(OSDir, Where).

py_add_lib_dir_(OSDir, Where) :-
    (   py_call(sys:path, Dirs0),
        memberchk(OSDir, Dirs0)
    ->  true
    ;   Where == last
    ->  py_call(sys:path:append(OSDir), _)
    ;   Where == first
    ->  py_call(sys:path:insert(0, OSDir), _)
    ;   must_be(oneof([first,last]), Where)
    ).

:- det(repl_add_cwd/0).
repl_add_cwd :-
    current_prolog_flag(break_level, Level),
    Level >= 0,
    !,
    (   py_call(sys:path:count(''), N),
        N > 0
    ->  true
    ;   print_message(informational, janus(add_cwd)),
        py_add_lib_dir_('', first)
    ).
repl_add_cwd.

:- multifile
    prolog:repl_loop_hook/2.

prolog:repl_loop_hook(begin, Level) :-
    Level >= 0,
    py_is_initialized,
    repl_add_cwd.


		 /*******************************
		 *           CALLBACK		*
		 *******************************/

:- dynamic py_call_cache/8 as volatile.

:- meta_predicate py_call_string(:, +, -).

%   py_call_string(:String, +DictIn, -Dict) is nondet.
%
%   Support janus.query_once() and janus.query(). Parses   String  into a goal
%   term. Next, all variables from the goal   term that appear in DictIn
%   are bound to the value from  this   dict.  Dict  is created from the
%   remaining variables, unless they  start   with  an underscore (e.g.,
%   `_Time`) and the key `truth. On   success,  the Dict values contain
%   the bindings from the  answer  and   `truth`  is  either  `true` or
%   `Undefined`. On failure, the Dict values are bound to `None` and the
%   `truth` is `false`.
%
%   Parsing and distributing the variables over the two dicts is cached.

py_call_string(M:String, Input, Dict) :-
    py_call_cache(String, Input, TV, M, Goal, Dict, Truth, OutVars),
    !,
    py_call(TV, M:Goal, Truth, OutVars).
py_call_string(M:String, Input, Dict) :-
    term_string(Goal, String, [variable_names(Map)]),
    unbind_dict(Input, VInput),
    exclude(not_in_projection(VInput), Map, OutBindings),
    dict_create(Dict, bindings, [truth=Truth|OutBindings]),
    maplist(arg(2), OutBindings, OutVars),
    TV = Input.get(truth, 'PLAIN_TRUTHVALS'),
    asserta(py_call_cache(String, VInput, TV, M, Goal, Dict, Truth, OutVars)),
    VInput = Input,
    py_call(TV, M:Goal, Truth, OutVars).

py_call('NO_TRUTHVALS', M:Goal, Truth, OutVars) =>
    (   call(M:Goal)
    *-> bind_status_no_no_truthvals(Truth)
    ;   Truth = @false,
	maplist(bind_none, OutVars)
    ).
py_call('PLAIN_TRUTHVALS', M:Goal, Truth, OutVars) =>
    (   call(M:Goal)
    *-> bind_status_plain_truthvals(Truth)
    ;   Truth = @false,
	maplist(bind_none, OutVars)
    ).
py_call('DELAY_LISTS', M:Goal, Truth, OutVars) =>
    (   call_delays(M:Goal, Delays)
    *-> bind_status_delay_lists(Delays, Truth)
    ;   Truth = @false,
	maplist(bind_none, OutVars)
    ).
py_call('RESIDUAL_PROGRAM', M:Goal, Truth, OutVars) =>
    (   call_delays(M:Goal, Delays)
    *-> bind_status_residual_program(Delays, Truth)
    ;   Truth = @false,
	maplist(bind_none, OutVars)
    ).

not_in_projection(Input, Name=Value) :-
    (   get_dict(Name, Input, Value)
    ->  true
    ;   sub_atom(Name, 0, _, _, '_')
    ).

bind_none(@none).

bind_status_no_no_truthvals(@true).

bind_status_plain_truthvals(Truth) =>
    (   '$tbl_delay_list'([])
    ->  Truth = @true
    ;   py_undefined(Truth)
    ).

bind_status_delay_lists(true, Truth) =>
    Truth = @true.
bind_status_delay_lists(Delays, Truth) =>
    py_call(janus:'Undefined'(prolog(Delays)), Truth).

bind_status_residual_program(true, Truth) =>
    Truth = @true.
bind_status_residual_program(Delays, Truth) =>
    delays_residual_program(Delays, Program),
    py_call(janus:'Undefined'(prolog(Program)), Truth).

py_undefined(X) :-
    py_call(janus:undefined, X).

unbind_dict(Dict0, Dict) :-
    dict_pairs(Dict0, Tag, Pairs0),
    maplist(unbind, Pairs0, Pairs),
    dict_pairs(Dict, Tag, Pairs).

unbind(Name-_, Name-_) :-
    sub_atom(Name, 0, 1, _, Char1),
    char_type(Char1, prolog_var_start),
    !.
unbind(NonVar, NonVar).


		 /*******************************
		 *     SUPPORT PYTHON CALLS     *
		 *******************************/

:- public
       px_cmd/3,
       px_call/4,
       px_comp/7.

% These predicates are helpers  for the corresponding Python functions
% in janus.py.


%   px_call(+Input:tuple, +Module, -Pred, -Ret)
%
%   Supports  px_qdet()  and  apply().  Note    that   these  predicates
%   explicitly address predicates  in  a   particular  module.  For meta
%   predicates, this implies they also control  the context module. This
%   leads to ``janus.cmd("consult", "consult", file)`` to consult _file_
%   into the module `consult`, which is not   what we want. Therefore we
%   set the context module to `user`, which is better, but probably also
%   not what we want.

px_call(-(), Module, Pred, Ret) =>
    @(call(Module:Pred, Ret), user).
px_call(-(A1), Module, Pred, Ret) =>
    @(call(Module:Pred, A1, Ret), user).
px_call(-(A1,A2), Module, Pred, Ret) =>
    @(call(Module:Pred, A1, A2, Ret), user).
px_call(-(A1,A2,A3), Module, Pred, Ret) =>
    @(call(Module:Pred, A1, A2, A3, Ret), user).
px_call(-(A1,A2,A3,A4), Module, Pred, Ret) =>
    @(call(Module:Pred, A1, A2, A3, A4, Ret), user).
px_call(Tuple, Module, Pred, Ret) =>
    compound_name_arguments(Tuple, _, Args),
    append(Args, [Ret], GArgs),
    Goal =.. [Pred|GArgs],
    @(Module:Goal, user).

px_cmd(Module, Pred, Tuple) :-
    (   compound(Tuple)
    ->  compound_name_arguments(Tuple, _, Args),
	Goal =.. [Pred|Args]
    ;   Goal = Pred
    ),
    @(Module:Goal, user).

px_comp(Module, Pred, Tuple, Vars, Set, TV, Ret) :-
    length(Out, Vars),
    (   compound(Tuple)
    ->  compound_name_arguments(Tuple, _, Args),
	append(Args, Out, GArgs),
	Goal =.. [Pred|GArgs]
    ;   Goal =.. [Pred|Out]
    ),
    compound_name_arguments(OTempl0, -, Out),
    tv_goal_and_template(TV, @(Module:Goal, user), FGoal, OTempl0, OTempl),
    findall(OTempl, FGoal, Ret0),
    (   Set == @true
    ->  sort(Ret0, Ret)
    ;   Ret = Ret0
    ).

:- meta_predicate
    call_delays_py(0, -).

% 0,1,2: TruthVal(Enum) from janus.py
tv_goal_and_template('NO_TRUTHVALS',
                     Goal, Goal, Templ, Templ) :- !.
tv_goal_and_template('PLAIN_TRUTHVALS',
                     Goal, ucall(Goal, TV), Templ, -(Templ,TV)) :- !.
tv_goal_and_template('DELAY_LISTS',
                     Goal, call_delays_py(Goal, TV), Templ, -(Templ,TV)) :- !.
tv_goal_and_template(Mode, _, _, _, _) :-
    domain_error("px_comp() truth", Mode).

:- public
    ucall/2,
    call_delays_py/2.

ucall(Goal, TV) :-
    call(Goal),
    (   '$tbl_delay_list'([])
    ->  TV = 1
    ;   TV = 2
    ).

call_delays_py(Goal, PyDelays) :-
    call_delays(Goal, Delays),
    (   Delays == true
    ->  PyDelays = []
    ;   comma_list(Delays, Array),
        maplist(term_string, Array, PyDelays)
    ).


		 /*******************************
		 *          PYTHON I/O          *
		 *******************************/

%   py_write(+Stream, -String) is det.
%   py_readline(+Stream, +Size, +Prompt, +Line) is det.
%
%   Called from redefined Python console  I/O   to  write/read using the
%   Prolog streams.

:- '$hide'((py_write/1,
	    py_readline/4)).

py_write(Stream, String) :-
    notrace(format(Stream, '~s', [String])).

py_readline(Stream, Size, Prompt, Line) :-
    notrace(py_readline_(Stream, Size, Prompt, Line)).

py_readline_(Stream, _Size, Prompt, Line) :-
    prompt1(Prompt),
    read_line_to_string(Stream, Read),
    (   Read == end_of_file
    ->  Line = ""
    ;   string_concat(Read, "\n", Line),
	py_add_history(Read)
    ).

py_add_history(Line) :-
    ignore(catch(prolog:history(user_input, add(Line)), _, true)).


		 /*******************************
		 *          COMPILING           *
		 *******************************/

%   py_consult(+File, +Data, +Module) is det.
%
%   Support janus.consult(file, data=None, module='user').

:- public py_consult/3.
py_consult(File, @none, Module) =>
    consult(Module:File).
py_consult(File, Data, Module) =>
    setup_call_cleanup(
	open_string(Data, In),
	load_files(Module:File, [stream(In)]),
	close(In)).


		 /*******************************
		 *           MESSAGES		*
		 *******************************/

:- multifile
    prolog:error_message//1,
    prolog:message_context//1,
    prolog:message//1.

prolog:error_message(python_error(Class, Value)) -->
    { py_str(Value, Message)
    },
    [ 'Python ', ansi(code, "'~w'", [Class]), ':', nl,
      '  ~w'-[Message]
    ].

prolog:message_context(context(_, PythonCtx)) -->
    { nonvar(PythonCtx),
      PythonCtx = python_stack(Stack),
      current_prolog_flag(py_backtrace, true),
      py_is_object(Stack),
      !,
      current_prolog_flag(py_backtrace_depth, Depth),
      py_call(traceback:format_tb(Stack, Depth), Frames)
    },
    [ nl, 'Python stack:', nl ],
    sequence(py_stack_frame, Frames).

py_stack_frame(String) -->
    { split_string(String, "\n", "", Lines)
    },
    sequence(msg_line, [nl], Lines).

msg_line(Line) -->
    [ '~s'-[Line] ].

prolog:message(janus(Msg)) -->
    message(Msg).

message(version(Janus, Python)) -->
    [ 'Janus ~w embeds Python ~w'-[Janus, Python] ].
message(venv(Dir, _EnvSiteDir)) -->
    [ 'Janus: using venv from ~p'-[Dir] ].
message(venv(no_site_package_dir(VEnvDir, Dir))) -->
    [ 'Janus: venv dirrectory ~p does not contain ~p'-[VEnvDir, Dir] ].
message(py_shell(no_janus)) -->
    [ 'Janus: py_shell/0: Importing janus into the Python shell requires Python 3.10 or later.', nl,
      'Run "', ansi(code, 'from janus import *', []), '" in the Python shell to import janus.'
    ].
message(add_cwd) -->
    [ 'Interactive session; added `.` to Python `sys.path`'-[] ].
