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.
@@ -0,0 +1,2 @@
1
+ # Copyright 2026 Precision AI
2
+ # SPDX-License-Identifier: Apache-2.0
@@ -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
@@ -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]