locutor 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- locutor/__init__.py +105 -0
- locutor/cli.py +126 -0
- locutor/language.py +233 -0
- locutor/languages/__init__.py +1 -0
- locutor/languages/julia.py +37 -0
- locutor/languages/python.py +70 -0
- locutor/languages/r.py +48 -0
- locutor/script.py +71 -0
- locutor/servers/session.R +220 -0
- locutor/servers/session.jl +373 -0
- locutor/session.py +454 -0
- locutor/wire.py +152 -0
- locutor-0.1.0.dist-info/METADATA +201 -0
- locutor-0.1.0.dist-info/RECORD +18 -0
- locutor-0.1.0.dist-info/WHEEL +5 -0
- locutor-0.1.0.dist-info/entry_points.txt +2 -0
- locutor-0.1.0.dist-info/licenses/LICENCE +287 -0
- locutor-0.1.0.dist-info/top_level.txt +1 -0
locutor/__init__.py
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"""locutor: call R and Julia from Python, as a function or as a script.
|
|
2
|
+
|
|
3
|
+
Two ways of calling, one for each lifetime::
|
|
4
|
+
|
|
5
|
+
import locutor
|
|
6
|
+
|
|
7
|
+
# a function, in memory: one persistent child per language, nothing on disk
|
|
8
|
+
rotated = locutor.call("methods/rotation.R:double_rotation", u, v, w)
|
|
9
|
+
total = locutor.call("stats.jl:total", series, skip_missing=True)
|
|
10
|
+
|
|
11
|
+
# a script, in a process of its own
|
|
12
|
+
locutor.run("fetch.R", "2026-11-13", out="data/raw.csv")
|
|
13
|
+
|
|
14
|
+
A configured interpreter is an object, for packages, a file of helpers, another
|
|
15
|
+
program than the one on PATH, or a session of its own::
|
|
16
|
+
|
|
17
|
+
with locutor.R(packages=["stats"], include="helpers.R") as r:
|
|
18
|
+
r.call("stats::median", series)
|
|
19
|
+
r.call("my_helper", series, window=5)
|
|
20
|
+
|
|
21
|
+
What crosses a call is a number, a string, a boolean, ``None``, a numeric array
|
|
22
|
+
and a list or a mapping of those (:mod:`locutor.wire`). Every double crosses as
|
|
23
|
+
its 64 bits: nothing is printed and parsed, and a NaN arrives as it left.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
import atexit
|
|
27
|
+
import os
|
|
28
|
+
|
|
29
|
+
from .language import Language, find_program, language, names, of_file
|
|
30
|
+
from .script import Result
|
|
31
|
+
from .session import Session, close_all
|
|
32
|
+
from .wire import Items, pack, unpack
|
|
33
|
+
|
|
34
|
+
__version__ = "0.1.0"
|
|
35
|
+
|
|
36
|
+
__all__ = ["Language", "R", "Julia", "Python", "Session", "Result", "Items",
|
|
37
|
+
"call", "run", "session", "available", "close", "names", "language",
|
|
38
|
+
"of_file", "find_program", "pack", "unpack", "close_all"]
|
|
39
|
+
|
|
40
|
+
#: The interpreter each language is called through when none is given: one of
|
|
41
|
+
#: each, built at its first use.
|
|
42
|
+
_DEFAULT = {}
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def __getattr__(name):
|
|
46
|
+
# The language classes by their own names, imported when asked for.
|
|
47
|
+
for known in names():
|
|
48
|
+
found = language(known)
|
|
49
|
+
if found.__name__ == name:
|
|
50
|
+
return found
|
|
51
|
+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def default(name):
|
|
55
|
+
"""The shared, unconfigured interpreter of one language."""
|
|
56
|
+
found = language(name)
|
|
57
|
+
if found.NAME not in _DEFAULT:
|
|
58
|
+
_DEFAULT[found.NAME] = found()
|
|
59
|
+
return _DEFAULT[found.NAME]
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def session(name):
|
|
63
|
+
"""The persistent child the module-level :func:`call` uses for a language."""
|
|
64
|
+
return default(name).session
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def call(target, *args, **kwargs):
|
|
68
|
+
"""Call ``path/file.ext:function`` with ``args`` and ``kwargs``.
|
|
69
|
+
|
|
70
|
+
The language is the file's, by its extension; the function runs in that
|
|
71
|
+
language's shared session, started at the first call and kept. For a
|
|
72
|
+
package's function or a session with packages of its own, build the
|
|
73
|
+
language (``locutor.R(packages=[...])``) and call that.
|
|
74
|
+
"""
|
|
75
|
+
path, sep, function = str(target).rpartition(":")
|
|
76
|
+
if not sep or not function or not os.path.splitext(path)[1]:
|
|
77
|
+
raise ValueError(
|
|
78
|
+
f"{target!r} is not 'path/file.ext:function'. A function that is "
|
|
79
|
+
f"not a file's is called on a language: locutor.R().call(...)")
|
|
80
|
+
return default(of_file(path).NAME).call_file(path, function, *args, **kwargs)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def run(script, *arguments, **options):
|
|
84
|
+
"""Run a script in a process of its own, by its extension's interpreter.
|
|
85
|
+
|
|
86
|
+
``arguments`` go on its command line as they are and keyword ``options`` as
|
|
87
|
+
``--key value``. ``check``, ``capture``, ``cwd``, ``env`` and ``timeout``
|
|
88
|
+
are the runner's own (:func:`locutor.script.run_script`). Returns a
|
|
89
|
+
:class:`Result`.
|
|
90
|
+
"""
|
|
91
|
+
return default(of_file(script).NAME).run(script, *arguments, **options)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def available():
|
|
95
|
+
"""``{language: its interpreter's path, or None}`` on this machine."""
|
|
96
|
+
return {name: default(name).find() for name in names()}
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def close():
|
|
100
|
+
"""End the shared sessions. Also done when the interpreter exits."""
|
|
101
|
+
for held in list(_DEFAULT.values()):
|
|
102
|
+
held.close()
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
atexit.register(close)
|
locutor/cli.py
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
"""``locutor``: the same two calls from a shell.
|
|
2
|
+
|
|
3
|
+
::
|
|
4
|
+
|
|
5
|
+
locutor languages
|
|
6
|
+
locutor run script.R 2026-11-13 --out data/raw.csv
|
|
7
|
+
locutor call stats.jl:total "[1, 2.5, 4]" --skip_missing true
|
|
8
|
+
|
|
9
|
+
``run`` hands everything after the script to the script, untouched, and exits
|
|
10
|
+
with its status. ``call`` reads each value as JSON where it is JSON and as text
|
|
11
|
+
where it is not, and prints the answer as JSON; a NaN is ``null`` there.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import argparse
|
|
15
|
+
import json
|
|
16
|
+
import math
|
|
17
|
+
import subprocess
|
|
18
|
+
import sys
|
|
19
|
+
|
|
20
|
+
import numpy as np
|
|
21
|
+
|
|
22
|
+
from . import __version__, available, call, close, default, names, of_file
|
|
23
|
+
from .wire import Items
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _value(text):
|
|
27
|
+
try:
|
|
28
|
+
return json.loads(text)
|
|
29
|
+
except ValueError:
|
|
30
|
+
return text
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _plain(value):
|
|
34
|
+
"""An answer as JSON can say it."""
|
|
35
|
+
if isinstance(value, Items):
|
|
36
|
+
items = [_plain(item) for item in value]
|
|
37
|
+
return dict(zip(value.names, items)) if value.names else items
|
|
38
|
+
if isinstance(value, np.ndarray):
|
|
39
|
+
return _plain(value.tolist())
|
|
40
|
+
if isinstance(value, (list, tuple)):
|
|
41
|
+
return [_plain(item) for item in value]
|
|
42
|
+
if isinstance(value, dict):
|
|
43
|
+
return {str(key): _plain(item) for key, item in value.items()}
|
|
44
|
+
if isinstance(value, (float, np.floating)):
|
|
45
|
+
return None if math.isnan(value) else float(value)
|
|
46
|
+
if isinstance(value, np.generic):
|
|
47
|
+
return value.item()
|
|
48
|
+
return value
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _split(words):
|
|
52
|
+
"""``(positional values, keyword values)`` of a call's command line."""
|
|
53
|
+
args, kwargs, at = [], {}, 0
|
|
54
|
+
while at < len(words):
|
|
55
|
+
word = words[at]
|
|
56
|
+
if word.startswith("--") and len(word) > 2:
|
|
57
|
+
key, sep, text = word[2:].partition("=")
|
|
58
|
+
if not sep:
|
|
59
|
+
at += 1
|
|
60
|
+
if at >= len(words):
|
|
61
|
+
raise SystemExit(f"--{key} is given no value")
|
|
62
|
+
text = words[at]
|
|
63
|
+
kwargs[key] = _value(text)
|
|
64
|
+
else:
|
|
65
|
+
args.append(_value(word))
|
|
66
|
+
at += 1
|
|
67
|
+
return args, kwargs
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _version(name, program):
|
|
71
|
+
flag = {"r": "--version", "julia": "--version", "python": "--version"}
|
|
72
|
+
try:
|
|
73
|
+
done = subprocess.run([program, flag.get(name, "--version")],
|
|
74
|
+
capture_output=True, text=True, timeout=60)
|
|
75
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
76
|
+
return ""
|
|
77
|
+
lines = (done.stdout or done.stderr).strip().splitlines()
|
|
78
|
+
return lines[0] if lines else ""
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def main(argv=None):
|
|
82
|
+
parser = argparse.ArgumentParser(
|
|
83
|
+
prog="locutor", description="Call R and Julia as a function or as a "
|
|
84
|
+
"script.")
|
|
85
|
+
parser.add_argument("--version", action="version",
|
|
86
|
+
version=f"locutor {__version__}")
|
|
87
|
+
commands = parser.add_subparsers(dest="command", required=True)
|
|
88
|
+
commands.add_parser("languages", help="the languages there are, and "
|
|
89
|
+
"which have an interpreter here")
|
|
90
|
+
running = commands.add_parser("run", help="run a script in a process of "
|
|
91
|
+
"its own")
|
|
92
|
+
running.add_argument("script")
|
|
93
|
+
running.add_argument("arguments", nargs=argparse.REMAINDER,
|
|
94
|
+
help="handed to the script as they are")
|
|
95
|
+
calling = commands.add_parser("call", help="call a function of a file, "
|
|
96
|
+
"in memory")
|
|
97
|
+
calling.add_argument("target", help="path/file.ext:function")
|
|
98
|
+
calling.add_argument("values", nargs=argparse.REMAINDER,
|
|
99
|
+
help="values as JSON, and --key value for keywords")
|
|
100
|
+
options = parser.parse_args(argv)
|
|
101
|
+
|
|
102
|
+
if options.command == "languages":
|
|
103
|
+
for name, program in available().items():
|
|
104
|
+
found = (f"{program} ({_version(name, program)})" if program
|
|
105
|
+
else f"not found: {default(name).GET}")
|
|
106
|
+
print(f"{name:8}{found}")
|
|
107
|
+
return 0
|
|
108
|
+
if options.command == "run":
|
|
109
|
+
language = default(of_file(options.script).NAME)
|
|
110
|
+
result = language.run(options.script, *options.arguments, check=False,
|
|
111
|
+
capture=False)
|
|
112
|
+
return result.returncode
|
|
113
|
+
args, kwargs = _split(options.values)
|
|
114
|
+
try:
|
|
115
|
+
answer = call(options.target, *args, **kwargs)
|
|
116
|
+
except (RuntimeError, ValueError, FileNotFoundError, TypeError) as exc:
|
|
117
|
+
print(f"locutor: {exc}", file=sys.stderr)
|
|
118
|
+
return 1
|
|
119
|
+
finally:
|
|
120
|
+
close()
|
|
121
|
+
print(json.dumps(_plain(answer)))
|
|
122
|
+
return 0
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
if __name__ == "__main__":
|
|
126
|
+
sys.exit(main())
|
locutor/language.py
ADDED
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
"""A language: where its interpreter is, how it is started, and what is asked of it.
|
|
2
|
+
|
|
3
|
+
One module of :mod:`locutor.languages` to a language, holding one subclass of
|
|
4
|
+
:class:`Language` and found by being there (:func:`names`). The subclass says
|
|
5
|
+
what the language is called, what its interpreter and its files are called, how
|
|
6
|
+
a session and a script are started, and how a missing package is installed.
|
|
7
|
+
Everything else -- finding the interpreter, the session, what crosses -- is the
|
|
8
|
+
same for all of them.
|
|
9
|
+
|
|
10
|
+
An instance is one configured interpreter: which program, which packages its
|
|
11
|
+
session attaches, which file it reads at its start. It runs scripts
|
|
12
|
+
(:meth:`Language.run`) and calls functions (:meth:`Language.call`,
|
|
13
|
+
:meth:`Language.call_file`), the second through one persistent child started at
|
|
14
|
+
the first call.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
import importlib
|
|
18
|
+
import os
|
|
19
|
+
import pkgutil
|
|
20
|
+
import re
|
|
21
|
+
import shutil
|
|
22
|
+
|
|
23
|
+
from . import languages as _languages
|
|
24
|
+
from .script import run_script
|
|
25
|
+
from .session import Session
|
|
26
|
+
|
|
27
|
+
#: How deep a ``search`` root is walked for an interpreter.
|
|
28
|
+
SEARCH_DEPTH = 4
|
|
29
|
+
|
|
30
|
+
_HERE = os.path.dirname(os.path.abspath(__file__))
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _natural(name):
|
|
34
|
+
return [int(part) if part.isdigit() else part.lower()
|
|
35
|
+
for part in re.split(r"(\d+)", name)]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def find_program(names, search=(), depth=SEARCH_DEPTH):
|
|
39
|
+
"""Absolute path to the first of ``names`` found, or ``None``.
|
|
40
|
+
|
|
41
|
+
PATH first, then each ``search`` root walked ``depth`` levels down, the
|
|
42
|
+
highest-numbered of side-by-side installs first (``R-4.4.0`` before
|
|
43
|
+
``R-4.3.1``), whatever order the filesystem lists them in.
|
|
44
|
+
"""
|
|
45
|
+
names = [name for name in names if name]
|
|
46
|
+
for name in names:
|
|
47
|
+
found = shutil.which(name)
|
|
48
|
+
if found:
|
|
49
|
+
return found
|
|
50
|
+
wanted = {n.lower() for n in names} | {n.lower() + ".exe" for n in names}
|
|
51
|
+
for root in search:
|
|
52
|
+
root = os.path.expanduser(os.path.expandvars(str(root)))
|
|
53
|
+
if not os.path.isdir(root):
|
|
54
|
+
continue
|
|
55
|
+
base = root.rstrip("/\\").count(os.sep)
|
|
56
|
+
for dirpath, dirnames, filenames in os.walk(root):
|
|
57
|
+
if dirpath.count(os.sep) - base >= depth:
|
|
58
|
+
dirnames[:] = []
|
|
59
|
+
else:
|
|
60
|
+
dirnames.sort(key=_natural, reverse=True)
|
|
61
|
+
for name in sorted(filenames):
|
|
62
|
+
path = os.path.join(dirpath, name)
|
|
63
|
+
if name.lower() in wanted and os.access(path, os.X_OK):
|
|
64
|
+
return path
|
|
65
|
+
return None
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class Language:
|
|
69
|
+
"""One configured interpreter of one language.
|
|
70
|
+
|
|
71
|
+
``program`` is the interpreter's name or path, found on PATH and then under
|
|
72
|
+
each ``search`` root. ``packages`` are what a session attaches when it
|
|
73
|
+
starts and ``include`` is one file it reads then, whose functions are
|
|
74
|
+
called by their bare names. ``label`` names the session in the log and in
|
|
75
|
+
errors, for a caller that holds several.
|
|
76
|
+
|
|
77
|
+
Building one starts nothing.
|
|
78
|
+
"""
|
|
79
|
+
|
|
80
|
+
#: The name the language is asked for by (``locutor.language("r")``).
|
|
81
|
+
NAME = ""
|
|
82
|
+
#: What the language is called where a message names it.
|
|
83
|
+
WORD = ""
|
|
84
|
+
#: The interpreter looked for when none is named.
|
|
85
|
+
PROGRAM = ""
|
|
86
|
+
#: What a file of the language ends in, the first being the usual one.
|
|
87
|
+
EXTENSIONS = ()
|
|
88
|
+
#: The child's end of a session, a file of ``locutor/servers``.
|
|
89
|
+
SERVER = ""
|
|
90
|
+
#: Where to get the language, for the refusal that says it is not here.
|
|
91
|
+
GET = ""
|
|
92
|
+
|
|
93
|
+
def __init__(self, program=None, search=(), packages=(), include=None,
|
|
94
|
+
label=""):
|
|
95
|
+
self.program = str(program or self.PROGRAM)
|
|
96
|
+
self.search = self._listed(search)
|
|
97
|
+
self.packages = self._listed(packages)
|
|
98
|
+
self.include = os.path.abspath(include) if include else ""
|
|
99
|
+
self.label = str(label or "")
|
|
100
|
+
self._session = None
|
|
101
|
+
|
|
102
|
+
@staticmethod
|
|
103
|
+
def _listed(value):
|
|
104
|
+
return [value] if isinstance(value, str) else [str(v) for v in value or []]
|
|
105
|
+
|
|
106
|
+
def __repr__(self):
|
|
107
|
+
return f"<{type(self).__name__} {self.program}>"
|
|
108
|
+
|
|
109
|
+
def __enter__(self):
|
|
110
|
+
return self
|
|
111
|
+
|
|
112
|
+
def __exit__(self, *exc):
|
|
113
|
+
self.close()
|
|
114
|
+
|
|
115
|
+
# ------------------------------------------------- what a language says #
|
|
116
|
+
def command(self, program, script, *arguments):
|
|
117
|
+
"""The command line that starts a session's child on ``script``."""
|
|
118
|
+
raise NotImplementedError
|
|
119
|
+
|
|
120
|
+
def script_command(self, program, script, arguments):
|
|
121
|
+
"""The command line that runs a script of the user's."""
|
|
122
|
+
return [program, script, *arguments]
|
|
123
|
+
|
|
124
|
+
def install(self, package, program=None):
|
|
125
|
+
"""The command that installs one package into what the child runs."""
|
|
126
|
+
raise NotImplementedError
|
|
127
|
+
|
|
128
|
+
def unready(self, program, hello):
|
|
129
|
+
"""The refusal for a child that started and cannot hold a session."""
|
|
130
|
+
return (f"the {self.WORD} at {program} cannot hold a session: "
|
|
131
|
+
f"{hello.get('error') or hello}")
|
|
132
|
+
|
|
133
|
+
# --------------------------------------------------------- the program #
|
|
134
|
+
@property
|
|
135
|
+
def server(self):
|
|
136
|
+
"""The file the session's child runs."""
|
|
137
|
+
return os.path.join(_HERE, "servers", self.SERVER)
|
|
138
|
+
|
|
139
|
+
def find(self):
|
|
140
|
+
"""The interpreter's absolute path, or ``None``: PATH, then ``search``."""
|
|
141
|
+
if os.path.isabs(self.program) and os.access(self.program, os.X_OK):
|
|
142
|
+
return self.program
|
|
143
|
+
return find_program([self.program], self.search)
|
|
144
|
+
|
|
145
|
+
def interpreter(self):
|
|
146
|
+
"""The interpreter's path, or the refusal that says where it was
|
|
147
|
+
looked for."""
|
|
148
|
+
path = self.find()
|
|
149
|
+
if path:
|
|
150
|
+
return path
|
|
151
|
+
under = (f", nor under {', '.join(self.search)}" if self.search else "")
|
|
152
|
+
raise RuntimeError(
|
|
153
|
+
f"{self.WORD} was asked for and {self.program!r} was not found: "
|
|
154
|
+
f"not on PATH{under}. Install {self.WORD} ({self.GET}) or put the "
|
|
155
|
+
f"directory that holds {self.program} on PATH.")
|
|
156
|
+
|
|
157
|
+
def uninstalled(self, program, problems, why=""):
|
|
158
|
+
"""The refusal for packages the child could not attach."""
|
|
159
|
+
lines = [f"The {self.WORD} at {program} cannot load what was asked of "
|
|
160
|
+
f"it" + (f", {why}" if why else "") + ":"]
|
|
161
|
+
for problem in problems:
|
|
162
|
+
package, message = problem.get("package"), problem.get("message")
|
|
163
|
+
if message:
|
|
164
|
+
lines.append(f" {package} is installed and did not load: "
|
|
165
|
+
f"{message}")
|
|
166
|
+
else:
|
|
167
|
+
lines.append(f" {package} is not installed. Install it:")
|
|
168
|
+
lines.append(f" {self.install(package, program)}")
|
|
169
|
+
return "\n".join(lines)
|
|
170
|
+
|
|
171
|
+
# ------------------------------------------------------------ the calls #
|
|
172
|
+
@property
|
|
173
|
+
def session(self):
|
|
174
|
+
"""This interpreter's persistent child; not started by being asked for."""
|
|
175
|
+
if self._session is None:
|
|
176
|
+
self._session = Session(self)
|
|
177
|
+
return self._session
|
|
178
|
+
|
|
179
|
+
def call(self, name, *args, **kwargs):
|
|
180
|
+
"""Call a function by the name the language knows it by: a package's
|
|
181
|
+
(``pkg::function``, ``Pkg.function``), or one ``include`` defines."""
|
|
182
|
+
return self.session.call(name, *args, **kwargs)
|
|
183
|
+
|
|
184
|
+
def call_file(self, path, function, *args, **kwargs):
|
|
185
|
+
"""Call ``function`` of the source file ``path``.
|
|
186
|
+
|
|
187
|
+
The child reads the file once, into a namespace of its own, so two
|
|
188
|
+
files may define one name.
|
|
189
|
+
"""
|
|
190
|
+
path = os.path.abspath(path)
|
|
191
|
+
if not os.path.isfile(path):
|
|
192
|
+
raise FileNotFoundError(f"no {self.WORD} file {path}")
|
|
193
|
+
return self.session.call_in(path, function, *args, **kwargs)
|
|
194
|
+
|
|
195
|
+
def run(self, script, *arguments, **options):
|
|
196
|
+
"""Run a script in a process of its own; see :func:`locutor.run`."""
|
|
197
|
+
return run_script(self, script, *arguments, **options)
|
|
198
|
+
|
|
199
|
+
def close(self):
|
|
200
|
+
"""End the session's child, if one was started."""
|
|
201
|
+
if self._session is not None:
|
|
202
|
+
self._session.close()
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
# --------------------------------------------------------------------------- #
|
|
206
|
+
# the languages there are
|
|
207
|
+
# --------------------------------------------------------------------------- #
|
|
208
|
+
def names():
|
|
209
|
+
"""The languages there are: the modules of :mod:`locutor.languages`."""
|
|
210
|
+
return tuple(sorted(info.name for info in
|
|
211
|
+
pkgutil.iter_modules(_languages.__path__)
|
|
212
|
+
if not info.name.startswith("_")))
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def language(name):
|
|
216
|
+
"""The :class:`Language` subclass of one language, by name."""
|
|
217
|
+
name = str(name).lower()
|
|
218
|
+
if name not in names():
|
|
219
|
+
raise ValueError(f"{name!r} is no language locutor speaks (known: "
|
|
220
|
+
f"{', '.join(names())})")
|
|
221
|
+
return importlib.import_module(f"{_languages.__name__}.{name}").LANGUAGE
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def of_file(path):
|
|
225
|
+
"""The :class:`Language` subclass a file is written in, by its extension."""
|
|
226
|
+
extension = os.path.splitext(str(path))[1].lower()
|
|
227
|
+
for name in names():
|
|
228
|
+
found = language(name)
|
|
229
|
+
if extension in {e.lower() for e in found.EXTENSIONS}:
|
|
230
|
+
return found
|
|
231
|
+
known = ", ".join(e for name in names() for e in language(name).EXTENSIONS)
|
|
232
|
+
raise ValueError(f"{path!r} is in no language locutor speaks, by its "
|
|
233
|
+
f"extension (known: {known})")
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""One module to a language. Adding a file here adds a language."""
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""Julia.
|
|
2
|
+
|
|
3
|
+
A session's child is ``julia``, started without the startup file and with the
|
|
4
|
+
environment (``JULIA_PROJECT``, ``JULIA_LOAD_PATH``, ``JULIA_NUM_THREADS``),
|
|
5
|
+
since where a user's packages are is said there. It needs nothing but Julia.
|
|
6
|
+
|
|
7
|
+
A function is called as ``Pkg.function`` (``Pkg.Sub.function``) where it
|
|
8
|
+
belongs to a package the session imported, or by its bare name where the
|
|
9
|
+
``include`` file defines it. Keyword arguments are passed as keywords. Julia
|
|
10
|
+
tells a number from a vector of one, so a number sent says it is one and a
|
|
11
|
+
vector of one coming back keeps its shape. ``missing`` in a float array comes
|
|
12
|
+
back as NaN.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from ..language import Language
|
|
16
|
+
|
|
17
|
+
FLAGS = ("--startup-file=no", "--history-file=no", "--color=no")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class Julia(Language):
|
|
21
|
+
NAME = "julia"
|
|
22
|
+
WORD = "Julia"
|
|
23
|
+
PROGRAM = "julia"
|
|
24
|
+
EXTENSIONS = (".jl",)
|
|
25
|
+
SERVER = "session.jl"
|
|
26
|
+
GET = "https://julialang.org/downloads"
|
|
27
|
+
|
|
28
|
+
def command(self, program, script, *arguments):
|
|
29
|
+
return [program, *FLAGS, script, *arguments]
|
|
30
|
+
|
|
31
|
+
def install(self, package, program=None):
|
|
32
|
+
program = program or self.find() or self.program
|
|
33
|
+
program = f'"{program}"' if " " in program else program
|
|
34
|
+
return f"{program} -e 'import Pkg; Pkg.add(\"{package}\")'"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
LANGUAGE = Julia
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""Python, which needs no translating.
|
|
2
|
+
|
|
3
|
+
Here so that a caller need not ask which language a file is in: a ``.py``
|
|
4
|
+
script runs with this interpreter, and a function of a ``.py`` file is imported
|
|
5
|
+
and called in this process, with no child and nothing crossing. A file is
|
|
6
|
+
imported once and again when it has changed on disk.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import importlib
|
|
10
|
+
import importlib.util
|
|
11
|
+
import os
|
|
12
|
+
import sys
|
|
13
|
+
|
|
14
|
+
from ..language import Language
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class Python(Language):
|
|
18
|
+
NAME = "python"
|
|
19
|
+
WORD = "Python"
|
|
20
|
+
PROGRAM = sys.executable or "python"
|
|
21
|
+
EXTENSIONS = (".py",)
|
|
22
|
+
GET = "https://www.python.org"
|
|
23
|
+
|
|
24
|
+
def __init__(self, *args, **kwargs):
|
|
25
|
+
super().__init__(*args, **kwargs)
|
|
26
|
+
self._modules = {}
|
|
27
|
+
|
|
28
|
+
def install(self, package, program=None):
|
|
29
|
+
return f"{program or self.program} -m pip install {package}"
|
|
30
|
+
|
|
31
|
+
def call(self, name, *args, **kwargs):
|
|
32
|
+
"""Call ``module:function`` (or ``module.function``) of an importable
|
|
33
|
+
module, or a function of the ``include`` file by its bare name."""
|
|
34
|
+
module, sep, function = name.rpartition(":")
|
|
35
|
+
if not sep:
|
|
36
|
+
module, sep, function = name.rpartition(".")
|
|
37
|
+
if not sep:
|
|
38
|
+
if not self.include:
|
|
39
|
+
raise RuntimeError(f"Python has no {name} to call: no file "
|
|
40
|
+
f"was included to define it")
|
|
41
|
+
return self.call_file(self.include, name, *args, **kwargs)
|
|
42
|
+
for package in self.packages:
|
|
43
|
+
importlib.import_module(package)
|
|
44
|
+
return getattr(importlib.import_module(module), function)(*args, **kwargs)
|
|
45
|
+
|
|
46
|
+
def call_file(self, path, function, *args, **kwargs):
|
|
47
|
+
path = os.path.abspath(path)
|
|
48
|
+
if not os.path.isfile(path):
|
|
49
|
+
raise FileNotFoundError(f"no Python file {path}")
|
|
50
|
+
stamp = os.stat(path).st_mtime_ns
|
|
51
|
+
held = self._modules.get(path)
|
|
52
|
+
if held is None or held[0] != stamp:
|
|
53
|
+
spec = importlib.util.spec_from_file_location(
|
|
54
|
+
"locutor_file_" + os.path.splitext(os.path.basename(path))[0],
|
|
55
|
+
path)
|
|
56
|
+
module = importlib.util.module_from_spec(spec)
|
|
57
|
+
spec.loader.exec_module(module)
|
|
58
|
+
held = self._modules[path] = (stamp, module)
|
|
59
|
+
try:
|
|
60
|
+
found = getattr(held[1], function)
|
|
61
|
+
except AttributeError:
|
|
62
|
+
raise RuntimeError(f"Python has no {function} to call: "
|
|
63
|
+
f"'{function}' is not defined by {path}") from None
|
|
64
|
+
return found(*args, **kwargs)
|
|
65
|
+
|
|
66
|
+
def close(self):
|
|
67
|
+
self._modules.clear()
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
LANGUAGE = Python
|
locutor/languages/r.py
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""R.
|
|
2
|
+
|
|
3
|
+
A session's child is ``Rscript``, started without the profiles (code that would
|
|
4
|
+
run before ours, free to print or to change an option) and with the environment
|
|
5
|
+
files, since where a user's packages are installed is said there. It reads its
|
|
6
|
+
requests with ``jsonlite``, which the R it runs has to have.
|
|
7
|
+
|
|
8
|
+
A function is called as ``pkg::function`` where it is an exported function of a
|
|
9
|
+
package the session attached, or by its bare name where the ``include`` file
|
|
10
|
+
defines it. R has no scalar, so a number arrives as a vector of one, and a
|
|
11
|
+
vector of one comes back as a number.
|
|
12
|
+
|
|
13
|
+
``NA`` and ``NaN`` are both NaN to Python; the 64 bits of each cross unchanged.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from ..language import Language
|
|
17
|
+
|
|
18
|
+
FLAGS = ("--no-save", "--no-restore", "--no-site-file", "--no-init-file")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class R(Language):
|
|
22
|
+
NAME = "r"
|
|
23
|
+
WORD = "R"
|
|
24
|
+
PROGRAM = "Rscript"
|
|
25
|
+
EXTENSIONS = (".R", ".r")
|
|
26
|
+
SERVER = "session.R"
|
|
27
|
+
GET = "https://cran.r-project.org"
|
|
28
|
+
|
|
29
|
+
def command(self, program, script, *arguments):
|
|
30
|
+
# Sourced rather than run as the file, so the same line works for any
|
|
31
|
+
# path the script may have.
|
|
32
|
+
return [program, *FLAGS, "-e", "source(commandArgs(TRUE)[1])", script,
|
|
33
|
+
*arguments]
|
|
34
|
+
|
|
35
|
+
def install(self, package, program=None):
|
|
36
|
+
program = program or self.find() or self.program
|
|
37
|
+
program = f'"{program}"' if " " in program else program
|
|
38
|
+
return f"{program} -e \"install.packages('{package}')\""
|
|
39
|
+
|
|
40
|
+
def unready(self, program, hello):
|
|
41
|
+
if hello.get("needs"):
|
|
42
|
+
return self.uninstalled(
|
|
43
|
+
program, [{"package": hello["needs"], "message": ""}],
|
|
44
|
+
why="which is what the session reads its requests with")
|
|
45
|
+
return super().unready(program, hello)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
LANGUAGE = R
|