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.
Files changed (50) hide show
  1. {outo_models_cli-0.2.0 → outo_models_cli-0.2.1}/PKG-INFO +1 -1
  2. {outo_models_cli-0.2.0 → outo_models_cli-0.2.1}/pyproject.toml +1 -1
  3. {outo_models_cli-0.2.0 → outo_models_cli-0.2.1}/src/outo_models_cli/api/lfs.py +73 -16
  4. {outo_models_cli-0.2.0 → outo_models_cli-0.2.1}/src/outo_models_cli/api/lfs_upload.py +49 -35
  5. outo_models_cli-0.2.1/tests/test_lfs_upload.py +66 -0
  6. {outo_models_cli-0.2.0 → outo_models_cli-0.2.1}/uv.lock +1 -1
  7. {outo_models_cli-0.2.0 → outo_models_cli-0.2.1}/.gitignore +0 -0
  8. {outo_models_cli-0.2.0 → outo_models_cli-0.2.1}/LICENSE +0 -0
  9. {outo_models_cli-0.2.0 → outo_models_cli-0.2.1}/README.md +0 -0
  10. {outo_models_cli-0.2.0 → outo_models_cli-0.2.1}/src/outo_models_cli/__init__.py +0 -0
  11. {outo_models_cli-0.2.0 → outo_models_cli-0.2.1}/src/outo_models_cli/__main__.py +0 -0
  12. {outo_models_cli-0.2.0 → outo_models_cli-0.2.1}/src/outo_models_cli/api/__init__.py +0 -0
  13. {outo_models_cli-0.2.0 → outo_models_cli-0.2.1}/src/outo_models_cli/api/auth.py +0 -0
  14. {outo_models_cli-0.2.0 → outo_models_cli-0.2.1}/src/outo_models_cli/api/lfs_batch.py +0 -0
  15. {outo_models_cli-0.2.0 → outo_models_cli-0.2.1}/src/outo_models_cli/api/repos.py +0 -0
  16. {outo_models_cli-0.2.0 → outo_models_cli-0.2.1}/src/outo_models_cli/api/upload.py +0 -0
  17. {outo_models_cli-0.2.0 → outo_models_cli-0.2.1}/src/outo_models_cli/commands/__init__.py +0 -0
  18. {outo_models_cli-0.2.0 → outo_models_cli-0.2.1}/src/outo_models_cli/commands/_shared.py +0 -0
  19. {outo_models_cli-0.2.0 → outo_models_cli-0.2.1}/src/outo_models_cli/commands/_upload_commit.py +0 -0
  20. {outo_models_cli-0.2.0 → outo_models_cli-0.2.1}/src/outo_models_cli/commands/auth.py +0 -0
  21. {outo_models_cli-0.2.0 → outo_models_cli-0.2.1}/src/outo_models_cli/commands/download.py +0 -0
  22. {outo_models_cli-0.2.0 → outo_models_cli-0.2.1}/src/outo_models_cli/commands/ls.py +0 -0
  23. {outo_models_cli-0.2.0 → outo_models_cli-0.2.1}/src/outo_models_cli/commands/repo.py +0 -0
  24. {outo_models_cli-0.2.0 → outo_models_cli-0.2.1}/src/outo_models_cli/commands/upload.py +0 -0
  25. {outo_models_cli-0.2.0 → outo_models_cli-0.2.1}/src/outo_models_cli/config.py +0 -0
  26. {outo_models_cli-0.2.0 → outo_models_cli-0.2.1}/src/outo_models_cli/downloader/__init__.py +0 -0
  27. {outo_models_cli-0.2.0 → outo_models_cli-0.2.1}/src/outo_models_cli/downloader/transfer.py +0 -0
  28. {outo_models_cli-0.2.0 → outo_models_cli-0.2.1}/src/outo_models_cli/downloader/walk.py +0 -0
  29. {outo_models_cli-0.2.0 → outo_models_cli-0.2.1}/src/outo_models_cli/errors.py +0 -0
  30. {outo_models_cli-0.2.0 → outo_models_cli-0.2.1}/src/outo_models_cli/http.py +0 -0
  31. {outo_models_cli-0.2.0 → outo_models_cli-0.2.1}/src/outo_models_cli/main.py +0 -0
  32. {outo_models_cli-0.2.0 → outo_models_cli-0.2.1}/src/outo_models_cli/matchers.py +0 -0
  33. {outo_models_cli-0.2.0 → outo_models_cli-0.2.1}/tests/conftest.py +0 -0
  34. {outo_models_cli-0.2.0 → outo_models_cli-0.2.1}/tests/test_api_crud.py +0 -0
  35. {outo_models_cli-0.2.0 → outo_models_cli-0.2.1}/tests/test_api_upload.py +0 -0
  36. {outo_models_cli-0.2.0 → outo_models_cli-0.2.1}/tests/test_cli_auth.py +0 -0
  37. {outo_models_cli-0.2.0 → outo_models_cli-0.2.1}/tests/test_cli_errors.py +0 -0
  38. {outo_models_cli-0.2.0 → outo_models_cli-0.2.1}/tests/test_cli_help.py +0 -0
  39. {outo_models_cli-0.2.0 → outo_models_cli-0.2.1}/tests/test_cli_repo.py +0 -0
  40. {outo_models_cli-0.2.0 → outo_models_cli-0.2.1}/tests/test_cli_upload_lfs.py +0 -0
  41. {outo_models_cli-0.2.0 → outo_models_cli-0.2.1}/tests/test_cli_xfer.py +0 -0
  42. {outo_models_cli-0.2.0 → outo_models_cli-0.2.1}/tests/test_config.py +0 -0
  43. {outo_models_cli-0.2.0 → outo_models_cli-0.2.1}/tests/test_download_lfs.py +0 -0
  44. {outo_models_cli-0.2.0 → outo_models_cli-0.2.1}/tests/test_download_repo.py +0 -0
  45. {outo_models_cli-0.2.0 → outo_models_cli-0.2.1}/tests/test_download_transfer.py +0 -0
  46. {outo_models_cli-0.2.0 → outo_models_cli-0.2.1}/tests/test_download_walk.py +0 -0
  47. {outo_models_cli-0.2.0 → outo_models_cli-0.2.1}/tests/test_errors.py +0 -0
  48. {outo_models_cli-0.2.0 → outo_models_cli-0.2.1}/tests/test_lfs.py +0 -0
  49. {outo_models_cli-0.2.0 → outo_models_cli-0.2.1}/tests/test_matchers.py +0 -0
  50. {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.0
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
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "outo-models-cli"
3
- version = "0.2.0"
3
+ version = "0.2.1"
4
4
  description = "Command-line client for self-hosted outo-models servers — auth, repo, download, and upload."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.12,<3.13"
@@ -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(path: Path) -> tuple[str, int]:
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(files: Iterable[Path], *, cap_bytes: int) -> Partition:
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
- for f in files:
141
- size = f.stat().st_size
142
- if size <= cap_bytes:
143
- small.append(f)
144
- continue
145
- oid, observed = _sha256_of(f)
146
- if observed != size:
147
- # Defensive: `stat().st_size` could race with concurrent
148
- # writers. Surface as `BadResponseError`-shaped failure so
149
- # the user sees a clean English line, not a Python traceback.
150
- raise BadResponseError(
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
- large.append(LargeFile(path=f, oid=oid, size=size))
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). The caller MUST ensure `actions` and
117
- `paths_by_oid` agree on the oid set.
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=40),
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 show_progress:
135
- progress_cm: Any = Progress(*columns, *overall_columns, expand=True)
136
- else:
137
- progress_cm = _NullProgress()
138
- with progress_cm as progress:
139
- overall = progress.add_task("total", total=total_bytes)
140
- for action in actions:
141
- sources = paths_by_oid.get(action.oid) or []
142
- if not sources:
143
- raise BadResponseError(
144
- f"LFS action for oid {action.oid} has no source file on disk.",
145
- )
146
- task_id = progress.add_task(
147
- action.oid[:12],
148
- total=action.size,
149
- )
150
- _put_stream(
151
- client,
152
- url=action.href,
153
- path=sources[0],
154
- extra_headers=action.header,
155
- progress=progress,
156
- task_id=task_id,
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
- progress.update(task_id, completed=action.size)
159
- progress.update(overall, advance=action.size)
160
- progress.remove_task(task_id)
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"
@@ -227,7 +227,7 @@ wheels = [
227
227
 
228
228
  [[package]]
229
229
  name = "outo-models-cli"
230
- version = "0.2.0"
230
+ version = "0.2.1"
231
231
  source = { editable = "." }
232
232
  dependencies = [
233
233
  { name = "httpx" },
File without changes