heulistic 0.1.0__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.
- heulistic-0.1.0/.gitignore +43 -0
- heulistic-0.1.0/LICENSE +21 -0
- heulistic-0.1.0/PKG-INFO +169 -0
- heulistic-0.1.0/README.md +144 -0
- heulistic-0.1.0/heulistic/__init__.py +0 -0
- heulistic-0.1.0/heulistic/__main__.py +11 -0
- heulistic-0.1.0/heulistic/client.py +161 -0
- heulistic-0.1.0/heulistic/download_cmd.py +92 -0
- heulistic-0.1.0/heulistic/gpu.py +90 -0
- heulistic-0.1.0/heulistic/init_cmd.py +152 -0
- heulistic-0.1.0/heulistic/local_compose.yml +101 -0
- heulistic-0.1.0/heulistic/local_runner.py +124 -0
- heulistic-0.1.0/heulistic/main.py +72 -0
- heulistic-0.1.0/heulistic/preflight.py +86 -0
- heulistic-0.1.0/heulistic/stack.py +176 -0
- heulistic-0.1.0/heulistic/status_cmd.py +99 -0
- heulistic-0.1.0/heulistic/train_cmd.py +137 -0
- heulistic-0.1.0/heulistic/validate_cmd.py +148 -0
- heulistic-0.1.0/pyproject.toml +47 -0
- heulistic-0.1.0/tests/test_client.py +145 -0
- heulistic-0.1.0/tests/test_commands.py +215 -0
- heulistic-0.1.0/tests/test_download_cmd.py +111 -0
- heulistic-0.1.0/tests/test_gpu.py +89 -0
- heulistic-0.1.0/tests/test_init_cmd.py +187 -0
- heulistic-0.1.0/tests/test_stack.py +190 -0
- heulistic-0.1.0/tests/test_train_cmd.py +181 -0
- heulistic-0.1.0/tests/test_validate_config.py +214 -0
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# Secrets — never commit actual env files
|
|
2
|
+
.env
|
|
3
|
+
.env.local
|
|
4
|
+
*.env.local
|
|
5
|
+
.env.local.example
|
|
6
|
+
|
|
7
|
+
# Python
|
|
8
|
+
__pycache__/
|
|
9
|
+
*.py[cod]
|
|
10
|
+
.venv/
|
|
11
|
+
*.egg-info/
|
|
12
|
+
dist/
|
|
13
|
+
.pytest_cache/
|
|
14
|
+
|
|
15
|
+
# Node
|
|
16
|
+
node_modules/
|
|
17
|
+
.next/
|
|
18
|
+
out/
|
|
19
|
+
|
|
20
|
+
# Terraform
|
|
21
|
+
**/.terraform/
|
|
22
|
+
*.tfstate
|
|
23
|
+
*.tfstate.backup
|
|
24
|
+
*.tfvars
|
|
25
|
+
!*.tfvars.example
|
|
26
|
+
|
|
27
|
+
# OS
|
|
28
|
+
.DS_Store
|
|
29
|
+
Thumbs.db
|
|
30
|
+
|
|
31
|
+
# miscellaneous files
|
|
32
|
+
prd.txt
|
|
33
|
+
config.yaml
|
|
34
|
+
ARCHITECTURE.md
|
|
35
|
+
qwen-config.yaml
|
|
36
|
+
stripe-usage-billing.md
|
|
37
|
+
|
|
38
|
+
# The scratch copy at the repo root is ignored, but the reference vendored into
|
|
39
|
+
# heulistic-axolotl is the generator's input and must be committed. Without the
|
|
40
|
+
# negation the bare pattern matched it at any depth, so it was silently absent
|
|
41
|
+
# from CI while present locally.
|
|
42
|
+
axolotl.yaml
|
|
43
|
+
!packages/heulistic-axolotl/reference/axolotl.yaml
|
heulistic-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2024 Heulistic
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
heulistic-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: heulistic
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Fine-tune LLMs without the infrastructure
|
|
5
|
+
Project-URL: Homepage, https://heulistic.com
|
|
6
|
+
Project-URL: Documentation, https://heulistic.com/docs
|
|
7
|
+
Author-email: Heulistic <hello@heulistic.com>
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Keywords: axolotl,fine-tuning,llm,lora,machine-learning,qlora
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Environment :: Console
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: Intended Audience :: Science/Research
|
|
15
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
21
|
+
Requires-Python: >=3.10
|
|
22
|
+
Requires-Dist: heulistic-axolotl<0.2.0,>=0.1.0
|
|
23
|
+
Requires-Dist: typer>=0.12
|
|
24
|
+
Description-Content-Type: text/markdown
|
|
25
|
+
|
|
26
|
+
# heulistic
|
|
27
|
+
|
|
28
|
+
Fine-tune LLMs without the infrastructure.
|
|
29
|
+
|
|
30
|
+
Run the full Heulistic stack locally on your own hardware — no cloud account, no billing, no setup beyond Docker.
|
|
31
|
+
|
|
32
|
+
## Installation
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
pip install heulistic
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Requires Python 3.10+ and Docker Desktop (or Docker Engine + Compose plugin).
|
|
39
|
+
|
|
40
|
+
## Quick start
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
pip install heulistic
|
|
44
|
+
|
|
45
|
+
heulistic init # starter config, sized to your GPU
|
|
46
|
+
heulistic validate-config config.yaml # check it — offline, no Docker needed
|
|
47
|
+
heulistic start # bring the local stack up
|
|
48
|
+
heulistic train config.yaml # submit and follow the logs
|
|
49
|
+
heulistic download <job-id> # collect the trained model
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
## Training from the terminal
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
heulistic train config.yaml
|
|
56
|
+
heulistic train config.yaml --dataset ./data.jsonl
|
|
57
|
+
heulistic train config.yaml --hf-token hf_... # gated base models
|
|
58
|
+
heulistic train config.yaml --detach # submit without following
|
|
59
|
+
heulistic train config.yaml --no-validate # skip the pre-flight check
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
The config is validated locally first, so a typo costs nothing. Logs stream
|
|
63
|
+
until the run ends; Ctrl-C detaches without stopping the job. On success you
|
|
64
|
+
are told where to get the model.
|
|
65
|
+
|
|
66
|
+
```bash
|
|
67
|
+
heulistic download <job-id> # ./<job-id>-model.tar.gz
|
|
68
|
+
heulistic download <job-id> -o model.tgz
|
|
69
|
+
heulistic download <job-id> --url-only
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
## Starting from a working config
|
|
73
|
+
|
|
74
|
+
```bash
|
|
75
|
+
heulistic init # writes config.yaml
|
|
76
|
+
heulistic init -m Qwen/Qwen2.5-0.5B
|
|
77
|
+
heulistic init -o experiments/run1.yaml
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
`init` reads your GPU and picks a base model that fits it, so your first run
|
|
81
|
+
does not OOM twenty minutes in. The generated file passes `validate-config`
|
|
82
|
+
with no findings.
|
|
83
|
+
|
|
84
|
+
## Checking a config
|
|
85
|
+
|
|
86
|
+
Validate an Axolotl config before spending a GPU hour on it. Works offline —
|
|
87
|
+
no Docker, no running stack, no account.
|
|
88
|
+
|
|
89
|
+
```bash
|
|
90
|
+
heulistic validate-config config.yaml
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
```
|
|
94
|
+
Checking config.yaml
|
|
95
|
+
|
|
96
|
+
error line 3 num_epochs num_epochs must be greater than 0
|
|
97
|
+
error line 5 micro_batch_size 'micro_batch_size' expects int | None, got str
|
|
98
|
+
warning line 2 lr Unknown field 'lr' — did you mean 'learning_rate'?
|
|
99
|
+
info bf16 'bf16' is not set — it defaults to 'auto'
|
|
100
|
+
|
|
101
|
+
2 errors, 1 warning, 1 suggestion
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
Field names, types, defaults and required-ness come from Axolotl's own
|
|
105
|
+
[config reference](https://docs.axolotl.ai/docs/config-reference.html), so the
|
|
106
|
+
checks track upstream rather than a hand-written list.
|
|
107
|
+
|
|
108
|
+
```bash
|
|
109
|
+
# Check it also fits your GPU
|
|
110
|
+
heulistic validate-config config.yaml --gpu
|
|
111
|
+
|
|
112
|
+
# ...or a card you don't have in front of you
|
|
113
|
+
heulistic validate-config config.yaml --vram 24
|
|
114
|
+
heulistic validate-config config.yaml --vram 88 --gpus 4
|
|
115
|
+
|
|
116
|
+
# Fail on warnings too, for CI
|
|
117
|
+
heulistic validate-config config.yaml --strict
|
|
118
|
+
|
|
119
|
+
# Errors only, or JSON for tooling
|
|
120
|
+
heulistic validate-config config.yaml --quiet
|
|
121
|
+
heulistic validate-config config.yaml --json
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
Exit codes: `0` clean, `1` problems found, `2` bad invocation.
|
|
125
|
+
|
|
126
|
+
## Running the local stack
|
|
127
|
+
|
|
128
|
+
```bash
|
|
129
|
+
# Start the stack (opens your browser automatically)
|
|
130
|
+
heulistic start
|
|
131
|
+
|
|
132
|
+
# GPU-less mode (CPU only — training will be slow)
|
|
133
|
+
heulistic start --no-gpu
|
|
134
|
+
|
|
135
|
+
# Use a HuggingFace token for gated models
|
|
136
|
+
heulistic start --hf-token hf_...
|
|
137
|
+
|
|
138
|
+
# Custom ports
|
|
139
|
+
heulistic start --port 3001 --api-port 8001
|
|
140
|
+
|
|
141
|
+
# What's running, and what are my jobs doing?
|
|
142
|
+
heulistic status
|
|
143
|
+
|
|
144
|
+
# View logs
|
|
145
|
+
heulistic logs
|
|
146
|
+
heulistic logs -f # follow
|
|
147
|
+
|
|
148
|
+
# Stop
|
|
149
|
+
heulistic stop
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
`heulistic status` exits `0` only when every service is up, so it can gate a
|
|
153
|
+
script:
|
|
154
|
+
|
|
155
|
+
```
|
|
156
|
+
Heulistic local stack
|
|
157
|
+
|
|
158
|
+
db running (healthy)
|
|
159
|
+
minio running
|
|
160
|
+
api running http://localhost:8000
|
|
161
|
+
scheduler running
|
|
162
|
+
frontend running http://localhost:3000
|
|
163
|
+
|
|
164
|
+
Jobs: 1 completed, 2 running
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
## Managed Cloud
|
|
168
|
+
|
|
169
|
+
Need more GPU or don't want to manage infrastructure? [Heulistic Cloud](https://heulistic.com) handles provisioning, scaling, and cost — pay only for what you use.
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
# heulistic
|
|
2
|
+
|
|
3
|
+
Fine-tune LLMs without the infrastructure.
|
|
4
|
+
|
|
5
|
+
Run the full Heulistic stack locally on your own hardware — no cloud account, no billing, no setup beyond Docker.
|
|
6
|
+
|
|
7
|
+
## Installation
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
pip install heulistic
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Requires Python 3.10+ and Docker Desktop (or Docker Engine + Compose plugin).
|
|
14
|
+
|
|
15
|
+
## Quick start
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
pip install heulistic
|
|
19
|
+
|
|
20
|
+
heulistic init # starter config, sized to your GPU
|
|
21
|
+
heulistic validate-config config.yaml # check it — offline, no Docker needed
|
|
22
|
+
heulistic start # bring the local stack up
|
|
23
|
+
heulistic train config.yaml # submit and follow the logs
|
|
24
|
+
heulistic download <job-id> # collect the trained model
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## Training from the terminal
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
heulistic train config.yaml
|
|
31
|
+
heulistic train config.yaml --dataset ./data.jsonl
|
|
32
|
+
heulistic train config.yaml --hf-token hf_... # gated base models
|
|
33
|
+
heulistic train config.yaml --detach # submit without following
|
|
34
|
+
heulistic train config.yaml --no-validate # skip the pre-flight check
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
The config is validated locally first, so a typo costs nothing. Logs stream
|
|
38
|
+
until the run ends; Ctrl-C detaches without stopping the job. On success you
|
|
39
|
+
are told where to get the model.
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
heulistic download <job-id> # ./<job-id>-model.tar.gz
|
|
43
|
+
heulistic download <job-id> -o model.tgz
|
|
44
|
+
heulistic download <job-id> --url-only
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
## Starting from a working config
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
heulistic init # writes config.yaml
|
|
51
|
+
heulistic init -m Qwen/Qwen2.5-0.5B
|
|
52
|
+
heulistic init -o experiments/run1.yaml
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
`init` reads your GPU and picks a base model that fits it, so your first run
|
|
56
|
+
does not OOM twenty minutes in. The generated file passes `validate-config`
|
|
57
|
+
with no findings.
|
|
58
|
+
|
|
59
|
+
## Checking a config
|
|
60
|
+
|
|
61
|
+
Validate an Axolotl config before spending a GPU hour on it. Works offline —
|
|
62
|
+
no Docker, no running stack, no account.
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
heulistic validate-config config.yaml
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
```
|
|
69
|
+
Checking config.yaml
|
|
70
|
+
|
|
71
|
+
error line 3 num_epochs num_epochs must be greater than 0
|
|
72
|
+
error line 5 micro_batch_size 'micro_batch_size' expects int | None, got str
|
|
73
|
+
warning line 2 lr Unknown field 'lr' — did you mean 'learning_rate'?
|
|
74
|
+
info bf16 'bf16' is not set — it defaults to 'auto'
|
|
75
|
+
|
|
76
|
+
2 errors, 1 warning, 1 suggestion
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
Field names, types, defaults and required-ness come from Axolotl's own
|
|
80
|
+
[config reference](https://docs.axolotl.ai/docs/config-reference.html), so the
|
|
81
|
+
checks track upstream rather than a hand-written list.
|
|
82
|
+
|
|
83
|
+
```bash
|
|
84
|
+
# Check it also fits your GPU
|
|
85
|
+
heulistic validate-config config.yaml --gpu
|
|
86
|
+
|
|
87
|
+
# ...or a card you don't have in front of you
|
|
88
|
+
heulistic validate-config config.yaml --vram 24
|
|
89
|
+
heulistic validate-config config.yaml --vram 88 --gpus 4
|
|
90
|
+
|
|
91
|
+
# Fail on warnings too, for CI
|
|
92
|
+
heulistic validate-config config.yaml --strict
|
|
93
|
+
|
|
94
|
+
# Errors only, or JSON for tooling
|
|
95
|
+
heulistic validate-config config.yaml --quiet
|
|
96
|
+
heulistic validate-config config.yaml --json
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
Exit codes: `0` clean, `1` problems found, `2` bad invocation.
|
|
100
|
+
|
|
101
|
+
## Running the local stack
|
|
102
|
+
|
|
103
|
+
```bash
|
|
104
|
+
# Start the stack (opens your browser automatically)
|
|
105
|
+
heulistic start
|
|
106
|
+
|
|
107
|
+
# GPU-less mode (CPU only — training will be slow)
|
|
108
|
+
heulistic start --no-gpu
|
|
109
|
+
|
|
110
|
+
# Use a HuggingFace token for gated models
|
|
111
|
+
heulistic start --hf-token hf_...
|
|
112
|
+
|
|
113
|
+
# Custom ports
|
|
114
|
+
heulistic start --port 3001 --api-port 8001
|
|
115
|
+
|
|
116
|
+
# What's running, and what are my jobs doing?
|
|
117
|
+
heulistic status
|
|
118
|
+
|
|
119
|
+
# View logs
|
|
120
|
+
heulistic logs
|
|
121
|
+
heulistic logs -f # follow
|
|
122
|
+
|
|
123
|
+
# Stop
|
|
124
|
+
heulistic stop
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
`heulistic status` exits `0` only when every service is up, so it can gate a
|
|
128
|
+
script:
|
|
129
|
+
|
|
130
|
+
```
|
|
131
|
+
Heulistic local stack
|
|
132
|
+
|
|
133
|
+
db running (healthy)
|
|
134
|
+
minio running
|
|
135
|
+
api running http://localhost:8000
|
|
136
|
+
scheduler running
|
|
137
|
+
frontend running http://localhost:3000
|
|
138
|
+
|
|
139
|
+
Jobs: 1 completed, 2 running
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
## Managed Cloud
|
|
143
|
+
|
|
144
|
+
Need more GPU or don't want to manage infrastructure? [Heulistic Cloud](https://heulistic.com) handles provisioning, scaling, and cost — pay only for what you use.
|
|
File without changes
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""Entry point for `python -m heulistic`.
|
|
2
|
+
|
|
3
|
+
The `heulistic` console script is the normal way in; this makes the module form
|
|
4
|
+
work too, which is how the CLI gets run inside a virtualenv that has not been
|
|
5
|
+
activated.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from .main import main
|
|
9
|
+
|
|
10
|
+
if __name__ == "__main__":
|
|
11
|
+
main()
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
"""Talk to the local Heulistic API.
|
|
2
|
+
|
|
3
|
+
Standard library only, so `pip install heulistic` stays light — which is why
|
|
4
|
+
multipart bodies are assembled here rather than handed to a library.
|
|
5
|
+
|
|
6
|
+
Local mode accepts a fixed bearer token in place of a Cognito JWT. When cloud
|
|
7
|
+
support lands this is the seam where a real credential goes.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
import mimetypes
|
|
14
|
+
import secrets
|
|
15
|
+
import urllib.error
|
|
16
|
+
import urllib.request
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from typing import Iterator
|
|
19
|
+
|
|
20
|
+
__all__ = [
|
|
21
|
+
"ApiError",
|
|
22
|
+
"encode_multipart",
|
|
23
|
+
"submit",
|
|
24
|
+
"get_job",
|
|
25
|
+
"download_url",
|
|
26
|
+
"stream_logs",
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
DEV_TOKEN = "dev-localuser"
|
|
30
|
+
_TIMEOUT = 30
|
|
31
|
+
# Training runs are long; the log stream must not time out mid-job.
|
|
32
|
+
_STREAM_TIMEOUT = 60 * 60 * 24
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class ApiError(Exception):
|
|
36
|
+
"""The API refused the request, carrying its `detail` where there is one."""
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _headers(content_type: str | None = None) -> dict[str, str]:
|
|
40
|
+
headers = {"Authorization": f"Bearer {DEV_TOKEN}"}
|
|
41
|
+
if content_type:
|
|
42
|
+
headers["Content-Type"] = content_type
|
|
43
|
+
return headers
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _raise_for_error(exc: urllib.error.HTTPError) -> None:
|
|
47
|
+
try:
|
|
48
|
+
detail = json.loads(exc.read()).get("detail", "")
|
|
49
|
+
except Exception:
|
|
50
|
+
detail = ""
|
|
51
|
+
raise ApiError(detail or f"HTTP {exc.code}") from exc
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _request(
|
|
55
|
+
method: str,
|
|
56
|
+
url: str,
|
|
57
|
+
*,
|
|
58
|
+
body: bytes | None = None,
|
|
59
|
+
content_type: str | None = None,
|
|
60
|
+
stream: bool = False,
|
|
61
|
+
):
|
|
62
|
+
"""Perform one HTTP request. Tests replace this wholesale."""
|
|
63
|
+
request = urllib.request.Request(
|
|
64
|
+
url, data=body, method=method, headers=_headers(content_type)
|
|
65
|
+
)
|
|
66
|
+
try:
|
|
67
|
+
response = urllib.request.urlopen(
|
|
68
|
+
request, timeout=_STREAM_TIMEOUT if stream else _TIMEOUT
|
|
69
|
+
)
|
|
70
|
+
except urllib.error.HTTPError as exc:
|
|
71
|
+
_raise_for_error(exc)
|
|
72
|
+
except urllib.error.URLError as exc:
|
|
73
|
+
raise ApiError(
|
|
74
|
+
f"Could not reach the Heulistic API at {url} — is it running? "
|
|
75
|
+
"Start it with: heulistic start"
|
|
76
|
+
) from exc
|
|
77
|
+
return response if stream else response.read()
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def encode_multipart(
|
|
81
|
+
fields: dict[str, str | None], files: dict[str, tuple[str, bytes]]
|
|
82
|
+
) -> tuple[str, bytes]:
|
|
83
|
+
"""Build a multipart/form-data body. Returns (content_type, body)."""
|
|
84
|
+
boundary = secrets.token_hex(16)
|
|
85
|
+
parts: list[bytes] = []
|
|
86
|
+
|
|
87
|
+
for name, value in fields.items():
|
|
88
|
+
if value in (None, ""):
|
|
89
|
+
continue # an empty optional field must not be sent at all
|
|
90
|
+
parts.append(
|
|
91
|
+
f"--{boundary}\r\n"
|
|
92
|
+
f'Content-Disposition: form-data; name="{name}"\r\n\r\n'
|
|
93
|
+
f"{value}\r\n".encode()
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
for name, (filename, content) in files.items():
|
|
97
|
+
mime = mimetypes.guess_type(filename)[0] or "application/octet-stream"
|
|
98
|
+
parts.append(
|
|
99
|
+
f"--{boundary}\r\n"
|
|
100
|
+
f'Content-Disposition: form-data; name="{name}"; filename="{filename}"\r\n'
|
|
101
|
+
f"Content-Type: {mime}\r\n\r\n".encode()
|
|
102
|
+
)
|
|
103
|
+
parts.append(content)
|
|
104
|
+
parts.append(b"\r\n")
|
|
105
|
+
|
|
106
|
+
parts.append(f"--{boundary}--\r\n".encode())
|
|
107
|
+
return f"multipart/form-data; boundary={boundary}", b"".join(parts)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def submit(
|
|
111
|
+
api_url: str,
|
|
112
|
+
config: Path,
|
|
113
|
+
dataset: Path | None = None,
|
|
114
|
+
hf_token: str | None = None,
|
|
115
|
+
) -> dict:
|
|
116
|
+
"""Submit a training job. Returns the API's job record."""
|
|
117
|
+
files = {"config": (config.name, config.read_bytes())}
|
|
118
|
+
if dataset is not None:
|
|
119
|
+
files["dataset"] = (dataset.name, dataset.read_bytes())
|
|
120
|
+
|
|
121
|
+
content_type, body = encode_multipart({"hf_token": hf_token}, files)
|
|
122
|
+
raw = _request(
|
|
123
|
+
"POST", f"{api_url}/api/jobs", body=body, content_type=content_type
|
|
124
|
+
)
|
|
125
|
+
return json.loads(raw)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def get_job(api_url: str, job_id: str) -> dict:
|
|
129
|
+
return json.loads(_request("GET", f"{api_url}/api/jobs/{job_id}"))
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def download_url(api_url: str, job_id: str) -> str:
|
|
133
|
+
raw = _request("GET", f"{api_url}/api/jobs/{job_id}/download")
|
|
134
|
+
return json.loads(raw)["url"]
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _stream_lines(url: str) -> Iterator[bytes]:
|
|
138
|
+
response = _request("GET", url, stream=True)
|
|
139
|
+
for raw_line in response:
|
|
140
|
+
yield raw_line.rstrip(b"\n")
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def stream_logs(api_url: str, job_id: str) -> Iterator[tuple[str, str]]:
|
|
144
|
+
"""Yield ("line", text) for each log line, then ("done", status) at the end.
|
|
145
|
+
|
|
146
|
+
Server-sent events; anything that is not a well-formed `data:` frame — a
|
|
147
|
+
keepalive, a blank separator — is skipped rather than surfaced.
|
|
148
|
+
"""
|
|
149
|
+
url = f"{api_url}/api/jobs/{job_id}/logs"
|
|
150
|
+
for raw_line in _stream_lines(url):
|
|
151
|
+
if not raw_line.startswith(b"data: "):
|
|
152
|
+
continue
|
|
153
|
+
try:
|
|
154
|
+
payload = json.loads(raw_line[len(b"data: "):])
|
|
155
|
+
except json.JSONDecodeError:
|
|
156
|
+
continue
|
|
157
|
+
if payload.get("event") == "done":
|
|
158
|
+
yield "done", payload.get("status", "")
|
|
159
|
+
return
|
|
160
|
+
if "line" in payload:
|
|
161
|
+
yield "line", payload["line"]
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""`heulistic download` — fetch a finished model.
|
|
2
|
+
|
|
3
|
+
Locally the artifact sits in MinIO inside Docker, so without this the output of
|
|
4
|
+
a run is hard to reach. The API hands back a URL; this follows it.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import shutil
|
|
10
|
+
import urllib.error
|
|
11
|
+
import urllib.request
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Optional
|
|
14
|
+
|
|
15
|
+
import typer
|
|
16
|
+
|
|
17
|
+
from . import client, stack
|
|
18
|
+
|
|
19
|
+
_SUFFIX = "-model.tar.gz"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _fetch(url: str, dest: Path) -> Path:
|
|
23
|
+
"""Stream the artifact to disk. Replaced in tests."""
|
|
24
|
+
with urllib.request.urlopen(url, timeout=300) as response, open(dest, "wb") as out:
|
|
25
|
+
shutil.copyfileobj(response, out)
|
|
26
|
+
return dest
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _resolve_destination(output: Optional[Path], job_id: str) -> Path:
|
|
30
|
+
default_name = f"{job_id}{_SUFFIX}"
|
|
31
|
+
if output is None:
|
|
32
|
+
return Path.cwd() / default_name
|
|
33
|
+
if output.is_dir():
|
|
34
|
+
return output / default_name
|
|
35
|
+
return output
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def download(
|
|
39
|
+
job_id: str = typer.Argument(..., help="Job ID to download the model for."),
|
|
40
|
+
output: Optional[Path] = typer.Option(
|
|
41
|
+
None, "--output", "-o", help="File or directory to write to."
|
|
42
|
+
),
|
|
43
|
+
force: bool = typer.Option(False, "--force", "-f", help="Overwrite an existing file."),
|
|
44
|
+
url_only: bool = typer.Option(
|
|
45
|
+
False, "--url-only", help="Print the download URL instead of fetching it."
|
|
46
|
+
),
|
|
47
|
+
) -> None:
|
|
48
|
+
"""Download the trained model for a completed job."""
|
|
49
|
+
api_url = stack.api_url()
|
|
50
|
+
if not api_url:
|
|
51
|
+
typer.secho("Heulistic is not running.", fg=typer.colors.RED)
|
|
52
|
+
typer.echo("Start it with: heulistic start")
|
|
53
|
+
raise typer.Exit(code=1)
|
|
54
|
+
|
|
55
|
+
try:
|
|
56
|
+
job = client.get_job(api_url, job_id)
|
|
57
|
+
except client.ApiError as exc:
|
|
58
|
+
typer.secho(str(exc), fg=typer.colors.RED)
|
|
59
|
+
raise typer.Exit(code=1)
|
|
60
|
+
|
|
61
|
+
status = job.get("status", "unknown")
|
|
62
|
+
if status != "completed":
|
|
63
|
+
typer.secho(f"Job {job_id} is '{status}' — a model is only available once "
|
|
64
|
+
"the job has completed.", fg=typer.colors.RED)
|
|
65
|
+
raise typer.Exit(code=1)
|
|
66
|
+
|
|
67
|
+
try:
|
|
68
|
+
url = client.download_url(api_url, job_id)
|
|
69
|
+
except client.ApiError as exc:
|
|
70
|
+
typer.secho(str(exc), fg=typer.colors.RED)
|
|
71
|
+
raise typer.Exit(code=1)
|
|
72
|
+
|
|
73
|
+
if url_only:
|
|
74
|
+
typer.echo(url)
|
|
75
|
+
raise typer.Exit(code=0)
|
|
76
|
+
|
|
77
|
+
dest = _resolve_destination(output, job_id)
|
|
78
|
+
if dest.exists() and not force:
|
|
79
|
+
typer.secho(f"{dest} already exists — pass --force to overwrite.",
|
|
80
|
+
fg=typer.colors.RED)
|
|
81
|
+
raise typer.Exit(code=1)
|
|
82
|
+
|
|
83
|
+
typer.echo(f"Downloading {job_id} → {dest}")
|
|
84
|
+
try:
|
|
85
|
+
_fetch(url, dest)
|
|
86
|
+
except (urllib.error.URLError, OSError) as exc:
|
|
87
|
+
typer.secho(f"Download failed: {exc}", fg=typer.colors.RED)
|
|
88
|
+
raise typer.Exit(code=1)
|
|
89
|
+
|
|
90
|
+
size_mb = dest.stat().st_size / (1024 * 1024)
|
|
91
|
+
typer.secho(f"Saved {dest} ({size_mb:.1f} MB)", fg=typer.colors.GREEN)
|
|
92
|
+
typer.echo(f" tar xzf {dest.name}")
|