astronereus 0.2.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,7 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ .venv/
4
+ venv/
5
+ dist/
6
+ build/
7
+ *.egg-info/
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Jose Vines
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,93 @@
1
+ Metadata-Version: 2.4
2
+ Name: astronereus
3
+ Version: 0.2.1
4
+ Summary: Python frontend for Nereus.jl — Nested-Evidence Recovery of Exoplanets by Unified Sampling
5
+ Project-URL: Homepage, https://github.com/jvines/Nereus.jl
6
+ Project-URL: Repository, https://github.com/jvines/nereus-py
7
+ Project-URL: Issues, https://github.com/jvines/nereus-py/issues
8
+ Author-email: Jose Vines <jose.vines.l@gmail.com>
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Keywords: astrometry,bayesian,exoplanets,gaia,mcmc,nested-sampling,radial-velocity
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Intended Audience :: Science/Research
14
+ Classifier: Operating System :: MacOS
15
+ Classifier: Operating System :: POSIX :: Linux
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Topic :: Scientific/Engineering :: Astronomy
18
+ Requires-Python: >=3.10
19
+ Requires-Dist: zstandard>=0.22; python_version < '3.14'
20
+ Provides-Extra: test
21
+ Requires-Dist: numpy>=1.24; extra == 'test'
22
+ Requires-Dist: pytest>=7; extra == 'test'
23
+ Provides-Extra: zstd
24
+ Requires-Dist: zstandard>=0.22; extra == 'zstd'
25
+ Description-Content-Type: text/markdown
26
+
27
+ # astronereus
28
+
29
+ Python frontend for [Nereus](https://github.com/jvines/Nereus.jl) — trans-dimensional
30
+ Bayesian inference for exoplanet orbits.
31
+
32
+ **You do not need Julia.** Nereus itself is Julia, but this package fetches a
33
+ prebuilt runtime on first use and runs it out-of-process in a warm daemon.
34
+ Nothing is compiled at install time.
35
+
36
+ ```sh
37
+ pip install astronereus
38
+ python -c "import astronereus; astronereus.install()"
39
+ ```
40
+
41
+ The distribution and the import are both `astronereus`. The name `nereus` on
42
+ PyPI belongs to an unrelated geophysics package, and a distribution whose import
43
+ name differs from its install name is a thing every user has to be told, so they
44
+ are deliberately the same string here.
45
+
46
+ ## Use
47
+
48
+ ```python
49
+ import astronereus
50
+
51
+ summary = astronereus.run_job(cfg) # one-shot
52
+
53
+ with astronereus.session() as s: # or reuse one warm daemon
54
+ a = s.run_job(cfg_a)
55
+ pg = s.detect.rv_periodogram(t=t, rv=rv, rv_err=err)
56
+ ```
57
+
58
+ Typed entry points per observable — `fit_rv`, `fit_transit`, `fit_astrometry`,
59
+ `fit_rm`, `fit_tomography`, `fit_ttv`, `fit_binary`, `fit_joint` — take channel
60
+ objects (`RV`, `Transit`, `Astrometry`, `RM`, `Night`, `TTV`, `SB2`) and return
61
+ a `JobResult`.
62
+
63
+ The first `install()` unpacks the runtime and warms it once (a few minutes);
64
+ every process after that starts in about twenty seconds. `NEREUS_BUNDLE_URL`
65
+ points the installer at a local file or `file://` URL — that is the intended
66
+ path for a workshop room, where thirty people pulling half a gigabyte over
67
+ conference wifi is not a plan.
68
+
69
+ ## Runtime bundles
70
+
71
+ Prebuilt runtimes are attached to the
72
+ [Nereus.jl releases](https://github.com/jvines/Nereus.jl/releases), with
73
+ `SHA256SUMS` alongside:
74
+
75
+ | platform | size |
76
+ |---|---|
77
+ | `macos-arm64` | 465 MiB |
78
+ | `linux-x86_64` | 688 MiB |
79
+
80
+ `install()` picks the one for your platform and verifies it against the
81
+ published checksum. For a classroom, fetch it once and point everyone at the
82
+ local copy instead:
83
+
84
+ ```sh
85
+ export NEREUS_BUNDLE_URL=/Volumes/NEREUS/nereus-runtime-1.11.9-macos-arm64.tar.zst
86
+ ```
87
+
88
+ `NEREUS_HOME` relocates the runtime cache out of the default platform cache
89
+ directory.
90
+
91
+ ## Licence
92
+
93
+ MIT — see `LICENSE`.
@@ -0,0 +1,67 @@
1
+ # astronereus
2
+
3
+ Python frontend for [Nereus](https://github.com/jvines/Nereus.jl) — trans-dimensional
4
+ Bayesian inference for exoplanet orbits.
5
+
6
+ **You do not need Julia.** Nereus itself is Julia, but this package fetches a
7
+ prebuilt runtime on first use and runs it out-of-process in a warm daemon.
8
+ Nothing is compiled at install time.
9
+
10
+ ```sh
11
+ pip install astronereus
12
+ python -c "import astronereus; astronereus.install()"
13
+ ```
14
+
15
+ The distribution and the import are both `astronereus`. The name `nereus` on
16
+ PyPI belongs to an unrelated geophysics package, and a distribution whose import
17
+ name differs from its install name is a thing every user has to be told, so they
18
+ are deliberately the same string here.
19
+
20
+ ## Use
21
+
22
+ ```python
23
+ import astronereus
24
+
25
+ summary = astronereus.run_job(cfg) # one-shot
26
+
27
+ with astronereus.session() as s: # or reuse one warm daemon
28
+ a = s.run_job(cfg_a)
29
+ pg = s.detect.rv_periodogram(t=t, rv=rv, rv_err=err)
30
+ ```
31
+
32
+ Typed entry points per observable — `fit_rv`, `fit_transit`, `fit_astrometry`,
33
+ `fit_rm`, `fit_tomography`, `fit_ttv`, `fit_binary`, `fit_joint` — take channel
34
+ objects (`RV`, `Transit`, `Astrometry`, `RM`, `Night`, `TTV`, `SB2`) and return
35
+ a `JobResult`.
36
+
37
+ The first `install()` unpacks the runtime and warms it once (a few minutes);
38
+ every process after that starts in about twenty seconds. `NEREUS_BUNDLE_URL`
39
+ points the installer at a local file or `file://` URL — that is the intended
40
+ path for a workshop room, where thirty people pulling half a gigabyte over
41
+ conference wifi is not a plan.
42
+
43
+ ## Runtime bundles
44
+
45
+ Prebuilt runtimes are attached to the
46
+ [Nereus.jl releases](https://github.com/jvines/Nereus.jl/releases), with
47
+ `SHA256SUMS` alongside:
48
+
49
+ | platform | size |
50
+ |---|---|
51
+ | `macos-arm64` | 465 MiB |
52
+ | `linux-x86_64` | 688 MiB |
53
+
54
+ `install()` picks the one for your platform and verifies it against the
55
+ published checksum. For a classroom, fetch it once and point everyone at the
56
+ local copy instead:
57
+
58
+ ```sh
59
+ export NEREUS_BUNDLE_URL=/Volumes/NEREUS/nereus-runtime-1.11.9-macos-arm64.tar.zst
60
+ ```
61
+
62
+ `NEREUS_HOME` relocates the runtime cache out of the default platform cache
63
+ directory.
64
+
65
+ ## Licence
66
+
67
+ MIT — see `LICENSE`.
@@ -0,0 +1,48 @@
1
+ [build-system]
2
+ # Hatchling >=1.28 emits Metadata-Version 2.5, which twine 6.2 rejects as
3
+ # invalid and which older installers do not understand. 2.4 is the newest
4
+ # version the whole toolchain agrees on.
5
+ requires = ["hatchling<1.28"]
6
+ build-backend = "hatchling.build"
7
+
8
+ [project]
9
+ name = "astronereus"
10
+ version = "0.2.1"
11
+ description = "Python frontend for Nereus.jl — Nested-Evidence Recovery of Exoplanets by Unified Sampling"
12
+ requires-python = ">=3.10"
13
+ dependencies = ["zstandard>=0.22; python_version < '3.14'"]
14
+ license = "MIT"
15
+ readme = "README.md"
16
+ authors = [{name = "Jose Vines", email = "jose.vines.l@gmail.com"}]
17
+ keywords = ["exoplanets", "radial-velocity", "astrometry", "gaia", "mcmc",
18
+ "nested-sampling", "bayesian"]
19
+ classifiers = [
20
+ "Development Status :: 4 - Beta",
21
+ "Intended Audience :: Science/Research",
22
+ "Topic :: Scientific/Engineering :: Astronomy",
23
+ "Programming Language :: Python :: 3",
24
+ "Operating System :: MacOS",
25
+ "Operating System :: POSIX :: Linux",
26
+ ]
27
+
28
+ [project.urls]
29
+ Homepage = "https://github.com/jvines/Nereus.jl"
30
+ Repository = "https://github.com/jvines/nereus-py"
31
+ Issues = "https://github.com/jvines/nereus-py/issues"
32
+
33
+ [project.optional-dependencies]
34
+ zstd = ["zstandard>=0.22"]
35
+ test = ["pytest>=7", "numpy>=1.24"]
36
+
37
+ [tool.hatch.build.targets.wheel]
38
+ packages = ["src/astronereus"]
39
+
40
+ [tool.hatch.build.targets.wheel.force-include]
41
+ "src/astronereus/julia/daemon.jl" = "astronereus/julia/daemon.jl"
42
+
43
+ [tool.hatch.build.targets.sdist]
44
+ # Explicit allow-list. The sdist once shipped DEPLOYMENT.md -- internal
45
+ # topology and fleet hostnames -- and a PyPI upload cannot be undone. That
46
+ # file now lives outside the repo entirely, but the allow-list stays: it is
47
+ # the cheaper guarantee.
48
+ include = ["src", "tools", "README.md", "LICENSE", "pyproject.toml"]
@@ -0,0 +1,42 @@
1
+ """Nereus — Nested-Evidence Recovery of Exoplanets by Unified Sampling.
2
+
3
+ Python frontend. Julia runs out-of-process in a warm daemon; you never need
4
+ Julia installed and nothing is compiled at install time.
5
+
6
+ import astronereus
7
+ astronereus.install(url=...) # once: fetch the runtime bundle
8
+
9
+ summary = nereus.run_job(cfg) # one-shot
10
+
11
+ with astronereus.session() as s: # or reuse one warm daemon
12
+ a = s.run_job(cfg_a)
13
+ pg = s.detect.rv_periodogram(t=t, rv=rv, rv_err=err)
14
+ """
15
+ from ._runtime import (JULIA_VERSION, CPU_TARGETS, BundleError, install,
16
+ find_julia, julia_home,
17
+ is_installed, platform_tag, runtime_dir, cache_root,
18
+ julia_env)
19
+ from ._daemon import JuliaDaemon, DaemonError
20
+ from ._api import Session, session, run_job, ping
21
+ from ._fit import (fit_rv, fit_transit, fit_astrometry, fit_rm,
22
+ fit_tomography, fit_ttv, fit_binary, fit_joint)
23
+ from .channels import RV, Transit, Astrometry, RM, Night, TTV, SB2
24
+ from .stopping import Stopping
25
+ from . import engines, channels
26
+ from ._result import JobResult, JobFailed, Figures
27
+
28
+ __all__ = ["JULIA_VERSION", "CPU_TARGETS", "BundleError", "DaemonError",
29
+ "JuliaDaemon", "Session", "install", "is_installed", "platform_tag",
30
+ "runtime_dir", "cache_root", "julia_env", "find_julia", "julia_home",
31
+ "daemon", "session", "run_job", "ping",
32
+ "JobResult", "JobFailed", "Figures",
33
+ "fit_rv", "fit_transit", "fit_astrometry", "fit_rm",
34
+ "fit_tomography", "fit_ttv", "fit_binary", "fit_joint",
35
+ "RV", "Transit", "Astrometry", "RM", "Night", "TTV", "SB2",
36
+ "Stopping", "engines", "channels"]
37
+ __version__ = "0.2.1"
38
+
39
+
40
+ def daemon(**kw) -> JuliaDaemon:
41
+ """A warm Julia daemon. Use as a context manager."""
42
+ return JuliaDaemon(**kw)
@@ -0,0 +1,181 @@
1
+ """The user-facing API.
2
+
3
+ `JuliaDaemon.call("run_job", cfg)` is transport plumbing and should never
4
+ appear in user code: it is stringly-typed, gives no completion, no signature,
5
+ no docstring, and any typo becomes a runtime error from Julia. This module is
6
+ the typed facade over it.
7
+
8
+ Three levels, in increasing order of control:
9
+
10
+ import astronereus
11
+ summary = nereus.run_job(cfg) # 1. just do the thing
12
+
13
+ with astronereus.session() as s: # 2. reuse one warm daemon
14
+ a = s.run_job(cfg_a)
15
+ b = s.run_job(cfg_b)
16
+ pg = s.detect.rv_periodogram(t, rv, err)
17
+
18
+ with astronereus.session() as s: # 3. escape hatch
19
+ s.raw("some.new.action", payload)
20
+
21
+ Level 1 lazily starts a process-wide daemon on first use and reuses it, so the
22
+ ~20 s `using Nereus` is paid once per interpreter, not per call.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import atexit
28
+ import threading
29
+ from typing import Any, Sequence
30
+
31
+ from ._daemon import JuliaDaemon
32
+ from ._result import JobResult, JobFailed
33
+ from . import _features as _F
34
+
35
+ _shared: "Session | None" = None
36
+ _shared_lock = threading.Lock()
37
+
38
+
39
+ class _Namespace:
40
+ """Groups related actions, e.g. ``s.detect.transits`` -> "detection.transits"."""
41
+
42
+ def __init__(self, session: "Session", prefix: str, actions: dict[str, str]):
43
+ self._s, self._prefix, self._actions = session, prefix, actions
44
+
45
+ def __dir__(self):
46
+ return sorted(self._actions)
47
+
48
+ def __getattr__(self, name: str):
49
+ if name not in self._actions:
50
+ raise AttributeError(
51
+ f"{self._prefix}.{name} is not a Nereus action. "
52
+ f"Available: {', '.join(sorted(self._actions))}")
53
+ action = self._actions[name]
54
+
55
+ def _call(**payload):
56
+ return self._s.raw(action, payload)
57
+
58
+ _call.__name__ = name
59
+ _call.__doc__ = f"Nereus compute action {action!r}."
60
+ return _call
61
+
62
+
63
+
64
+ class Session:
65
+ """A warm Julia session. Use as a context manager."""
66
+
67
+ def __init__(self, **daemon_kw):
68
+ self._daemon = JuliaDaemon(**daemon_kw)
69
+ g = lambda: self
70
+ self.detect = _F._Group(g, "detect", _F.DETECT)
71
+ self.detrend = _F._Group(g, "detrend", _F.DETREND)
72
+ self.tomogram = _F._Group(g, "tomogram", _F.TOMOGRAM)
73
+ self.diagnostics = _F._Group(g, "diagnostics", _F.DIAGNOSTICS)
74
+
75
+ # -- lifecycle ---------------------------------------------------------
76
+ def start(self) -> "Session":
77
+ self._daemon.start()
78
+ return self
79
+
80
+ def close(self) -> None:
81
+ self._daemon.stop()
82
+
83
+ def __enter__(self) -> "Session":
84
+ return self.start()
85
+
86
+ def __exit__(self, *exc) -> None:
87
+ self.close()
88
+
89
+ # -- the actual API ----------------------------------------------------
90
+ def run_job(self, config: dict[str, Any] | str, *,
91
+ check: bool = True,
92
+ timeout: float | None = None) -> JobResult:
93
+ """Run a full Nereus job and return its summary.
94
+
95
+ `config` is a JOB_CONFIG dict (or a path to one). Nereus writes
96
+ `summary.json`, `chains.nc` and the plot tree into `config["output_dir"]`
97
+ and returns the summary, which includes a `figures` manifest mapping
98
+ logical names to paths.
99
+ """
100
+ # Validate client-side: a missing output_dir otherwise surfaces as an
101
+ # opaque Julia error several minutes into a run.
102
+ if isinstance(config, dict) and not config.get("output_dir"):
103
+ raise ValueError("job config needs an 'output_dir' — Nereus writes "
104
+ "summary.json, chains.nc and the plot tree there")
105
+ res = JobResult(self.raw("run_job", config, timeout=timeout))
106
+ return res.check() if check else res
107
+
108
+ def ping(self) -> dict[str, Any]:
109
+ """Liveness + which Julia/CPU/thread count the daemon actually has."""
110
+ return self.raw("ping", {})
111
+
112
+ def raw(self, action: str, payload: Any = None, *,
113
+ timeout: float | None = None) -> Any:
114
+ """Escape hatch: call an action by name.
115
+
116
+ Only for actions newer than this client. If you find yourself using it
117
+ routinely, the action belongs in the typed API above.
118
+ """
119
+ return self._daemon.call(action, payload, timeout=timeout)
120
+
121
+ @property
122
+ def log_path(self):
123
+ """Where the daemon's stdout/stderr goes — read this when a job dies."""
124
+ return self._daemon.log_path
125
+
126
+
127
+ def session(**daemon_kw) -> Session:
128
+ """A new warm Julia session (not the process-wide shared one)."""
129
+ return Session(**daemon_kw)
130
+
131
+
132
+ def _shared_session(**kw) -> Session:
133
+ global _shared
134
+ with _shared_lock:
135
+ if _shared is None:
136
+ _shared = Session(**kw).start()
137
+ atexit.register(_shared.close)
138
+ return _shared
139
+
140
+
141
+ def run_job(config: dict[str, Any] | str, *, check: bool = True,
142
+ timeout: float | None = None, **session_kw) -> JobResult:
143
+ """Run a job on a lazily-started, process-wide daemon.
144
+
145
+ Convenience for one-shot use. Prefer `with astronereus.session() as s:` when
146
+ making several calls, so the lifetime is explicit.
147
+ """
148
+ return _shared_session(**session_kw).run_job(config, check=check, timeout=timeout)
149
+
150
+
151
+ def ping(**session_kw) -> dict[str, Any]:
152
+ return _shared_session(**session_kw).ping()
153
+
154
+
155
+ # --- per-technique entry points, bound to a session -------------------------
156
+ def _bind(name):
157
+ from . import _fit
158
+ fn = getattr(_fit, name)
159
+ def method(self, *a, **kw):
160
+ kw.setdefault("session", self)
161
+ return fn(*a, **kw)
162
+ method.__name__ = name
163
+ method.__doc__ = fn.__doc__
164
+ return method
165
+
166
+
167
+ for _n in ("fit_rv", "fit_transit", "fit_astrometry", "fit_rm",
168
+ "fit_tomography", "fit_ttv", "fit_binary", "fit_joint"):
169
+ setattr(Session, _n, _bind(_n))
170
+
171
+ # post-hoc operations that act on a finished run
172
+ for _n, _act in (("evidence", "evidence"),
173
+ ("detection_limits", "detection_limits"),
174
+ ("select_planets", "select_planets"),
175
+ ("select_noise", "select_noise"),
176
+ ("pre_white", "pre_white")):
177
+ def _mk(action):
178
+ def m(self, **payload):
179
+ return self.raw(action, payload)
180
+ return m
181
+ setattr(Session, _n, _mk(_act))
@@ -0,0 +1,197 @@
1
+ """Supervise a warm Julia daemon and talk to it over a unix socket.
2
+
3
+ Deliberately NOT juliacall: embedding deadlocks under Julia's `@threads`, which
4
+ every sampler uses. A separate process also means a crashing job cannot take the
5
+ Python interpreter with it.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import atexit
11
+ import json
12
+ import os
13
+ import socket
14
+ import struct
15
+ import subprocess
16
+ import tempfile
17
+ import threading
18
+ import time
19
+ from pathlib import Path
20
+
21
+ from . import _runtime
22
+
23
+ _HDR = struct.Struct(">Q")
24
+
25
+
26
+ def _enc(o):
27
+ """JSON fallback for the types a scientific payload actually carries.
28
+
29
+ Channels are built with `dataclasses.asdict`, which leaves numpy arrays
30
+ untouched, so every realistic call ships ndarrays. Non-finite floats pass
31
+ through as NaN/Infinity — `daemon.jl` reads with `allow_inf = true`.
32
+ """
33
+ if hasattr(o, "tolist"): # ndarray, numpy scalar
34
+ return o.tolist()
35
+ if hasattr(o, "item"): # any remaining 0-d numpy scalar
36
+ return o.item()
37
+ if isinstance(o, (set, frozenset)):
38
+ return sorted(o)
39
+ raise TypeError(f"cannot send {type(o).__name__} to Julia")
40
+
41
+
42
+ class DaemonError(RuntimeError):
43
+ pass
44
+
45
+
46
+ class JuliaDaemon:
47
+ def __init__(self, version: str = _runtime.JULIA_VERSION,
48
+ project: str | os.PathLike | None = None,
49
+ threads: str | int = "auto",
50
+ preload: str | None = "Nereus",
51
+ api_file: str | list[str] | None = None,
52
+ startup_timeout: float = 300.0,
53
+ idle_timeout: float = 1800.0):
54
+ self.version = version
55
+ self.project = str(project) if project else None
56
+ self.threads = threads
57
+ self.preload = preload
58
+ # Extra Julia file to include after `using` — the public API surface.
59
+ # In the shipped package this lives inside Nereus itself; during
60
+ # bring-up it is loaded from a path so it can be iterated on.
61
+ self.api_file = api_file
62
+ self.startup_timeout = startup_timeout
63
+ # The daemon exits on its own if we die abnormally (atexit cannot run
64
+ # on SIGKILL/OOM) or if nothing talks to it for this long. Verified:
65
+ # without this a -9'd parent leaves a ~530 MB Julia process behind.
66
+ self.idle_timeout = idle_timeout
67
+ self._proc: subprocess.Popen | None = None
68
+ self._sock: socket.socket | None = None
69
+ self._lock = threading.Lock()
70
+ self._tmp = Path(tempfile.mkdtemp(prefix="nereus-daemon-"))
71
+ self.sock_path = self._tmp / "daemon.sock"
72
+ self.log_path = self._tmp / "daemon.log"
73
+
74
+ # -- lifecycle ---------------------------------------------------------
75
+ def start(self) -> "JuliaDaemon":
76
+ if self._proc is not None:
77
+ return self
78
+ julia, env = _runtime.julia_env(self.version)
79
+ entry = Path(__file__).parent / "julia" / "daemon.jl"
80
+
81
+ cmd = [str(julia), f"-t{self.threads}", "--startup-file=no"]
82
+ if self.project:
83
+ cmd.append(f"--project={self.project}")
84
+ # Preload inside the daemon so the ~20 s `using` cost is paid once, at
85
+ # boot, rather than on the first job.
86
+ ppid = os.getpid()
87
+ if self.preload:
88
+ _files = ([self.api_file] if isinstance(self.api_file, str)
89
+ else list(self.api_file or []))
90
+ api_inc = "".join(f'include("{p}"); ' for p in _files)
91
+ cmd += ["-e", f"@eval using {self.preload}; "
92
+ f"{api_inc}"
93
+ f"include(\"{entry}\"); "
94
+ f"serve(ARGS[1]; parent_pid=parse(Int,ARGS[2]), "
95
+ f"idle=parse(Float64,ARGS[3]))",
96
+ str(self.sock_path), str(ppid), str(self.idle_timeout)]
97
+ else:
98
+ cmd += [str(entry), str(self.sock_path), str(ppid),
99
+ str(self.idle_timeout)]
100
+
101
+ log = self.log_path.open("wb")
102
+ self._proc = subprocess.Popen(cmd, env=env, stdout=log, stderr=log)
103
+ atexit.register(self.stop)
104
+
105
+ deadline = time.time() + self.startup_timeout
106
+ while time.time() < deadline:
107
+ if self._proc.poll() is not None:
108
+ raise DaemonError(
109
+ f"daemon exited with {self._proc.returncode}\n"
110
+ f"--- log ---\n{self.log_path.read_text(errors='replace')[-4000:]}")
111
+ if self.sock_path.exists():
112
+ try:
113
+ s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
114
+ s.connect(str(self.sock_path))
115
+ self._sock = s
116
+ return self
117
+ except OSError:
118
+ pass
119
+ time.sleep(0.1)
120
+ self.stop()
121
+ raise DaemonError(
122
+ f"daemon did not become ready within {self.startup_timeout}s\n"
123
+ f"--- log ---\n{self.log_path.read_text(errors='replace')[-4000:]}")
124
+
125
+ def stop(self) -> None:
126
+ if self._sock is not None:
127
+ try:
128
+ self._send({"action": "shutdown"})
129
+ except Exception:
130
+ pass
131
+ try:
132
+ self._sock.close()
133
+ except Exception:
134
+ pass
135
+ self._sock = None
136
+ if self._proc is not None:
137
+ try:
138
+ self._proc.wait(timeout=10)
139
+ except Exception:
140
+ self._proc.kill()
141
+ self._proc = None
142
+
143
+ # -- transport ---------------------------------------------------------
144
+ def _send(self, obj) -> None:
145
+ body = json.dumps(obj, default=_enc).encode()
146
+ self._sock.sendall(_HDR.pack(len(body)) + body)
147
+
148
+ def _recvn(self, n: int) -> bytes:
149
+ buf = bytearray()
150
+ while len(buf) < n:
151
+ chunk = self._sock.recv(n - len(buf))
152
+ if not chunk:
153
+ raise DaemonError("daemon closed the connection")
154
+ buf += chunk
155
+ return bytes(buf)
156
+
157
+ def call(self, action: str, payload=None, timeout: float | None = None):
158
+ with self._lock:
159
+ # start() INSIDE the lock: two threads that both saw _sock is None
160
+ # would otherwise each spawn a Julia process.
161
+ if self._sock is None:
162
+ self.start()
163
+ try:
164
+ self._sock.settimeout(timeout)
165
+ self._send({"action": action, "payload": payload or {}})
166
+ (n,) = _HDR.unpack(self._recvn(8))
167
+ resp = json.loads(self._recvn(n))
168
+ except Exception:
169
+ # Framing is length-prefixed, so a half-read response leaves
170
+ # the stream desynchronised — the next call would read the
171
+ # tail of this one as an 8-byte header. There is no
172
+ # resynchronisation point, so drop the connection; the next
173
+ # call reconnects.
174
+ #
175
+ # NOTE: a timeout here does NOT stop the Julia job. It keeps
176
+ # running in the daemon. Call stop() if you need it dead.
177
+ self._drop()
178
+ raise
179
+ if not resp.get("ok"):
180
+ raise DaemonError(resp.get("error", "unknown error") + "\n"
181
+ + resp.get("backtrace", ""))
182
+ return resp.get("result")
183
+
184
+ def _drop(self) -> None:
185
+ """Close the socket and forget it, so the next call reconnects."""
186
+ if self._sock is not None:
187
+ try:
188
+ self._sock.close()
189
+ except Exception:
190
+ pass
191
+ self._sock = None
192
+
193
+ def __enter__(self):
194
+ return self.start()
195
+
196
+ def __exit__(self, *exc):
197
+ self.stop()