asteroidpipeline 0.2.1__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.
Files changed (60) hide show
  1. asteroid_ui/__init__.py +3 -0
  2. asteroid_ui/__main__.py +3 -0
  3. asteroid_ui/app.py +89 -0
  4. asteroid_ui/backend.py +126 -0
  5. asteroid_ui/clipboard.py +27 -0
  6. asteroid_ui/config.py +58 -0
  7. asteroid_ui/designations.py +75 -0
  8. asteroid_ui/engine/__init__.py +129 -0
  9. asteroid_ui/engine/server.jl +291 -0
  10. asteroid_ui/imaging.py +181 -0
  11. asteroid_ui/julia/AsteroidPipeline/LICENSE +21 -0
  12. asteroid_ui/julia/AsteroidPipeline/Project.toml +53 -0
  13. asteroid_ui/julia/AsteroidPipeline/src/AsteroidPipeline.jl +62 -0
  14. asteroid_ui/julia/AsteroidPipeline/src/astrometric_refinement.jl +230 -0
  15. asteroid_ui/julia/AsteroidPipeline/src/astrometry.jl +162 -0
  16. asteroid_ui/julia/AsteroidPipeline/src/calibrated_photometry.jl +135 -0
  17. asteroid_ui/julia/AsteroidPipeline/src/campaign.jl +702 -0
  18. asteroid_ui/julia/AsteroidPipeline/src/crossmatch.jl +434 -0
  19. asteroid_ui/julia/AsteroidPipeline/src/detection.jl +208 -0
  20. asteroid_ui/julia/AsteroidPipeline/src/digest2.jl +119 -0
  21. asteroid_ui/julia/AsteroidPipeline/src/injection.jl +181 -0
  22. asteroid_ui/julia/AsteroidPipeline/src/known_nearby.jl +156 -0
  23. asteroid_ui/julia/AsteroidPipeline/src/linking.jl +241 -0
  24. asteroid_ui/julia/AsteroidPipeline/src/linking_multinight.jl +109 -0
  25. asteroid_ui/julia/AsteroidPipeline/src/mpc_export.jl +276 -0
  26. asteroid_ui/julia/AsteroidPipeline/src/mpcorb.jl +175 -0
  27. asteroid_ui/julia/AsteroidPipeline/src/neocp.jl +149 -0
  28. asteroid_ui/julia/AsteroidPipeline/src/observatories.jl +166 -0
  29. asteroid_ui/julia/AsteroidPipeline/src/pipeline.jl +626 -0
  30. asteroid_ui/julia/AsteroidPipeline/src/platesolve.jl +144 -0
  31. asteroid_ui/julia/AsteroidPipeline/src/psf.jl +202 -0
  32. asteroid_ui/julia/AsteroidPipeline/src/reference.jl +174 -0
  33. asteroid_ui/julia/AsteroidPipeline/src/rotation.jl +89 -0
  34. asteroid_ui/julia/AsteroidPipeline/src/stack_difference.jl +316 -0
  35. asteroid_ui/julia/AsteroidPipeline/src/variables.jl +298 -0
  36. asteroid_ui/julia/AsteroidPipeline/src/zogy.jl +205 -0
  37. asteroid_ui/labels.py +122 -0
  38. asteroid_ui/profiles.py +99 -0
  39. asteroid_ui/screens/__init__.py +0 -0
  40. asteroid_ui/screens/analysis.py +138 -0
  41. asteroid_ui/screens/check.py +134 -0
  42. asteroid_ui/screens/help.py +28 -0
  43. asteroid_ui/screens/home.py +164 -0
  44. asteroid_ui/screens/open.py +142 -0
  45. asteroid_ui/screens/profile.py +99 -0
  46. asteroid_ui/screens/reason.py +40 -0
  47. asteroid_ui/screens/report.py +118 -0
  48. asteroid_ui/screens/results.py +411 -0
  49. asteroid_ui/screens/settings.py +130 -0
  50. asteroid_ui/screens/summary.py +59 -0
  51. asteroid_ui/sets.py +128 -0
  52. asteroid_ui/styles.tcss +299 -0
  53. asteroid_ui/texts.py +119 -0
  54. asteroid_ui/theme.py +22 -0
  55. asteroid_ui/widgets.py +77 -0
  56. asteroidpipeline-0.2.1.dist-info/METADATA +95 -0
  57. asteroidpipeline-0.2.1.dist-info/RECORD +60 -0
  58. asteroidpipeline-0.2.1.dist-info/WHEEL +4 -0
  59. asteroidpipeline-0.2.1.dist-info/entry_points.txt +2 -0
  60. asteroidpipeline-0.2.1.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,3 @@
