freesolo-flash-dev 0.2.25__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.
- flash/__init__.py +29 -0
- flash/_channel.py +23 -0
- flash/_fileio.py +35 -0
- flash/_logging.py +49 -0
- flash/_update_check.py +266 -0
- flash/catalog.py +253 -0
- flash/cli/__init__.py +1 -0
- flash/cli/main/__init__.py +227 -0
- flash/cli/main/__main__.py +6 -0
- flash/cli/main/commands.py +636 -0
- flash/cli/main/envpush.py +317 -0
- flash/cli/main/render.py +599 -0
- flash/cli/main/training_doc.py +455 -0
- flash/client/__init__.py +14 -0
- flash/client/config.py +70 -0
- flash/client/http.py +372 -0
- flash/client/runtime_secrets.py +69 -0
- flash/client/specs.py +20 -0
- flash/cost/__init__.py +16 -0
- flash/cost/analytical.py +175 -0
- flash/cost/facts.py +114 -0
- flash/cost/spec.py +113 -0
- flash/cost/types.py +158 -0
- flash/engine/__init__.py +6 -0
- flash/engine/accounting.py +36 -0
- flash/engine/chalk_kernels.py +116 -0
- flash/engine/multiturn_rollout.py +780 -0
- flash/engine/recipe.py +86 -0
- flash/engine/vram.py +603 -0
- flash/engine/worker/__init__.py +2916 -0
- flash/engine/worker/__main__.py +4 -0
- flash/engine/worker/kernel_warmup.py +400 -0
- flash/engine/worker/lora.py +796 -0
- flash/engine/worker/packing.py +366 -0
- flash/engine/worker/perf.py +1048 -0
- flash/envs/__init__.py +10 -0
- flash/envs/adapter/__init__.py +883 -0
- flash/envs/adapter/rubric.py +222 -0
- flash/envs/base.py +52 -0
- flash/envs/registry.py +62 -0
- flash/mcp/__init__.py +1 -0
- flash/mcp/server.py +85 -0
- flash/providers/__init__.py +59 -0
- flash/providers/_auth.py +24 -0
- flash/providers/_http.py +230 -0
- flash/providers/_instance.py +416 -0
- flash/providers/_instance_bootstrap.py +517 -0
- flash/providers/_poll.py +311 -0
- flash/providers/allocator.py +193 -0
- flash/providers/base.py +431 -0
- flash/providers/hyperstack/__init__.py +127 -0
- flash/providers/hyperstack/api.py +522 -0
- flash/providers/hyperstack/auth.py +17 -0
- flash/providers/hyperstack/gpus.py +29 -0
- flash/providers/hyperstack/jobs/__init__.py +632 -0
- flash/providers/hyperstack/jobs/builders.py +122 -0
- flash/providers/hyperstack/preflight.py +23 -0
- flash/providers/hyperstack/pricing.py +26 -0
- flash/providers/hyperstack/train.py +25 -0
- flash/providers/lambdalabs/__init__.py +139 -0
- flash/providers/lambdalabs/api.py +261 -0
- flash/providers/lambdalabs/auth.py +18 -0
- flash/providers/lambdalabs/gpus.py +29 -0
- flash/providers/lambdalabs/jobs/__init__.py +724 -0
- flash/providers/lambdalabs/jobs/builders.py +118 -0
- flash/providers/lambdalabs/preflight.py +27 -0
- flash/providers/lambdalabs/pricing.py +51 -0
- flash/providers/lambdalabs/train.py +27 -0
- flash/providers/preflight.py +55 -0
- flash/providers/realized.py +80 -0
- flash/providers/runpod/__init__.py +130 -0
- flash/providers/runpod/api.py +186 -0
- flash/providers/runpod/auth.py +37 -0
- flash/providers/runpod/cost.py +57 -0
- flash/providers/runpod/gpus.py +46 -0
- flash/providers/runpod/jobs.py +956 -0
- flash/providers/runpod/keys.py +139 -0
- flash/providers/runpod/preflight.py +30 -0
- flash/providers/runpod/preload.py +915 -0
- flash/providers/runpod/pricing.py +18 -0
- flash/providers/runpod/slots.py +79 -0
- flash/providers/runpod/train/__init__.py +150 -0
- flash/providers/runpod/train/deps.py +395 -0
- flash/providers/runpod/train/endpoints.py +820 -0
- flash/py.typed +0 -0
- flash/runner/__init__.py +686 -0
- flash/runner/checkpoints.py +82 -0
- flash/runner/deploy.py +422 -0
- flash/runner/lifecycle.py +672 -0
- flash/schema/__init__.py +375 -0
- flash/schema/fields.py +331 -0
- flash/serve/__init__.py +1 -0
- flash/serve/deploy.py +326 -0
- flash/serve/pricing.py +60 -0
- flash/server/__init__.py +1 -0
- flash/server/__main__.py +20 -0
- flash/server/app.py +961 -0
- flash/server/auth.py +263 -0
- flash/server/billing.py +124 -0
- flash/server/checkpoints.py +110 -0
- flash/server/db.py +160 -0
- flash/server/environment_registry.py +102 -0
- flash/server/envs.py +360 -0
- flash/server/reconcile.py +163 -0
- flash/server/run_registry.py +150 -0
- flash/spec.py +333 -0
- freesolo_flash_dev-0.2.25.dist-info/METADATA +192 -0
- freesolo_flash_dev-0.2.25.dist-info/RECORD +111 -0
- freesolo_flash_dev-0.2.25.dist-info/WHEEL +4 -0
- freesolo_flash_dev-0.2.25.dist-info/entry_points.txt +3 -0
- freesolo_flash_dev-0.2.25.dist-info/licenses/LICENSE +201 -0
flash/__init__.py
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""Flash — managed LoRA post-training: log in with your freesolo key, train.
|
|
2
|
+
|
|
3
|
+
A focused developer experience (TOML run specs, pluggable environments,
|
|
4
|
+
CLI/API/MCP entry points, adapter deployment). Users authenticate with their
|
|
5
|
+
freesolo API key (`flash login`); the control plane runs each job on a managed
|
|
6
|
+
RunPod GPU behind the scenes.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from importlib.metadata import version as _dist_version
|
|
10
|
+
|
|
11
|
+
from flash._channel import DIST_NAME as _DIST_NAME
|
|
12
|
+
|
|
13
|
+
__all__ = ["__version__"]
|
|
14
|
+
|
|
15
|
+
# single source of truth for the version is pyproject `[project].version`, which hatchling bakes
|
|
16
|
+
# into the installed distribution metadata at build time. read it back here instead of keeping a
|
|
17
|
+
# second hand-maintained literal: that duplicate is what desynced in 0.2.20 (the wheel said 0.2.20
|
|
18
|
+
# while __init__ still hard-coded 0.2.19), making flash nag to upgrade forever while uv reported
|
|
19
|
+
# nothing to upgrade. the distribution name (_DIST_NAME: "freesolo-flash", or "freesolo-flash-dev"
|
|
20
|
+
# for the dev channel) differs from the import package, and is selected by flash/_channel.py.
|
|
21
|
+
try:
|
|
22
|
+
__version__ = _dist_version(_DIST_NAME)
|
|
23
|
+
except Exception:
|
|
24
|
+
# no readable dist metadata: running from a source tree that was never installed, or an
|
|
25
|
+
# unreadable/corrupt METADATA file. fall back to a clearly-fake version rather than letting
|
|
26
|
+
# `import flash` (the package root, imported by every entry point) crash. this only happens off
|
|
27
|
+
# the installed path; a released wheel always has real metadata. a bare-checkout run on a tty
|
|
28
|
+
# may then show the update notice, which is fine for an uninstalled dev build.
|
|
29
|
+
__version__ = "0+unknown"
|
flash/_channel.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""Release channel of the installed distribution — the single switch between prod and dev.
|
|
2
|
+
|
|
3
|
+
The production package ``freesolo-flash`` ships ``CHANNEL = "prod"`` (the checked-in default
|
|
4
|
+
below): it installs a ``flash`` CLI that talks to the production control plane. The dev-channel
|
|
5
|
+
package ``freesolo-flash-dev`` is built from this *same source* with only this one line rewritten
|
|
6
|
+
to ``CHANNEL = "dev"`` (see ``scripts/build_dev_dist.py``); everything that differs between the two
|
|
7
|
+
channels — the CLI name, the PyPI distribution name, the default control-plane URL — derives from
|
|
8
|
+
it below, so there is exactly one thing to flip. An explicit ``FLASH_API_URL`` /
|
|
9
|
+
``flash login --api-url`` always wins; the channel only picks the *default* plane.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
# The one line scripts/build_dev_dist.py rewrites to "dev" for the dev-channel build.
|
|
15
|
+
CHANNEL = "dev"
|
|
16
|
+
|
|
17
|
+
# Console-script + argparse program name. Kept in lockstep with [project.scripts] in
|
|
18
|
+
# pyproject.toml (which the build script also rewrites: flash -> flash-dev).
|
|
19
|
+
CLI_NAME = "flash-dev" if CHANNEL == "dev" else "flash"
|
|
20
|
+
|
|
21
|
+
# PyPI distribution name. Used to read back __version__ from installed metadata and to point the
|
|
22
|
+
# update check at the right project (kept in lockstep with [project].name in pyproject.toml).
|
|
23
|
+
DIST_NAME = "freesolo-flash-dev" if CHANNEL == "dev" else "freesolo-flash"
|
flash/_fileio.py
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""Small shared file-IO helpers for credential/manifest JSON under ``~/.flash``."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import contextlib
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def read_json_or_empty(path: Path) -> dict:
|
|
12
|
+
"""Parse a JSON object file, returning ``{}`` if it's missing or unreadable."""
|
|
13
|
+
try:
|
|
14
|
+
return json.loads(path.read_text())
|
|
15
|
+
except (OSError, ValueError):
|
|
16
|
+
return {}
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def secure_json_write(path: Path, data: dict) -> None:
|
|
20
|
+
"""Write ``data`` as JSON with private permissions (the file may hold a secret).
|
|
21
|
+
|
|
22
|
+
Creates the parent dir (0700) and opens the file 0600 from the start — never
|
|
23
|
+
write_text + chmod, which leaves it umask-readable in between. ``O_NOFOLLOW``
|
|
24
|
+
(where available) refuses to follow a symlink planted at ``path`` so the write
|
|
25
|
+
can't be redirected to clobber an arbitrary file.
|
|
26
|
+
"""
|
|
27
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
28
|
+
with contextlib.suppress(OSError):
|
|
29
|
+
os.chmod(path.parent, 0o700)
|
|
30
|
+
flags = os.O_WRONLY | os.O_CREAT | os.O_TRUNC | getattr(os, "O_NOFOLLOW", 0)
|
|
31
|
+
fd = os.open(path, flags, 0o600)
|
|
32
|
+
with os.fdopen(fd, "w") as f:
|
|
33
|
+
json.dump(data, f, indent=2, sort_keys=True)
|
|
34
|
+
with contextlib.suppress(OSError):
|
|
35
|
+
os.chmod(path, 0o600)
|
flash/_logging.py
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""Package logging helpers.
|
|
2
|
+
|
|
3
|
+
Library code logs through the ``flash`` logger and never configures handlers on import (it
|
|
4
|
+
attaches a :class:`logging.NullHandler`), so importing Flash stays silent for downstream
|
|
5
|
+
applications. The CLI calls :func:`configure_logging` to attach a console handler whose
|
|
6
|
+
level is controlled by ``-v/--verbose``.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import logging
|
|
12
|
+
|
|
13
|
+
_ROOT_NAME = "flash"
|
|
14
|
+
|
|
15
|
+
# Attach a NullHandler once so "No handlers could be found" warnings never appear and
|
|
16
|
+
# importing the library produces no output unless the app opts in.
|
|
17
|
+
_root = logging.getLogger(_ROOT_NAME)
|
|
18
|
+
if not any(isinstance(h, logging.NullHandler) for h in _root.handlers):
|
|
19
|
+
_root.addHandler(logging.NullHandler())
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def get_logger(name: str | None = None) -> logging.Logger:
|
|
23
|
+
"""Return a logger under the ``flash`` namespace (e.g. ``get_logger(__name__)``)."""
|
|
24
|
+
if not name or name == _ROOT_NAME:
|
|
25
|
+
return logging.getLogger(_ROOT_NAME)
|
|
26
|
+
if name.startswith(_ROOT_NAME + "."):
|
|
27
|
+
return logging.getLogger(name)
|
|
28
|
+
return logging.getLogger(f"{_ROOT_NAME}.{name}")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def configure_logging(verbosity: int = 0, level: int | None = None) -> None:
|
|
32
|
+
"""Attach a console handler to the ``flash`` logger and set its level.
|
|
33
|
+
|
|
34
|
+
``verbosity`` maps repeated ``-v`` flags to levels (0=WARNING, 1=INFO, >=2=DEBUG).
|
|
35
|
+
An explicit ``level`` overrides the verbosity mapping.
|
|
36
|
+
"""
|
|
37
|
+
if level is None:
|
|
38
|
+
level = {0: logging.WARNING, 1: logging.INFO}.get(verbosity, logging.DEBUG)
|
|
39
|
+
|
|
40
|
+
logger = logging.getLogger(_ROOT_NAME)
|
|
41
|
+
logger.setLevel(level)
|
|
42
|
+
# Replace any prior console handler we installed so repeated calls don't stack handlers.
|
|
43
|
+
for h in [h for h in logger.handlers if getattr(h, "_flash_console", False)]:
|
|
44
|
+
logger.removeHandler(h)
|
|
45
|
+
handler = logging.StreamHandler() # stderr
|
|
46
|
+
handler.setLevel(level)
|
|
47
|
+
handler.setFormatter(logging.Formatter("%(levelname)s %(name)s: %(message)s"))
|
|
48
|
+
handler._flash_console = True # type: ignore[attr-defined]
|
|
49
|
+
logger.addHandler(handler)
|
flash/_update_check.py
ADDED
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
"""Background "a new release is available" notice for the `flash` CLI.
|
|
2
|
+
|
|
3
|
+
The client CLI is pure standard library (no extra deps), so this is too: it queries PyPI
|
|
4
|
+
with ``urllib`` and compares the published version against the installed ``__version__``.
|
|
5
|
+
|
|
6
|
+
Design constraints that keep it from ever getting in the way:
|
|
7
|
+
|
|
8
|
+
- **Stays out of the way.** The PyPI lookup runs in a daemon thread (so it overlaps the
|
|
9
|
+
command) and every failure (offline, timeout, bad JSON) is swallowed. Only the once-a-day
|
|
10
|
+
refresh waits briefly for that thread; every other command builds the notice from cache with
|
|
11
|
+
zero network I/O.
|
|
12
|
+
- **Cached once per day.** The latest version is stored in ``~/.flash/update_check.json``;
|
|
13
|
+
we only hit PyPI when that cache is older than :data:`_CHECK_INTERVAL_S`. The check time is
|
|
14
|
+
stamped synchronously before the lookup so the daily back-off holds even if the worker thread
|
|
15
|
+
is killed at process exit before it records a result.
|
|
16
|
+
- **stderr only, TTY only.** The notice prints to stderr (never stdout), so it can't corrupt
|
|
17
|
+
JSON piped to ``jq`` or captured output, and it's suppressed entirely when stderr isn't a
|
|
18
|
+
terminal (pipes, redirects, CI, tests). Color is dropped when ``NO_COLOR`` is set.
|
|
19
|
+
- **Opt-out.** Set ``FLASH_NO_UPDATE_CHECK=1`` to disable the check and notice completely.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import contextlib
|
|
25
|
+
import json
|
|
26
|
+
import os
|
|
27
|
+
import re
|
|
28
|
+
import sys
|
|
29
|
+
import threading
|
|
30
|
+
import time
|
|
31
|
+
import urllib.error
|
|
32
|
+
import urllib.request
|
|
33
|
+
|
|
34
|
+
from flash import __version__
|
|
35
|
+
from flash._channel import DIST_NAME
|
|
36
|
+
from flash._fileio import read_json_or_empty, secure_json_write
|
|
37
|
+
from flash._logging import get_logger
|
|
38
|
+
from flash.client.config import CONFIG_DIR
|
|
39
|
+
|
|
40
|
+
logger = get_logger("flash.update_check")
|
|
41
|
+
|
|
42
|
+
# The PyPI distribution name (== pyproject `name`) and the command that upgrades it. Follows the
|
|
43
|
+
# installed channel (freesolo-flash, or freesolo-flash-dev for the dev build) — see flash/_channel.py.
|
|
44
|
+
PACKAGE_NAME = DIST_NAME
|
|
45
|
+
UPGRADE_COMMAND = f"uv tool upgrade {PACKAGE_NAME}"
|
|
46
|
+
_PYPI_JSON_URL = f"https://pypi.org/pypi/{PACKAGE_NAME}/json"
|
|
47
|
+
|
|
48
|
+
CACHE_PATH = CONFIG_DIR / "update_check.json"
|
|
49
|
+
|
|
50
|
+
# Re-check PyPI at most once a day; the notice itself is shown on every command from cache.
|
|
51
|
+
_CHECK_INTERVAL_S = 24 * 60 * 60
|
|
52
|
+
# How long the lookup may take, and how long the once-a-day refresh waits for it at the end of a
|
|
53
|
+
# command. Keep the join >= the fetch timeout so the worker thread finishes (and records its
|
|
54
|
+
# result) within the wait instead of being killed at process exit mid-write.
|
|
55
|
+
_FETCH_TIMEOUT_S = 1.5
|
|
56
|
+
_JOIN_TIMEOUT_S = 2.0
|
|
57
|
+
|
|
58
|
+
_OPT_OUT_ENV = "FLASH_NO_UPDATE_CHECK"
|
|
59
|
+
|
|
60
|
+
# A PEP 440 version only uses this charset. We reject anything else (control chars, ANSI escape
|
|
61
|
+
# sequences, newlines) before printing the value to a terminal, so a poisoned cache or a hostile
|
|
62
|
+
# response can't inject escape codes into the notice. The length bound is just a sanity cap.
|
|
63
|
+
_SAFE_VERSION = re.compile(r"\A[A-Za-z0-9][A-Za-z0-9.+!_-]{0,63}\Z")
|
|
64
|
+
|
|
65
|
+
# A coarse subset of the PEP 440 grammar: the numeric release plus optional pre/post/dev markers.
|
|
66
|
+
# Enough to order the simple versions this package ships and to spot pre-releases; this is not a
|
|
67
|
+
# full PEP 440 implementation (the stdlib-only client can't depend on `packaging`).
|
|
68
|
+
_VERSION_RE = re.compile(
|
|
69
|
+
r"""\A\s*v?
|
|
70
|
+
(?P<release>\d+(?:\.\d+)*)
|
|
71
|
+
(?P<pre>[._-]?(?:alpha|beta|preview|pre|rc|a|b|c)\d*)?
|
|
72
|
+
(?P<post>[._-]?(?:post|rev|r)\d*|-\d+)?
|
|
73
|
+
(?P<dev>[._-]?dev\d*)?
|
|
74
|
+
""",
|
|
75
|
+
re.IGNORECASE | re.VERBOSE,
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _enabled() -> bool:
|
|
80
|
+
"""The whole feature is off unless stderr is a TTY and the user hasn't opted out."""
|
|
81
|
+
if os.environ.get(_OPT_OUT_ENV):
|
|
82
|
+
return False
|
|
83
|
+
try:
|
|
84
|
+
return bool(sys.stderr.isatty())
|
|
85
|
+
except Exception:
|
|
86
|
+
# stderr may be detached/closed/replaced (e.g. some embedded contexts); any failure
|
|
87
|
+
# here is treated as "not a TTY" so the check can never crash a command.
|
|
88
|
+
return False
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _normalize_release(release: tuple[int, ...]) -> tuple[int, ...]:
|
|
92
|
+
"""Drop trailing zeros so ``1.0`` and ``1.0.0`` compare equal (keep at least one segment)."""
|
|
93
|
+
parts = list(release)
|
|
94
|
+
while len(parts) > 1 and parts[-1] == 0:
|
|
95
|
+
parts.pop()
|
|
96
|
+
return tuple(parts)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _version_key(version: str) -> tuple[tuple[int, ...], int, int]:
|
|
100
|
+
"""A coarse PEP 440 sort key ``(release, final_rank, post)`` where higher means newer.
|
|
101
|
+
|
|
102
|
+
The release segment is normalized (``1.0 == 1.0.0``). A pre-release/dev version ranks below
|
|
103
|
+
the final release of the same number (``final_rank`` 0 vs 1); a post-release ranks above it
|
|
104
|
+
via ``post``. Epochs and local versions are ignored — the catalog ships only simple versions.
|
|
105
|
+
Returns an empty release for unparseable input, which compares as "older than everything".
|
|
106
|
+
"""
|
|
107
|
+
match = _VERSION_RE.match(version or "")
|
|
108
|
+
if not match:
|
|
109
|
+
return ((), 1, 0)
|
|
110
|
+
release = _normalize_release(tuple(int(part) for part in match.group("release").split(".")))
|
|
111
|
+
is_pre = bool(match.group("pre") or match.group("dev"))
|
|
112
|
+
post_digits = re.search(r"\d+", match.group("post") or "")
|
|
113
|
+
post = int(post_digits.group()) if post_digits else 0
|
|
114
|
+
return (release, 0 if is_pre else 1, post)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _is_prerelease(version: str) -> bool:
|
|
118
|
+
"""True when the version carries a pre-release or dev marker (a/b/c/rc/alpha/beta/dev)."""
|
|
119
|
+
match = _VERSION_RE.match(version or "")
|
|
120
|
+
return bool(match and (match.group("pre") or match.group("dev")))
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _is_newer(latest: str, current: str) -> bool:
|
|
124
|
+
"""True only when ``latest`` is a strictly newer version than ``current`` (PEP 440 order)."""
|
|
125
|
+
latest_key = _version_key(latest)
|
|
126
|
+
return bool(latest_key[0]) and latest_key > _version_key(current)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _clean_version(value: object) -> str | None:
|
|
130
|
+
"""Return ``value`` only if it's a safe, escape-free version string, else ``None``.
|
|
131
|
+
|
|
132
|
+
Guards both the PyPI response and the on-disk cache: ``_version_key`` parses just the
|
|
133
|
+
numeric/marker prefix, so without this an injected suffix (ANSI codes, newlines) could reach
|
|
134
|
+
the terminal. Non-strings (and anything outside the PEP 440 charset) are rejected.
|
|
135
|
+
"""
|
|
136
|
+
return value if isinstance(value, str) and _SAFE_VERSION.match(value) else None
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _read_cache() -> dict:
|
|
140
|
+
# read_json_or_empty returns whatever the file parses to; a non-object (e.g. ``[]``) would
|
|
141
|
+
# make the ``.get()`` callers raise, and _check_due runs before main()'s error handling — so
|
|
142
|
+
# coerce anything that isn't a dict back to an empty one.
|
|
143
|
+
cache = read_json_or_empty(CACHE_PATH)
|
|
144
|
+
return cache if isinstance(cache, dict) else {}
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _check_due(now: float) -> bool:
|
|
148
|
+
"""True when there's no fresh cached check (so we should hit PyPI)."""
|
|
149
|
+
cache = _read_cache()
|
|
150
|
+
checked_at = cache.get("checked_at")
|
|
151
|
+
if not isinstance(checked_at, (int, float)):
|
|
152
|
+
return True
|
|
153
|
+
return (now - checked_at) >= _CHECK_INTERVAL_S
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _fetch_latest_version(timeout: float = _FETCH_TIMEOUT_S) -> str | None:
|
|
157
|
+
"""Return PyPI's latest version for the package, or ``None`` on any failure/odd response."""
|
|
158
|
+
req = urllib.request.Request(
|
|
159
|
+
_PYPI_JSON_URL,
|
|
160
|
+
headers={
|
|
161
|
+
"Accept": "application/json",
|
|
162
|
+
"User-Agent": f"{PACKAGE_NAME}/{__version__}",
|
|
163
|
+
},
|
|
164
|
+
)
|
|
165
|
+
try:
|
|
166
|
+
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
|
167
|
+
payload = json.loads(resp.read())
|
|
168
|
+
except (urllib.error.URLError, OSError, ValueError, TimeoutError) as exc:
|
|
169
|
+
logger.debug("update check: PyPI lookup failed: %s", exc)
|
|
170
|
+
return None
|
|
171
|
+
# Expected shape is {"info": {"version": ...}}; tolerate anything else (a proxy error page,
|
|
172
|
+
# ``[]``, ``{"info": null}``, ...) instead of letting a dereference raise into the caller.
|
|
173
|
+
info = payload.get("info") if isinstance(payload, dict) else None
|
|
174
|
+
version = info.get("version") if isinstance(info, dict) else None
|
|
175
|
+
return _clean_version(version)
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _stamp_check_time() -> None:
|
|
179
|
+
"""Record "checked just now" (keeping any cached version), synchronously and best-effort.
|
|
180
|
+
|
|
181
|
+
Done before the background lookup starts so the daily back-off holds even if the daemon worker
|
|
182
|
+
is killed at process exit before it records its own result — otherwise a stale/missing cache
|
|
183
|
+
would make every command re-run (and wait on) the lookup. Never raises (runs before main()'s
|
|
184
|
+
error handling).
|
|
185
|
+
"""
|
|
186
|
+
with contextlib.suppress(Exception):
|
|
187
|
+
cache = _read_cache()
|
|
188
|
+
cache["checked_at"] = time.time()
|
|
189
|
+
secure_json_write(CACHE_PATH, cache)
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _refresh_cache() -> None:
|
|
193
|
+
"""Fetch from PyPI and persist the version on success; runs in a daemon thread, never raises.
|
|
194
|
+
|
|
195
|
+
The attempt time is already stamped by :func:`_stamp_check_time`, so a failed lookup just
|
|
196
|
+
returns and lets the daily back-off (set there) stand.
|
|
197
|
+
"""
|
|
198
|
+
try:
|
|
199
|
+
latest = _fetch_latest_version()
|
|
200
|
+
if not latest:
|
|
201
|
+
return
|
|
202
|
+
cache = _read_cache()
|
|
203
|
+
cache["checked_at"] = time.time()
|
|
204
|
+
cache["pypi_version"] = latest
|
|
205
|
+
secure_json_write(CACHE_PATH, cache)
|
|
206
|
+
except Exception as exc: # truly never let a background thread escape
|
|
207
|
+
logger.debug("update check: refresh failed: %s", exc)
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _supports_color() -> bool:
|
|
211
|
+
return not os.environ.get("NO_COLOR")
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _red(text: str) -> str:
|
|
215
|
+
return f"\033[31m{text}\033[0m" if _supports_color() else text
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def _build_notice() -> str | None:
|
|
219
|
+
"""Build the upgrade notice from the cached PyPI version, or ``None`` if up to date."""
|
|
220
|
+
latest = _clean_version(_read_cache().get("pypi_version"))
|
|
221
|
+
# Only nudge toward stable releases: never advertise a pre-release (rc/dev) as an upgrade.
|
|
222
|
+
if not latest or _is_prerelease(latest) or not _is_newer(latest, __version__):
|
|
223
|
+
return None
|
|
224
|
+
return _red(
|
|
225
|
+
f"A new release of {PACKAGE_NAME} is available: {__version__} -> {latest}\n"
|
|
226
|
+
f"Update with `{UPGRADE_COMMAND}`."
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def maybe_start_update_check() -> threading.Thread | None:
|
|
231
|
+
"""Kick off a background PyPI refresh if one is due. Returns the thread (or ``None``).
|
|
232
|
+
|
|
233
|
+
Pass the return value to :func:`emit_update_notice`. No-ops (returns ``None``) when the
|
|
234
|
+
feature is disabled or the cached check is still fresh, so the common path is free.
|
|
235
|
+
"""
|
|
236
|
+
if not _enabled() or not _check_due(time.time()):
|
|
237
|
+
return None
|
|
238
|
+
# Stamp the attempt synchronously before spawning the worker, so the daily back-off holds even
|
|
239
|
+
# if the daemon is killed at process exit before it writes (see _stamp_check_time).
|
|
240
|
+
_stamp_check_time()
|
|
241
|
+
thread = threading.Thread(target=_refresh_cache, name="flash-update-check", daemon=True)
|
|
242
|
+
try:
|
|
243
|
+
thread.start()
|
|
244
|
+
except RuntimeError:
|
|
245
|
+
# can't spawn a thread (e.g. interpreter shutting down) — skip the check silently.
|
|
246
|
+
return None
|
|
247
|
+
return thread
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def emit_update_notice(notifier: threading.Thread | None = None) -> None:
|
|
251
|
+
"""Print the upgrade notice (if any) to stderr at the end of a command.
|
|
252
|
+
|
|
253
|
+
Briefly waits for an in-flight refresh so a freshly fetched version can be shown the same
|
|
254
|
+
run; if it doesn't finish in time we just use whatever is already cached.
|
|
255
|
+
"""
|
|
256
|
+
if not _enabled():
|
|
257
|
+
return
|
|
258
|
+
if notifier is not None:
|
|
259
|
+
with contextlib.suppress(RuntimeError):
|
|
260
|
+
notifier.join(timeout=_JOIN_TIMEOUT_S)
|
|
261
|
+
# This runs from main()'s finally block, so it must never raise: a broken pipe
|
|
262
|
+
# (`flash ... | head`), full disk, or closed stderr would otherwise crash the command.
|
|
263
|
+
with contextlib.suppress(Exception):
|
|
264
|
+
notice = _build_notice()
|
|
265
|
+
if notice:
|
|
266
|
+
print(notice, file=sys.stderr)
|
flash/catalog.py
ADDED
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
"""Curated model catalog for one-consumer-GPU LoRA jobs."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import math
|
|
6
|
+
from dataclasses import asdict, dataclass
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
ALGORITHMS = ("sft", "grpo")
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def normalize_algorithm(value: str) -> str:
|
|
13
|
+
"""Canonical (lowercased, validated) algorithm name."""
|
|
14
|
+
value = (value or "grpo").lower()
|
|
15
|
+
if value not in ALGORITHMS:
|
|
16
|
+
raise ValueError(f"unsupported algorithm: {value}; known: {', '.join(ALGORITHMS)}")
|
|
17
|
+
return value
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
# The default GPU class used as the open-model-policy
|
|
21
|
+
# sizing reference and the spec/from_dict fallback). The managed GPU class set (KNOWN)
|
|
22
|
+
# lives in providers.base; RunPod pricing lives under providers/runpod. Defined above
|
|
23
|
+
# ModelInfo so it can back the recommended_gpu field default.
|
|
24
|
+
DEFAULT_GPU = "RTX 5090"
|
|
25
|
+
|
|
26
|
+
# Output vocab (== config.vocab_size, the lm_head / logits width — the PADDED model vocab,
|
|
27
|
+
# NOT the raw tokenizer token count). Sizes the GRPO fp32-logits VRAM term (engine.vram) and
|
|
28
|
+
# the per-device completion cap (engine.worker.rl_per_device_comps). This is the open-model
|
|
29
|
+
# fallback; curated per-model values live on each ModelInfo below and are read via
|
|
30
|
+
# vocab_size_for(). Over-estimating is the memory-SAFE direction (smaller cap, larger VRAM
|
|
31
|
+
# estimate), so the fallback is the largest catalog vocab.
|
|
32
|
+
_DEFAULT_VOCAB_SIZE = 248_320
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass(frozen=True)
|
|
36
|
+
class ModelInfo:
|
|
37
|
+
id: str
|
|
38
|
+
display_name: str
|
|
39
|
+
params: str
|
|
40
|
+
algos: tuple[str, ...]
|
|
41
|
+
min_vram_gb: int
|
|
42
|
+
quant: str = "bf16"
|
|
43
|
+
recommended_gpu: str = DEFAULT_GPU
|
|
44
|
+
# GRPO needs more VRAM than SFT (a colocated vLLM rollout engine holds a second copy of
|
|
45
|
+
# the weights + KV cache). 0 => GRPO uses ``min_vram_gb`` like SFT; set it when the GRPO
|
|
46
|
+
# tier needs a bigger card than SFT (the colocate 2nd weight copy + KV pool). Consumed by
|
|
47
|
+
# engine.vram.model_required_vram_gb.
|
|
48
|
+
grpo_min_vram_gb: int = 0
|
|
49
|
+
notes: str = ""
|
|
50
|
+
# Worker container disk this model needs (GB). 0 = the platform default (64 GB)
|
|
51
|
+
# suffices. The runner raises gpu.disk_gb to at least this, so big-checkpoint
|
|
52
|
+
# models whose weights alone exceed 64 GB work out of the box.
|
|
53
|
+
min_disk_gb: int = 0
|
|
54
|
+
# Thinking/reasoning capability of the checkpoint's chat template:
|
|
55
|
+
# "none" no <think> support (or a non-thinking variant) — `thinking = true` is
|
|
56
|
+
# rejected for these models
|
|
57
|
+
# "hybrid" template honors enable_thinking (Qwen3-style hybrid reasoning)
|
|
58
|
+
# "always" the model always emits reasoning; enable_thinking can't turn it off,
|
|
59
|
+
# so `thinking = true` is required
|
|
60
|
+
# "unknown" open-model-policy entries (capability not verified)
|
|
61
|
+
thinking: str = "none"
|
|
62
|
+
# Output vocab = config.vocab_size (lm_head / logits width, the padded model vocab — not
|
|
63
|
+
# the raw tokenizer count). Drives the GRPO fp32-logits memory term and the per-device
|
|
64
|
+
# completion cap. Curated per model below; defaults to the open-model fallback.
|
|
65
|
+
vocab_size: int = _DEFAULT_VOCAB_SIZE
|
|
66
|
+
# Total parameters in billions — the numeric model size the cost estimator reads directly
|
|
67
|
+
# (no parsing of the ``params`` display string). Curated per catalog model below.
|
|
68
|
+
params_b: float = 0.0
|
|
69
|
+
|
|
70
|
+
def to_dict(self) -> dict[str, Any]:
|
|
71
|
+
return asdict(self)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
# The default model Flash trains when a config omits one. A current-gen dense 4B
|
|
75
|
+
# (text-only fine-tune) on the modern worker stack — the safe out-of-the-box choice for
|
|
76
|
+
# the average developer. It is thinking-"hybrid"; the thinking flag defaults OFF.
|
|
77
|
+
DEFAULT_MODEL = "Qwen/Qwen3.5-4B"
|
|
78
|
+
|
|
79
|
+
MODELS: dict[str, ModelInfo] = {
|
|
80
|
+
"openbmb/MiniCPM5-1B": ModelInfo(
|
|
81
|
+
id="openbmb/MiniCPM5-1B",
|
|
82
|
+
display_name="MiniCPM5 1B",
|
|
83
|
+
params="1.2B dense (Llama arch)",
|
|
84
|
+
params_b=1.2,
|
|
85
|
+
vocab_size=130_560,
|
|
86
|
+
algos=("sft", "grpo"),
|
|
87
|
+
min_vram_gb=12,
|
|
88
|
+
recommended_gpu="RTX 4090",
|
|
89
|
+
thinking="hybrid",
|
|
90
|
+
notes="On-device class SLM (131k ctx); standard Llama architecture.",
|
|
91
|
+
),
|
|
92
|
+
# ---- Qwen3.5 dense family: validated on the modern worker stack ----
|
|
93
|
+
# (trl 1.x / vllm 0.19 / transformers 5.x). Trained + served TEXT-ONLY: the
|
|
94
|
+
# checkpoints are natively multimodal, so LoRA excludes the vision tower and vLLM
|
|
95
|
+
# loads language_model_only (see flash.engine.worker). Each entry passed a real
|
|
96
|
+
# train+eval smoke on its recommended GPU (bench/results/phase1/).
|
|
97
|
+
"Qwen/Qwen3.5-0.8B": ModelInfo(
|
|
98
|
+
id="Qwen/Qwen3.5-0.8B",
|
|
99
|
+
display_name="Qwen3.5 0.8B",
|
|
100
|
+
params="0.9B (text-only fine-tune)",
|
|
101
|
+
params_b=0.9,
|
|
102
|
+
vocab_size=248_320,
|
|
103
|
+
algos=("sft", "grpo"),
|
|
104
|
+
min_vram_gb=12,
|
|
105
|
+
recommended_gpu="RTX 4090",
|
|
106
|
+
thinking="hybrid",
|
|
107
|
+
notes="Smallest Qwen3.5; cheap smoke/dev runs with the modern arch.",
|
|
108
|
+
),
|
|
109
|
+
"Qwen/Qwen3.5-2B": ModelInfo(
|
|
110
|
+
id="Qwen/Qwen3.5-2B",
|
|
111
|
+
display_name="Qwen3.5 2B",
|
|
112
|
+
params="2.3B (text-only fine-tune)",
|
|
113
|
+
params_b=2.3,
|
|
114
|
+
vocab_size=248_320,
|
|
115
|
+
algos=("sft", "grpo"),
|
|
116
|
+
min_vram_gb=16,
|
|
117
|
+
recommended_gpu="RTX 4090",
|
|
118
|
+
thinking="hybrid",
|
|
119
|
+
),
|
|
120
|
+
"Qwen/Qwen3.5-4B": ModelInfo(
|
|
121
|
+
id="Qwen/Qwen3.5-4B",
|
|
122
|
+
display_name="Qwen3.5 4B",
|
|
123
|
+
params="4.7B (text-only fine-tune)",
|
|
124
|
+
params_b=4.7,
|
|
125
|
+
vocab_size=248_320,
|
|
126
|
+
algos=("sft", "grpo"),
|
|
127
|
+
min_vram_gb=32,
|
|
128
|
+
recommended_gpu="RTX 5090",
|
|
129
|
+
thinking="hybrid",
|
|
130
|
+
notes="Current-gen 4B. GRPO uses the sleep-mode memory recipe (hybrid arch needs "
|
|
131
|
+
"extra engine state-cache); fused DeltaNet kernels ship in the default stack.",
|
|
132
|
+
),
|
|
133
|
+
"Qwen/Qwen3.5-9B": ModelInfo(
|
|
134
|
+
id="Qwen/Qwen3.5-9B",
|
|
135
|
+
display_name="Qwen3.5 9B",
|
|
136
|
+
params="9.7B (text-only fine-tune)",
|
|
137
|
+
params_b=9.7,
|
|
138
|
+
vocab_size=248_320,
|
|
139
|
+
algos=("sft", "grpo"),
|
|
140
|
+
min_vram_gb=48,
|
|
141
|
+
# bf16 LoRA (NOT QLoRA). 4-bit QLoRA was abandoned for the 9B because the GRPO vLLM
|
|
142
|
+
# rollout MERGES the LoRA into the 4-bit base (peft bnb merge), and that rounding makes
|
|
143
|
+
# the sampler policy diverge from the bf16 trainer -> TRL importance-sampling ratio
|
|
144
|
+
# collapses to 0 (no learning) + runaway/non-terminating generations. bf16 keeps the
|
|
145
|
+
# rollout and trainer in the same precision so GRPO actually learns. Costs a bigger GPU:
|
|
146
|
+
# ~19 GB weights; SFT fits a 48 GB card, colocated GRPO (two bf16 copies + KV + the
|
|
147
|
+
# 248k-vocab fp32 logits) needs an 80 GB class -> grpo_min_vram_gb floor below.
|
|
148
|
+
grpo_min_vram_gb=80,
|
|
149
|
+
quant="bf16",
|
|
150
|
+
recommended_gpu="A100 PCIe",
|
|
151
|
+
thinking="hybrid",
|
|
152
|
+
notes="bf16 LoRA. ~19 GB of weights; SFT fits a 48 GB card, while colocated GRPO "
|
|
153
|
+
"(two bf16 copies + KV + the 248k-vocab fp32 logits) needs an 80 GB-class card "
|
|
154
|
+
"(grpo_min_vram_gb floor).",
|
|
155
|
+
),
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def list_models() -> list[ModelInfo]:
|
|
160
|
+
return sorted(MODELS.values(), key=lambda m: (m.min_vram_gb, m.id))
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def get_model(model_id: str) -> ModelInfo:
|
|
164
|
+
try:
|
|
165
|
+
return MODELS[model_id]
|
|
166
|
+
except KeyError as exc:
|
|
167
|
+
allowed = ", ".join(MODELS)
|
|
168
|
+
raise ValueError(
|
|
169
|
+
f"unsupported model {model_id!r}; choose one of: {allowed} — or set "
|
|
170
|
+
f'model_policy = "allow" in the config to run any HF model that fits the GPU '
|
|
171
|
+
f"(open-model policy)"
|
|
172
|
+
) from exc
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def vocab_size_for(model_id: str) -> int:
|
|
176
|
+
"""Output vocab (== config.vocab_size, the lm_head / logits width) for a model — the
|
|
177
|
+
number that sizes the GRPO fp32-logits VRAM term and the per-device completion cap.
|
|
178
|
+
Returns the curated catalog value, else the safe default for open-model-policy entries.
|
|
179
|
+
This is the PADDED model vocab, not the raw tokenizer token count."""
|
|
180
|
+
info = MODELS.get(model_id)
|
|
181
|
+
return info.vocab_size if info is not None else _DEFAULT_VOCAB_SIZE
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def resolve_model(
|
|
185
|
+
model_id: str,
|
|
186
|
+
algorithm: str,
|
|
187
|
+
policy: str = "catalog",
|
|
188
|
+
gpu: str | None = None,
|
|
189
|
+
) -> ModelInfo:
|
|
190
|
+
"""Resolve a model under the configured policy.
|
|
191
|
+
|
|
192
|
+
``catalog`` (default): the model must be a curated catalog entry.
|
|
193
|
+
``allow``: any HF model is accepted; a coarse VRAM-fit estimate (HF safetensors
|
|
194
|
+
metadata, no download) blocks only provably-impossible fits and warns on tight ones.
|
|
195
|
+
"""
|
|
196
|
+
algo = normalize_algorithm(algorithm)
|
|
197
|
+
if model_id in MODELS:
|
|
198
|
+
return validate_model_for_algorithm(model_id, algo)
|
|
199
|
+
if policy != "allow":
|
|
200
|
+
# Reuse get_model's error (includes the open-model hint).
|
|
201
|
+
return get_model(model_id)
|
|
202
|
+
return _resolve_open_model(model_id, algo, gpu)
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _resolve_open_model(model_id: str, algo: str, gpu: str | None) -> ModelInfo:
|
|
206
|
+
"""Synthesize a ModelInfo for the open-model "allow" policy from a coarse VRAM-fit
|
|
207
|
+
estimate (HF safetensors metadata, no download). Blocks provably-impossible fits and
|
|
208
|
+
warns on tight ones. Isolates the engine.vram dependency + disk-floor heuristic from
|
|
209
|
+
the curated-catalog path in resolve_model."""
|
|
210
|
+
from flash.engine.vram import check_fit
|
|
211
|
+
|
|
212
|
+
est = check_fit(model_id, algo, gpu or DEFAULT_GPU)
|
|
213
|
+
if est.verdict == "too_big":
|
|
214
|
+
raise ValueError(
|
|
215
|
+
f"{model_id} does not fit the requested GPU: {est.describe()}. "
|
|
216
|
+
f"Pick a smaller model or a larger supported GPU."
|
|
217
|
+
)
|
|
218
|
+
if est.verdict in ("tight", "unknown"):
|
|
219
|
+
print(f"warning: open-model policy: {est.describe()}")
|
|
220
|
+
params = f"{est.params_b:.1f}B" if est.params_b else "unknown size"
|
|
221
|
+
# Disk floor for the open model: a bf16 checkpoint is ~2 GB per billion params;
|
|
222
|
+
# add worker-stack headroom so a large model that passes the VRAM check can't
|
|
223
|
+
# provision a paid worker and then fail in prefetch_model when the checkpoint
|
|
224
|
+
# overflows the 64 GB container default. 0 (unknown size) leaves the default
|
|
225
|
+
# (the user can still raise it with gpu.disk_gb).
|
|
226
|
+
min_disk = int(est.params_b * 2) + 64 if est.params_b else 0
|
|
227
|
+
return ModelInfo(
|
|
228
|
+
id=model_id,
|
|
229
|
+
display_name=model_id,
|
|
230
|
+
params=params,
|
|
231
|
+
algos=ALGORITHMS,
|
|
232
|
+
min_vram_gb=math.ceil(est.est_gb) if est.est_gb else 24,
|
|
233
|
+
min_disk_gb=min_disk,
|
|
234
|
+
recommended_gpu=gpu or DEFAULT_GPU,
|
|
235
|
+
thinking="unknown",
|
|
236
|
+
notes="unlisted model accepted via the open-model policy (not curated/validated)",
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def validate_model_for_algorithm(model_id: str, algorithm: str) -> ModelInfo:
|
|
241
|
+
info = get_model(model_id)
|
|
242
|
+
algo = normalize_algorithm(algorithm)
|
|
243
|
+
# Catalog entries advertise the capability classes "sft" and "grpo": grpo needs the
|
|
244
|
+
# colocated rollout engine, sft is trainer-only.
|
|
245
|
+
required = "grpo" if algo == "grpo" else "sft"
|
|
246
|
+
if required not in info.algos:
|
|
247
|
+
allowed = ", ".join(info.algos)
|
|
248
|
+
raise ValueError(f"{model_id} supports {allowed}, not {algo}")
|
|
249
|
+
return info
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def public_model_rows() -> list[dict[str, Any]]:
|
|
253
|
+
return [m.to_dict() for m in list_models()]
|
flash/cli/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""CLI package."""
|