pythonhere 0.3.1__py3-none-any.whl → 0.4.0__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.
- pythonhere/.agents/skills/pythonhere/SKILL.md +3 -1
- pythonhere/.agents/skills/pythonhere/references/litert.md +121 -0
- pythonhere/ml_here/__init__.py +17 -0
- pythonhere/ml_here/huggingface.py +998 -0
- pythonhere/ml_here/storage.py +145 -0
- pythonhere/version_here.py +1 -1
- {pythonhere-0.3.1.dist-info → pythonhere-0.4.0.dist-info}/METADATA +4 -1
- {pythonhere-0.3.1.dist-info → pythonhere-0.4.0.dist-info}/RECORD +11 -7
- {pythonhere-0.3.1.dist-info → pythonhere-0.4.0.dist-info}/WHEEL +0 -0
- {pythonhere-0.3.1.dist-info → pythonhere-0.4.0.dist-info}/licenses/LICENSE +0 -0
- {pythonhere-0.3.1.dist-info → pythonhere-0.4.0.dist-info}/top_level.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: pythonhere
|
|
3
|
-
description: "Build, inspect, and debug PythonHere applications by executing Python through the `there` CLI in an already-running Kivy/Python-for-Android process. Use when a task targets a PythonHere device or `there.env` connection and involves live Kivy UI, KV language, Android APIs, Pyjnius, permissions, installed Android packages, media/files, Plyer device features, BLE with able, or
|
|
3
|
+
description: "Build, inspect, and debug PythonHere applications by executing Python through the `there` CLI in an already-running Kivy/Python-for-Android process. Use when a task targets a PythonHere device or `there.env` connection and involves live Kivy UI, KV language, Android APIs, Pyjnius, permissions, installed Android packages, media/files, Plyer device features, BLE with able, MIDI with midistream, or on-device AI with LiteRT-LM and `.litertlm` models."
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# Operate PythonHere with `there`
|
|
@@ -40,6 +40,8 @@ when the task depends on it broadly or requires its exact current contents.
|
|
|
40
40
|
recording, camera, file selection, GPS, battery, or sensors.
|
|
41
41
|
- Consult [able.md](references/able.md) for Bluetooth Low Energy work.
|
|
42
42
|
- Consult [midi.md](references/midi.md) for MIDI or synthesizer work.
|
|
43
|
+
- Consult [litert.md](references/litert.md) for PythonHere's ML model helpers
|
|
44
|
+
and LiteRT-LM usage.
|
|
43
45
|
|
|
44
46
|
Combine references when a feature genuinely needs details from several
|
|
45
47
|
concerns; for example, an Android gallery may need permission, MediaStore, and
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
# ML model helpers for LiteRT
|
|
2
|
+
|
|
3
|
+
PythonHere provides `ml_here` for acquiring and locating ML model files. It
|
|
4
|
+
does not wrap LiteRT-LM inference.
|
|
5
|
+
|
|
6
|
+
All public model helpers return paths as strings (a list of strings for
|
|
7
|
+
`discover_models()`), ready to pass to `litert_lm.Engine()`.
|
|
8
|
+
|
|
9
|
+
```python
|
|
10
|
+
from ml_here import (
|
|
11
|
+
discover_models,
|
|
12
|
+
download_hf_model,
|
|
13
|
+
model_path,
|
|
14
|
+
models_directory,
|
|
15
|
+
require_model,
|
|
16
|
+
)
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
- `models_directory(create=False)` returns PythonHere's model directory. On
|
|
20
|
+
Android this is the app-specific external files directory under `models`; on
|
|
21
|
+
Linux it is `~/.local/share/pythonhere/models`.
|
|
22
|
+
- `model_path(repo_id, filename)` returns the namespaced path for a model. It
|
|
23
|
+
does not create directories and rejects unsafe path components.
|
|
24
|
+
- `require_model(repo_id, filename)` returns that path when the model is
|
|
25
|
+
installed and raises `FileNotFoundError` otherwise.
|
|
26
|
+
- `discover_models(extra_directories=(), extensions=".litertlm")` recursively
|
|
27
|
+
returns sorted, unique LiteRT-LM model paths from managed storage and any
|
|
28
|
+
additional directories. Pass another extension or iterable to select other
|
|
29
|
+
formats, or `extensions=None` to include every completed stored artifact.
|
|
30
|
+
- `download_hf_model(repo_id, filename, *, revision="main", token=None,
|
|
31
|
+
destination=None, progress=None, cancelled=None, overwrite=False,
|
|
32
|
+
expected_sha256=None)` downloads a Hugging Face model file of any format. By
|
|
33
|
+
default, it stores the file at `model_path(repo_id, filename)`.
|
|
34
|
+
|
|
35
|
+
Choose by intent: use `require_model()` to load a known installed model, the
|
|
36
|
+
path returned by `download_hf_model()` after a download, and
|
|
37
|
+
`discover_models()` only to inventory models whose identities are not already
|
|
38
|
+
known. Use `model_path()` when only the expected location is needed.
|
|
39
|
+
|
|
40
|
+
Downloads are resumable across restarts and make the final file visible only
|
|
41
|
+
after the download completes. Cancellation preserves the partial download for
|
|
42
|
+
resuming. Pass a token for private or gated models. The `progress` callback
|
|
43
|
+
receives the following interface; use it directly rather than redefining it:
|
|
44
|
+
|
|
45
|
+
```python
|
|
46
|
+
class DownloadProgress:
|
|
47
|
+
repo_id: str
|
|
48
|
+
filename: str
|
|
49
|
+
destination: str
|
|
50
|
+
downloaded: int
|
|
51
|
+
total: int | None
|
|
52
|
+
elapsed: float
|
|
53
|
+
speed_bps: float
|
|
54
|
+
fraction: float | None
|
|
55
|
+
percent: float | None
|
|
56
|
+
speed_mbps: float
|
|
57
|
+
remaining: int | None
|
|
58
|
+
eta: float | None
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
Use `litert_lm` directly to load and run the returned model path.
|
|
62
|
+
|
|
63
|
+
## Run an installed model
|
|
64
|
+
|
|
65
|
+
Run model loading and inference on a worker (`--worker`) to keep Kivy
|
|
66
|
+
responsive. In the app runtime, import model helpers from `ml_here`.
|
|
67
|
+
|
|
68
|
+
```python
|
|
69
|
+
import litert_lm
|
|
70
|
+
from ml_here import require_model
|
|
71
|
+
|
|
72
|
+
model_file = require_model(
|
|
73
|
+
"litert-community/SmolLM2-135M-Instruct",
|
|
74
|
+
"SmolLM2_135M_Instruct.litertlm",
|
|
75
|
+
)
|
|
76
|
+
engine = litert_lm.Engine(model_file)
|
|
77
|
+
try:
|
|
78
|
+
with engine.create_conversation(max_output_tokens=128) as conversation:
|
|
79
|
+
response = conversation.send_message(
|
|
80
|
+
"What is the Kivy framework? Answer in one short paragraph."
|
|
81
|
+
)
|
|
82
|
+
text = "".join(
|
|
83
|
+
part["text"]
|
|
84
|
+
for part in response["content"]
|
|
85
|
+
if part.get("type") == "text"
|
|
86
|
+
)
|
|
87
|
+
print(text)
|
|
88
|
+
finally:
|
|
89
|
+
engine.close()
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
The conversation closes before the engine, including when inference raises.
|
|
93
|
+
For an interactive chat, keep the engine loaded and reuse the conversation
|
|
94
|
+
for follow-up messages; close both when the chat is finished.
|
|
95
|
+
|
|
96
|
+
`litert_lm.Engine(model_file)` uses CPU by default. For a model and device
|
|
97
|
+
that support GPU inference, replace the engine construction above with:
|
|
98
|
+
|
|
99
|
+
```python
|
|
100
|
+
engine = litert_lm.Engine(model_file, backend=litert_lm.Backend.GPU())
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
## Download first when needed
|
|
104
|
+
|
|
105
|
+
If the model is not installed, download it on a worker before inference:
|
|
106
|
+
|
|
107
|
+
```python
|
|
108
|
+
from ml_here import download_hf_model
|
|
109
|
+
|
|
110
|
+
model_file = download_hf_model(
|
|
111
|
+
"litert-community/SmolLM2-135M-Instruct",
|
|
112
|
+
"SmolLM2_135M_Instruct.litertlm",
|
|
113
|
+
)
|
|
114
|
+
print(model_file)
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
When downloading and loading in the same script, pass the returned
|
|
118
|
+
`model_file` directly to `litert_lm.Engine()`.
|
|
119
|
+
|
|
120
|
+
See the [upstream Python API](https://github.com/google-ai-edge/LiteRT-LM/tree/main/python)
|
|
121
|
+
for streaming, sampling, and multimodal inference.
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""Acquire, store, index, and discover ML model files for PythonHere."""
|
|
2
|
+
|
|
3
|
+
from .huggingface import download_hf_model
|
|
4
|
+
from .storage import (
|
|
5
|
+
discover_models,
|
|
6
|
+
model_path,
|
|
7
|
+
models_directory,
|
|
8
|
+
require_model,
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
__all__ = (
|
|
12
|
+
"discover_models",
|
|
13
|
+
"download_hf_model",
|
|
14
|
+
"model_path",
|
|
15
|
+
"models_directory",
|
|
16
|
+
"require_model",
|
|
17
|
+
)
|
|
@@ -0,0 +1,998 @@
|
|
|
1
|
+
"""
|
|
2
|
+
A small cross-platform Hugging Face Hub downloader designed for PythonHere.
|
|
3
|
+
|
|
4
|
+
Uses requests to download public, private, or gated files with persistent resume,
|
|
5
|
+
progress callbacks, retries, integrity checks, and atomic completion. This module
|
|
6
|
+
implements only the Hub features needed for model downloads; it is not a general
|
|
7
|
+
replacement for huggingface_hub.
|
|
8
|
+
|
|
9
|
+
Typical use:
|
|
10
|
+
|
|
11
|
+
from pythonhere.ml_here import download_hf_model
|
|
12
|
+
|
|
13
|
+
path = download_hf_model(
|
|
14
|
+
"litert-community/some-model",
|
|
15
|
+
"model.tflite",
|
|
16
|
+
token=oauth_access_token,
|
|
17
|
+
progress=lambda p: print(p.percent, p.speed_mbps),
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
For a public model, omit `token`.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import hashlib
|
|
26
|
+
import json
|
|
27
|
+
import os
|
|
28
|
+
import random
|
|
29
|
+
import re
|
|
30
|
+
import time
|
|
31
|
+
from collections.abc import Callable
|
|
32
|
+
from dataclasses import asdict, dataclass, field
|
|
33
|
+
from http import HTTPStatus
|
|
34
|
+
from pathlib import Path
|
|
35
|
+
from typing import Any
|
|
36
|
+
from urllib.parse import quote
|
|
37
|
+
|
|
38
|
+
import requests
|
|
39
|
+
|
|
40
|
+
from .storage import model_path
|
|
41
|
+
|
|
42
|
+
__all__ = [
|
|
43
|
+
"HFClient",
|
|
44
|
+
"DownloadProgress",
|
|
45
|
+
"RemoteFile",
|
|
46
|
+
"HFError",
|
|
47
|
+
"HFAuthError",
|
|
48
|
+
"HFGatedError",
|
|
49
|
+
"HFNotFoundError",
|
|
50
|
+
"HFRateLimitError",
|
|
51
|
+
"HFIntegrityError",
|
|
52
|
+
"HFCancelled",
|
|
53
|
+
"download_hf_model",
|
|
54
|
+
]
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class HFError(RuntimeError):
|
|
58
|
+
"""Base error for Hugging Face Hub operations."""
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class HFAuthError(HFError):
|
|
62
|
+
"""Authentication is missing, invalid, or insufficient."""
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class HFGatedError(HFAuthError):
|
|
66
|
+
"""The model is gated and this user does not currently have access."""
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class HFNotFoundError(HFError):
|
|
70
|
+
"""Repository, revision, or file was not found."""
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class HFRateLimitError(HFError):
|
|
74
|
+
"""Hugging Face rejected the request because of rate limiting."""
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class HFIntegrityError(HFError):
|
|
78
|
+
"""Downloaded data did not match expected size/hash."""
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class HFCancelled(HFError):
|
|
82
|
+
"""Download was cancelled by the caller."""
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@dataclass(frozen=True)
|
|
86
|
+
class RemoteFile:
|
|
87
|
+
"""Remote identity and metadata used to validate a download."""
|
|
88
|
+
|
|
89
|
+
repo_id: str
|
|
90
|
+
filename: str
|
|
91
|
+
revision: str
|
|
92
|
+
size: int | None
|
|
93
|
+
etag: str | None
|
|
94
|
+
commit: str | None
|
|
95
|
+
url: str
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
@dataclass(frozen=True)
|
|
99
|
+
class DownloadProgress:
|
|
100
|
+
"""Byte counts and timing for a download progress notification."""
|
|
101
|
+
|
|
102
|
+
repo_id: str
|
|
103
|
+
filename: str
|
|
104
|
+
destination: str
|
|
105
|
+
downloaded: int
|
|
106
|
+
total: int | None
|
|
107
|
+
elapsed: float
|
|
108
|
+
speed_bps: float
|
|
109
|
+
|
|
110
|
+
@property
|
|
111
|
+
def fraction(self) -> float | None:
|
|
112
|
+
if not self.total:
|
|
113
|
+
return None
|
|
114
|
+
return min(1.0, self.downloaded / self.total)
|
|
115
|
+
|
|
116
|
+
@property
|
|
117
|
+
def percent(self) -> float | None:
|
|
118
|
+
value = self.fraction
|
|
119
|
+
return None if value is None else value * 100.0
|
|
120
|
+
|
|
121
|
+
@property
|
|
122
|
+
def speed_mbps(self) -> float:
|
|
123
|
+
return self.speed_bps / (1024 * 1024)
|
|
124
|
+
|
|
125
|
+
@property
|
|
126
|
+
def remaining(self) -> int | None:
|
|
127
|
+
if self.total is None:
|
|
128
|
+
return None
|
|
129
|
+
return max(0, self.total - self.downloaded)
|
|
130
|
+
|
|
131
|
+
@property
|
|
132
|
+
def eta(self) -> float | None:
|
|
133
|
+
remaining = self.remaining
|
|
134
|
+
if remaining is None or self.speed_bps <= 0:
|
|
135
|
+
return None
|
|
136
|
+
return remaining / self.speed_bps
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
@dataclass
|
|
140
|
+
class _PartialMeta:
|
|
141
|
+
repo_id: str
|
|
142
|
+
filename: str
|
|
143
|
+
revision: str
|
|
144
|
+
etag: str | None
|
|
145
|
+
commit: str | None
|
|
146
|
+
expected_size: int | None
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
@dataclass
|
|
150
|
+
class _ProgressReporter:
|
|
151
|
+
remote: RemoteFile
|
|
152
|
+
destination: Path
|
|
153
|
+
callback: Callable[[DownloadProgress], None] | None
|
|
154
|
+
interval: float
|
|
155
|
+
started: float = field(init=False, default=0.0)
|
|
156
|
+
initial_size: int = field(init=False, default=0)
|
|
157
|
+
last_time: float = field(init=False, default=0.0)
|
|
158
|
+
last_size: int = field(init=False, default=0)
|
|
159
|
+
|
|
160
|
+
def start(self, size: int) -> None:
|
|
161
|
+
self.started = self.last_time = time.monotonic()
|
|
162
|
+
self.initial_size = self.last_size = size
|
|
163
|
+
|
|
164
|
+
def report(self, current: int) -> None:
|
|
165
|
+
now = time.monotonic()
|
|
166
|
+
if self.callback and (
|
|
167
|
+
now - self.last_time >= self.interval
|
|
168
|
+
or (self.remote.size is not None and current >= self.remote.size)
|
|
169
|
+
):
|
|
170
|
+
self.callback(
|
|
171
|
+
DownloadProgress(
|
|
172
|
+
repo_id=self.remote.repo_id,
|
|
173
|
+
filename=self.remote.filename,
|
|
174
|
+
destination=str(self.destination),
|
|
175
|
+
downloaded=current,
|
|
176
|
+
total=self.remote.size,
|
|
177
|
+
elapsed=now - self.started,
|
|
178
|
+
speed_bps=(current - self.last_size)
|
|
179
|
+
/ max(now - self.last_time, 1e-9),
|
|
180
|
+
)
|
|
181
|
+
)
|
|
182
|
+
self.last_time = now
|
|
183
|
+
self.last_size = current
|
|
184
|
+
|
|
185
|
+
def finish(self) -> None:
|
|
186
|
+
if self.callback:
|
|
187
|
+
end = time.monotonic()
|
|
188
|
+
size = self.destination.stat().st_size
|
|
189
|
+
self.callback(
|
|
190
|
+
DownloadProgress(
|
|
191
|
+
repo_id=self.remote.repo_id,
|
|
192
|
+
filename=self.remote.filename,
|
|
193
|
+
destination=str(self.destination),
|
|
194
|
+
downloaded=size,
|
|
195
|
+
total=self.remote.size or size,
|
|
196
|
+
elapsed=end - self.started,
|
|
197
|
+
speed_bps=(size - self.initial_size)
|
|
198
|
+
/ max(end - self.started, 1e-9),
|
|
199
|
+
)
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _raise_download_error(response: requests.Response, filename: str) -> None:
|
|
204
|
+
if response.status_code == HTTPStatus.TOO_MANY_REQUESTS:
|
|
205
|
+
raise HFRateLimitError(
|
|
206
|
+
f"Hugging Face rate limit exceeded while downloading {filename!r}."
|
|
207
|
+
)
|
|
208
|
+
raise HFError(f"HTTP {response.status_code} while downloading {filename!r}.")
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
_BYTES_PER_KIB = 1024
|
|
212
|
+
_SECONDS_PER_MINUTE = 60
|
|
213
|
+
_MINUTES_PER_HOUR = 60
|
|
214
|
+
|
|
215
|
+
_RETRYABLE_STATUS = {408, 425, 429, 500, 502, 503, 504}
|
|
216
|
+
_SAFE_ETAG = re.compile(r'^(?:W/)?"?(.*?)"?$')
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def _clean_etag(value: str | None) -> str | None:
|
|
220
|
+
if not value:
|
|
221
|
+
return None
|
|
222
|
+
value = value.strip()
|
|
223
|
+
m = _SAFE_ETAG.match(value)
|
|
224
|
+
return m.group(1) if m else value.strip('"')
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def _int_header(
|
|
228
|
+
headers: requests.structures.CaseInsensitiveDict, name: str
|
|
229
|
+
) -> int | None:
|
|
230
|
+
value = headers.get(name)
|
|
231
|
+
if value is None:
|
|
232
|
+
return None
|
|
233
|
+
try:
|
|
234
|
+
return int(value)
|
|
235
|
+
except (TypeError, ValueError):
|
|
236
|
+
return None
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def _remote_size(response: requests.Response) -> int | None:
|
|
240
|
+
# HF exposes the actual object size through X-Linked-Size for LFS-backed
|
|
241
|
+
# files. On a final ranged/CDN response, Content-Range is the strongest
|
|
242
|
+
# source. Fall back to ordinary Content-Length.
|
|
243
|
+
linked = _int_header(response.headers, "X-Linked-Size")
|
|
244
|
+
if linked is not None:
|
|
245
|
+
return linked
|
|
246
|
+
|
|
247
|
+
content_range = response.headers.get("Content-Range")
|
|
248
|
+
if content_range and "/" in content_range:
|
|
249
|
+
tail = content_range.rsplit("/", 1)[-1]
|
|
250
|
+
if tail != "*":
|
|
251
|
+
try:
|
|
252
|
+
return int(tail)
|
|
253
|
+
except ValueError:
|
|
254
|
+
pass
|
|
255
|
+
|
|
256
|
+
# Only trust Content-Length when the body is not content-encoded.
|
|
257
|
+
encoding = response.headers.get("Content-Encoding", "identity").lower()
|
|
258
|
+
if encoding == "identity":
|
|
259
|
+
return _int_header(response.headers, "Content-Length")
|
|
260
|
+
return None
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def _retry_after(response: requests.Response) -> float | None:
|
|
264
|
+
value = response.headers.get("Retry-After")
|
|
265
|
+
if not value:
|
|
266
|
+
return None
|
|
267
|
+
try:
|
|
268
|
+
return max(0.0, float(value))
|
|
269
|
+
except ValueError:
|
|
270
|
+
return None
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def _atomic_json_write(path: Path, data: dict[str, Any]) -> None:
|
|
274
|
+
tmp = path.with_name(path.name + ".tmp")
|
|
275
|
+
tmp.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
|
|
276
|
+
os.replace(tmp, path)
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
class HFClient:
|
|
280
|
+
"""
|
|
281
|
+
Lightweight Hugging Face Hub download client.
|
|
282
|
+
|
|
283
|
+
Parameters
|
|
284
|
+
----------
|
|
285
|
+
token:
|
|
286
|
+
Hugging Face user token or OAuth access token. Use None for public files.
|
|
287
|
+
endpoint:
|
|
288
|
+
Hub base URL. Mainly useful for testing or custom Hub-compatible hosts.
|
|
289
|
+
user_agent:
|
|
290
|
+
Sent on all requests.
|
|
291
|
+
timeout:
|
|
292
|
+
`(connect_timeout, read_timeout)` passed to requests.
|
|
293
|
+
The read timeout is per socket read, not a total download timeout.
|
|
294
|
+
chunk_size:
|
|
295
|
+
Streaming chunk size.
|
|
296
|
+
max_retries:
|
|
297
|
+
Retries after transient network/server failures.
|
|
298
|
+
backoff:
|
|
299
|
+
Base exponential retry delay.
|
|
300
|
+
"""
|
|
301
|
+
|
|
302
|
+
# Public connection options remain individually configurable.
|
|
303
|
+
def __init__( # noqa: PLR0913
|
|
304
|
+
self,
|
|
305
|
+
token: str | None = None,
|
|
306
|
+
*,
|
|
307
|
+
endpoint: str = "https://huggingface.co",
|
|
308
|
+
user_agent: str = "pythonhere-hf-downloader/1.0",
|
|
309
|
+
timeout: tuple[float, float] = (15.0, 60.0),
|
|
310
|
+
chunk_size: int = 1024 * 1024,
|
|
311
|
+
max_retries: int = 5,
|
|
312
|
+
backoff: float = 1.0,
|
|
313
|
+
session: requests.Session | None = None,
|
|
314
|
+
):
|
|
315
|
+
# pylint: disable=too-many-arguments
|
|
316
|
+
self.token = token
|
|
317
|
+
self.endpoint = endpoint.rstrip("/")
|
|
318
|
+
self.user_agent = user_agent
|
|
319
|
+
self.timeout = timeout
|
|
320
|
+
self.chunk_size = chunk_size
|
|
321
|
+
self.max_retries = max_retries
|
|
322
|
+
self.backoff = backoff
|
|
323
|
+
self.session = session or requests.Session()
|
|
324
|
+
|
|
325
|
+
def resolve_url(self, repo_id: str, filename: str, revision: str = "main") -> str:
|
|
326
|
+
repo = "/".join(quote(part, safe="") for part in repo_id.split("/"))
|
|
327
|
+
rev = quote(revision, safe="")
|
|
328
|
+
file_path = "/".join(quote(part, safe="") for part in filename.split("/"))
|
|
329
|
+
return f"{self.endpoint}/{repo}/resolve/{rev}/{file_path}"
|
|
330
|
+
|
|
331
|
+
def raw_url(self, repo_id: str, filename: str, revision: str = "main") -> str:
|
|
332
|
+
repo = "/".join(quote(part, safe="") for part in repo_id.split("/"))
|
|
333
|
+
rev = quote(revision, safe="")
|
|
334
|
+
file_path = "/".join(quote(part, safe="") for part in filename.split("/"))
|
|
335
|
+
return f"{self.endpoint}/{repo}/raw/{rev}/{file_path}"
|
|
336
|
+
|
|
337
|
+
def model_url(self, repo_id: str) -> str:
|
|
338
|
+
return f"{self.endpoint}/{repo_id}"
|
|
339
|
+
|
|
340
|
+
def _headers(self, extra: dict[str, str] | None = None) -> dict[str, str]:
|
|
341
|
+
# identity matters for reliable byte ranges / sizes.
|
|
342
|
+
headers = {
|
|
343
|
+
"User-Agent": self.user_agent,
|
|
344
|
+
"Accept-Encoding": "identity",
|
|
345
|
+
}
|
|
346
|
+
if self.token:
|
|
347
|
+
headers["Authorization"] = f"Bearer {self.token}"
|
|
348
|
+
if extra:
|
|
349
|
+
headers.update(extra)
|
|
350
|
+
return headers
|
|
351
|
+
|
|
352
|
+
def model_info(
|
|
353
|
+
self, repo_id: str, *, revision: str | None = None
|
|
354
|
+
) -> dict[str, Any]:
|
|
355
|
+
"""
|
|
356
|
+
Return raw model metadata from the Hub REST API.
|
|
357
|
+
|
|
358
|
+
The response includes useful fields such as `sha`, `gated`, and `siblings`.
|
|
359
|
+
"""
|
|
360
|
+
repo = "/".join(quote(part, safe="") for part in repo_id.split("/"))
|
|
361
|
+
if revision:
|
|
362
|
+
rev = quote(revision, safe="")
|
|
363
|
+
url = f"{self.endpoint}/api/models/{repo}/revision/{rev}"
|
|
364
|
+
else:
|
|
365
|
+
url = f"{self.endpoint}/api/models/{repo}"
|
|
366
|
+
|
|
367
|
+
response = self._request_with_retry("GET", url, headers=self._headers())
|
|
368
|
+
self._raise_hf_error(response, repo_id=repo_id)
|
|
369
|
+
return response.json()
|
|
370
|
+
|
|
371
|
+
def list_files(self, repo_id: str, *, revision: str | None = None) -> list[str]:
|
|
372
|
+
info = self.model_info(repo_id, revision=revision)
|
|
373
|
+
return [
|
|
374
|
+
item["rfilename"]
|
|
375
|
+
for item in info.get("siblings", [])
|
|
376
|
+
if isinstance(item, dict) and item.get("rfilename")
|
|
377
|
+
]
|
|
378
|
+
|
|
379
|
+
def file_info(
|
|
380
|
+
self,
|
|
381
|
+
repo_id: str,
|
|
382
|
+
filename: str,
|
|
383
|
+
*,
|
|
384
|
+
revision: str = "main",
|
|
385
|
+
) -> RemoteFile:
|
|
386
|
+
"""
|
|
387
|
+
Resolve a Hub file and return size/ETag/commit metadata without
|
|
388
|
+
downloading the body.
|
|
389
|
+
"""
|
|
390
|
+
url = self.resolve_url(repo_id, filename, revision)
|
|
391
|
+
|
|
392
|
+
response = self._request_with_retry(
|
|
393
|
+
"HEAD",
|
|
394
|
+
url,
|
|
395
|
+
headers=self._headers(),
|
|
396
|
+
allow_redirects=True,
|
|
397
|
+
)
|
|
398
|
+
self._raise_hf_error(
|
|
399
|
+
response,
|
|
400
|
+
repo_id=repo_id,
|
|
401
|
+
filename=filename,
|
|
402
|
+
)
|
|
403
|
+
|
|
404
|
+
etag = _clean_etag(
|
|
405
|
+
response.headers.get("X-Linked-Etag") or response.headers.get("ETag")
|
|
406
|
+
)
|
|
407
|
+
commit = response.headers.get("X-Repo-Commit")
|
|
408
|
+
size = _remote_size(response)
|
|
409
|
+
|
|
410
|
+
return RemoteFile(
|
|
411
|
+
repo_id=repo_id,
|
|
412
|
+
filename=filename,
|
|
413
|
+
revision=revision,
|
|
414
|
+
size=size,
|
|
415
|
+
etag=etag,
|
|
416
|
+
commit=commit,
|
|
417
|
+
# Intentionally expose the stable HF resolve URL, not an expiring
|
|
418
|
+
# signed CDN/Xet redirect target.
|
|
419
|
+
url=url,
|
|
420
|
+
)
|
|
421
|
+
|
|
422
|
+
def git_lfs_info(
|
|
423
|
+
self,
|
|
424
|
+
repo_id: str,
|
|
425
|
+
filename: str,
|
|
426
|
+
*,
|
|
427
|
+
revision: str = "main",
|
|
428
|
+
) -> dict[str, Any] | None:
|
|
429
|
+
"""Return SHA-256 and size when the repository stores a Git LFS pointer."""
|
|
430
|
+
|
|
431
|
+
response = self._request_with_retry(
|
|
432
|
+
"GET",
|
|
433
|
+
self.raw_url(repo_id, filename, revision),
|
|
434
|
+
headers=self._headers({"Range": "bytes=0-1024"}),
|
|
435
|
+
stream=True,
|
|
436
|
+
)
|
|
437
|
+
try:
|
|
438
|
+
self._raise_hf_error(response, repo_id=repo_id, filename=filename)
|
|
439
|
+
data = response.raw.read(1025)
|
|
440
|
+
finally:
|
|
441
|
+
response.close()
|
|
442
|
+
|
|
443
|
+
try:
|
|
444
|
+
text = data.decode("utf-8")
|
|
445
|
+
except UnicodeDecodeError:
|
|
446
|
+
return None
|
|
447
|
+
match = re.fullmatch(
|
|
448
|
+
r"version https://git-lfs\.github\.com/spec/v1\r?\n"
|
|
449
|
+
r"oid sha256:([0-9a-fA-F]{64})\r?\n"
|
|
450
|
+
r"size ([0-9]+)\r?\n?",
|
|
451
|
+
text,
|
|
452
|
+
)
|
|
453
|
+
if match:
|
|
454
|
+
return {"sha256": match.group(1).lower(), "size": int(match.group(2))}
|
|
455
|
+
if text.startswith("version https://git-lfs.github.com/spec/v1"):
|
|
456
|
+
raise HFIntegrityError(f"Malformed Git LFS pointer for {filename}")
|
|
457
|
+
return None
|
|
458
|
+
|
|
459
|
+
# Preserve the public download keyword options.
|
|
460
|
+
def download( # noqa: PLR0913
|
|
461
|
+
self,
|
|
462
|
+
repo_id: str,
|
|
463
|
+
filename: str,
|
|
464
|
+
destination: str | os.PathLike[str],
|
|
465
|
+
*,
|
|
466
|
+
revision: str = "main",
|
|
467
|
+
resume: bool = True,
|
|
468
|
+
overwrite: bool = False,
|
|
469
|
+
progress: Callable[[DownloadProgress], None] | None = None,
|
|
470
|
+
cancelled: Callable[[], bool] | None = None,
|
|
471
|
+
expected_sha256: str | None = None,
|
|
472
|
+
progress_interval: float = 0.15,
|
|
473
|
+
) -> Path:
|
|
474
|
+
"""
|
|
475
|
+
Download one file from a Hugging Face model repository.
|
|
476
|
+
|
|
477
|
+
The final destination is written atomically:
|
|
478
|
+
destination.part
|
|
479
|
+
destination.part.json
|
|
480
|
+
-> destination
|
|
481
|
+
|
|
482
|
+
Existing partial downloads are resumed when their remote metadata still
|
|
483
|
+
matches. If the revision/file changed, the stale partial is discarded.
|
|
484
|
+
|
|
485
|
+
Parameters
|
|
486
|
+
----------
|
|
487
|
+
progress:
|
|
488
|
+
Callback receiving DownloadProgress. It runs on the thread calling
|
|
489
|
+
`download`; for Kivy, marshal UI changes onto the UI thread.
|
|
490
|
+
cancelled:
|
|
491
|
+
Callback returning True when cancellation is requested. The .part
|
|
492
|
+
file is preserved, so calling download() later resumes it.
|
|
493
|
+
expected_sha256:
|
|
494
|
+
Optional explicit SHA-256 integrity check. Usually unnecessary for
|
|
495
|
+
ordinary HF use, but useful when your app ships a trusted catalog.
|
|
496
|
+
"""
|
|
497
|
+
# Public options account for eleven of the seventeen local names.
|
|
498
|
+
# pylint: disable=too-many-arguments,too-many-locals
|
|
499
|
+
dest = Path(destination)
|
|
500
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
501
|
+
part = dest.with_name(dest.name + ".part")
|
|
502
|
+
meta_path = dest.with_name(dest.name + ".part.json")
|
|
503
|
+
|
|
504
|
+
remote = self.file_info(repo_id, filename, revision=revision)
|
|
505
|
+
|
|
506
|
+
if dest.exists() and not overwrite:
|
|
507
|
+
self._check_existing(dest, remote, expected_sha256)
|
|
508
|
+
return dest
|
|
509
|
+
|
|
510
|
+
# Files above regular HF HTTP's supported range require Xet.
|
|
511
|
+
if remote.size is not None and remote.size > 50 * 1024**3:
|
|
512
|
+
raise HFError(
|
|
513
|
+
f"{filename!r} is larger than 50 GiB. Hugging Face does not "
|
|
514
|
+
"serve files of this size through its regular HTTP download "
|
|
515
|
+
"path; an Xet-capable client is required."
|
|
516
|
+
)
|
|
517
|
+
|
|
518
|
+
expected_meta = self._prepare_partial(part, meta_path, remote, resume)
|
|
519
|
+
|
|
520
|
+
# If a previous invocation finished the bytes but died before rename,
|
|
521
|
+
# finish locally without a network GET.
|
|
522
|
+
if remote.size is not None and part.stat().st_size == remote.size:
|
|
523
|
+
self._verify_and_commit(
|
|
524
|
+
part,
|
|
525
|
+
dest,
|
|
526
|
+
meta_path,
|
|
527
|
+
expected_size=remote.size,
|
|
528
|
+
expected_sha256=expected_sha256,
|
|
529
|
+
)
|
|
530
|
+
return dest
|
|
531
|
+
|
|
532
|
+
reporter = _ProgressReporter(remote, dest, progress, progress_interval)
|
|
533
|
+
reporter.start(part.stat().st_size)
|
|
534
|
+
self._download_with_retry(
|
|
535
|
+
part, meta_path, expected_meta, reporter, cancelled=cancelled, resume=resume
|
|
536
|
+
)
|
|
537
|
+
|
|
538
|
+
self._verify_and_commit(
|
|
539
|
+
part,
|
|
540
|
+
dest,
|
|
541
|
+
meta_path,
|
|
542
|
+
expected_size=remote.size,
|
|
543
|
+
expected_sha256=expected_sha256,
|
|
544
|
+
)
|
|
545
|
+
|
|
546
|
+
reporter.finish()
|
|
547
|
+
|
|
548
|
+
return dest
|
|
549
|
+
|
|
550
|
+
@staticmethod
|
|
551
|
+
def _check_existing(dest: Path, remote: RemoteFile, expected_sha256: str | None):
|
|
552
|
+
actual_size = dest.stat().st_size
|
|
553
|
+
if remote.size is not None and actual_size != remote.size:
|
|
554
|
+
raise HFIntegrityError(
|
|
555
|
+
f"Existing {dest.name} has {actual_size} bytes; expected "
|
|
556
|
+
f"{remote.size}. The file was left unchanged."
|
|
557
|
+
)
|
|
558
|
+
if expected_sha256 is not None:
|
|
559
|
+
actual_sha256 = HFClient._sha256(dest)
|
|
560
|
+
if actual_sha256.lower() != expected_sha256.lower():
|
|
561
|
+
raise HFIntegrityError(
|
|
562
|
+
f"Existing {dest.name} has SHA-256 {actual_sha256}; "
|
|
563
|
+
f"expected {expected_sha256}. The file was left unchanged."
|
|
564
|
+
)
|
|
565
|
+
|
|
566
|
+
def _prepare_partial(self, part, meta_path, remote, resume):
|
|
567
|
+
expected_meta = _PartialMeta(
|
|
568
|
+
repo_id=remote.repo_id,
|
|
569
|
+
filename=remote.filename,
|
|
570
|
+
revision=remote.revision,
|
|
571
|
+
etag=remote.etag,
|
|
572
|
+
commit=remote.commit,
|
|
573
|
+
expected_size=remote.size,
|
|
574
|
+
)
|
|
575
|
+
|
|
576
|
+
if not resume:
|
|
577
|
+
self._remove_partial(part, meta_path)
|
|
578
|
+
else:
|
|
579
|
+
self._validate_partial(part, meta_path, expected_meta)
|
|
580
|
+
|
|
581
|
+
if not part.exists():
|
|
582
|
+
part.touch()
|
|
583
|
+
_atomic_json_write(meta_path, asdict(expected_meta))
|
|
584
|
+
|
|
585
|
+
return expected_meta
|
|
586
|
+
|
|
587
|
+
def _download_with_retry( # noqa: PLR0913
|
|
588
|
+
self, part, meta_path, expected_meta, reporter, *, cancelled, resume
|
|
589
|
+
):
|
|
590
|
+
# Retry state includes partial provenance and caller cancellation policy.
|
|
591
|
+
# pylint: disable=too-many-arguments
|
|
592
|
+
remote = reporter.remote
|
|
593
|
+
filename = remote.filename
|
|
594
|
+
attempt = 0
|
|
595
|
+
while True:
|
|
596
|
+
if cancelled and cancelled():
|
|
597
|
+
raise HFCancelled(f"Download cancelled: {filename}")
|
|
598
|
+
|
|
599
|
+
current = part.stat().st_size
|
|
600
|
+
if remote.size is not None and current > remote.size:
|
|
601
|
+
self._remove_partial(part, meta_path)
|
|
602
|
+
part.touch()
|
|
603
|
+
_atomic_json_write(meta_path, asdict(expected_meta))
|
|
604
|
+
current = 0
|
|
605
|
+
|
|
606
|
+
range_headers: dict[str, str] = {}
|
|
607
|
+
if resume and current > 0:
|
|
608
|
+
range_headers["Range"] = f"bytes={current}-"
|
|
609
|
+
|
|
610
|
+
try:
|
|
611
|
+
response = self.session.get(
|
|
612
|
+
remote.url,
|
|
613
|
+
headers=self._headers(range_headers),
|
|
614
|
+
stream=True,
|
|
615
|
+
allow_redirects=True,
|
|
616
|
+
timeout=self.timeout,
|
|
617
|
+
)
|
|
618
|
+
|
|
619
|
+
# A stale/exact-complete range can produce 416.
|
|
620
|
+
if response.status_code == HTTPStatus.REQUESTED_RANGE_NOT_SATISFIABLE:
|
|
621
|
+
if remote.size is not None and current == remote.size:
|
|
622
|
+
response.close()
|
|
623
|
+
break
|
|
624
|
+
response.close()
|
|
625
|
+
# Do not append to something the server rejects.
|
|
626
|
+
self._remove_partial(part, meta_path)
|
|
627
|
+
part.touch()
|
|
628
|
+
_atomic_json_write(meta_path, asdict(expected_meta))
|
|
629
|
+
attempt += 1
|
|
630
|
+
if attempt > self.max_retries:
|
|
631
|
+
raise HFIntegrityError(
|
|
632
|
+
f"Server rejected resume range for {filename!r}."
|
|
633
|
+
)
|
|
634
|
+
continue
|
|
635
|
+
|
|
636
|
+
if response.status_code in _RETRYABLE_STATUS:
|
|
637
|
+
delay = _retry_after(response)
|
|
638
|
+
response.close()
|
|
639
|
+
if attempt >= self.max_retries:
|
|
640
|
+
_raise_download_error(response, filename)
|
|
641
|
+
self._sleep_backoff(attempt, delay)
|
|
642
|
+
attempt += 1
|
|
643
|
+
continue
|
|
644
|
+
|
|
645
|
+
self._raise_hf_error(
|
|
646
|
+
response,
|
|
647
|
+
repo_id=remote.repo_id,
|
|
648
|
+
filename=filename,
|
|
649
|
+
)
|
|
650
|
+
|
|
651
|
+
self._stream_response(response, part, reporter, cancelled)
|
|
652
|
+
|
|
653
|
+
# A cleanly closed response should be complete. Verify below.
|
|
654
|
+
break
|
|
655
|
+
|
|
656
|
+
except (
|
|
657
|
+
requests.ConnectionError,
|
|
658
|
+
requests.Timeout,
|
|
659
|
+
requests.exceptions.ChunkedEncodingError,
|
|
660
|
+
) as exc:
|
|
661
|
+
if attempt >= self.max_retries:
|
|
662
|
+
raise HFError(
|
|
663
|
+
f"Network error while downloading {filename!r} after "
|
|
664
|
+
f"{self.max_retries + 1} attempts: {exc}"
|
|
665
|
+
) from exc
|
|
666
|
+
self._sleep_backoff(attempt)
|
|
667
|
+
attempt += 1
|
|
668
|
+
# Loop reopens .part and resumes from its current byte count.
|
|
669
|
+
|
|
670
|
+
def _stream_response(self, response, part, reporter, cancelled):
|
|
671
|
+
current = part.stat().st_size
|
|
672
|
+
# Never append a full response when the server ignores Range.
|
|
673
|
+
if current > 0 and response.status_code == HTTPStatus.PARTIAL_CONTENT:
|
|
674
|
+
mode = "ab"
|
|
675
|
+
else:
|
|
676
|
+
mode = "wb"
|
|
677
|
+
current = 0
|
|
678
|
+
|
|
679
|
+
with response, part.open(mode) as fp:
|
|
680
|
+
for chunk in response.iter_content(chunk_size=self.chunk_size):
|
|
681
|
+
if cancelled and cancelled():
|
|
682
|
+
fp.flush()
|
|
683
|
+
try:
|
|
684
|
+
os.fsync(fp.fileno())
|
|
685
|
+
except OSError:
|
|
686
|
+
pass
|
|
687
|
+
raise HFCancelled(f"Download cancelled: {reporter.remote.filename}")
|
|
688
|
+
if not chunk:
|
|
689
|
+
continue
|
|
690
|
+
fp.write(chunk)
|
|
691
|
+
current += len(chunk)
|
|
692
|
+
reporter.report(current)
|
|
693
|
+
|
|
694
|
+
def _request_with_retry(
|
|
695
|
+
self, method: str, url: str, **kwargs: Any
|
|
696
|
+
) -> requests.Response:
|
|
697
|
+
last_exc: BaseException | None = None
|
|
698
|
+
|
|
699
|
+
for attempt in range(self.max_retries + 1):
|
|
700
|
+
try:
|
|
701
|
+
response = self.session.request(
|
|
702
|
+
method,
|
|
703
|
+
url,
|
|
704
|
+
timeout=self.timeout,
|
|
705
|
+
**kwargs,
|
|
706
|
+
)
|
|
707
|
+
except (requests.ConnectionError, requests.Timeout) as exc:
|
|
708
|
+
last_exc = exc
|
|
709
|
+
if attempt >= self.max_retries:
|
|
710
|
+
break
|
|
711
|
+
self._sleep_backoff(attempt)
|
|
712
|
+
continue
|
|
713
|
+
|
|
714
|
+
if response.status_code not in _RETRYABLE_STATUS:
|
|
715
|
+
return response
|
|
716
|
+
|
|
717
|
+
if attempt >= self.max_retries:
|
|
718
|
+
return response
|
|
719
|
+
|
|
720
|
+
delay = _retry_after(response)
|
|
721
|
+
response.close()
|
|
722
|
+
self._sleep_backoff(attempt, delay)
|
|
723
|
+
|
|
724
|
+
raise HFError(f"Network request failed: {last_exc}") from last_exc
|
|
725
|
+
|
|
726
|
+
def _sleep_backoff(self, attempt: int, explicit: float | None = None) -> None:
|
|
727
|
+
if explicit is not None:
|
|
728
|
+
delay = explicit
|
|
729
|
+
else:
|
|
730
|
+
delay = self.backoff * (2**attempt)
|
|
731
|
+
delay *= random.uniform(0.8, 1.2)
|
|
732
|
+
time.sleep(min(delay, 30.0))
|
|
733
|
+
|
|
734
|
+
def _validate_partial(
|
|
735
|
+
self,
|
|
736
|
+
part: Path,
|
|
737
|
+
meta_path: Path,
|
|
738
|
+
expected: _PartialMeta,
|
|
739
|
+
) -> None:
|
|
740
|
+
if not part.exists():
|
|
741
|
+
if meta_path.exists():
|
|
742
|
+
meta_path.unlink()
|
|
743
|
+
return
|
|
744
|
+
|
|
745
|
+
if not meta_path.exists():
|
|
746
|
+
# Unknown provenance: unsafe to resume.
|
|
747
|
+
part.unlink()
|
|
748
|
+
return
|
|
749
|
+
|
|
750
|
+
try:
|
|
751
|
+
saved_raw = json.loads(meta_path.read_text(encoding="utf-8"))
|
|
752
|
+
saved = _PartialMeta(**saved_raw)
|
|
753
|
+
except Exception:
|
|
754
|
+
self._remove_partial(part, meta_path)
|
|
755
|
+
return
|
|
756
|
+
|
|
757
|
+
same_identity = (
|
|
758
|
+
saved.repo_id == expected.repo_id
|
|
759
|
+
and saved.filename == expected.filename
|
|
760
|
+
and saved.revision == expected.revision
|
|
761
|
+
)
|
|
762
|
+
|
|
763
|
+
# Prefer commit/etag as version identity. If HF didn't expose either,
|
|
764
|
+
# size still gives a weak but useful guard.
|
|
765
|
+
version_matches = True
|
|
766
|
+
if expected.commit and saved.commit:
|
|
767
|
+
version_matches = expected.commit == saved.commit
|
|
768
|
+
elif expected.etag and saved.etag:
|
|
769
|
+
version_matches = expected.etag == saved.etag
|
|
770
|
+
elif expected.expected_size is not None and saved.expected_size is not None:
|
|
771
|
+
version_matches = expected.expected_size == saved.expected_size
|
|
772
|
+
|
|
773
|
+
if not same_identity or not version_matches:
|
|
774
|
+
self._remove_partial(part, meta_path)
|
|
775
|
+
|
|
776
|
+
@staticmethod
|
|
777
|
+
def _remove_partial(part: Path, meta_path: Path) -> None:
|
|
778
|
+
for path in (part, meta_path):
|
|
779
|
+
try:
|
|
780
|
+
path.unlink()
|
|
781
|
+
except FileNotFoundError:
|
|
782
|
+
pass
|
|
783
|
+
|
|
784
|
+
@staticmethod
|
|
785
|
+
def _sha256(path: Path) -> str:
|
|
786
|
+
digest = hashlib.sha256()
|
|
787
|
+
with path.open("rb") as fp:
|
|
788
|
+
while True:
|
|
789
|
+
chunk = fp.read(1024 * 1024)
|
|
790
|
+
if not chunk:
|
|
791
|
+
break
|
|
792
|
+
digest.update(chunk)
|
|
793
|
+
return digest.hexdigest()
|
|
794
|
+
|
|
795
|
+
def _verify_and_commit(
|
|
796
|
+
self,
|
|
797
|
+
part: Path,
|
|
798
|
+
dest: Path,
|
|
799
|
+
meta_path: Path,
|
|
800
|
+
*,
|
|
801
|
+
expected_size: int | None,
|
|
802
|
+
expected_sha256: str | None,
|
|
803
|
+
) -> None:
|
|
804
|
+
# Paths and integrity expectations are explicit at the commit boundary.
|
|
805
|
+
# pylint: disable=too-many-arguments
|
|
806
|
+
actual_size = part.stat().st_size
|
|
807
|
+
|
|
808
|
+
if expected_size is not None and actual_size != expected_size:
|
|
809
|
+
raise HFIntegrityError(
|
|
810
|
+
f"Incomplete download: expected {expected_size} bytes, "
|
|
811
|
+
f"got {actual_size} bytes. Partial file was kept for resume."
|
|
812
|
+
)
|
|
813
|
+
|
|
814
|
+
if expected_sha256 is not None:
|
|
815
|
+
actual = self._sha256(part)
|
|
816
|
+
if actual.lower() != expected_sha256.lower():
|
|
817
|
+
raise HFIntegrityError(
|
|
818
|
+
f"SHA-256 mismatch for {dest.name}: expected "
|
|
819
|
+
f"{expected_sha256}, got {actual}. Partial file was kept."
|
|
820
|
+
)
|
|
821
|
+
|
|
822
|
+
os.replace(part, dest)
|
|
823
|
+
try:
|
|
824
|
+
meta_path.unlink()
|
|
825
|
+
except FileNotFoundError:
|
|
826
|
+
pass
|
|
827
|
+
|
|
828
|
+
def _raise_hf_error(
|
|
829
|
+
self,
|
|
830
|
+
response: requests.Response,
|
|
831
|
+
*,
|
|
832
|
+
repo_id: str | None = None,
|
|
833
|
+
filename: str | None = None,
|
|
834
|
+
) -> None:
|
|
835
|
+
status = response.status_code
|
|
836
|
+
if HTTPStatus.OK <= status < HTTPStatus.BAD_REQUEST:
|
|
837
|
+
return
|
|
838
|
+
|
|
839
|
+
request_id = response.headers.get("X-Request-Id") or response.headers.get(
|
|
840
|
+
"X-Amzn-Trace-Id"
|
|
841
|
+
)
|
|
842
|
+
|
|
843
|
+
body = ""
|
|
844
|
+
try:
|
|
845
|
+
body = response.text[:4096]
|
|
846
|
+
except Exception:
|
|
847
|
+
pass
|
|
848
|
+
|
|
849
|
+
lower = body.lower()
|
|
850
|
+
subject = repo_id or "Hugging Face resource"
|
|
851
|
+
if filename:
|
|
852
|
+
subject = f"{subject}/{filename}"
|
|
853
|
+
|
|
854
|
+
suffix = f" [request id: {request_id}]" if request_id else ""
|
|
855
|
+
|
|
856
|
+
if status == HTTPStatus.TOO_MANY_REQUESTS:
|
|
857
|
+
raise HFRateLimitError(
|
|
858
|
+
f"Hugging Face rate limit exceeded for {subject}.{suffix}"
|
|
859
|
+
)
|
|
860
|
+
|
|
861
|
+
# HF gated-resource responses are commonly 401/403 with an explanatory
|
|
862
|
+
# body. Detect this before the generic auth case.
|
|
863
|
+
if status in (401, 403) and (
|
|
864
|
+
"gated" in lower
|
|
865
|
+
or "access to model" in lower
|
|
866
|
+
or "access to this model" in lower
|
|
867
|
+
or "request access" in lower
|
|
868
|
+
):
|
|
869
|
+
model_page = self.model_url(repo_id) if repo_id else self.endpoint
|
|
870
|
+
raise HFGatedError(
|
|
871
|
+
f"Access to {subject} is gated. The signed-in Hugging Face user "
|
|
872
|
+
f"must be granted/accept access first: {model_page}{suffix}"
|
|
873
|
+
)
|
|
874
|
+
|
|
875
|
+
if status in (401, 403):
|
|
876
|
+
if self.token:
|
|
877
|
+
raise HFAuthError(
|
|
878
|
+
f"Hugging Face denied access to {subject}. The token may "
|
|
879
|
+
f"be invalid or lack permission.{suffix}"
|
|
880
|
+
)
|
|
881
|
+
raise HFAuthError(
|
|
882
|
+
f"{subject} requires Hugging Face authentication.{suffix}"
|
|
883
|
+
)
|
|
884
|
+
|
|
885
|
+
if status == HTTPStatus.NOT_FOUND:
|
|
886
|
+
raise HFNotFoundError(
|
|
887
|
+
f"Hugging Face repository, revision, or file not found: "
|
|
888
|
+
f"{subject}.{suffix}"
|
|
889
|
+
)
|
|
890
|
+
|
|
891
|
+
message = f"Hugging Face returned HTTP {status} for {subject}"
|
|
892
|
+
if body:
|
|
893
|
+
# Keep server text useful but bounded.
|
|
894
|
+
compact = " ".join(body.split())
|
|
895
|
+
message += f": {compact[:500]}"
|
|
896
|
+
raise HFError(message + suffix)
|
|
897
|
+
|
|
898
|
+
|
|
899
|
+
# Keep the convenience wrapper compatible with existing callers.
|
|
900
|
+
def download_hf_model( # noqa: PLR0913
|
|
901
|
+
repo_id: str,
|
|
902
|
+
filename: str,
|
|
903
|
+
*,
|
|
904
|
+
revision: str = "main",
|
|
905
|
+
token: str | None = None,
|
|
906
|
+
destination: str | os.PathLike[str] | None = None,
|
|
907
|
+
progress: Callable[[DownloadProgress], None] | None = None,
|
|
908
|
+
cancelled: Callable[[], bool] | None = None,
|
|
909
|
+
overwrite: bool = False,
|
|
910
|
+
expected_sha256: str | None = None,
|
|
911
|
+
) -> str:
|
|
912
|
+
"""Download one Hugging Face model file and return its local path as a string."""
|
|
913
|
+
|
|
914
|
+
# pylint: disable=too-many-arguments
|
|
915
|
+
client = HFClient(token=token)
|
|
916
|
+
remote = client.file_info(repo_id, filename, revision=revision)
|
|
917
|
+
lfs_info = client.git_lfs_info(repo_id, filename, revision=revision)
|
|
918
|
+
if lfs_info is not None:
|
|
919
|
+
if remote.size is not None and lfs_info["size"] != remote.size:
|
|
920
|
+
raise HFIntegrityError(
|
|
921
|
+
f"Conflicting sizes for {filename}: download metadata reports "
|
|
922
|
+
f"{remote.size}, Git LFS reports {lfs_info['size']}"
|
|
923
|
+
)
|
|
924
|
+
if expected_sha256 is None:
|
|
925
|
+
expected_sha256 = lfs_info["sha256"]
|
|
926
|
+
|
|
927
|
+
if destination is None:
|
|
928
|
+
destination = model_path(repo_id, filename)
|
|
929
|
+
else:
|
|
930
|
+
destination = Path(destination)
|
|
931
|
+
|
|
932
|
+
path = client.download(
|
|
933
|
+
repo_id,
|
|
934
|
+
filename,
|
|
935
|
+
destination,
|
|
936
|
+
revision=revision,
|
|
937
|
+
overwrite=overwrite,
|
|
938
|
+
progress=progress,
|
|
939
|
+
cancelled=cancelled,
|
|
940
|
+
expected_sha256=expected_sha256,
|
|
941
|
+
)
|
|
942
|
+
return str(path)
|
|
943
|
+
|
|
944
|
+
|
|
945
|
+
def format_bytes(value: int | None) -> str:
|
|
946
|
+
if value is None:
|
|
947
|
+
return "?"
|
|
948
|
+
n = float(value)
|
|
949
|
+
for unit in ("B", "KiB", "MiB", "GiB", "TiB"):
|
|
950
|
+
if n < _BYTES_PER_KIB or unit == "TiB":
|
|
951
|
+
return f"{n:.1f} {unit}" if unit != "B" else f"{int(n)} B"
|
|
952
|
+
n /= _BYTES_PER_KIB
|
|
953
|
+
return f"{n:.1f} TiB"
|
|
954
|
+
|
|
955
|
+
|
|
956
|
+
def format_eta(seconds: float | None) -> str:
|
|
957
|
+
if seconds is None:
|
|
958
|
+
return "?"
|
|
959
|
+
seconds = max(0, int(seconds))
|
|
960
|
+
if seconds < _SECONDS_PER_MINUTE:
|
|
961
|
+
return f"{seconds}s"
|
|
962
|
+
minutes, sec = divmod(seconds, _SECONDS_PER_MINUTE)
|
|
963
|
+
if minutes < _MINUTES_PER_HOUR:
|
|
964
|
+
return f"{minutes}m {sec:02d}s"
|
|
965
|
+
hours, minutes = divmod(minutes, _MINUTES_PER_HOUR)
|
|
966
|
+
return f"{hours}h {minutes:02d}m"
|
|
967
|
+
|
|
968
|
+
|
|
969
|
+
if __name__ == "__main__":
|
|
970
|
+
import argparse
|
|
971
|
+
|
|
972
|
+
parser = argparse.ArgumentParser(description="Download one file from Hugging Face.")
|
|
973
|
+
parser.add_argument("repo_id")
|
|
974
|
+
parser.add_argument("filename")
|
|
975
|
+
parser.add_argument("destination")
|
|
976
|
+
parser.add_argument("--revision", default="main")
|
|
977
|
+
parser.add_argument("--token", default=os.environ.get("HF_TOKEN"))
|
|
978
|
+
args = parser.parse_args()
|
|
979
|
+
|
|
980
|
+
cli_client = HFClient(token=args.token)
|
|
981
|
+
|
|
982
|
+
def show(p: DownloadProgress) -> None:
|
|
983
|
+
pct = f"{p.percent:5.1f}%" if p.percent is not None else " ? "
|
|
984
|
+
print(
|
|
985
|
+
f"\r{pct} {format_bytes(p.downloaded)} / {format_bytes(p.total)} "
|
|
986
|
+
f"{p.speed_mbps:.1f} MiB/s ETA {format_eta(p.eta)}",
|
|
987
|
+
end="",
|
|
988
|
+
flush=True,
|
|
989
|
+
)
|
|
990
|
+
|
|
991
|
+
result = cli_client.download(
|
|
992
|
+
args.repo_id,
|
|
993
|
+
args.filename,
|
|
994
|
+
args.destination,
|
|
995
|
+
revision=args.revision,
|
|
996
|
+
progress=show,
|
|
997
|
+
)
|
|
998
|
+
print(f"\nSaved to {result}")
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
"""Find ML model files managed by PythonHere."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import sys
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def _android_models_directory():
|
|
8
|
+
"""Return the model directory belonging to the running Android app."""
|
|
9
|
+
|
|
10
|
+
from jnius import autoclass
|
|
11
|
+
|
|
12
|
+
PythonActivity = autoclass("org.kivy.android.PythonActivity")
|
|
13
|
+
activity = PythonActivity.mActivity
|
|
14
|
+
if activity is None:
|
|
15
|
+
raise RuntimeError("A running PythonHere activity is required")
|
|
16
|
+
|
|
17
|
+
external_files = activity.getExternalFilesDir(None)
|
|
18
|
+
if external_files is None:
|
|
19
|
+
raise RuntimeError("Android external app storage is unavailable")
|
|
20
|
+
|
|
21
|
+
return os.path.join(str(external_files.getAbsolutePath()), "models")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _is_android():
|
|
25
|
+
return sys.platform == "android" or "ANDROID_ARGUMENT" in os.environ
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def models_directory(create=False):
|
|
29
|
+
"""Return PythonHere's platform-specific model directory.
|
|
30
|
+
|
|
31
|
+
Android uses the app-specific external files directory. Linux uses
|
|
32
|
+
PythonHere's per-user application data directory.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
if _is_android():
|
|
36
|
+
path = _android_models_directory()
|
|
37
|
+
else:
|
|
38
|
+
path = os.path.expanduser("~/.local/share/pythonhere/models")
|
|
39
|
+
|
|
40
|
+
path = os.path.abspath(path)
|
|
41
|
+
if create:
|
|
42
|
+
os.makedirs(path, exist_ok=True)
|
|
43
|
+
return path
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _relative_parts(value, label):
|
|
47
|
+
"""Return safe POSIX-style path components for a remote identifier."""
|
|
48
|
+
|
|
49
|
+
parts = value.split("/")
|
|
50
|
+
if not value or any(part in {"", ".", ".."} for part in parts):
|
|
51
|
+
raise ValueError(f"{label} must be a relative path without dot components")
|
|
52
|
+
return parts
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def model_path(repo_id, filename):
|
|
56
|
+
"""Return the local path for a model identified by repository and file."""
|
|
57
|
+
|
|
58
|
+
path = os.path.join(
|
|
59
|
+
models_directory(),
|
|
60
|
+
*_relative_parts(repo_id, "repo_id"),
|
|
61
|
+
*_relative_parts(filename, "filename"),
|
|
62
|
+
)
|
|
63
|
+
return os.path.abspath(path)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def require_model(repo_id, filename):
|
|
67
|
+
"""Return an installed model path, or raise ``FileNotFoundError``."""
|
|
68
|
+
|
|
69
|
+
path = model_path(repo_id, filename)
|
|
70
|
+
if not os.path.isfile(path):
|
|
71
|
+
raise FileNotFoundError(
|
|
72
|
+
f"ML model is not installed: {repo_id}/{filename} ({path})"
|
|
73
|
+
)
|
|
74
|
+
return path
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _model_extensions(extensions):
|
|
78
|
+
"""Normalize an optional model-extension filter."""
|
|
79
|
+
|
|
80
|
+
if extensions is None:
|
|
81
|
+
return None
|
|
82
|
+
if isinstance(extensions, str):
|
|
83
|
+
extensions = (extensions,)
|
|
84
|
+
|
|
85
|
+
normalized = set()
|
|
86
|
+
for extension in extensions:
|
|
87
|
+
if not isinstance(extension, str) or not extension:
|
|
88
|
+
raise ValueError("extensions must contain non-empty strings")
|
|
89
|
+
normalized_extension = extension.lower()
|
|
90
|
+
normalized.add(
|
|
91
|
+
normalized_extension
|
|
92
|
+
if normalized_extension.startswith(".")
|
|
93
|
+
else f".{normalized_extension}"
|
|
94
|
+
)
|
|
95
|
+
return tuple(sorted(normalized))
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _discover_directory(directory, extensions):
|
|
99
|
+
"""Yield model files recursively beneath a directory."""
|
|
100
|
+
|
|
101
|
+
path = os.path.abspath(os.fspath(directory))
|
|
102
|
+
if not os.path.isdir(path):
|
|
103
|
+
return
|
|
104
|
+
|
|
105
|
+
def ignore_error(_error):
|
|
106
|
+
pass
|
|
107
|
+
|
|
108
|
+
for root, directories, files in os.walk(path, onerror=ignore_error):
|
|
109
|
+
directories.sort(key=str.casefold)
|
|
110
|
+
for filename in files:
|
|
111
|
+
lower_filename = filename.lower()
|
|
112
|
+
if lower_filename.endswith((".part", ".part.json")):
|
|
113
|
+
continue
|
|
114
|
+
if extensions is None or lower_filename.endswith(extensions):
|
|
115
|
+
yield os.path.abspath(os.path.join(root, filename))
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def discover_models(extra_directories=(), *, extensions=".litertlm"):
|
|
119
|
+
"""Find local model files, optionally filtered by filename extension.
|
|
120
|
+
|
|
121
|
+
``extensions`` may be one extension or an iterable, with or without the
|
|
122
|
+
leading dot. The default finds LiteRT-LM models. Pass ``None`` to return
|
|
123
|
+
all completed files. Temporary files belonging to an in-progress download
|
|
124
|
+
are always ignored.
|
|
125
|
+
"""
|
|
126
|
+
|
|
127
|
+
if isinstance(extra_directories, (str, bytes, os.PathLike)):
|
|
128
|
+
extra_directories = (extra_directories,)
|
|
129
|
+
extensions = _model_extensions(extensions)
|
|
130
|
+
|
|
131
|
+
models = []
|
|
132
|
+
seen = set()
|
|
133
|
+
for directory in (models_directory(), *extra_directories):
|
|
134
|
+
for discovered_path in _discover_directory(directory, extensions):
|
|
135
|
+
key = os.path.normcase(discovered_path)
|
|
136
|
+
if key not in seen:
|
|
137
|
+
seen.add(key)
|
|
138
|
+
models.append(discovered_path)
|
|
139
|
+
return sorted(
|
|
140
|
+
models,
|
|
141
|
+
key=lambda path: (os.path.basename(path).casefold(), path.casefold()),
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
__all__ = ("discover_models", "model_path", "models_directory", "require_model")
|
pythonhere/version_here.py
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
__version__ = "0.
|
|
1
|
+
__version__ = "0.4.0"
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: pythonhere
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.0
|
|
4
4
|
Summary: Here is the Kivy based app to run code from the Jupyter magic %there
|
|
5
5
|
Author-email: b3b <ash.b3b@gmail.com>
|
|
6
6
|
License-Expression: MIT
|
|
@@ -20,6 +20,9 @@ Requires-Dist: herethere[magic]>=0.3.2
|
|
|
20
20
|
Requires-Dist: ipython
|
|
21
21
|
Requires-Dist: ipywidgets
|
|
22
22
|
Requires-Dist: Pillow
|
|
23
|
+
Provides-Extra: litert
|
|
24
|
+
Requires-Dist: litert-lm-api==0.16.0; extra == "litert"
|
|
25
|
+
Requires-Dist: requests; extra == "litert"
|
|
23
26
|
Dynamic: license-file
|
|
24
27
|
|
|
25
28
|
PythonHere
|
|
@@ -10,9 +10,9 @@ pythonhere/patches_here.py,sha256=zB7Ohbk5PIXt0imnnrV5Mtl-Y7jpD_p5IBPlijQcCCQ,17
|
|
|
10
10
|
pythonhere/pythonhere.kv,sha256=x1xwiKvJAevqBhtXGLb_axIf-VRos4V_jM9Q2i44f2U,1453
|
|
11
11
|
pythonhere/server_here.py,sha256=dhNMJj1Abz6RZq6SGqkh8-PVOxKThpI519Msoy5WCgM,1862
|
|
12
12
|
pythonhere/tools_here.py,sha256=RWPBX309d7E4mjkiszzsoJwdaSwXZIqb7ODcRBljC0Y,11437
|
|
13
|
-
pythonhere/version_here.py,sha256=
|
|
13
|
+
pythonhere/version_here.py,sha256=42STGor_9nKYXumfeV5tiyD_M8VdcddX7CEexmibPBk,22
|
|
14
14
|
pythonhere/window_here.py,sha256=oFPoNMGafGo4Eij3iU1WwaftmRa05Thm1jzuEAQIwK8,2584
|
|
15
|
-
pythonhere/.agents/skills/pythonhere/SKILL.md,sha256=
|
|
15
|
+
pythonhere/.agents/skills/pythonhere/SKILL.md,sha256=sZruiB7DaMeFLNAm2YLrPE7emmHELj2SEieMfxGrGJw,11080
|
|
16
16
|
pythonhere/.agents/skills/pythonhere/agents/openai.yaml,sha256=LuAs_jPFRl9Mhvb8bU8E8JSnCc8uc5JYFqYxlhd5elw,210
|
|
17
17
|
pythonhere/.agents/skills/pythonhere/references/able.md,sha256=3KFblzcGy8VDmriqgcWtqyfZZXoYfDV7kZyKIESCZ_c,22918
|
|
18
18
|
pythonhere/.agents/skills/pythonhere/references/android-media.md,sha256=vM55sxJmvkLQmKAjHZYL_z0F0SR__PjOHSXtGIgP1BI,6631
|
|
@@ -22,6 +22,7 @@ pythonhere/.agents/skills/pythonhere/references/android-runtime.md,sha256=VCZkyw
|
|
|
22
22
|
pythonhere/.agents/skills/pythonhere/references/jnius.md,sha256=lyjdGUsZXw2lS-0eppS2-JNTVgEED30z1DglzT6H0D8,14999
|
|
23
23
|
pythonhere/.agents/skills/pythonhere/references/kivy-kv.md,sha256=9PQR4j-LT66cOUKYCM6AuHXIs5ywmutOQKfxr3frDYw,8546
|
|
24
24
|
pythonhere/.agents/skills/pythonhere/references/kivy-runtime.md,sha256=ggPCmea5lkdg4p6LlxIVUstJhxC40SbrysqXXQ7-lmM,14616
|
|
25
|
+
pythonhere/.agents/skills/pythonhere/references/litert.md,sha256=MAchOo-acMlSwkmtSkAizNMdwKJHzIe87gFyzq6hyZE,4238
|
|
25
26
|
pythonhere/.agents/skills/pythonhere/references/midi.md,sha256=1rB4t7LWnMi139Ou896FzgU-6CGFSNVO1QF9kRBvUgo,10934
|
|
26
27
|
pythonhere/.agents/skills/pythonhere/references/plyer.md,sha256=FYqumbRo826aLrOiRGckS8Oq26v_PoVrSTVbTBZRBs8,6713
|
|
27
28
|
pythonhere/data/logo/logo-128.png,sha256=4sitDJcVBHOU5UPxicmrzc6YqFY7_xtYrc_ULyrw9ks,4161
|
|
@@ -41,6 +42,9 @@ pythonhere/magic_here/prompts/kivy-kv.md,sha256=ajIDSo6PG1_7gHcgfNrOARQOUlUEPUae
|
|
|
41
42
|
pythonhere/magic_here/prompts/kivy-runtime.md,sha256=cAgOF0uE3RKYdKQus7h4lklKtANaLPcOYilGShjNtgM,14621
|
|
42
43
|
pythonhere/magic_here/prompts/midi.md,sha256=2rKvgrwlmeA4MfdnRH9xnTSN0OFOm4mTxtgdYdMAd3Q,10611
|
|
43
44
|
pythonhere/magic_here/prompts/plyer.md,sha256=wplCHPXkN1vU_thGIxvALuYPB6QDdjCHFhoc93ppvAE,6708
|
|
45
|
+
pythonhere/ml_here/__init__.py,sha256=TiAtiBcGl5jkTmm3Rbt1mlPWRFo4BQTcgHHAzYOs_i0,346
|
|
46
|
+
pythonhere/ml_here/huggingface.py,sha256=857iOkJh90jbxhwyTo1EJ2enVwql3rI8-wUvK04UM9s,33030
|
|
47
|
+
pythonhere/ml_here/storage.py,sha256=JlKIad1bjVR7OSY2KFkyujyQA0dTH2DQPFJvFGH7AEE,4606
|
|
44
48
|
pythonhere/ui_here/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
45
49
|
pythonhere/ui_here/actionbar_here.kv,sha256=cOjY4ByOyBDUbeS9kbI3JAhi_X3kJ3Pb5h4RL58urwU,214
|
|
46
50
|
pythonhere/ui_here/common_here.kv,sha256=vnNyDwAKKNbRKvh0QKMbTvR0jNDKiY8DdGRnoEEexSc,324
|
|
@@ -51,8 +55,8 @@ pythonhere/ui_here/server_screen_here.kv,sha256=x0Se8hmgjS57gFIPa1r2lf6yJchs3lXO
|
|
|
51
55
|
pythonhere/ui_here/server_screen_here.py,sha256=2zQv1YFmiKNxa__7UZDYbAAAwYFLoZPSZPhr7vjCAkU,925
|
|
52
56
|
pythonhere/ui_here/settings_here.kv,sha256=stMcDq8dCYjay4idK30_7EUu5edkMDAQVPv0H0GuAcM,710
|
|
53
57
|
pythonhere/ui_here/settings_here.py,sha256=7NflZBUqY2ANZaSEuubaX9wIKniqbekcNQkUbqhhmto,4093
|
|
54
|
-
pythonhere-0.
|
|
55
|
-
pythonhere-0.
|
|
56
|
-
pythonhere-0.
|
|
57
|
-
pythonhere-0.
|
|
58
|
-
pythonhere-0.
|
|
58
|
+
pythonhere-0.4.0.dist-info/licenses/LICENSE,sha256=nW9_eVi3dSMOkr6xghsobAovBEJiffjl_5WjujJTa74,1071
|
|
59
|
+
pythonhere-0.4.0.dist-info/METADATA,sha256=_Fwxkb2YFoMe4IHOHzOaRLEmWsHjUi8A6JGiLKzN3-4,5470
|
|
60
|
+
pythonhere-0.4.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
61
|
+
pythonhere-0.4.0.dist-info/top_level.txt,sha256=dvkfRGF1tFbkjXzD9vwqXTge1Znkv7ga5fRJy5yhJsE,11
|
|
62
|
+
pythonhere-0.4.0.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|