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 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