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 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,6 @@
1
+ """`python -m tailtune` runs the command-line interface."""
2
+
3
+ from tailtune.cli import main
4
+
5
+ if __name__ == "__main__":
6
+ raise SystemExit(main())
@@ -0,0 +1 @@
1
+ """Private support layer. Imports nothing else from tailtune."""
@@ -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}"
@@ -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"]