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.
- tuff_rl-0.1.0.dist-info/METADATA +171 -0
- tuff_rl-0.1.0.dist-info/RECORD +31 -0
- tuff_rl-0.1.0.dist-info/WHEEL +5 -0
- tuff_rl-0.1.0.dist-info/entry_points.txt +2 -0
- tuff_rl-0.1.0.dist-info/licenses/LICENSE +21 -0
- tuff_rl-0.1.0.dist-info/top_level.txt +1 -0
- tuffrl/__init__.py +53 -0
- tuffrl/__main__.py +3 -0
- tuffrl/board/__init__.py +5 -0
- tuffrl/board/launch.py +144 -0
- tuffrl/board/lock.py +40 -0
- tuffrl/board/server.py +189 -0
- tuffrl/board/static/assets/index-D4-8ALNT.js +1 -0
- tuffrl/board/static/assets/index-DWNZ33px.js +1 -0
- tuffrl/board/static/assets/index-fGqPXvQK.js +38 -0
- tuffrl/board/static/assets/index-xWSStQ1s.css +1 -0
- tuffrl/board/static/index.html +18 -0
- tuffrl/board/store.py +193 -0
- tuffrl/capture/__init__.py +6 -0
- tuffrl/capture/stdout.py +71 -0
- tuffrl/capture/system.py +90 -0
- tuffrl/cli.py +50 -0
- tuffrl/compat.py +82 -0
- tuffrl/events/__init__.py +21 -0
- tuffrl/events/crc32c.py +52 -0
- tuffrl/events/proto.py +108 -0
- tuffrl/events/reader.py +75 -0
- tuffrl/events/records.py +68 -0
- tuffrl/events/schema.py +353 -0
- tuffrl/events/writer.py +79 -0
- tuffrl/run.py +183 -0
|
@@ -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
|
+
[](https://pypi.org/project/tuff-rl/)
|
|
43
|
+
[](https://pypi.org/project/tuff-rl/)
|
|
44
|
+
[](https://github.com/talex-touch/tuff-rl/actions/workflows/ci.yml)
|
|
45
|
+
[](https://github.com/talex-touch/tuff-rl/blob/main/LICENSE)
|
|
46
|
+
|
|
47
|
+

|
|
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
|
+
|  |  |
|
|
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
|
+
|  |  |
|
|
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,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
tuffrl/board/__init__.py
ADDED
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))
|