tailtune 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.
- tailtune/__init__.py +46 -0
- tailtune/__main__.py +6 -0
- tailtune/_core/__init__.py +1 -0
- tailtune/_core/errors.py +177 -0
- tailtune/_core/hub.py +186 -0
- tailtune/_core/versions.py +80 -0
- tailtune/cli/__init__.py +85 -0
- tailtune/cli/app.py +141 -0
- tailtune/cli/render.py +102 -0
- tailtune/hardware/__init__.py +1 -0
- tailtune/hardware/doctor.py +671 -0
- tailtune/hardware/estimate.py +554 -0
- tailtune/hardware/gpus.py +84 -0
- tailtune/hardware/probe.py +275 -0
- tailtune/hardware/shapes.py +178 -0
- tailtune/py.typed +0 -0
- tailtune-0.0.1.dist-info/METADATA +74 -0
- tailtune-0.0.1.dist-info/RECORD +22 -0
- tailtune-0.0.1.dist-info/WHEEL +4 -0
- tailtune-0.0.1.dist-info/entry_points.txt +2 -0
- tailtune-0.0.1.dist-info/licenses/LICENSE +201 -0
- tailtune-0.0.1.dist-info/licenses/NOTICE +7 -0
tailtune/__init__.py
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""tailtune: fine-tune LLMs with far less forgetting, and prove it.
|
|
2
|
+
|
|
3
|
+
0.0.1 ships two tools that need no GPU and no torch:
|
|
4
|
+
|
|
5
|
+
- ``tailtune.doctor()`` reports what this machine can run, and at which support tier.
|
|
6
|
+
- ``tailtune.estimate(model)`` prices every training route in GPU and host memory,
|
|
7
|
+
from the model's ``config.json`` alone.
|
|
8
|
+
|
|
9
|
+
Every memory figure is computed from shapes and has not yet been checked against
|
|
10
|
+
hardware. Gate 1 of the plan calibrates it.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from typing import TYPE_CHECKING, Any
|
|
16
|
+
|
|
17
|
+
from tailtune._core.errors import TailtuneDeprecationWarning, TailtuneError
|
|
18
|
+
|
|
19
|
+
__version__ = "0.0.1"
|
|
20
|
+
|
|
21
|
+
if TYPE_CHECKING:
|
|
22
|
+
from tailtune.hardware.doctor import DoctorReport
|
|
23
|
+
from tailtune.hardware.estimate import Estimate
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def doctor(*, deep: bool = False) -> DoctorReport:
|
|
27
|
+
"""Run the machine checks (D01 to D18). Makes no network calls."""
|
|
28
|
+
from tailtune.hardware.doctor import run_doctor
|
|
29
|
+
|
|
30
|
+
return run_doctor(deep=deep)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def estimate(model: str, **kwargs: Any) -> Estimate:
|
|
34
|
+
"""Estimate GPU and host memory per route. See ``tailtune.hardware.estimate.estimate``."""
|
|
35
|
+
from tailtune.hardware.estimate import estimate as _estimate
|
|
36
|
+
|
|
37
|
+
return _estimate(model, **kwargs)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
__all__ = [
|
|
41
|
+
"TailtuneDeprecationWarning",
|
|
42
|
+
"TailtuneError",
|
|
43
|
+
"__version__",
|
|
44
|
+
"doctor",
|
|
45
|
+
"estimate",
|
|
46
|
+
]
|
tailtune/__main__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Private support layer. Imports nothing else from tailtune."""
|
tailtune/_core/errors.py
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
"""Exception hierarchy, stable error codes and the exit-code contract.
|
|
2
|
+
|
|
3
|
+
Exit codes (README contract, docs/plan/04-api-and-ux.md section 4.1):
|
|
4
|
+
|
|
5
|
+
0 success
|
|
6
|
+
1 internal or unexpected error, Hub unreachable, declined confirmation
|
|
7
|
+
2 blocking data validation failure
|
|
8
|
+
3 regression gate failed
|
|
9
|
+
4 refused by a pre-flight check, including model access and policy refusals
|
|
10
|
+
5 invalid configuration or usage
|
|
11
|
+
130 interrupted with Ctrl-C
|
|
12
|
+
|
|
13
|
+
Only the classes that 0.0.1 raises are defined here. The full tree is in
|
|
14
|
+
docs/plan/02-architecture.md section 10.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
EXIT_OK = 0
|
|
20
|
+
EXIT_ERROR = 1
|
|
21
|
+
EXIT_DATA = 2
|
|
22
|
+
EXIT_REGRESSION = 3
|
|
23
|
+
EXIT_REFUSED = 4
|
|
24
|
+
EXIT_USAGE = 5
|
|
25
|
+
EXIT_INTERRUPTED = 130
|
|
26
|
+
|
|
27
|
+
DOCS_ERRORS = (
|
|
28
|
+
"https://github.com/inboxpraveen/tailtune/blob/main/docs/plan/04-api-and-ux.md"
|
|
29
|
+
"#8-error-message-catalogue"
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class TailtuneDeprecationWarning(FutureWarning):
|
|
34
|
+
"""Raised for deprecated tailtune behaviour. Subclasses FutureWarning so users see it."""
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class TailtuneError(Exception):
|
|
38
|
+
"""Base class. Every error carries a stable code, a message, a cause and a fix."""
|
|
39
|
+
|
|
40
|
+
exit_code: int = EXIT_ERROR
|
|
41
|
+
default_code: str = "TT-X000"
|
|
42
|
+
|
|
43
|
+
def __init__(
|
|
44
|
+
self,
|
|
45
|
+
message: str,
|
|
46
|
+
*,
|
|
47
|
+
code: str | None = None,
|
|
48
|
+
cause: str | None = None,
|
|
49
|
+
fix: str | None = None,
|
|
50
|
+
) -> None:
|
|
51
|
+
super().__init__(message)
|
|
52
|
+
self.message = message
|
|
53
|
+
self.code = code or self.default_code
|
|
54
|
+
self.cause = cause
|
|
55
|
+
self.fix = fix
|
|
56
|
+
|
|
57
|
+
def render(self) -> str:
|
|
58
|
+
"""Format the error in the catalogue's shape, for stderr."""
|
|
59
|
+
lines = [f"error[{self.code}] {self.message}"]
|
|
60
|
+
if self.cause:
|
|
61
|
+
lines.append(f" cause {self.cause}")
|
|
62
|
+
if self.fix:
|
|
63
|
+
lines.append(f" fix {self.fix}")
|
|
64
|
+
lines.append(f" docs {DOCS_ERRORS}")
|
|
65
|
+
return "\n".join(lines)
|
|
66
|
+
|
|
67
|
+
def to_dict(self) -> dict[str, object]:
|
|
68
|
+
return {
|
|
69
|
+
"code": self.code,
|
|
70
|
+
"exit_code": self.exit_code,
|
|
71
|
+
"message": self.message,
|
|
72
|
+
"cause": self.cause,
|
|
73
|
+
"fix": self.fix,
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class ConfigError(TailtuneError):
|
|
78
|
+
"""A bad value, an unknown key or an out-of-range option (TT-C001, TT-C003)."""
|
|
79
|
+
|
|
80
|
+
exit_code = EXIT_USAGE
|
|
81
|
+
default_code = "TT-C003"
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class UsageError(ConfigError):
|
|
85
|
+
"""A command line that could not be parsed. Click's exit code 2 is remapped to 5."""
|
|
86
|
+
|
|
87
|
+
default_code = "TT-C003"
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class ModelAccessError(TailtuneError):
|
|
91
|
+
"""A model tailtune cannot access, or will not load under its policy (D-08)."""
|
|
92
|
+
|
|
93
|
+
exit_code = EXIT_REFUSED
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class GatedModelError(ModelAccessError):
|
|
97
|
+
default_code = "TT-M001"
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class MissingTokenError(ModelAccessError):
|
|
101
|
+
default_code = "TT-M002"
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class RemoteCodeRequiredError(ModelAccessError):
|
|
105
|
+
default_code = "TT-M003"
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
class UnsafeWeightsError(ModelAccessError):
|
|
109
|
+
default_code = "TT-M004"
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
class HubUnavailableError(TailtuneError):
|
|
113
|
+
"""The Hugging Face Hub could not be reached and nothing usable was cached."""
|
|
114
|
+
|
|
115
|
+
exit_code = EXIT_ERROR
|
|
116
|
+
default_code = "TT-M005"
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
class RefusedError(TailtuneError):
|
|
120
|
+
"""A pre-flight refusal. Nothing was loaded or changed."""
|
|
121
|
+
|
|
122
|
+
exit_code = EXIT_REFUSED
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
class DoesNotFitError(RefusedError):
|
|
126
|
+
default_code = "TT-H001"
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
class TargetTypeError(RefusedError):
|
|
130
|
+
"""Conv1D or quantized trainable targets (TT-T002, TT-T003)."""
|
|
131
|
+
|
|
132
|
+
default_code = "TT-T003"
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
class UnsupportedModelError(RefusedError):
|
|
136
|
+
"""An architecture whose shapes this version cannot read reliably."""
|
|
137
|
+
|
|
138
|
+
default_code = "TT-T004"
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
class DoctorStrictError(RefusedError):
|
|
142
|
+
"""`doctor --strict` found at least one failed check."""
|
|
143
|
+
|
|
144
|
+
default_code = "TT-H006"
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
class InternalError(TailtuneError):
|
|
148
|
+
"""An invariant broke. Always a bug."""
|
|
149
|
+
|
|
150
|
+
default_code = "TT-X001"
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
__all__ = [
|
|
154
|
+
"EXIT_DATA",
|
|
155
|
+
"EXIT_ERROR",
|
|
156
|
+
"EXIT_INTERRUPTED",
|
|
157
|
+
"EXIT_OK",
|
|
158
|
+
"EXIT_REFUSED",
|
|
159
|
+
"EXIT_REGRESSION",
|
|
160
|
+
"EXIT_USAGE",
|
|
161
|
+
"ConfigError",
|
|
162
|
+
"DoctorStrictError",
|
|
163
|
+
"DoesNotFitError",
|
|
164
|
+
"GatedModelError",
|
|
165
|
+
"HubUnavailableError",
|
|
166
|
+
"InternalError",
|
|
167
|
+
"MissingTokenError",
|
|
168
|
+
"ModelAccessError",
|
|
169
|
+
"RefusedError",
|
|
170
|
+
"RemoteCodeRequiredError",
|
|
171
|
+
"TailtuneDeprecationWarning",
|
|
172
|
+
"TailtuneError",
|
|
173
|
+
"TargetTypeError",
|
|
174
|
+
"UnsafeWeightsError",
|
|
175
|
+
"UnsupportedModelError",
|
|
176
|
+
"UsageError",
|
|
177
|
+
]
|
tailtune/_core/hub.py
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
"""Model config access: a local path or a Hugging Face Hub id.
|
|
2
|
+
|
|
3
|
+
Downloads `config.json` only, never weights. Maps Hub failures to tailtune's
|
|
4
|
+
error codes: gated or tokenless access, pickle-only weights and remote code are
|
|
5
|
+
refusals (exit 4); an unknown id or revision is a usage error (exit 5).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import warnings
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any, Literal
|
|
15
|
+
|
|
16
|
+
from tailtune._core.errors import (
|
|
17
|
+
ConfigError,
|
|
18
|
+
GatedModelError,
|
|
19
|
+
HubUnavailableError,
|
|
20
|
+
MissingTokenError,
|
|
21
|
+
UnsafeWeightsError,
|
|
22
|
+
UnsupportedModelError,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
PICKLE_SUFFIXES = (".bin", ".pt", ".pth", ".ckpt", ".pkl")
|
|
26
|
+
SAFE_SUFFIX = ".safetensors"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass(frozen=True)
|
|
30
|
+
class ModelSource:
|
|
31
|
+
"""Where a model's config came from."""
|
|
32
|
+
|
|
33
|
+
ref: str
|
|
34
|
+
kind: Literal["hub", "local", "hub-cache"]
|
|
35
|
+
config: dict[str, Any]
|
|
36
|
+
revision: str | None = None
|
|
37
|
+
weight_files: tuple[str, ...] = field(default_factory=tuple)
|
|
38
|
+
|
|
39
|
+
@property
|
|
40
|
+
def label(self) -> str:
|
|
41
|
+
if self.kind == "local":
|
|
42
|
+
return self.ref
|
|
43
|
+
rev = self.revision[:12] if self.revision else "unresolved revision"
|
|
44
|
+
suffix = " (from local cache, Hub unreachable)" if self.kind == "hub-cache" else ""
|
|
45
|
+
return f"{self.ref} @ {rev}{suffix}"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def check_weight_formats(ref: str, files: list[str]) -> None:
|
|
49
|
+
"""Refuse a model whose only weights are pickle files (D-08, TT-M004)."""
|
|
50
|
+
names = [f.lower() for f in files]
|
|
51
|
+
has_safe = any(n.endswith(SAFE_SUFFIX) for n in names)
|
|
52
|
+
has_pickle = any(n.endswith(PICKLE_SUFFIXES) for n in names)
|
|
53
|
+
if has_pickle and not has_safe:
|
|
54
|
+
raise UnsafeWeightsError(
|
|
55
|
+
f"{ref} has no safetensors weights. tailtune does not load pickle files.",
|
|
56
|
+
cause="Only .bin, .pt or similar pickle checkpoints were found (D-08).",
|
|
57
|
+
fix="Use a safetensors revision, or convert the weights yourself in an "
|
|
58
|
+
"isolated environment.",
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _read_json(path: Path) -> dict[str, Any]:
|
|
63
|
+
try:
|
|
64
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
65
|
+
except (OSError, UnicodeDecodeError, json.JSONDecodeError) as exc:
|
|
66
|
+
raise ConfigError(
|
|
67
|
+
f'Invalid value for MODEL: "{path}". The config file could not be read as JSON.',
|
|
68
|
+
cause=str(exc),
|
|
69
|
+
) from exc
|
|
70
|
+
if not isinstance(data, dict):
|
|
71
|
+
raise ConfigError(f'Invalid value for MODEL: "{path}". config.json must hold an object.')
|
|
72
|
+
return data
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _load_local(ref: str, path: Path) -> ModelSource:
|
|
76
|
+
if path.is_file():
|
|
77
|
+
return ModelSource(ref=ref, kind="local", config=_read_json(path))
|
|
78
|
+
cfg = path / "config.json"
|
|
79
|
+
if not cfg.is_file():
|
|
80
|
+
raise ConfigError(
|
|
81
|
+
f'Invalid value for MODEL: "{ref}". The directory has no config.json.',
|
|
82
|
+
fix="Pass a model directory saved by Transformers, a config.json file, or a Hub id.",
|
|
83
|
+
)
|
|
84
|
+
files = [p.name for p in path.iterdir() if p.is_file()]
|
|
85
|
+
check_weight_formats(ref, files)
|
|
86
|
+
return ModelSource(
|
|
87
|
+
ref=ref, kind="local", config=_read_json(cfg), weight_files=tuple(sorted(files))
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _has_token() -> bool:
|
|
92
|
+
from huggingface_hub import get_token
|
|
93
|
+
|
|
94
|
+
return get_token() is not None
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _gated(ref: str) -> Exception:
|
|
98
|
+
if _has_token():
|
|
99
|
+
return GatedModelError(
|
|
100
|
+
f"{ref} is gated, and this account has not been granted access.",
|
|
101
|
+
fix="Accept the licence on the model's Hugging Face page with the same account, "
|
|
102
|
+
"then rerun.",
|
|
103
|
+
)
|
|
104
|
+
return MissingTokenError(
|
|
105
|
+
f"{ref} requires a Hugging Face token, and none was found.",
|
|
106
|
+
fix="Set HF_TOKEN, or log in with the Hugging Face CLI. In notebooks, add HF_TOKEN "
|
|
107
|
+
"to Colab or Kaggle secrets.",
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _load_hub(ref: str, revision: str | None) -> ModelSource:
|
|
112
|
+
from huggingface_hub import HfApi, hf_hub_download
|
|
113
|
+
from huggingface_hub.errors import (
|
|
114
|
+
EntryNotFoundError,
|
|
115
|
+
GatedRepoError,
|
|
116
|
+
HFValidationError,
|
|
117
|
+
LocalEntryNotFoundError,
|
|
118
|
+
RepositoryNotFoundError,
|
|
119
|
+
RevisionNotFoundError,
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
try:
|
|
123
|
+
info = HfApi().model_info(ref, revision=revision)
|
|
124
|
+
except HFValidationError as exc:
|
|
125
|
+
raise ConfigError(
|
|
126
|
+
f'Invalid value for MODEL: "{ref}". It is neither a local path nor a valid Hub id.',
|
|
127
|
+
cause=str(exc).splitlines()[0],
|
|
128
|
+
) from exc
|
|
129
|
+
except GatedRepoError as exc:
|
|
130
|
+
raise _gated(ref) from exc
|
|
131
|
+
except RevisionNotFoundError as exc:
|
|
132
|
+
raise ConfigError(
|
|
133
|
+
f'Invalid value for --revision: "{revision}". {ref} has no such branch, tag or commit.'
|
|
134
|
+
) from exc
|
|
135
|
+
except RepositoryNotFoundError as exc:
|
|
136
|
+
hint = "" if _has_token() else " If it is private, set HF_TOKEN."
|
|
137
|
+
raise ConfigError(
|
|
138
|
+
f'Invalid value for MODEL: "{ref}". No such model on the Hugging Face Hub.{hint}'
|
|
139
|
+
) from exc
|
|
140
|
+
except Exception as exc: # network failure: fall back to the local cache
|
|
141
|
+
try:
|
|
142
|
+
path = hf_hub_download(ref, "config.json", revision=revision, local_files_only=True)
|
|
143
|
+
except (LocalEntryNotFoundError, OSError, ValueError):
|
|
144
|
+
raise HubUnavailableError(
|
|
145
|
+
f"Could not reach the Hugging Face Hub to read {ref}, and its config is not "
|
|
146
|
+
"cached.",
|
|
147
|
+
cause=f"{type(exc).__name__}: {exc}",
|
|
148
|
+
fix="Check the network or proxy settings, or pass a local model directory.",
|
|
149
|
+
) from exc
|
|
150
|
+
return ModelSource(ref=ref, kind="hub-cache", config=_read_json(Path(str(path))))
|
|
151
|
+
|
|
152
|
+
files = [s.rfilename for s in (info.siblings or [])]
|
|
153
|
+
check_weight_formats(ref, files)
|
|
154
|
+
try:
|
|
155
|
+
path = hf_hub_download(ref, "config.json", revision=info.sha)
|
|
156
|
+
except GatedRepoError as exc:
|
|
157
|
+
raise _gated(ref) from exc
|
|
158
|
+
except EntryNotFoundError as exc:
|
|
159
|
+
raise UnsupportedModelError(
|
|
160
|
+
f"{ref} has no config.json, so tailtune cannot read its shapes.",
|
|
161
|
+
fix="Use a model repository saved by Transformers.",
|
|
162
|
+
) from exc
|
|
163
|
+
except Exception as exc:
|
|
164
|
+
raise HubUnavailableError(
|
|
165
|
+
f"Could not download config.json for {ref}.",
|
|
166
|
+
cause=f"{type(exc).__name__}: {exc}",
|
|
167
|
+
fix="Check the network or proxy settings, then rerun.",
|
|
168
|
+
) from exc
|
|
169
|
+
return ModelSource(
|
|
170
|
+
ref=ref,
|
|
171
|
+
kind="hub",
|
|
172
|
+
config=_read_json(Path(str(path))),
|
|
173
|
+
revision=info.sha,
|
|
174
|
+
weight_files=tuple(sorted(files)),
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def load_config(model: str, revision: str | None = None) -> ModelSource:
|
|
179
|
+
"""Read a model's config from a local path or the Hub, without weights."""
|
|
180
|
+
path = Path(model).expanduser()
|
|
181
|
+
if path.exists():
|
|
182
|
+
return _load_local(model, path)
|
|
183
|
+
with warnings.catch_warnings():
|
|
184
|
+
# The Hub client warns on Windows without symlink support; caching still works.
|
|
185
|
+
warnings.filterwarnings("ignore", message=r".*cache-system uses symlinks.*")
|
|
186
|
+
return _load_hub(model, revision)
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""Installed package versions, compared with the ranges tailtune is tested against.
|
|
2
|
+
|
|
3
|
+
Reads metadata only. Nothing here imports the packages it reports on.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from importlib import metadata
|
|
10
|
+
from typing import Literal
|
|
11
|
+
|
|
12
|
+
from packaging.version import InvalidVersion, Version
|
|
13
|
+
|
|
14
|
+
Standing = Literal["tested", "newer", "older", "unsupported", "missing", "unknown"]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass(frozen=True)
|
|
18
|
+
class TestedRange:
|
|
19
|
+
"""`low <= v < high` is tested. Below `floor` is unsupported outright."""
|
|
20
|
+
|
|
21
|
+
package: str
|
|
22
|
+
low: str
|
|
23
|
+
high: str
|
|
24
|
+
floor: str | None = None
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
# The ranges v0.1 will be tested against (docs/plan/11-engineering-and-release.md section 2).
|
|
28
|
+
# 0.0.1 requires none of these packages; doctor reports them so users can see where they stand.
|
|
29
|
+
TESTED: dict[str, TestedRange] = {
|
|
30
|
+
"torch": TestedRange("torch", "2.14", "2.16", floor="2.14"),
|
|
31
|
+
"transformers": TestedRange("transformers", "5.22", "5.23", floor="5.0"),
|
|
32
|
+
"accelerate": TestedRange("accelerate", "1.14", "2.0", floor="1.14"),
|
|
33
|
+
"peft": TestedRange("peft", "0.21.2", "0.22"),
|
|
34
|
+
"trl": TestedRange("trl", "1.17", "1.18"),
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass(frozen=True)
|
|
39
|
+
class Installed:
|
|
40
|
+
package: str
|
|
41
|
+
version: str | None
|
|
42
|
+
standing: Standing
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def installed_version(package: str) -> str | None:
|
|
46
|
+
try:
|
|
47
|
+
return metadata.version(package)
|
|
48
|
+
except metadata.PackageNotFoundError:
|
|
49
|
+
return None
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _release(v: Version) -> Version:
|
|
53
|
+
"""Drop local and pre-release parts, so `2.14.1+cu130` compares as `2.14.1`."""
|
|
54
|
+
return Version(v.base_version)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def standing(package: str, version: str | None = None) -> Installed:
|
|
58
|
+
"""Where an installed version sits against TESTED. `version` overrides the lookup."""
|
|
59
|
+
found = version if version is not None else installed_version(package)
|
|
60
|
+
if found is None:
|
|
61
|
+
return Installed(package, None, "missing")
|
|
62
|
+
rng = TESTED.get(package)
|
|
63
|
+
if rng is None:
|
|
64
|
+
return Installed(package, found, "unknown")
|
|
65
|
+
try:
|
|
66
|
+
v = _release(Version(found))
|
|
67
|
+
except InvalidVersion:
|
|
68
|
+
return Installed(package, found, "unknown")
|
|
69
|
+
if rng.floor is not None and v < Version(rng.floor):
|
|
70
|
+
return Installed(package, found, "unsupported")
|
|
71
|
+
if v < Version(rng.low):
|
|
72
|
+
return Installed(package, found, "older")
|
|
73
|
+
if v >= Version(rng.high):
|
|
74
|
+
return Installed(package, found, "newer")
|
|
75
|
+
return Installed(package, found, "tested")
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def describe_range(package: str) -> str:
|
|
79
|
+
rng = TESTED[package]
|
|
80
|
+
return f">={rng.low},<{rng.high}"
|
tailtune/cli/__init__.py
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""Command-line entry point: exit-code mapping and Ctrl-C handling.
|
|
2
|
+
|
|
3
|
+
Click exits with 2 on a parse error, which would collide with tailtune's code 2
|
|
4
|
+
(data validation). Every parse error is remapped to 5 here.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import contextlib
|
|
10
|
+
import sys
|
|
11
|
+
import traceback
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _configure_streams() -> None:
|
|
15
|
+
for stream in (sys.stdout, sys.stderr):
|
|
16
|
+
reconfigure = getattr(stream, "reconfigure", None)
|
|
17
|
+
if reconfigure is not None:
|
|
18
|
+
with contextlib.suppress(OSError, ValueError):
|
|
19
|
+
reconfigure(errors="replace")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _click_like(exc: BaseException) -> bool:
|
|
23
|
+
"""A Click exception. Typer 0.28+ vendors Click, older Typer uses the click package,
|
|
24
|
+
so the classes are matched by shape rather than imported."""
|
|
25
|
+
return hasattr(exc, "format_message") and hasattr(exc, "exit_code")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def main(argv: list[str] | None = None) -> int:
|
|
29
|
+
"""Run the CLI and return its exit code."""
|
|
30
|
+
from tailtune._core.errors import (
|
|
31
|
+
EXIT_ERROR,
|
|
32
|
+
EXIT_INTERRUPTED,
|
|
33
|
+
EXIT_OK,
|
|
34
|
+
EXIT_USAGE,
|
|
35
|
+
InternalError,
|
|
36
|
+
TailtuneError,
|
|
37
|
+
UsageError,
|
|
38
|
+
)
|
|
39
|
+
from tailtune.cli.app import app
|
|
40
|
+
|
|
41
|
+
_configure_streams()
|
|
42
|
+
args = sys.argv[1:] if argv is None else argv
|
|
43
|
+
verbose = "-v" in args or "--verbose" in args
|
|
44
|
+
try:
|
|
45
|
+
result = app(args=args, prog_name="tailtune", standalone_mode=False)
|
|
46
|
+
except KeyboardInterrupt:
|
|
47
|
+
print("Interrupted.", file=sys.stderr)
|
|
48
|
+
return EXIT_INTERRUPTED
|
|
49
|
+
except TailtuneError as exc:
|
|
50
|
+
print(exc.render(), file=sys.stderr)
|
|
51
|
+
return exc.exit_code
|
|
52
|
+
except Exception as exc:
|
|
53
|
+
name = type(exc).__name__
|
|
54
|
+
if name == "Abort": # Click turns Ctrl-C at a prompt into Abort
|
|
55
|
+
print("Interrupted.", file=sys.stderr)
|
|
56
|
+
return EXIT_INTERRUPTED
|
|
57
|
+
if name == "NoArgsIsHelpError" and _click_like(exc):
|
|
58
|
+
print(exc.format_message()) # type: ignore[attr-defined]
|
|
59
|
+
return EXIT_OK
|
|
60
|
+
if _click_like(exc) and getattr(exc, "exit_code", None) == 2:
|
|
61
|
+
ctx = getattr(exc, "ctx", None)
|
|
62
|
+
path = getattr(ctx, "command_path", "tailtune")
|
|
63
|
+
err = UsageError(
|
|
64
|
+
f"Invalid usage: {exc.format_message()}", # type: ignore[attr-defined]
|
|
65
|
+
fix=f"Run `{path} --help` for the options.",
|
|
66
|
+
)
|
|
67
|
+
print(err.render(), file=sys.stderr)
|
|
68
|
+
return EXIT_USAGE
|
|
69
|
+
if _click_like(exc):
|
|
70
|
+
print(f"error: {exc.format_message()}", file=sys.stderr) # type: ignore[attr-defined]
|
|
71
|
+
return EXIT_ERROR
|
|
72
|
+
err = InternalError(
|
|
73
|
+
f"Internal error: {name}: {exc}",
|
|
74
|
+
fix="This is a bug. Rerun with --verbose and attach the output to an issue.",
|
|
75
|
+
)
|
|
76
|
+
print(err.render(), file=sys.stderr)
|
|
77
|
+
if verbose:
|
|
78
|
+
traceback.print_exc()
|
|
79
|
+
return EXIT_ERROR
|
|
80
|
+
if isinstance(result, int):
|
|
81
|
+
return result
|
|
82
|
+
return EXIT_OK
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
__all__ = ["main"]
|