precisionai-pace 0.0.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.
- precisionai/__init__.py +2 -0
- precisionai/pace/__init__.py +52 -0
- precisionai/pace/_adapters.py +166 -0
- precisionai/pace/_env.py +208 -0
- precisionai/pace/_render.py +234 -0
- precisionai/pace/capture.py +78 -0
- precisionai/pace/core.py +342 -0
- precisionai/pace/forecast.py +759 -0
- precisionai/pace/lightning.py +160 -0
- precisionai/pace/protocol.py +584 -0
- precisionai/pace/scopes.py +600 -0
- precisionai/pace/transformers.py +125 -0
- precisionai/pace/version.py +31 -0
- precisionai_pace-0.0.1.dist-info/METADATA +404 -0
- precisionai_pace-0.0.1.dist-info/RECORD +18 -0
- precisionai_pace-0.0.1.dist-info/WHEEL +5 -0
- precisionai_pace-0.0.1.dist-info/licenses/LICENSE.md +201 -0
- precisionai_pace-0.0.1.dist-info/top_level.txt +1 -0
precisionai/__init__.py
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# Copyright 2026 Precision AI
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
"""Pace — a logger with progress reporting whose output says when the work will finish.
|
|
5
|
+
|
|
6
|
+
Each progress record is a readable log line that also carries a short, versioned
|
|
7
|
+
trailer, so the same output serves a person watching a terminal and a scheduler
|
|
8
|
+
estimating when the job will release its hardware. See ``docs/design.md``.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from precisionai.pace.capture import PaceHandler, capture_logging
|
|
12
|
+
from precisionai.pace.core import Logger, logger
|
|
13
|
+
from precisionai.pace.forecast import (
|
|
14
|
+
Forecast,
|
|
15
|
+
ForecastConfig,
|
|
16
|
+
ForecastState,
|
|
17
|
+
JobEstimate,
|
|
18
|
+
JobForecast,
|
|
19
|
+
ScopeForecast,
|
|
20
|
+
)
|
|
21
|
+
from precisionai.pace.protocol import Event, LineSplitter, Record, format_trailer, parse_line
|
|
22
|
+
from precisionai.pace.scopes import Phase, Scope, phase, plan, progress, timed, track
|
|
23
|
+
from precisionai.pace.version import get_version
|
|
24
|
+
|
|
25
|
+
__version__ = get_version()
|
|
26
|
+
|
|
27
|
+
__all__ = [
|
|
28
|
+
"Event",
|
|
29
|
+
"Forecast",
|
|
30
|
+
"ForecastConfig",
|
|
31
|
+
"ForecastState",
|
|
32
|
+
"JobEstimate",
|
|
33
|
+
"JobForecast",
|
|
34
|
+
"LineSplitter",
|
|
35
|
+
"Logger",
|
|
36
|
+
"PaceHandler",
|
|
37
|
+
"Phase",
|
|
38
|
+
"Record",
|
|
39
|
+
"Scope",
|
|
40
|
+
"ScopeForecast",
|
|
41
|
+
"__version__",
|
|
42
|
+
"capture_logging",
|
|
43
|
+
"format_trailer",
|
|
44
|
+
"get_version",
|
|
45
|
+
"logger",
|
|
46
|
+
"parse_line",
|
|
47
|
+
"phase",
|
|
48
|
+
"plan",
|
|
49
|
+
"progress",
|
|
50
|
+
"timed",
|
|
51
|
+
"track",
|
|
52
|
+
]
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
# Copyright 2026 Precision AI
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
"""The scope bookkeeping the framework adapters share.
|
|
5
|
+
|
|
6
|
+
An adapter reports one main scope (the training run) and, while it is open, at most one
|
|
7
|
+
child scope (a validation or evaluation pass) whose parent is the main scope. This module
|
|
8
|
+
holds that bookkeeping with the standard library only, so it is tested without any
|
|
9
|
+
framework installed.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from precisionai.pace.core import Logger
|
|
15
|
+
from precisionai.pace.scopes import Scope
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class AdapterScopes:
|
|
19
|
+
"""Own the main scope of a run and the child scope of the pass currently running inside it.
|
|
20
|
+
|
|
21
|
+
Every method is safe to call in any order and any number of times: a scope that was
|
|
22
|
+
never started, or has already ended, is left alone. Only the first scope this object
|
|
23
|
+
starts is marked as the job's root, because a process has at most one root.
|
|
24
|
+
|
|
25
|
+
Parameters
|
|
26
|
+
----------
|
|
27
|
+
description : str
|
|
28
|
+
Label of the main scope.
|
|
29
|
+
unit : str
|
|
30
|
+
Unit of the main scope's counts.
|
|
31
|
+
scope : str, optional
|
|
32
|
+
Explicit scope id for the main scope.
|
|
33
|
+
root : bool
|
|
34
|
+
Whether the first scope this object starts stands for the whole job.
|
|
35
|
+
logger : Logger, optional
|
|
36
|
+
Output to use; the module-level logger by default.
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
def __init__(
|
|
40
|
+
self,
|
|
41
|
+
description: str,
|
|
42
|
+
unit: str = "step",
|
|
43
|
+
*,
|
|
44
|
+
scope: str | None = None,
|
|
45
|
+
root: bool = True,
|
|
46
|
+
logger: Logger | None = None,
|
|
47
|
+
) -> None:
|
|
48
|
+
self.description = description
|
|
49
|
+
self.unit = unit
|
|
50
|
+
self._scope_id = scope
|
|
51
|
+
self._root_allowed = root
|
|
52
|
+
self._logger = logger
|
|
53
|
+
self._main: Scope | None = None
|
|
54
|
+
self._child: Scope | None = None
|
|
55
|
+
|
|
56
|
+
@property
|
|
57
|
+
def main(self) -> Scope | None:
|
|
58
|
+
"""The main scope while it is open, otherwise ``None``."""
|
|
59
|
+
return self._main
|
|
60
|
+
|
|
61
|
+
@property
|
|
62
|
+
def child(self) -> Scope | None:
|
|
63
|
+
"""The child scope while it is open, otherwise ``None``."""
|
|
64
|
+
return self._child
|
|
65
|
+
|
|
66
|
+
def begin(self, total: float | None, completed: float = 0.0, resumed: bool = False) -> Scope:
|
|
67
|
+
"""Start the main scope, ending any main scope that is still open.
|
|
68
|
+
|
|
69
|
+
Parameters
|
|
70
|
+
----------
|
|
71
|
+
total : float, optional
|
|
72
|
+
Total amount; ``None`` when unknown.
|
|
73
|
+
completed : float
|
|
74
|
+
Amount already done, above zero when resuming.
|
|
75
|
+
resumed : bool
|
|
76
|
+
Whether this resumes earlier work.
|
|
77
|
+
|
|
78
|
+
Returns
|
|
79
|
+
-------
|
|
80
|
+
Scope
|
|
81
|
+
The started scope.
|
|
82
|
+
"""
|
|
83
|
+
self.end()
|
|
84
|
+
scope = Scope(
|
|
85
|
+
self.description,
|
|
86
|
+
total,
|
|
87
|
+
self.unit,
|
|
88
|
+
scope=self._scope_id,
|
|
89
|
+
root=self._take_root(),
|
|
90
|
+
completed=completed,
|
|
91
|
+
resumed=resumed,
|
|
92
|
+
logger=self._logger,
|
|
93
|
+
)
|
|
94
|
+
scope.start()
|
|
95
|
+
self._main = scope
|
|
96
|
+
return scope
|
|
97
|
+
|
|
98
|
+
def update(self, completed: float) -> None:
|
|
99
|
+
"""Set the main scope's completed amount."""
|
|
100
|
+
if self._main is not None:
|
|
101
|
+
self._main.update(completed)
|
|
102
|
+
|
|
103
|
+
def begin_child(self, description: str, total: float | None, unit: str) -> Scope:
|
|
104
|
+
"""Start a child scope, ending the previous one if it is still open.
|
|
105
|
+
|
|
106
|
+
Its parent is the main scope while that is open. With no main scope it is a
|
|
107
|
+
top-level scope but never the root: an evaluation run before training must
|
|
108
|
+
not mark the job complete when it ends, so only training can be the root.
|
|
109
|
+
|
|
110
|
+
Parameters
|
|
111
|
+
----------
|
|
112
|
+
description : str
|
|
113
|
+
Label of the child scope.
|
|
114
|
+
total : float, optional
|
|
115
|
+
Total amount; ``None`` when unknown.
|
|
116
|
+
unit : str
|
|
117
|
+
Unit of the child scope's counts.
|
|
118
|
+
|
|
119
|
+
Returns
|
|
120
|
+
-------
|
|
121
|
+
Scope
|
|
122
|
+
The started scope.
|
|
123
|
+
"""
|
|
124
|
+
self.end_child()
|
|
125
|
+
scope = Scope(description, total, unit, logger=self._logger)
|
|
126
|
+
if self._main is None:
|
|
127
|
+
scope.start()
|
|
128
|
+
else:
|
|
129
|
+
self._main.bind()
|
|
130
|
+
try:
|
|
131
|
+
scope.start()
|
|
132
|
+
finally:
|
|
133
|
+
self._main.unbind()
|
|
134
|
+
self._child = scope
|
|
135
|
+
return scope
|
|
136
|
+
|
|
137
|
+
def advance_child(self, n: float = 1) -> None:
|
|
138
|
+
"""Add ``n`` to the child scope's completed amount."""
|
|
139
|
+
if self._child is not None:
|
|
140
|
+
self._child.advance(n)
|
|
141
|
+
|
|
142
|
+
def end_child(self) -> None:
|
|
143
|
+
"""End the child scope if one is open."""
|
|
144
|
+
child, self._child = self._child, None
|
|
145
|
+
if child is not None:
|
|
146
|
+
child.end()
|
|
147
|
+
|
|
148
|
+
def end(self) -> None:
|
|
149
|
+
"""End the child scope and then the main scope, whichever are open."""
|
|
150
|
+
self.end_child()
|
|
151
|
+
main, self._main = self._main, None
|
|
152
|
+
if main is not None:
|
|
153
|
+
main.end()
|
|
154
|
+
|
|
155
|
+
def fail(self) -> None:
|
|
156
|
+
"""Mark the child scope and then the main scope failed, whichever are open."""
|
|
157
|
+
child, self._child = self._child, None
|
|
158
|
+
if child is not None:
|
|
159
|
+
child.fail()
|
|
160
|
+
main, self._main = self._main, None
|
|
161
|
+
if main is not None:
|
|
162
|
+
main.fail()
|
|
163
|
+
|
|
164
|
+
def _take_root(self) -> bool:
|
|
165
|
+
taken, self._root_allowed = self._root_allowed, False
|
|
166
|
+
return taken
|
precisionai/pace/_env.py
ADDED
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
# Copyright 2026 Precision AI
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
"""Configuration, process rank and the clock behind the logger and progress APIs.
|
|
5
|
+
|
|
6
|
+
Every setting resolves in the same order: a value passed to ``Logger.configure``,
|
|
7
|
+
then the ``PACE_*`` environment variable, then the built-in default. Environment
|
|
8
|
+
variables are read each time they are needed, so a scheduler (or a test) can change
|
|
9
|
+
them without restarting the process.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import math
|
|
15
|
+
import os
|
|
16
|
+
import sys
|
|
17
|
+
import time
|
|
18
|
+
from dataclasses import dataclass
|
|
19
|
+
from typing import Any, Callable
|
|
20
|
+
|
|
21
|
+
DEFAULT_MIN_INTERVAL = 15.0
|
|
22
|
+
DEFAULT_HEARTBEAT = 120.0
|
|
23
|
+
|
|
24
|
+
LEVELS: dict[str, int] = {
|
|
25
|
+
"DEBUG": 10,
|
|
26
|
+
"INFO": 20,
|
|
27
|
+
"SUCCESS": 25,
|
|
28
|
+
"WARNING": 30,
|
|
29
|
+
"ERROR": 40,
|
|
30
|
+
"CRITICAL": 50,
|
|
31
|
+
}
|
|
32
|
+
DEFAULT_LEVEL = LEVELS["INFO"]
|
|
33
|
+
|
|
34
|
+
_RANK_VARIABLES = ("RANK", "LOCAL_RANK", "SLURM_PROCID")
|
|
35
|
+
_COLOR_MODES = ("auto", "always", "never")
|
|
36
|
+
_TRUE_WORDS = frozenset({"1", "true", "yes", "on"})
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass(frozen=True)
|
|
40
|
+
class Clock:
|
|
41
|
+
"""The two clocks the package reads.
|
|
42
|
+
|
|
43
|
+
Parameters
|
|
44
|
+
----------
|
|
45
|
+
monotonic : callable
|
|
46
|
+
Seconds on a monotonic clock. Used for every rate, interval and ``e=`` value.
|
|
47
|
+
wall : callable
|
|
48
|
+
Seconds since the epoch. Used only for the printed timestamp.
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
monotonic: Callable[[], float] = time.monotonic
|
|
52
|
+
wall: Callable[[], float] = time.time
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def parse_level(value: Any) -> int | None:
|
|
56
|
+
"""Return the numeric level for a name or number, or ``None`` if it is not one.
|
|
57
|
+
|
|
58
|
+
Parameters
|
|
59
|
+
----------
|
|
60
|
+
value : str or int
|
|
61
|
+
A level name (any case) or a number.
|
|
62
|
+
|
|
63
|
+
Returns
|
|
64
|
+
-------
|
|
65
|
+
int or None
|
|
66
|
+
The numeric level.
|
|
67
|
+
"""
|
|
68
|
+
if isinstance(value, bool):
|
|
69
|
+
return None
|
|
70
|
+
if isinstance(value, int):
|
|
71
|
+
return value
|
|
72
|
+
if isinstance(value, str):
|
|
73
|
+
return LEVELS.get(value.strip().upper())
|
|
74
|
+
return None
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def level_name(number: int) -> str:
|
|
78
|
+
"""Return the name of the highest level that does not exceed ``number``.
|
|
79
|
+
|
|
80
|
+
Parameters
|
|
81
|
+
----------
|
|
82
|
+
number : int
|
|
83
|
+
A numeric level, such as the one standard ``logging`` uses.
|
|
84
|
+
|
|
85
|
+
Returns
|
|
86
|
+
-------
|
|
87
|
+
str
|
|
88
|
+
A level name; ``DEBUG`` for anything below it.
|
|
89
|
+
"""
|
|
90
|
+
name = "DEBUG"
|
|
91
|
+
for candidate, value in LEVELS.items():
|
|
92
|
+
if value <= number:
|
|
93
|
+
name = candidate
|
|
94
|
+
return name
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def current_rank(env: dict[str, str] | None = None) -> int:
|
|
98
|
+
"""Return this process's rank from ``RANK``, ``LOCAL_RANK`` or ``SLURM_PROCID``.
|
|
99
|
+
|
|
100
|
+
Parameters
|
|
101
|
+
----------
|
|
102
|
+
env : mapping, optional
|
|
103
|
+
Environment to read; defaults to ``os.environ``.
|
|
104
|
+
|
|
105
|
+
Returns
|
|
106
|
+
-------
|
|
107
|
+
int
|
|
108
|
+
The first variable that holds an integer, or ``0`` when none does.
|
|
109
|
+
"""
|
|
110
|
+
source = os.environ if env is None else env
|
|
111
|
+
for name in _RANK_VARIABLES:
|
|
112
|
+
rank = _parse_int(source.get(name))
|
|
113
|
+
if rank is not None:
|
|
114
|
+
return rank
|
|
115
|
+
return 0
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _parse_int(text: str | None) -> int | None:
|
|
119
|
+
try:
|
|
120
|
+
return int((text or "").strip())
|
|
121
|
+
except ValueError:
|
|
122
|
+
return None
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _env_text(name: str) -> str | None:
|
|
126
|
+
value = os.environ.get(name)
|
|
127
|
+
if value is None or not value.strip():
|
|
128
|
+
return None
|
|
129
|
+
return value.strip().lower()
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _env_seconds(name: str) -> float | None:
|
|
133
|
+
text = _env_text(name)
|
|
134
|
+
if text is None:
|
|
135
|
+
return None
|
|
136
|
+
try:
|
|
137
|
+
value = float(text)
|
|
138
|
+
except ValueError:
|
|
139
|
+
return None
|
|
140
|
+
return value if math.isfinite(value) and value >= 0 else None
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _choice(explicit: str | None, variable: str) -> str:
|
|
144
|
+
for candidate in (explicit, _env_text(variable)):
|
|
145
|
+
if candidate in _COLOR_MODES:
|
|
146
|
+
return str(candidate)
|
|
147
|
+
return "auto"
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
@dataclass
|
|
151
|
+
class Settings:
|
|
152
|
+
"""Explicit settings, each ``None`` until ``configure`` sets it.
|
|
153
|
+
|
|
154
|
+
A ``None`` field defers to the environment variable, then to the default.
|
|
155
|
+
"""
|
|
156
|
+
|
|
157
|
+
level: int | None = None
|
|
158
|
+
stream: Any = None
|
|
159
|
+
color: str | None = None
|
|
160
|
+
bar: str | None = None
|
|
161
|
+
min_interval: float | None = None
|
|
162
|
+
heartbeat: float | None = None
|
|
163
|
+
all_ranks: bool | None = None
|
|
164
|
+
|
|
165
|
+
def resolve_level(self) -> int:
|
|
166
|
+
"""Return the minimum level that is written."""
|
|
167
|
+
if self.level is not None:
|
|
168
|
+
return self.level
|
|
169
|
+
text = _env_text("PACE_LEVEL")
|
|
170
|
+
parsed = None if text is None else parse_level(text)
|
|
171
|
+
return DEFAULT_LEVEL if parsed is None else parsed
|
|
172
|
+
|
|
173
|
+
def resolve_stream(self) -> Any:
|
|
174
|
+
"""Return the stream to write to, looked up now so redirection is honoured."""
|
|
175
|
+
if self.stream is not None and not isinstance(self.stream, str):
|
|
176
|
+
return self.stream
|
|
177
|
+
name = self.stream if isinstance(self.stream, str) else _env_text("PACE_STREAM")
|
|
178
|
+
return sys.stderr if name == "stderr" else sys.stdout
|
|
179
|
+
|
|
180
|
+
def resolve_color_mode(self) -> str:
|
|
181
|
+
"""Return ``auto``, ``always`` or ``never``."""
|
|
182
|
+
return _choice(self.color, "PACE_COLOR")
|
|
183
|
+
|
|
184
|
+
def resolve_bar_mode(self) -> str:
|
|
185
|
+
"""Return whether to draw an in-place bar: ``auto`` (on a terminal), ``always`` or ``never``."""
|
|
186
|
+
return _choice(self.bar, "PACE_BAR")
|
|
187
|
+
|
|
188
|
+
def resolve_min_interval(self) -> float:
|
|
189
|
+
"""Return the minimum seconds between progress lines of one scope."""
|
|
190
|
+
if self.min_interval is not None:
|
|
191
|
+
return self.min_interval
|
|
192
|
+
value = _env_seconds("PACE_MIN_INTERVAL")
|
|
193
|
+
return DEFAULT_MIN_INTERVAL if value is None else value
|
|
194
|
+
|
|
195
|
+
def resolve_heartbeat(self) -> float:
|
|
196
|
+
"""Return the seconds after which a progress line is written whatever the progress."""
|
|
197
|
+
if self.heartbeat is not None:
|
|
198
|
+
return self.heartbeat
|
|
199
|
+
value = _env_seconds("PACE_HEARTBEAT")
|
|
200
|
+
return DEFAULT_HEARTBEAT if value is None else value
|
|
201
|
+
|
|
202
|
+
def progress_enabled(self) -> bool:
|
|
203
|
+
"""Return whether this process writes progress lines (rank 0, or every rank if allowed)."""
|
|
204
|
+
if self.all_ranks is not None:
|
|
205
|
+
allowed = self.all_ranks
|
|
206
|
+
else:
|
|
207
|
+
allowed = _env_text("PACE_ALL_RANKS") in _TRUE_WORDS
|
|
208
|
+
return allowed or current_rank() == 0
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
# Copyright 2026 Precision AI
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
"""Turn log messages and progress records into the readable part of an output line.
|
|
5
|
+
|
|
6
|
+
Nothing here writes; it only builds strings. The text is for people and may change
|
|
7
|
+
between releases. Only the ``[pace:1 ...]`` trailer that follows it is a contract.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import codecs
|
|
13
|
+
import re
|
|
14
|
+
import time
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
from typing import Sequence
|
|
17
|
+
|
|
18
|
+
from precisionai.pace.forecast import Forecast, ForecastState
|
|
19
|
+
from precisionai.pace.protocol import Event, Record
|
|
20
|
+
|
|
21
|
+
PROGRESS_LABEL = "PROGRESS"
|
|
22
|
+
LABEL_WIDTH = 8
|
|
23
|
+
BAR_WIDTH = 20
|
|
24
|
+
MAX_DESCRIPTION_CHARS = 80
|
|
25
|
+
SEPARATOR = " "
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass(frozen=True)
|
|
29
|
+
class Charset:
|
|
30
|
+
"""The glyphs used in the readable part of a line."""
|
|
31
|
+
|
|
32
|
+
dot: str
|
|
33
|
+
ellipsis: str
|
|
34
|
+
bar_full: str
|
|
35
|
+
bar_empty: str
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
UNICODE = Charset(dot="·", ellipsis="…", bar_full="━", bar_empty="─")
|
|
39
|
+
ASCII = Charset(dot="|", ellipsis="...", bar_full="#", bar_empty="-")
|
|
40
|
+
|
|
41
|
+
_COLOR_CODES: dict[str, str] = {
|
|
42
|
+
"DEBUG": "2",
|
|
43
|
+
"SUCCESS": "32",
|
|
44
|
+
"WARNING": "33",
|
|
45
|
+
"ERROR": "31",
|
|
46
|
+
"CRITICAL": "1;31",
|
|
47
|
+
PROGRESS_LABEL: "36",
|
|
48
|
+
}
|
|
49
|
+
_ESCAPE = "\x1b["
|
|
50
|
+
_WHITESPACE = re.compile(r"\s+")
|
|
51
|
+
# The same escape sequences precisionai.pace.protocol strips before parsing.
|
|
52
|
+
_ANSI_SEQUENCE = re.compile(r"\x1b\[[0-?]*[ -/]*[@-~]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)")
|
|
53
|
+
_FORGED_MARKER = re.compile(r"\[(?=pace:)")
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def is_utf8(encoding: str) -> bool:
|
|
57
|
+
"""Return whether ``encoding`` is UTF-8, so Unicode glyphs are safe to print."""
|
|
58
|
+
try:
|
|
59
|
+
return codecs.lookup(encoding).name == "utf-8"
|
|
60
|
+
except LookupError:
|
|
61
|
+
return False
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def charset_for(encoding: str) -> Charset:
|
|
65
|
+
"""Return the Unicode glyphs for a UTF-8 stream and the ASCII ones for any other."""
|
|
66
|
+
return UNICODE if is_utf8(encoding) else ASCII
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def encode_safely(text: str, encoding: str) -> str:
|
|
70
|
+
"""Return ``text`` with every character the encoding cannot represent replaced.
|
|
71
|
+
|
|
72
|
+
Parameters
|
|
73
|
+
----------
|
|
74
|
+
text : str
|
|
75
|
+
Text about to be written.
|
|
76
|
+
encoding : str
|
|
77
|
+
The stream's encoding. An unknown encoding is treated as ASCII.
|
|
78
|
+
|
|
79
|
+
Returns
|
|
80
|
+
-------
|
|
81
|
+
str
|
|
82
|
+
Text the stream can write without raising ``UnicodeEncodeError``.
|
|
83
|
+
"""
|
|
84
|
+
try:
|
|
85
|
+
return text.encode(encoding, "replace").decode(encoding, "replace")
|
|
86
|
+
except LookupError:
|
|
87
|
+
return text.encode("ascii", "replace").decode("ascii")
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def format_timestamp(wall: float) -> str:
|
|
91
|
+
"""Return ``wall`` (seconds since the epoch) as ISO-8601 UTC with a ``Z``, to the second."""
|
|
92
|
+
return time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(wall))
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def colorize(label: str, enabled: bool) -> str:
|
|
96
|
+
"""Wrap a level label in its colour when ``enabled``; the trailer is never coloured."""
|
|
97
|
+
code = _COLOR_CODES.get(label)
|
|
98
|
+
if not enabled or code is None:
|
|
99
|
+
return label
|
|
100
|
+
return f"{_ESCAPE}{code}m{label}{_ESCAPE}0m"
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def log_line(timestamp: str, label: str, message: str, color: bool) -> str:
|
|
104
|
+
"""Return a log line: timestamp, level padded to eight characters, message."""
|
|
105
|
+
pad = " " * max(LABEL_WIDTH - len(label), 0)
|
|
106
|
+
return f"{timestamp} {colorize(label, color)}{pad} {neutralize(message)}"
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def neutralize(message: str) -> str:
|
|
110
|
+
"""Break up any ``[pace:`` in a user message so it cannot pass for a progress trailer.
|
|
111
|
+
|
|
112
|
+
A reader removes terminal escape sequences before it looks for a trailer, so they
|
|
113
|
+
are removed here first: ``[pa`` + ESC ``[31m`` + ``ce:`` must not survive as a
|
|
114
|
+
marker. Any ESC left over is dropped too.
|
|
115
|
+
"""
|
|
116
|
+
plain = _ANSI_SEQUENCE.sub("", message).replace("\x1b", "")
|
|
117
|
+
return _FORGED_MARKER.sub("[ ", plain)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def clean_text(text: str, limit: int = MAX_DESCRIPTION_CHARS) -> str:
|
|
121
|
+
"""Return ``text`` on one line (whitespace and control characters become one space), cut to ``limit``."""
|
|
122
|
+
one_line = _WHITESPACE.sub(" ", "".join(" " if ord(char) < 32 else char for char in text)).strip()
|
|
123
|
+
return neutralize(one_line)[:limit]
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def format_duration(seconds: float) -> str:
|
|
127
|
+
"""Return a short duration such as ``45s``, ``21m57s``, ``3h05m`` or ``2d4h``."""
|
|
128
|
+
total = max(int(seconds + 0.5), 0)
|
|
129
|
+
minutes, secs = divmod(total, 60)
|
|
130
|
+
hours, minutes = divmod(minutes, 60)
|
|
131
|
+
days, hours = divmod(hours, 24)
|
|
132
|
+
if days:
|
|
133
|
+
return f"{days}d{hours}h"
|
|
134
|
+
if hours:
|
|
135
|
+
return f"{hours}h{minutes:02d}m"
|
|
136
|
+
if minutes:
|
|
137
|
+
return f"{minutes}m{secs:02d}s"
|
|
138
|
+
return f"{secs}s"
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def format_count(value: float) -> str:
|
|
142
|
+
"""Return a count without a trailing ``.0``."""
|
|
143
|
+
if float(value).is_integer():
|
|
144
|
+
return str(int(value))
|
|
145
|
+
return f"{value:.4g}"
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def format_rate(rate: float, unit: str) -> str:
|
|
149
|
+
"""Return a rate such as ``2.4 step/s``."""
|
|
150
|
+
shown = f"{rate:.0f}" if rate >= 100 else f"{rate:.3g}"
|
|
151
|
+
return f"{shown} {unit}/s"
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def format_bar(fraction: float, charset: Charset) -> str:
|
|
155
|
+
"""Return a fixed-width bar, ``fraction`` of it filled."""
|
|
156
|
+
filled = min(max(int(fraction * BAR_WIDTH), 0), BAR_WIDTH)
|
|
157
|
+
return charset.bar_full * filled + charset.bar_empty * (BAR_WIDTH - filled)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def estimate_text(record: Record, forecast: Forecast, charset: Charset) -> str:
|
|
161
|
+
"""Describe the outcome of a finished scope, or its time remaining, or why there is no estimate."""
|
|
162
|
+
if record.event is Event.END:
|
|
163
|
+
return f"done in {format_duration(record.elapsed)}"
|
|
164
|
+
if record.event is Event.FAIL:
|
|
165
|
+
return f"failed after {format_duration(record.elapsed)}"
|
|
166
|
+
remaining = forecast.remaining_seconds
|
|
167
|
+
state = forecast.state
|
|
168
|
+
if remaining is not None and state in (ForecastState.LIVE, ForecastState.STALE, ForecastState.BOOTSTRAP):
|
|
169
|
+
note = {ForecastState.STALE: " (stale)", ForecastState.BOOTSTRAP: " (guess)"}.get(state, "")
|
|
170
|
+
return f"~{format_duration(remaining)}{note}"
|
|
171
|
+
if state is ForecastState.LEARNING:
|
|
172
|
+
return f"estimating{charset.ellipsis}"
|
|
173
|
+
return "no estimate"
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def progress_text(
|
|
177
|
+
record: Record,
|
|
178
|
+
forecast: Forecast,
|
|
179
|
+
description: str,
|
|
180
|
+
unit: str,
|
|
181
|
+
charset: Charset,
|
|
182
|
+
bar: bool = False,
|
|
183
|
+
) -> str:
|
|
184
|
+
"""Return the readable part of a progress line, for example ``Train 36.8% 1840/5000 step · 2.4 step/s · ~21m57s``.
|
|
185
|
+
|
|
186
|
+
Parameters
|
|
187
|
+
----------
|
|
188
|
+
record : Record
|
|
189
|
+
The record being written.
|
|
190
|
+
forecast : Forecast
|
|
191
|
+
The scope's estimate after ``record``.
|
|
192
|
+
description : str
|
|
193
|
+
What the scope is doing; the scope id is used when empty.
|
|
194
|
+
unit : str
|
|
195
|
+
Unit of the counts.
|
|
196
|
+
charset : Charset
|
|
197
|
+
Unicode or ASCII glyphs.
|
|
198
|
+
bar : bool
|
|
199
|
+
Whether to include a bar after the description (the in-place terminal line).
|
|
200
|
+
|
|
201
|
+
Returns
|
|
202
|
+
-------
|
|
203
|
+
str
|
|
204
|
+
One line of text with no timestamp, level or trailer.
|
|
205
|
+
"""
|
|
206
|
+
completed = record.completed or 0.0
|
|
207
|
+
total = record.total
|
|
208
|
+
parts: list[str] = [clean_text(description) or record.scope]
|
|
209
|
+
if total is not None:
|
|
210
|
+
if bar:
|
|
211
|
+
parts.append(format_bar(completed / total, charset))
|
|
212
|
+
parts.append(f"{100 * completed / total:.1f}%")
|
|
213
|
+
count = format_count(completed) if total is None else f"{format_count(completed)}/{format_count(total)}"
|
|
214
|
+
details = [f"{count} {clean_text(unit, 32)}".rstrip()]
|
|
215
|
+
if forecast.rate is not None:
|
|
216
|
+
details.append(format_rate(forecast.rate, clean_text(unit, 32) or "it"))
|
|
217
|
+
details.append(estimate_text(record, forecast, charset))
|
|
218
|
+
parts.append(f" {charset.dot} ".join(details))
|
|
219
|
+
return SEPARATOR.join(parts)
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def plan_text(phases: Sequence[tuple[str, float]]) -> str:
|
|
223
|
+
"""Return the readable part of a ``plan`` line, for example ``Plan prep 5%, train 90%``."""
|
|
224
|
+
whole = sum(weight for _, weight in phases) or 1.0
|
|
225
|
+
shares = ", ".join(f"{name} {round(100 * weight / whole)}%" for name, weight in phases)
|
|
226
|
+
return f"Plan{SEPARATOR}{shares}"
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def fit(text: str, width: int, charset: Charset | None = None) -> str:
|
|
230
|
+
"""Return ``text`` cut so it fits ``width`` columns, ending in an ellipsis when it was cut."""
|
|
231
|
+
if len(text) <= width:
|
|
232
|
+
return text
|
|
233
|
+
mark = (charset or ASCII).ellipsis
|
|
234
|
+
return (text[: max(width - len(mark), 0)] + mark)[:width]
|