outo-models-cli 0.2.0__tar.gz → 0.2.1__tar.gz
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.
- {outo_models_cli-0.2.0 → outo_models_cli-0.2.1}/PKG-INFO +1 -1
- {outo_models_cli-0.2.0 → outo_models_cli-0.2.1}/pyproject.toml +1 -1
- {outo_models_cli-0.2.0 → outo_models_cli-0.2.1}/src/outo_models_cli/api/lfs.py +73 -16
- {outo_models_cli-0.2.0 → outo_models_cli-0.2.1}/src/outo_models_cli/api/lfs_upload.py +49 -35
- outo_models_cli-0.2.1/tests/test_lfs_upload.py +66 -0
- {outo_models_cli-0.2.0 → outo_models_cli-0.2.1}/uv.lock +1 -1
- {outo_models_cli-0.2.0 → outo_models_cli-0.2.1}/.gitignore +0 -0
- {outo_models_cli-0.2.0 → outo_models_cli-0.2.1}/LICENSE +0 -0
- {outo_models_cli-0.2.0 → outo_models_cli-0.2.1}/README.md +0 -0
- {outo_models_cli-0.2.0 → outo_models_cli-0.2.1}/src/outo_models_cli/__init__.py +0 -0
- {outo_models_cli-0.2.0 → outo_models_cli-0.2.1}/src/outo_models_cli/__main__.py +0 -0
- {outo_models_cli-0.2.0 → outo_models_cli-0.2.1}/src/outo_models_cli/api/__init__.py +0 -0
- {outo_models_cli-0.2.0 → outo_models_cli-0.2.1}/src/outo_models_cli/api/auth.py +0 -0
- {outo_models_cli-0.2.0 → outo_models_cli-0.2.1}/src/outo_models_cli/api/lfs_batch.py +0 -0
- {outo_models_cli-0.2.0 → outo_models_cli-0.2.1}/src/outo_models_cli/api/repos.py +0 -0
- {outo_models_cli-0.2.0 → outo_models_cli-0.2.1}/src/outo_models_cli/api/upload.py +0 -0
- {outo_models_cli-0.2.0 → outo_models_cli-0.2.1}/src/outo_models_cli/commands/__init__.py +0 -0
- {outo_models_cli-0.2.0 → outo_models_cli-0.2.1}/src/outo_models_cli/commands/_shared.py +0 -0
- {outo_models_cli-0.2.0 → outo_models_cli-0.2.1}/src/outo_models_cli/commands/_upload_commit.py +0 -0
- {outo_models_cli-0.2.0 → outo_models_cli-0.2.1}/src/outo_models_cli/commands/auth.py +0 -0
- {outo_models_cli-0.2.0 → outo_models_cli-0.2.1}/src/outo_models_cli/commands/download.py +0 -0
- {outo_models_cli-0.2.0 → outo_models_cli-0.2.1}/src/outo_models_cli/commands/ls.py +0 -0
- {outo_models_cli-0.2.0 → outo_models_cli-0.2.1}/src/outo_models_cli/commands/repo.py +0 -0
- {outo_models_cli-0.2.0 → outo_models_cli-0.2.1}/src/outo_models_cli/commands/upload.py +0 -0
- {outo_models_cli-0.2.0 → outo_models_cli-0.2.1}/src/outo_models_cli/config.py +0 -0
- {outo_models_cli-0.2.0 → outo_models_cli-0.2.1}/src/outo_models_cli/downloader/__init__.py +0 -0
- {outo_models_cli-0.2.0 → outo_models_cli-0.2.1}/src/outo_models_cli/downloader/transfer.py +0 -0
- {outo_models_cli-0.2.0 → outo_models_cli-0.2.1}/src/outo_models_cli/downloader/walk.py +0 -0
- {outo_models_cli-0.2.0 → outo_models_cli-0.2.1}/src/outo_models_cli/errors.py +0 -0
- {outo_models_cli-0.2.0 → outo_models_cli-0.2.1}/src/outo_models_cli/http.py +0 -0
- {outo_models_cli-0.2.0 → outo_models_cli-0.2.1}/src/outo_models_cli/main.py +0 -0
- {outo_models_cli-0.2.0 → outo_models_cli-0.2.1}/src/outo_models_cli/matchers.py +0 -0
- {outo_models_cli-0.2.0 → outo_models_cli-0.2.1}/tests/conftest.py +0 -0
- {outo_models_cli-0.2.0 → outo_models_cli-0.2.1}/tests/test_api_crud.py +0 -0
- {outo_models_cli-0.2.0 → outo_models_cli-0.2.1}/tests/test_api_upload.py +0 -0
- {outo_models_cli-0.2.0 → outo_models_cli-0.2.1}/tests/test_cli_auth.py +0 -0
- {outo_models_cli-0.2.0 → outo_models_cli-0.2.1}/tests/test_cli_errors.py +0 -0
- {outo_models_cli-0.2.0 → outo_models_cli-0.2.1}/tests/test_cli_help.py +0 -0
- {outo_models_cli-0.2.0 → outo_models_cli-0.2.1}/tests/test_cli_repo.py +0 -0
- {outo_models_cli-0.2.0 → outo_models_cli-0.2.1}/tests/test_cli_upload_lfs.py +0 -0
- {outo_models_cli-0.2.0 → outo_models_cli-0.2.1}/tests/test_cli_xfer.py +0 -0
- {outo_models_cli-0.2.0 → outo_models_cli-0.2.1}/tests/test_config.py +0 -0
- {outo_models_cli-0.2.0 → outo_models_cli-0.2.1}/tests/test_download_lfs.py +0 -0
- {outo_models_cli-0.2.0 → outo_models_cli-0.2.1}/tests/test_download_repo.py +0 -0
- {outo_models_cli-0.2.0 → outo_models_cli-0.2.1}/tests/test_download_transfer.py +0 -0
- {outo_models_cli-0.2.0 → outo_models_cli-0.2.1}/tests/test_download_walk.py +0 -0
- {outo_models_cli-0.2.0 → outo_models_cli-0.2.1}/tests/test_errors.py +0 -0
- {outo_models_cli-0.2.0 → outo_models_cli-0.2.1}/tests/test_lfs.py +0 -0
- {outo_models_cli-0.2.0 → outo_models_cli-0.2.1}/tests/test_matchers.py +0 -0
- {outo_models_cli-0.2.0 → outo_models_cli-0.2.1}/tests/test_upload_commit.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: outo-models-cli
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.1
|
|
4
4
|
Summary: Command-line client for self-hosted outo-models servers — auth, repo, download, and upload.
|
|
5
5
|
Project-URL: Homepage, https://github.com/outo-models/outo-models
|
|
6
6
|
Project-URL: Documentation, https://github.com/outo-models/outo-models/blob/main/omc/README.md
|
|
@@ -40,7 +40,7 @@ import hashlib
|
|
|
40
40
|
from collections.abc import Iterable
|
|
41
41
|
from dataclasses import dataclass, field
|
|
42
42
|
from pathlib import Path
|
|
43
|
-
from typing import Any
|
|
43
|
+
from typing import Any, Protocol
|
|
44
44
|
|
|
45
45
|
LFS_CONTENT_TYPE = "application/vnd.git-lfs+json"
|
|
46
46
|
|
|
@@ -104,10 +104,17 @@ class Partition:
|
|
|
104
104
|
large: list[LargeFile] = field(default_factory=list)
|
|
105
105
|
|
|
106
106
|
|
|
107
|
-
def _sha256_of(
|
|
107
|
+
def _sha256_of(
|
|
108
|
+
path: Path,
|
|
109
|
+
*,
|
|
110
|
+
progress: _ProgressSink | None = None,
|
|
111
|
+
task_id: Any = None,
|
|
112
|
+
) -> tuple[str, int]:
|
|
108
113
|
"""Stream `path` through sha256; return `(hex_digest, size)`.
|
|
109
114
|
|
|
110
115
|
A 1 MiB read buffer keeps memory bounded regardless of file size.
|
|
116
|
+
When `progress`/`task_id` are given, hashing advances the task so a
|
|
117
|
+
multi-GiB file does not sit in silent disk reads for minutes.
|
|
111
118
|
Errors from the underlying `open()` (file removed between the
|
|
112
119
|
partition step and the PUT) surface unchanged so the caller's
|
|
113
120
|
`OmcError` mapping sees the real cause.
|
|
@@ -121,10 +128,17 @@ def _sha256_of(path: Path) -> tuple[str, int]:
|
|
|
121
128
|
break
|
|
122
129
|
hasher.update(chunk)
|
|
123
130
|
total += len(chunk)
|
|
131
|
+
if progress is not None and task_id is not None:
|
|
132
|
+
progress.update(task_id, advance=len(chunk))
|
|
124
133
|
return hasher.hexdigest(), total
|
|
125
134
|
|
|
126
135
|
|
|
127
|
-
def partition_files(
|
|
136
|
+
def partition_files(
|
|
137
|
+
files: Iterable[Path],
|
|
138
|
+
*,
|
|
139
|
+
cap_bytes: int,
|
|
140
|
+
show_progress: bool = True,
|
|
141
|
+
) -> Partition:
|
|
128
142
|
"""Split `files` into small (multipart) and large (LFS) buckets.
|
|
129
143
|
|
|
130
144
|
The function streams each large file exactly once to compute its
|
|
@@ -137,24 +151,67 @@ def partition_files(files: Iterable[Path], *, cap_bytes: int) -> Partition:
|
|
|
137
151
|
|
|
138
152
|
small: list[Path] = []
|
|
139
153
|
large: list[LargeFile] = []
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
f"Size of {f} changed during hashing ({observed} != {size}); retry.",
|
|
154
|
+
hashing: _ProgressSink | None = None
|
|
155
|
+
try:
|
|
156
|
+
for f in files:
|
|
157
|
+
size = f.stat().st_size
|
|
158
|
+
if size <= cap_bytes:
|
|
159
|
+
small.append(f)
|
|
160
|
+
continue
|
|
161
|
+
if hashing is None and show_progress:
|
|
162
|
+
hashing = _make_hash_progress()
|
|
163
|
+
task_id = (
|
|
164
|
+
hashing.add_task(f"hash {f.name}", total=size) if hashing is not None else None
|
|
152
165
|
)
|
|
153
|
-
|
|
166
|
+
oid, observed = _sha256_of(f, progress=hashing, task_id=task_id)
|
|
167
|
+
if hashing is not None and task_id is not None:
|
|
168
|
+
hashing.remove_task(task_id)
|
|
169
|
+
if observed != size:
|
|
170
|
+
# Defensive: `stat().st_size` could race with concurrent
|
|
171
|
+
# writers. Surface as `BadResponseError`-shaped failure so
|
|
172
|
+
# the user sees a clean English line, not a Python traceback.
|
|
173
|
+
raise BadResponseError(
|
|
174
|
+
f"Size of {f} changed during hashing ({observed} != {size}); retry.",
|
|
175
|
+
)
|
|
176
|
+
large.append(LargeFile(path=f, oid=oid, size=size))
|
|
177
|
+
finally:
|
|
178
|
+
if hashing is not None:
|
|
179
|
+
hashing.stop()
|
|
154
180
|
large.sort(key=lambda lf: lf.size, reverse=True)
|
|
155
181
|
return Partition(small=small, large=large)
|
|
156
182
|
|
|
157
183
|
|
|
184
|
+
class _ProgressSink(Protocol):
|
|
185
|
+
"""The add/update/remove/stop surface partition_files needs."""
|
|
186
|
+
|
|
187
|
+
def add_task(self, description: str, *, total: float | None = None) -> Any: ...
|
|
188
|
+
def update(self, task_id: Any, **kwargs: Any) -> None: ...
|
|
189
|
+
def remove_task(self, task_id: Any) -> None: ...
|
|
190
|
+
def stop(self) -> None: ...
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _make_hash_progress() -> _ProgressSink:
|
|
194
|
+
"""A Rich progress dedicated to the hashing phase (file-named tasks)."""
|
|
195
|
+
from rich.progress import (
|
|
196
|
+
BarColumn,
|
|
197
|
+
DownloadColumn,
|
|
198
|
+
Progress,
|
|
199
|
+
TextColumn,
|
|
200
|
+
TimeRemainingColumn,
|
|
201
|
+
TransferSpeedColumn,
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
progress = Progress(
|
|
205
|
+
TextColumn("[bold cyan]{task.description}[/bold cyan]", justify="left"),
|
|
206
|
+
BarColumn(bar_width=40),
|
|
207
|
+
DownloadColumn(),
|
|
208
|
+
TransferSpeedColumn(),
|
|
209
|
+
TimeRemainingColumn(),
|
|
210
|
+
)
|
|
211
|
+
progress.start()
|
|
212
|
+
return progress
|
|
213
|
+
|
|
214
|
+
|
|
158
215
|
# ---------------------------------------------------------------------------
|
|
159
216
|
# Deduplication — identical (oid, size) pairs collapse to one batch entry
|
|
160
217
|
# ---------------------------------------------------------------------------
|
|
@@ -26,6 +26,7 @@ from rich.progress import (
|
|
|
26
26
|
Progress,
|
|
27
27
|
TaskID,
|
|
28
28
|
TextColumn,
|
|
29
|
+
TimeRemainingColumn,
|
|
29
30
|
TransferSpeedColumn,
|
|
30
31
|
)
|
|
31
32
|
|
|
@@ -108,56 +109,69 @@ def upload_objects(
|
|
|
108
109
|
actions: list[BatchAction],
|
|
109
110
|
paths_by_oid: dict[str, list[Path]],
|
|
110
111
|
show_progress: bool = True,
|
|
112
|
+
progress: _ProgressLike | None = None,
|
|
111
113
|
) -> None:
|
|
112
114
|
"""Stream-PUT every `BatchAction` from disk with a Rich progress bar.
|
|
113
115
|
|
|
114
116
|
`paths_by_oid` maps an oid to its one-or-more source paths; the
|
|
115
117
|
first one is used as the byte source (dedup is exact — same sha256
|
|
116
|
-
means same bytes)
|
|
117
|
-
|
|
118
|
+
means same bytes) AND as the human-facing task label — users track
|
|
119
|
+
"weights-00002-of-00004.safetensors", never a sha prefix. The caller
|
|
120
|
+
MUST ensure `actions` and `paths_by_oid` agree on the oid set.
|
|
121
|
+
`progress` lets tests inject a recorder instead of Rich.
|
|
118
122
|
"""
|
|
119
123
|
if not actions:
|
|
120
124
|
return
|
|
125
|
+
# ONE column set for every task row. (The previous layout concatenated
|
|
126
|
+
# a per-object group and a "total" group, so every row rendered both
|
|
127
|
+
# groups and the output read as doubled, unlabeled bars.)
|
|
121
128
|
columns: tuple[Any, ...] = (
|
|
122
129
|
TextColumn("[bold blue]{task.description}[/bold blue]", justify="left"),
|
|
123
|
-
BarColumn(bar_width=
|
|
124
|
-
DownloadColumn(),
|
|
125
|
-
TransferSpeedColumn(),
|
|
126
|
-
)
|
|
127
|
-
overall_columns: tuple[Any, ...] = (
|
|
128
|
-
TextColumn("[bold green]total[/bold green]"),
|
|
129
|
-
BarColumn(bar_width=40),
|
|
130
|
+
BarColumn(bar_width=32),
|
|
130
131
|
DownloadColumn(),
|
|
131
132
|
TransferSpeedColumn(),
|
|
133
|
+
TimeRemainingColumn(),
|
|
132
134
|
)
|
|
133
135
|
total_bytes = sum(a.size for a in actions)
|
|
134
|
-
if
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
136
|
+
if progress is not None:
|
|
137
|
+
_upload_all(
|
|
138
|
+
progress, client, actions=actions, paths_by_oid=paths_by_oid, total_bytes=total_bytes
|
|
139
|
+
)
|
|
140
|
+
return
|
|
141
|
+
progress_cm: Any = Progress(*columns, expand=True) if show_progress else _NullProgress()
|
|
142
|
+
with progress_cm as active:
|
|
143
|
+
_upload_all(
|
|
144
|
+
active, client, actions=actions, paths_by_oid=paths_by_oid, total_bytes=total_bytes
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _upload_all(
|
|
149
|
+
progress: Any,
|
|
150
|
+
client: httpx.Client,
|
|
151
|
+
*,
|
|
152
|
+
actions: list[BatchAction],
|
|
153
|
+
paths_by_oid: dict[str, list[Path]],
|
|
154
|
+
total_bytes: int,
|
|
155
|
+
) -> None:
|
|
156
|
+
overall = progress.add_task(f"Total ({len(actions)} object(s))", total=total_bytes)
|
|
157
|
+
for action in actions:
|
|
158
|
+
sources = paths_by_oid.get(action.oid) or []
|
|
159
|
+
if not sources:
|
|
160
|
+
raise BadResponseError(
|
|
161
|
+
f"LFS action for oid {action.oid} has no source file on disk.",
|
|
157
162
|
)
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
163
|
+
task_id = progress.add_task(sources[0].name, total=action.size)
|
|
164
|
+
_put_stream(
|
|
165
|
+
client,
|
|
166
|
+
url=action.href,
|
|
167
|
+
path=sources[0],
|
|
168
|
+
extra_headers=action.header,
|
|
169
|
+
progress=progress,
|
|
170
|
+
task_id=task_id,
|
|
171
|
+
)
|
|
172
|
+
progress.update(task_id, completed=action.size)
|
|
173
|
+
progress.update(overall, advance=action.size)
|
|
174
|
+
progress.remove_task(task_id)
|
|
161
175
|
|
|
162
176
|
|
|
163
177
|
class _NullProgress:
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
class _Recorder:
|
|
2
|
+
"""Stand-in progress sink capturing task descriptions for assertions."""
|
|
3
|
+
|
|
4
|
+
def __init__(self) -> None:
|
|
5
|
+
self.descriptions: list[str] = []
|
|
6
|
+
self.advances: list[int] = []
|
|
7
|
+
self._next = 0
|
|
8
|
+
|
|
9
|
+
def add_task(self, description: str, *, total: float | None = None) -> int:
|
|
10
|
+
self.descriptions.append(description)
|
|
11
|
+
self._next += 1
|
|
12
|
+
return self._next - 1
|
|
13
|
+
|
|
14
|
+
def update(self, task_id: int, advance: float = 0, **kwargs: object) -> None:
|
|
15
|
+
self.advances.append(int(advance))
|
|
16
|
+
|
|
17
|
+
def remove_task(self, task_id: int) -> None:
|
|
18
|
+
return None
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class TestUploadObjectsLabels:
|
|
22
|
+
"""Users track file NAMES, not sha prefixes (the field complaint)."""
|
|
23
|
+
|
|
24
|
+
def test_tasks_are_named_after_files(self, tmp_path, monkeypatch):
|
|
25
|
+
import httpx
|
|
26
|
+
|
|
27
|
+
from outo_models_cli.api.lfs_batch import BatchAction
|
|
28
|
+
from outo_models_cli.api.lfs_upload import upload_objects
|
|
29
|
+
|
|
30
|
+
big = tmp_path / "weights-00001-of-00002.safetensors"
|
|
31
|
+
big.write_bytes(b"x" * 4096)
|
|
32
|
+
action = BatchAction(
|
|
33
|
+
oid="a" * 64, size=big.stat().st_size, href="http://srv/put", header={}
|
|
34
|
+
)
|
|
35
|
+
recorder = _Recorder()
|
|
36
|
+
|
|
37
|
+
def fake_put(client, **kwargs):
|
|
38
|
+
pass
|
|
39
|
+
|
|
40
|
+
monkeypatch.setattr("outo_models_cli.api.lfs_upload._put_stream", fake_put)
|
|
41
|
+
client = httpx.Client()
|
|
42
|
+
try:
|
|
43
|
+
upload_objects(
|
|
44
|
+
client,
|
|
45
|
+
actions=[action],
|
|
46
|
+
paths_by_oid={action.oid: [big]},
|
|
47
|
+
show_progress=False,
|
|
48
|
+
progress=recorder,
|
|
49
|
+
)
|
|
50
|
+
finally:
|
|
51
|
+
client.close()
|
|
52
|
+
assert "weights-00001-of-00002.safetensors" in recorder.descriptions
|
|
53
|
+
assert any(d.startswith("Total (1 object") for d in recorder.descriptions)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class TestPartitionHashingProgress:
|
|
57
|
+
def test_show_progress_true_does_not_crash(self, tmp_path):
|
|
58
|
+
from outo_models_cli.api.lfs import partition_files
|
|
59
|
+
|
|
60
|
+
big = tmp_path / "big.bin"
|
|
61
|
+
big.write_bytes(b"y" * 2048)
|
|
62
|
+
small = tmp_path / "small.txt"
|
|
63
|
+
small.write_bytes(b"y")
|
|
64
|
+
partition = partition_files([big, small], cap_bytes=512, show_progress=True)
|
|
65
|
+
assert [f.name for f in partition.small] == ["small.txt"]
|
|
66
|
+
assert partition.large and partition.large[0].path.name == "big.bin"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{outo_models_cli-0.2.0 → outo_models_cli-0.2.1}/src/outo_models_cli/commands/_upload_commit.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|