tuff-rl 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.
@@ -0,0 +1,171 @@
1
+ Metadata-Version: 2.4
2
+ Name: tuff-rl
3
+ Version: 0.1.0
4
+ Summary: Training logs as TensorBoard event files, and a live board that follows them
5
+ Author: TalexDreamSoul
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/talex-touch/tuff-rl
8
+ Project-URL: Repository, https://github.com/talex-touch/tuff-rl
9
+ Project-URL: Issues, https://github.com/talex-touch/tuff-rl/issues
10
+ Project-URL: Changelog, https://github.com/talex-touch/tuff-rl/blob/main/CHANGELOG.md
11
+ Keywords: reinforcement-learning,tensorboard,tfevents,experiment-tracking,training-dashboard,machine-learning
12
+ Classifier: Development Status :: 3 - Alpha
13
+ Classifier: Environment :: Web Environment
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: Intended Audience :: Science/Research
16
+ Classifier: Operating System :: MacOS
17
+ Classifier: Operating System :: POSIX :: Linux
18
+ Classifier: Programming Language :: Python :: 3
19
+ Classifier: Programming Language :: Python :: 3.10
20
+ Classifier: Programming Language :: Python :: 3.11
21
+ Classifier: Programming Language :: Python :: 3.12
22
+ Classifier: Programming Language :: Python :: 3.13
23
+ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
24
+ Classifier: Topic :: Scientific/Engineering :: Visualization
25
+ Requires-Python: >=3.10
26
+ Description-Content-Type: text/markdown
27
+ License-File: LICENSE
28
+ Provides-Extra: system
29
+ Requires-Dist: psutil>=5.9; extra == "system"
30
+ Provides-Extra: dev
31
+ Requires-Dist: pytest>=8.0; extra == "dev"
32
+ Requires-Dist: tensorboard>=2.15; extra == "dev"
33
+ Requires-Dist: tensorboardX>=2.6; extra == "dev"
34
+ Dynamic: license-file
35
+
36
+ # tuff-rl
37
+
38
+ **Training logs as TensorBoard event files, and a live board that follows them.**
39
+
40
+ [English](https://github.com/talex-touch/tuff-rl/blob/main/README.md) · [简体中文](https://github.com/talex-touch/tuff-rl/blob/main/README.zh.md)
41
+
42
+ [![PyPI](https://img.shields.io/pypi/v/tuff-rl)](https://pypi.org/project/tuff-rl/)
43
+ [![Python](https://img.shields.io/pypi/pyversions/tuff-rl)](https://pypi.org/project/tuff-rl/)
44
+ [![CI](https://github.com/talex-touch/tuff-rl/actions/workflows/ci.yml/badge.svg)](https://github.com/talex-touch/tuff-rl/actions/workflows/ci.yml)
45
+ [![License: MIT](https://img.shields.io/badge/license-MIT-blue)](https://github.com/talex-touch/tuff-rl/blob/main/LICENSE)
46
+
47
+ ![The board following six CartPole runs while they train](https://raw.githubusercontent.com/talex-touch/tuff-rl/main/docs/images/board-live.gif)
48
+
49
+ One call in a training script writes everything about the run into TensorBoard event files
50
+ under `logs/`: the metrics, the config, what the script prints, and the CPU, memory and GPU
51
+ it used. The same call starts a board in your browser that follows those files as they grow.
52
+ `tensorboard --logdir logs` opens the same files, and the board opens directories that
53
+ TensorBoard, tensorboardX or PyTorch wrote. The package has no dependencies.
54
+
55
+ ## Install
56
+
57
+ ```bash
58
+ pip install tuff-rl
59
+ pip install "tuff-rl[system]" # adds CPU and memory metrics, through psutil
60
+ ```
61
+
62
+ ## Quick start
63
+
64
+ ```python
65
+ import tuffrl
66
+
67
+ run = tuffrl.init(logdir="logs", group="cartpole/lr-0.5", name="seed-0", config={"lr": 0.5, "gamma": 0.99})
68
+ for step in range(10_000):
69
+ ...
70
+ run.log({"train": {"loss": loss, "return": episode_return}}, step=step)
71
+ run.finish()
72
+ ```
73
+
74
+ The first script to start opens the board at <http://127.0.0.1:6066>; scripts started later
75
+ join it, so a sweep of many processes shares one board. A run that ends with an exception is
76
+ marked failed, and one stopped with Ctrl-C is marked stopped. Under `torch.distributed`, only
77
+ rank 0 starts the board.
78
+
79
+ Code that already uses PyTorch's `SummaryWriter` changes only its import:
80
+
81
+ ```python
82
+ from tuffrl import SummaryWriter
83
+ ```
84
+
85
+ The shim covers `add_scalar`, `add_scalars`, `add_text`, `add_hparams`, `flush` and `close`.
86
+
87
+ ## Try the demo
88
+
89
+ ```bash
90
+ pip install "tuff-rl[system]"
91
+ curl -O https://raw.githubusercontent.com/talex-touch/tuff-rl/main/examples/cartpole.py
92
+ python cartpole.py
93
+ ```
94
+
95
+ REINFORCE on CartPole in plain Python: two learning rates, three seeds each, six processes
96
+ in parallel. It takes about 35 seconds.
97
+
98
+ ## The board
99
+
100
+ | | |
101
+ | --- | --- |
102
+ | ![Two learning rates, each drawn as the mean of three seeds with a band for their spread](https://raw.githubusercontent.com/talex-touch/tuff-rl/main/docs/images/board-groups.png) | ![The config of six runs side by side, with the two differing keys marked](https://raw.githubusercontent.com/talex-touch/tuff-rl/main/docs/images/board-config.png) |
103
+ | Runs in one directory form a group. Merged, a group is drawn as its mean with a band for the spread of its runs. | The config of every run side by side, with the keys that differ marked. |
104
+ | ![The printed output of one run, with stderr in red](https://raw.githubusercontent.com/talex-touch/tuff-rl/main/docs/images/board-output.png) | ![CPU and memory charts for the runs](https://raw.githubusercontent.com/talex-touch/tuff-rl/main/docs/images/board-system.png) |
105
+ | What each run printed, stdout and stderr in order, searchable, following new lines. | CPU and memory of the training machine and process, and the GPUs that `nvidia-smi` reports. |
106
+
107
+ Charts smooth with an exponential moving average and can also show the raw values and a log
108
+ x-axis. Drag across a chart to zoom in, and double-click it to zoom back out. The board
109
+ follows the system's light or dark theme.
110
+
111
+ ## Training on another machine
112
+
113
+ Over SSH the board starts on the training machine and does not open a browser. Instead it
114
+ prints the command that forwards its port to your own machine:
115
+
116
+ ```text
117
+ [tuffrl] board: http://127.0.0.1:6066 (logdir /data/logs)
118
+ [tuffrl] from your own machine: ssh -N -L 6066:127.0.0.1:6066 you@gpu-box then open http://127.0.0.1:6066
119
+ ```
120
+
121
+ The board has no login. It listens on 127.0.0.1 unless `TUFFRL_BOARD_HOST` says otherwise;
122
+ listening on another address shows your logs and config to anyone who can reach that port.
123
+
124
+ ## Command line and settings
125
+
126
+ ```bash
127
+ tuff-rl board logs # serve a log directory, including one TensorBoard wrote
128
+ tuff-rl stop logs # stop the board serving it
129
+ ```
130
+
131
+ | Variable | Effect |
132
+ | --- | --- |
133
+ | `TUFFRL_BOARD=0` | Training scripts do not start the board. |
134
+ | `TUFFRL_OPEN_BROWSER=0` | The board starts but no browser opens. |
135
+ | `TUFFRL_BOARD_PORT` | Port to try first (default 6066); the next free one is used when it is taken. |
136
+ | `TUFFRL_BOARD_HOST` | Address to listen on (default 127.0.0.1). |
137
+
138
+ ## How it works
139
+
140
+ - **Event files.** The writer frames records the way TFRecord does (length, CRC32C, data,
141
+ CRC32C) and encodes the `Event` and `Summary` protobufs by hand, so neither TensorFlow nor
142
+ protobuf is needed. Metrics are scalar summaries, printed output uses the text plugin, and
143
+ the config is an HParams session, so TensorBoard's HParams tab lists the runs and groups
144
+ them by `group`.
145
+ - **The board.** A standard-library HTTP server reads each event file from where it stopped,
146
+ and the page receives a snapshot followed by a stream of new points (server-sent events).
147
+ The page is a Vue app built on [TuffEx](https://www.npmjs.com/package/@talex-touch/tuffex)
148
+ and ships inside the wheel.
149
+ - **One board per log directory.** `logs/.tuffrl/board.json` records which board serves the
150
+ directory. Scripts starting at the same moment agree on one of them to launch it, and the
151
+ board keeps running after training ends until `tuff-rl stop`.
152
+
153
+ ## Status
154
+
155
+ 0.1 is an alpha. The board reads images and audio but does not show them yet; that and the
156
+ rest of the plan are in the [roadmap issues](https://github.com/talex-touch/tuff-rl/issues?q=is%3Aissue+label%3Aroadmap).
157
+
158
+ ## Development
159
+
160
+ ```bash
161
+ cd web && pnpm install && pnpm build && cd .. # builds the board page into src/tuffrl/board/static
162
+ pip install -e ".[dev,system]"
163
+ python -m pytest
164
+ uvx ruff check .
165
+ ```
166
+
167
+ See [CONTRIBUTING.md](https://github.com/talex-touch/tuff-rl/blob/main/CONTRIBUTING.md) for the rest, including how a release is made.
168
+
169
+ ## License
170
+
171
+ [MIT](https://github.com/talex-touch/tuff-rl/blob/main/LICENSE)
@@ -0,0 +1,31 @@
1
+ tuff_rl-0.1.0.dist-info/licenses/LICENSE,sha256=oSD0uISEueTD8yCbTYFq8NeTxuNPvuzoFK0t0-xbKG0,1081
2
+ tuffrl/__init__.py,sha256=STyyLLCc4_S6-Q_wGbQieMmYBJbaVKhBCwzDEdjCit0,1630
3
+ tuffrl/__main__.py,sha256=k1ocEWawweo1qCJWNFAAvyxz3tcY13dzvCenHszij30,48
4
+ tuffrl/cli.py,sha256=1UhKuh8v5XDVnn2es3snWUlMdTkzRz3s7b2smIq6t4g,1878
5
+ tuffrl/compat.py,sha256=NOFxT45hgd_Ip6TDQVPA114AYbNAFuxys58BFZTdQyQ,3540
6
+ tuffrl/run.py,sha256=wdB9hChZYEt0ISz9YNCixeGlGu3J6PU6v96knS_icZ4,6722
7
+ tuffrl/board/__init__.py,sha256=oj1U1KWPEkHNByXUoHyaaX6o-KB7aupP7BcCEZTxNck,176
8
+ tuffrl/board/launch.py,sha256=meUOBf58v4cyM1fcPmWlj-cbCUtF4L_PplpdcBiJ6yU,5019
9
+ tuffrl/board/lock.py,sha256=3DRYuIzblG0JuBNa_NmlqhL2T2fm6n5IPs2qBtpv99E,1175
10
+ tuffrl/board/server.py,sha256=DFNteLduy-qIarOHn_5vieLesw3AyNfOo2vx-AIpOaw,7895
11
+ tuffrl/board/store.py,sha256=6z6nmDWigbgZtFFM-YO-WmtFu15rErLMqW2_htnpUGE,8445
12
+ tuffrl/board/static/index.html,sha256=1MuX9uobRR0xANWUR8P0PTFCDqW1GRZTE2qyboK6a_w,660
13
+ tuffrl/board/static/assets/index-D4-8ALNT.js,sha256=huGu4wbmEPXiEcbjt6I0gFB4Lp5wCoeXottCr7RRvkk,70426
14
+ tuffrl/board/static/assets/index-DWNZ33px.js,sha256=JM34GALSOxJsRQEOzW4ESacdi9999lMM4ckizi_1xsg,6053
15
+ tuffrl/board/static/assets/index-fGqPXvQK.js,sha256=y2NbTw6zNb1f6p0UwIL_cGncElFl7kiRGeAwNv9u8k4,429137
16
+ tuffrl/board/static/assets/index-xWSStQ1s.css,sha256=HCjwCOQ3kKBHst4Wu-G5h0zfpPCVqE3ari0Jsh7WvkQ,191068
17
+ tuffrl/capture/__init__.py,sha256=rTITidcWEGnUPlzOu_1NKs8eJLk0ucIaK8nf-pwQQTs,204
18
+ tuffrl/capture/stdout.py,sha256=zvHZWPSNcvMwjIQb3q3rZjnecpIbpzBMKXp1MH0Whco,2483
19
+ tuffrl/capture/system.py,sha256=uzdGz5DrgMQnSJsxnZ2VqWlp660kYIMBpzMJxhUlUeA,3533
20
+ tuffrl/events/__init__.py,sha256=KwvhWRB9LkjkMa37GH9MyQfynziFLNlaSoCDjFAbkSo,541
21
+ tuffrl/events/crc32c.py,sha256=gUeQlY_TA8hG0jlxyeHm-4EqN4xTyQpMWD8nj1-VTHo,1440
22
+ tuffrl/events/proto.py,sha256=wE5kUmyMKea2GCypWzhR7TKov-ISbcFL7lUkuzMjKmQ,3111
23
+ tuffrl/events/reader.py,sha256=3HeO6rMrEYEki3V4J4Ur31m3CZwdM67UF23WZDpz9cw,2583
24
+ tuffrl/events/records.py,sha256=ShrymEdeH4NprEk2_2fBXXVGaYrI3AlAcqZydQcoQLM,2621
25
+ tuffrl/events/schema.py,sha256=ON3wwDBMRuXXHe_sKJfKJ4GITgFuCj7t7tWEgyUQgI0,13885
26
+ tuffrl/events/writer.py,sha256=FuVoP5-weWvQtzWOieLu5GsZwqTNbXZFsyDrRCv5IpI,2681
27
+ tuff_rl-0.1.0.dist-info/METADATA,sha256=3Yp_od4g6ppyVtYzAHt-BtfhvDik47uvCJHrBTtGsL8,7975
28
+ tuff_rl-0.1.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
29
+ tuff_rl-0.1.0.dist-info/entry_points.txt,sha256=mYEdhA4cTP5XD49QwGbad0HLx_NTQ5vbzMv20q5OUi4,44
30
+ tuff_rl-0.1.0.dist-info/top_level.txt,sha256=kMdtOCbwMYnhVHl8FsU4mROBiTsQLk3t7XqRIF9kCDU,7
31
+ tuff_rl-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.2.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ tuff-rl = tuffrl.cli:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Talex-Touch contributors
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 @@
1
+ tuffrl
tuffrl/__init__.py ADDED
@@ -0,0 +1,53 @@
1
+ """tuff-rl: training logs as TensorBoard event files, with a live local board that starts itself.
2
+
3
+ import tuffrl
4
+ run = tuffrl.init(logdir="logs", name="sac-seed1", group="sac", config={"lr": 3e-4})
5
+ run.log({"train/loss": loss, "eval/return": ret}, step=step)
6
+ run.finish()
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from collections.abc import Mapping
12
+ from typing import Any
13
+
14
+ __version__ = "0.1.0"
15
+
16
+ from .board import ensure_board # noqa: E402 - needs __version__ above
17
+ from .compat import SummaryWriter # noqa: E402
18
+ from .run import Run # noqa: E402
19
+
20
+ _current: Run | None = None
21
+
22
+
23
+ def init(
24
+ logdir: str = "logs",
25
+ name: str | None = None,
26
+ group: str | None = None,
27
+ config: Mapping[str, Any] | None = None,
28
+ **options: Any,
29
+ ) -> Run:
30
+ """Starts a run under ``logdir`` and, unless ``board=False``, the board that shows it."""
31
+ global _current
32
+ _current = Run(logdir=logdir, name=name, group=group, config=config, **options)
33
+ return _current
34
+
35
+
36
+ def log(metrics: Mapping[str, Any], step: int | None = None) -> None:
37
+ """Logs to the run started by :func:`init`."""
38
+ if _current is None:
39
+ raise RuntimeError("call tuffrl.init() first")
40
+ _current.log(metrics, step=step)
41
+
42
+
43
+ def finish(status: str = "success") -> None:
44
+ if _current is not None:
45
+ _current.finish(status)
46
+
47
+
48
+ def board(logdir: str = "logs", **options: Any) -> str | None:
49
+ """Opens (or finds) the board for a log directory, for runs written by any TensorBoard writer."""
50
+ return ensure_board(logdir, **options)
51
+
52
+
53
+ __all__ = ["Run", "SummaryWriter", "__version__", "board", "finish", "init", "log"]
tuffrl/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ raise SystemExit(main())
@@ -0,0 +1,5 @@
1
+ """The live board: a local web page that follows the event files in a log directory."""
2
+
3
+ from .launch import ensure_board, stop_board
4
+
5
+ __all__ = ["ensure_board", "stop_board"]
tuffrl/board/launch.py ADDED
@@ -0,0 +1,144 @@
1
+ """Starting the board from a training process, or finding the one already serving the log directory."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import contextlib
6
+ import getpass
7
+ import json
8
+ import os
9
+ import socket
10
+ import subprocess
11
+ import sys
12
+ import time
13
+ import urllib.request
14
+ import webbrowser
15
+ from typing import Any
16
+
17
+ from .lock import STATE_DIR, lock_path, read_lock
18
+
19
+ DEFAULT_PORT = 6066
20
+ START_TIMEOUT_SECS = 10.0
21
+
22
+
23
+ def _healthy(info: dict[str, Any], logdir: str) -> bool:
24
+ url = info.get("url")
25
+ if not url:
26
+ return False
27
+ try:
28
+ with urllib.request.urlopen(f"{url}/api/health", timeout=1.5) as response: # noqa: S310 - local URL from our own lock
29
+ health = json.load(response)
30
+ except (OSError, ValueError):
31
+ return False
32
+ return bool(health.get("ok")) and os.path.abspath(health.get("logdir", "")) == os.path.abspath(logdir)
33
+
34
+
35
+ def is_remote_session() -> bool:
36
+ return any(os.environ.get(name) for name in ("SSH_CONNECTION", "SSH_CLIENT", "SSH_TTY"))
37
+
38
+
39
+ def can_open_browser() -> bool:
40
+ override = os.environ.get("TUFFRL_OPEN_BROWSER")
41
+ if override is not None:
42
+ return override.strip().lower() not in ("0", "false", "no", "off", "")
43
+ if is_remote_session() or os.environ.get("CI"):
44
+ return False
45
+ if sys.platform.startswith("linux") and not (os.environ.get("DISPLAY") or os.environ.get("WAYLAND_DISPLAY")):
46
+ return False
47
+ return True
48
+
49
+
50
+ def _spawn(logdir: str, host: str, port: int) -> None:
51
+ state_dir = os.path.join(os.path.abspath(logdir), STATE_DIR)
52
+ os.makedirs(state_dir, exist_ok=True)
53
+ log = open(os.path.join(state_dir, "board.log"), "ab") # noqa: SIM115 - handed to the child
54
+ kwargs: dict[str, Any] = {"stdin": subprocess.DEVNULL, "stdout": log, "stderr": log, "close_fds": True}
55
+ if os.name == "nt": # pragma: no cover
56
+ kwargs["creationflags"] = subprocess.DETACHED_PROCESS | subprocess.CREATE_NEW_PROCESS_GROUP
57
+ else:
58
+ kwargs["start_new_session"] = True # the board outlives the training process
59
+ subprocess.Popen(
60
+ [sys.executable, "-m", "tuffrl", "board", os.path.abspath(logdir), "--host", host, "--port", str(port), "--no-browser"],
61
+ **kwargs,
62
+ )
63
+ log.close()
64
+
65
+
66
+ def _claim_start(logdir: str) -> bool:
67
+ """Only one of several processes starting at once launches the board; the rest wait for it."""
68
+ path = f"{lock_path(logdir)}.starting"
69
+ os.makedirs(os.path.dirname(path), exist_ok=True)
70
+ try:
71
+ if time.time() - os.path.getmtime(path) > START_TIMEOUT_SECS * 2:
72
+ os.remove(path) # left behind by a launch that died
73
+ except OSError:
74
+ pass
75
+ try:
76
+ os.close(os.open(path, os.O_CREAT | os.O_EXCL | os.O_WRONLY))
77
+ return True
78
+ except FileExistsError:
79
+ return False
80
+
81
+
82
+ def _release_start(logdir: str) -> None:
83
+ with contextlib.suppress(OSError):
84
+ os.remove(f"{lock_path(logdir)}.starting")
85
+
86
+
87
+ def ensure_board(
88
+ logdir: str,
89
+ *,
90
+ host: str = "127.0.0.1",
91
+ port: int | None = None,
92
+ open_browser: bool | None = None,
93
+ announce: bool = True,
94
+ ) -> str | None:
95
+ """Returns the URL of the board serving ``logdir``, starting one when none is running."""
96
+ port = port or int(os.environ.get("TUFFRL_BOARD_PORT", DEFAULT_PORT))
97
+ host = os.environ.get("TUFFRL_BOARD_HOST", host)
98
+ info = read_lock(logdir)
99
+ started = False
100
+ if not (info and _healthy(info, logdir)):
101
+ claimed = _claim_start(logdir)
102
+ try:
103
+ if claimed:
104
+ _spawn(logdir, host, port)
105
+ started = True
106
+ deadline = time.time() + START_TIMEOUT_SECS
107
+ info = None
108
+ while time.time() < deadline:
109
+ candidate = read_lock(logdir)
110
+ if candidate and _healthy(candidate, logdir):
111
+ info = candidate
112
+ break
113
+ time.sleep(0.2)
114
+ finally:
115
+ if claimed:
116
+ _release_start(logdir)
117
+ if not info:
118
+ if announce:
119
+ print(f"[tuffrl] the board did not start; see {os.path.join(logdir, STATE_DIR, 'board.log')}", file=sys.stderr)
120
+ return None
121
+ url = str(info["url"])
122
+ if announce:
123
+ print(f"[tuffrl] board: {url} (logdir {os.path.abspath(logdir)})", file=sys.stderr, flush=True)
124
+ if is_remote_session():
125
+ board_port = info.get("port")
126
+ print(
127
+ f"[tuffrl] from your own machine: ssh -N -L {board_port}:127.0.0.1:{board_port} "
128
+ f"{getpass.getuser()}@{socket.gethostname()} then open http://127.0.0.1:{board_port}",
129
+ file=sys.stderr, flush=True,
130
+ )
131
+ if started and (can_open_browser() if open_browser is None else open_browser):
132
+ webbrowser.open(url)
133
+ return url
134
+
135
+
136
+ def stop_board(logdir: str) -> bool:
137
+ info = read_lock(logdir)
138
+ if not info or not info.get("pid"):
139
+ return False
140
+ try:
141
+ os.kill(int(info["pid"]), 15)
142
+ except OSError:
143
+ return False
144
+ return True
tuffrl/board/lock.py ADDED
@@ -0,0 +1,40 @@
1
+ """The file in a log directory that says which board is serving it."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import contextlib
6
+ import json
7
+ import os
8
+ from typing import Any
9
+
10
+ STATE_DIR = ".tuffrl"
11
+ LOCK_NAME = "board.json"
12
+
13
+
14
+ def lock_path(logdir: str) -> str:
15
+ return os.path.join(os.path.abspath(logdir), STATE_DIR, LOCK_NAME)
16
+
17
+
18
+ def read_lock(logdir: str) -> dict[str, Any] | None:
19
+ try:
20
+ with open(lock_path(logdir), encoding="utf-8") as handle:
21
+ data = json.load(handle)
22
+ except (OSError, ValueError):
23
+ return None
24
+ return data if isinstance(data, dict) else None
25
+
26
+
27
+ def write_lock(logdir: str, data: dict[str, Any]) -> None:
28
+ path = lock_path(logdir)
29
+ os.makedirs(os.path.dirname(path), exist_ok=True)
30
+ temporary = f"{path}.{os.getpid()}.tmp"
31
+ with open(temporary, "w", encoding="utf-8") as handle:
32
+ json.dump(data, handle)
33
+ os.replace(temporary, path) # readers see the old file or the new one, never half of one
34
+
35
+
36
+ def clear_lock(logdir: str, pid: int) -> None:
37
+ data = read_lock(logdir)
38
+ if data and data.get("pid") == pid:
39
+ with contextlib.suppress(OSError):
40
+ os.remove(lock_path(logdir))