1
+ """Terminal interface for AsteroidPipeline: image sets in, MPC reports out."""
2
+
3
+ __version__ = "0.2.1"
@@ -0,0 +1,3 @@
1
+ from asteroid_ui.app import main
2
+
3
+ main()
asteroid_ui/app.py ADDED
@@ -0,0 +1,89 @@
1
+ from __future__ import annotations
2
+
3
+ from textual.app import App
4
+ from textual.reactive import reactive
5
+
6
+ # textual-image finds out which graphics the terminal supports (Kitty, Sixel...)
7
+ # by asking it with escape sequences; that only works before Textual takes the
8
+ # terminal over, so it is imported here, at start-up.
9
+ try:
10
+ import textual_image.widget # noqa: F401
11
+ except ImportError:
12
+ pass
13
+
14
+ from asteroid_ui.backend import Backend
15
+ from asteroid_ui.config import load_config
16
+ from asteroid_ui.texts import STATUS_MESSAGES
17
+ from asteroid_ui.theme import GRUVBOX
18
+
19
+
20
+ class AsteroidApp(App):
21
+ CSS_PATH = "styles.tcss"
22
+ TITLE = "asteroids"
23
+ SUB_TITLE = "AsteroidPipeline"
24
+
25
+ engine_status: reactive[str] = reactive("Starting the analysis engine (Julia)… this can take a minute.")
26
+
27
+ def __init__(self) -> None:
28
+ super().__init__()
29
+ self.config = load_config()
30
+ self.backend = Backend()
31
+ self.backend.on_status = lambda code: setattr(self, "engine_status", STATUS_MESSAGES.get(code, code))
32
+
33
+ def on_mount(self) -> None:
34
+ self.register_theme(GRUVBOX)
35
+ self.theme = "gruvbox-asteroides"
36
+ from asteroid_ui.screens.home import HomeScreen
37
+
38
+ self.push_screen(HomeScreen())
39
+ self.run_worker(self._start_backend())
40
+
41
+ async def _start_backend(self) -> None:
42
+ await self.backend.start()
43
+ await self.backend.ready.wait()
44
+ self.engine_status = f"✗ {self.backend.failed}" if self.backend.failed else "Analysis engine ready."
45
+
46
+ async def on_unmount(self) -> None:
47
+ await self.backend.stop()
48
+
49
+
50
+ USAGE = """\
51
+ usage: asteroidpipeline [--setup | --version | --help]
52
+
53
+ Find moving objects in astronomical image sets (.zip files, folders or FITS
54
+ files), check the candidates, and write MPC reports.
55
+
56
+ --setup install the analysis engine (Julia packages) and exit;
57
+ the app otherwise does it on its first run
58
+ --version print the version and exit
59
+ """
60
+
61
+
62
+ def main() -> None:
63
+ import sys
64
+
65
+ args = sys.argv[1:]
66
+ if "--help" in args or "-h" in args:
67
+ print(USAGE)
68
+ return
69
+ if "--version" in args:
70
+ from asteroid_ui import __version__
71
+
72
+ print(f"asteroidpipeline {__version__}")
73
+ return
74
+ if "--setup" in args:
75
+ from asteroid_ui import engine
76
+
77
+ try:
78
+ julia = engine.julia_executable()
79
+ print(f"Setting up the analysis engine with {julia} (several minutes)…")
80
+ engine.setup(julia, print)
81
+ except engine.EngineError as e:
82
+ sys.exit(str(e))
83
+ print("Done.")
84
+ return
85
+ AsteroidApp().run()
86
+
87
+
88
+ if __name__ == "__main__":
89
+ main()
asteroid_ui/backend.py ADDED
@@ -0,0 +1,126 @@
1
+ """Client for the analysis engine (ui/backend/server.jl).
2
+
3
+ The engine is a Julia process kept open for the whole session: loading and
4
+ compiling AsteroidPipeline takes ~20 s, so it is paid once. It speaks JSON,
5
+ one message per line (see server.jl).
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import asyncio
11
+ import itertools
12
+ import json
13
+ from collections.abc import Awaitable, Callable
14
+ from typing import Any
15
+
16
+ from asteroid_ui import engine
17
+ from asteroid_ui.config import BACKEND_LOG
18
+
19
+ Event = dict[str, Any]
20
+
21
+
22
+ class BackendError(Exception):
23
+ """The engine answered with an error, or could not be started."""
24
+
25
+
26
+ class Backend:
27
+ def __init__(self) -> None:
28
+ self._proc: asyncio.subprocess.Process | None = None
29
+ self._ids = itertools.count(1)
30
+ self._queues: dict[int, asyncio.Queue[Event]] = {}
31
+ self._lock = asyncio.Lock() # the engine handles one command at a time
32
+ self.ready = asyncio.Event()
33
+ self.failed: str | None = None
34
+ self.on_status: Callable[[str], None] = lambda message: None
35
+
36
+ @property
37
+ def busy(self) -> bool:
38
+ return self._lock.locked()
39
+
40
+ async def start(self) -> None:
41
+ """Find Julia, set the engine up if this is the first run (or an upgrade), and start it."""
42
+ try:
43
+ julia = await asyncio.to_thread(engine.julia_executable)
44
+ if await asyncio.to_thread(engine.needs_setup):
45
+ self.on_status("Installing the analysis engine — the first time only, it takes several minutes…")
46
+ loop = asyncio.get_running_loop()
47
+
48
+ def progress(line: str) -> None:
49
+ loop.call_soon_threadsafe(self.on_status, f"Installing the analysis engine: {line[:90]}")
50
+
51
+ await asyncio.to_thread(engine.setup, julia, progress)
52
+ self.on_status("Starting the analysis engine…")
53
+ except engine.EngineError as e:
54
+ self.failed = str(e)
55
+ self.ready.set()
56
+ return
57
+ BACKEND_LOG.parent.mkdir(parents=True, exist_ok=True)
58
+ log = open(BACKEND_LOG, "a", buffering=1)
59
+ self._proc = await asyncio.create_subprocess_exec(
60
+ *engine.server_command(julia),
61
+ stdin=asyncio.subprocess.PIPE, stdout=asyncio.subprocess.PIPE, stderr=log,
62
+ limit=64 * 1024 * 1024,
63
+ )
64
+ asyncio.create_task(self._read_loop())
65
+
66
+ async def _read_loop(self) -> None:
67
+ assert self._proc and self._proc.stdout
68
+ while line := await self._proc.stdout.readline():
69
+ try:
70
+ event = json.loads(line)
71
+ except ValueError:
72
+ continue
73
+ if event.get("event") == "ready":
74
+ self.ready.set()
75
+ continue
76
+ if event.get("event") == "status":
77
+ self.on_status(event.get("message", ""))
78
+ queue = self._queues.get(event.get("id"))
79
+ if queue is not None:
80
+ await queue.put(event)
81
+ code = await self._proc.wait()
82
+ self.failed = f"The analysis engine stopped (exit code {code}). Details in {BACKEND_LOG}"
83
+ self.ready.set()
84
+ for queue in self._queues.values():
85
+ await queue.put({"event": "error", "message": self.failed})
86
+
87
+ async def request(self, cmd: str, on_event: Callable[[Event], Awaitable[None] | None] | None = None,
88
+ **args: Any) -> Event:
89
+ """Send a command and wait for its final event (`result`, `report`, `report_check`, `inspection` or `error`).
90
+
91
+ Events along the way (progress, status) go to `on_event`.
92
+ """
93
+ await self.ready.wait()
94
+ if self.failed:
95
+ raise BackendError(self.failed)
96
+ async with self._lock:
97
+ assert self._proc and self._proc.stdin
98
+ request_id = next(self._ids)
99
+ queue: asyncio.Queue[Event] = asyncio.Queue()
100
+ self._queues[request_id] = queue
101
+ try:
102
+ self._proc.stdin.write((json.dumps({"cmd": cmd, "id": request_id, **args}) + "\n").encode())
103
+ await self._proc.stdin.drain()
104
+ while True:
105
+ event = await queue.get()
106
+ kind = event.get("event")
107
+ if kind == "error":
108
+ raise BackendError(event.get("message", "unknown error"))
109
+ if kind in ("result", "report", "report_check", "inspection"):
110
+ return event
111
+ if on_event is not None:
112
+ maybe = on_event(event)
113
+ if asyncio.iscoroutine(maybe):
114
+ await maybe
115
+ finally:
116
+ del self._queues[request_id]
117
+
118
+ async def stop(self) -> None:
119
+ if self._proc and self._proc.returncode is None:
120
+ try:
121
+ assert self._proc.stdin
122
+ self._proc.stdin.write(b'{"cmd": "quit"}\n')
123
+ await self._proc.stdin.drain()
124
+ await asyncio.wait_for(self._proc.wait(), 3)
125
+ except (asyncio.TimeoutError, BrokenPipeError, ConnectionResetError):
126
+ self._proc.kill()
@@ -0,0 +1,27 @@
1
+ """Copy text to the clipboard.
2
+
3
+ Textual's own copy (an OSC 52 escape sequence) works in most terminals, but not
4
+ all of them allow it; the desktop's clipboard tool is used as well when there
5
+ is one, so a copied report is really there to paste.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import shutil
11
+ import subprocess
12
+
13
+ from textual.app import App
14
+
15
+ _TOOLS = (["wl-copy"], ["xclip", "-selection", "clipboard"], ["xsel", "--clipboard", "--input"])
16
+
17
+
18
+ def copy_text(app: App, text: str) -> None:
19
+ app.copy_to_clipboard(text)
20
+ for cmd in _TOOLS:
21
+ if shutil.which(cmd[0]):
22
+ try:
23
+ subprocess.run(cmd, input=text.encode(), timeout=3, check=False,
24
+ stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
25
+ except (OSError, subprocess.TimeoutExpired):
26
+ continue
27
+ break
asteroid_ui/config.py ADDED
@@ -0,0 +1,58 @@
1
+ """Paths and preferences.
2
+
3
+ Everything the user can change lives in ~/.config/asteroid-ui/config.json and
4
+ in the Settings screen; the rest follows from where this package sits in the
5
+ repository.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import os
12
+ from dataclasses import asdict, dataclass
13
+ from pathlib import Path
14
+
15
+ _XDG_CONFIG = Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config"))
16
+ _XDG_CACHE = Path(os.environ.get("XDG_CACHE_HOME", Path.home() / ".cache"))
17
+ _XDG_DATA = Path(os.environ.get("XDG_DATA_HOME", Path.home() / ".local" / "share"))
18
+ CONFIG_FILE = _XDG_CONFIG / "asteroid-ui" / "config.json"
19
+ CACHE_DIR = _XDG_CACHE / "asteroid-ui"
20
+ SETS_CACHE = CACHE_DIR / "sets"
21
+ BACKEND_LOG = CACHE_DIR / "engine.log"
22
+ # Every candidate a person has labelled, with its pixels: training data for a
23
+ # future classifier. Kept apart from the cache, which can be thrown away.
24
+ DATA_DIR = _XDG_DATA / "asteroid-ui"
25
+ DATASET_DIR = DATA_DIR / "dataset"
26
+
27
+
28
+ @dataclass
29
+ class Config:
30
+ # Shown big on the start screen: your team's name, or the program's.
31
+ title: str = "NEO Hunters"
32
+ # Folder searched (with its subfolders) for image-set .zip files.
33
+ downloads: str = str(Path.home() / "Downloads")
34
+ # The MPC report's MEA and CON lines: who measures (you, your team) and
35
+ # how to reach them. The telescope's own lines (COD, OBS, TEL) come with
36
+ # its profile (profiles.py).
37
+ measurers: str = ""
38
+ contact: str = ""
39
+ # Your own temporary designations for the candidates you report: a prefix
40
+ # ("NHU") and the next number (NHU0002). Empty: the program's own (P2).
41
+ designation_prefix: str = ""
42
+ next_designation: int = 1
43
+ # A report file to check before submitting it (from this program or any other).
44
+ report_to_check: str = ""
45
+ # Animation speed, images per second (at most 3: no fast flashing).
46
+ fps: float = 1.5
47
+
48
+ def save(self) -> None:
49
+ CONFIG_FILE.parent.mkdir(parents=True, exist_ok=True)
50
+ CONFIG_FILE.write_text(json.dumps(asdict(self), indent=2, ensure_ascii=False))
51
+
52
+
53
+ def load_config() -> Config:
54
+ try:
55
+ data = json.loads(CONFIG_FILE.read_text())
56
+ except (OSError, ValueError):
57
+ return Config()
58
+ return Config(**{k: v for k, v in data.items() if k in Config.__dataclass_fields__})
@@ -0,0 +1,75 @@
1
+ """Temporary designations for the candidates you report.
2
+
3
+ The MPC's 80-column format has 7 characters for an observer's own
4
+ designation of an object it doesn't know yet. Teams number their finds with
5
+ a prefix of their own — "NHU0001", "NHU0002"… — set in Settings. Each
6
+ candidate keeps the designation it was first given, whenever its report is
7
+ written again; numbers are never reused.
8
+
9
+ A candidate on the MPC's NEO Confirmation Page is reported under the NEOCP's
10
+ designation instead: those observations help confirm someone's find.
11
+ Without a prefix, reports use the program's own: "P" and the tracklet's
12
+ number.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import json
18
+ from typing import Any
19
+
20
+ from asteroid_ui.config import CONFIG_FILE
21
+
22
+ DESIGNATIONS_FILE = CONFIG_FILE.parent / "designations.json"
23
+ MAX_LENGTH = 7
24
+
25
+
26
+ def _load() -> dict[str, Any]:
27
+ try:
28
+ return json.loads(DESIGNATIONS_FILE.read_text())
29
+ except (OSError, ValueError):
30
+ return {"assigned": {}}
31
+
32
+
33
+ def _save(data: dict[str, Any]) -> None:
34
+ DESIGNATIONS_FILE.parent.mkdir(parents=True, exist_ok=True)
35
+ DESIGNATIONS_FILE.write_text(json.dumps(data, indent=1))
36
+
37
+
38
+ def valid_prefix(prefix: str) -> bool:
39
+ return prefix == "" or (prefix.isalnum() and prefix.isascii() and len(prefix) <= MAX_LENGTH - 2)
40
+
41
+
42
+ def neocp_designation(tracklet: dict[str, Any]) -> str | None:
43
+ check = tracklet.get("neocp") or {}
44
+ if check.get("verdict") != "on_neocp":
45
+ return None
46
+ agreeing = [o for o in check.get("objects", []) if o.get("agreement") == "motion"]
47
+ return agreeing[0]["designation"] if agreeing else None
48
+
49
+
50
+ def assign(config, set_name: str, tracklets: list[dict[str, Any]]) -> dict[str, str]:
51
+ """Designation for each of `tracklets` (tracklet id, as a string, => designation),
52
+ giving new numbers to those that have none yet."""
53
+ data = _load()
54
+ assigned: dict[str, str] = data.setdefault("assigned", {})
55
+ out: dict[str, str] = {}
56
+ prefix = config.designation_prefix.strip().upper()
57
+ for t in tracklets:
58
+ neocp = neocp_designation(t)
59
+ if neocp:
60
+ out[str(t["id"])] = neocp
61
+ continue
62
+ if not prefix:
63
+ continue
64
+ key = f"{set_name}/{t['id']}"
65
+ if key not in assigned:
66
+ digits = MAX_LENGTH - len(prefix)
67
+ number = max(int(config.next_designation), 1)
68
+ if len(str(number)) > digits:
69
+ raise ValueError(f"{prefix} has room for {digits} digits; {number} doesn't fit")
70
+ assigned[key] = f"{prefix}{number:0{digits}d}"
71
+ config.next_designation = number + 1
72
+ config.save()
73
+ out[str(t["id"])] = assigned[key]
74
+ _save(data)
75
+ return out
@@ -0,0 +1,129 @@
1
+ """The analysis engine: AsteroidPipeline.jl, run by Julia.
2
+
3
+ The Julia package travels inside this Python package (julia/AsteroidPipeline,
4
+ put there when the wheel is built; in a checkout of the repository, the
5
+ repository itself is used). The first time the app runs — and again after an
6
+ upgrade — it gets an environment ready for it:
7
+
8
+ 1. find Julia: $ASTEROIDPIPELINE_JULIA, `julia` on the PATH, or, if neither,
9
+ a private copy that `juliapkg` downloads;
10
+ 2. make a Julia environment in the user's data folder with AsteroidPipeline
11
+ and the few packages server.jl needs, and precompile it (several minutes,
12
+ once).
13
+
14
+ After that, server.jl starts in seconds.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import hashlib
20
+ import os
21
+ import shutil
22
+ import subprocess
23
+ from collections.abc import Callable
24
+ from pathlib import Path
25
+
26
+ from asteroid_ui import __version__
27
+ from asteroid_ui.config import BACKEND_LOG, DATA_DIR
28
+
29
+ ENGINE_DIR = Path(__file__).resolve().parent
30
+ SERVER = ENGINE_DIR / "server.jl"
31
+ _BUNDLED = ENGINE_DIR.parent / "julia" / "AsteroidPipeline"
32
+ _CHECKOUT = ENGINE_DIR.parents[2] # ui/asteroid_ui/engine -> repository root
33
+ _SERVER_PACKAGES = ["FITSIO", "JSON", "WCS", "TypedTables", "Dates", "Statistics"]
34
+ MIN_JULIA = (1, 11)
35
+
36
+
37
+ class EngineError(Exception):
38
+ """The engine can't be found or set up; the message says what to do."""
39
+
40
+
41
+ def julia_package() -> Path:
42
+ """Where AsteroidPipeline.jl's source is: bundled in the wheel, or this checkout."""
43
+ for candidate in (_BUNDLED, _CHECKOUT):
44
+ if (candidate / "Project.toml").is_file() and (candidate / "src" / "AsteroidPipeline.jl").is_file():
45
+ return candidate
46
+ raise EngineError("AsteroidPipeline.jl's source is missing from this installation; reinstall it.")
47
+
48
+
49
+ def env_dir() -> Path:
50
+ return DATA_DIR / "engine"
51
+
52
+
53
+ def julia_executable() -> str:
54
+ explicit = os.environ.get("ASTEROIDPIPELINE_JULIA")
55
+ if explicit:
56
+ return explicit
57
+ found = shutil.which("julia")
58
+ if found:
59
+ return found
60
+ try:
61
+ import juliapkg # downloads a private Julia if there is none
62
+ except ImportError:
63
+ raise EngineError("Julia isn't installed. Install it from https://julialang.org/install "
64
+ "(or `pip install juliapkg` to have it downloaded) and start the app again.") from None
65
+ return juliapkg.executable()
66
+
67
+
68
+ def julia_version(julia: str) -> tuple[int, ...]:
69
+ out = subprocess.run([julia, "--startup-file=no", "-e", "print(VERSION.major, ' ', VERSION.minor)"],
70
+ capture_output=True, text=True, timeout=120)
71
+ try:
72
+ return tuple(int(x) for x in out.stdout.split())
73
+ except ValueError:
74
+ raise EngineError(f"Could not run Julia ({julia}): {out.stderr.strip()[:300]}") from None
75
+
76
+
77
+ def _fingerprint(package: Path) -> str:
78
+ """Changes whenever the engine must be set up again: app version, package sources' manifest, Julia."""
79
+ h = hashlib.sha256(__version__.encode())
80
+ h.update((package / "Project.toml").read_bytes())
81
+ h.update(str(package).encode())
82
+ for f in sorted((package / "src").glob("*.jl")):
83
+ h.update(f.name.encode())
84
+ h.update(str(f.stat().st_size).encode())
85
+ return h.hexdigest()
86
+
87
+
88
+ def needs_setup() -> bool:
89
+ try:
90
+ stamp = (env_dir() / "ready").read_text().strip()
91
+ except OSError:
92
+ return True
93
+ return stamp != _fingerprint(julia_package())
94
+
95
+
96
+ def setup(julia: str, on_line: Callable[[str], None] = lambda line: None) -> None:
97
+ """Make the engine's Julia environment; lines of Julia's output go to `on_line` and the log."""
98
+ version = julia_version(julia)
99
+ if version < MIN_JULIA:
100
+ raise EngineError(f"Julia {'.'.join(map(str, version))} is too old; AsteroidPipeline needs "
101
+ f"{'.'.join(map(str, MIN_JULIA))} or newer.")
102
+ package = julia_package()
103
+ env = env_dir()
104
+ if env.exists():
105
+ shutil.rmtree(env)
106
+ env.mkdir(parents=True)
107
+ script = (
108
+ "using Pkg; "
109
+ f"Pkg.develop(path={str(package)!r}); "
110
+ f"Pkg.add({_SERVER_PACKAGES!r}); "
111
+ "Pkg.instantiate(); Pkg.precompile()"
112
+ ).replace("'", '"')
113
+ BACKEND_LOG.parent.mkdir(parents=True, exist_ok=True)
114
+ with open(BACKEND_LOG, "a", buffering=1) as log:
115
+ log.write(f"--- setting up the engine in {env} with {julia}\n")
116
+ proc = subprocess.Popen([julia, f"--project={env}", "--startup-file=no", "-e", script],
117
+ stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True)
118
+ assert proc.stdout is not None
119
+ for line in proc.stdout:
120
+ log.write(line)
121
+ if line.strip():
122
+ on_line(line.rstrip())
123
+ if proc.wait() != 0:
124
+ raise EngineError(f"Setting up the analysis engine failed; details in {BACKEND_LOG}")
125
+ (env / "ready").write_text(_fingerprint(package))
126
+
127
+
128
+ def server_command(julia: str) -> list[str]:
129
+ return [julia, f"--project={env_dir()}", "--startup-file=no", str(SERVER)]