hf2ollama-python-cli-tool 1.0.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.
hf2ollama/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """hf2ollama - Search, download, and import HuggingFace GGUF models into Ollama."""
2
+
3
+ __version__ = "1.0.0"
hf2ollama/cli.py ADDED
@@ -0,0 +1,329 @@
1
+ """Click-based CLI for hf2ollama."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import click
6
+ from rich.console import Console
7
+ from rich.table import Table
8
+
9
+ from hf2ollama import __version__
10
+
11
+ console = Console()
12
+
13
+
14
+ @click.group()
15
+ @click.version_option(__version__, prog_name="hf2ollama")
16
+ def main() -> None:
17
+ """Search, download, and import HuggingFace GGUF models into Ollama."""
18
+
19
+
20
+ @main.command()
21
+ @click.argument("repo")
22
+ @click.option(
23
+ "--quant", "-q", default=None, help="Quantization (Q2_K, Q3_K_M, Q4_K_M, Q5_K_M, Q6_K, Q8_0)."
24
+ )
25
+ @click.option(
26
+ "--name", "-n", default=None, help="Ollama model name. Auto-generated if not specified."
27
+ )
28
+ @click.option("--keep-gguf", is_flag=True, help="Keep the GGUF file after import.")
29
+ @click.option(
30
+ "--alt-download", is_flag=True, help="Use alternative PowerShell download (Windows only)."
31
+ )
32
+ @click.option("--ssl-fix", is_flag=True, help="Use OS certificate store for SSL verification.")
33
+ def pull(
34
+ repo: str,
35
+ quant: str | None,
36
+ name: str | None,
37
+ keep_gguf: bool,
38
+ alt_download: bool,
39
+ ssl_fix: bool,
40
+ ) -> None:
41
+ """Download a GGUF model and import into Ollama.
42
+
43
+ REPO is a HuggingFace repo ID (e.g., bartowski/Qwen2.5-Coder-7B-Instruct-GGUF).
44
+ """
45
+ from hf2ollama.core.config import ensure_download_dir, load_config
46
+ from hf2ollama.core.ollama import create_model, find_ollama
47
+ from hf2ollama.hf.api import find_gguf_by_quant, get_download_url, list_gguf_files
48
+ from hf2ollama.hf.download import download_file, validate_file_size
49
+ from hf2ollama.modelfile.generator import (
50
+ detect_family,
51
+ generate_modelfile,
52
+ sanitize_model_name,
53
+ )
54
+ from hf2ollama.network.ssl_fix import apply_ssl_fix
55
+
56
+ config = load_config()
57
+ resolved_quant = quant or config.default_quant
58
+
59
+ if ssl_fix:
60
+ console.print("[dim]Configuring SSL certificates...[/dim]")
61
+ apply_ssl_fix(proxy=config.proxy)
62
+
63
+ try:
64
+ find_ollama()
65
+ except FileNotFoundError as e:
66
+ console.print(f"[red]{e}[/red]")
67
+ raise SystemExit(1)
68
+
69
+ console.print(f"[bold]Searching for {resolved_quant} in {repo}...[/bold]")
70
+
71
+ import httpx
72
+
73
+ try:
74
+ client = httpx.Client(timeout=15.0)
75
+ gguf = find_gguf_by_quant(repo, resolved_quant, client=client)
76
+
77
+ if gguf is None:
78
+ console.print(f"[yellow]No {resolved_quant} file found. Available files:[/yellow]")
79
+ files = list_gguf_files(repo, client=client)
80
+ for f in files:
81
+ console.print(f" {f.filename} ({f.size_display})")
82
+ client.close()
83
+ raise SystemExit(1)
84
+
85
+ console.print(f"[green]Found:[/green] {gguf.filename} ({gguf.size_display})")
86
+
87
+ dl_dir = ensure_download_dir(config)
88
+ dest = dl_dir / gguf.filename
89
+ url = get_download_url(repo, gguf.filename)
90
+
91
+ if alt_download:
92
+ from hf2ollama.hf.alt_download import alt_download as _alt_dl
93
+
94
+ console.print("[bold yellow]Using alternative download method...[/bold yellow]")
95
+ dest = _alt_dl(repo, gguf.filename, str(dl_dir))
96
+ else:
97
+ console.print("[dim]Downloading...[/dim]")
98
+ dest = download_file(url, str(dest), client=client)
99
+
100
+ client.close()
101
+
102
+ if not validate_file_size(str(dest), gguf.size_bytes):
103
+ console.print("[red]File size mismatch - download may be incomplete.[/red]")
104
+ raise SystemExit(1)
105
+
106
+ except httpx.ConnectError as e:
107
+ err = str(e).lower()
108
+ if "ssl" in err or "certificate" in err:
109
+ console.print("[red]SSL certificate error.[/red]")
110
+ console.print("Try: [bold]hf2ollama pull --ssl-fix <repo>[/bold]")
111
+ else:
112
+ console.print(f"[red]Connection error: {e}[/red]")
113
+ raise SystemExit(1)
114
+
115
+ model_name = name or sanitize_model_name(gguf.filename)
116
+ family = detect_family(gguf.filename)
117
+ console.print(f"[dim]Detected family: {family}[/dim]")
118
+
119
+ modelfile_content = generate_modelfile(str(dest), family=family)
120
+ modelfile_path = dest.parent / "Modelfile"
121
+ modelfile_path.write_text(modelfile_content, encoding="utf-8")
122
+
123
+ console.print(f"[bold]Creating Ollama model: {model_name}[/bold]")
124
+ try:
125
+ output = create_model(model_name, str(modelfile_path))
126
+ console.print(f"[green]{output}[/green]")
127
+ except RuntimeError as e:
128
+ console.print(f"[red]{e}[/red]")
129
+ raise SystemExit(1)
130
+
131
+ modelfile_path.unlink(missing_ok=True)
132
+ if not keep_gguf:
133
+ dest.unlink(missing_ok=True)
134
+ console.print("[dim]Cleaned up GGUF file.[/dim]")
135
+
136
+ console.print(f"\n[bold green]Done![/bold green] Run: [bold]ollama run {model_name}[/bold]")
137
+
138
+
139
+ @main.command()
140
+ @click.argument("query")
141
+ @click.option("--limit", "-l", default=15, help="Max results.")
142
+ @click.option(
143
+ "--sort", "-s", default="downloads", type=click.Choice(["downloads", "likes", "lastModified"])
144
+ )
145
+ @click.option("--ssl-fix", is_flag=True, help="Use OS certificate store.")
146
+ def search(query: str, limit: int, sort: str, ssl_fix: bool) -> None:
147
+ """Search HuggingFace for GGUF model repos."""
148
+ from hf2ollama.hf.api import search_gguf_repos
149
+ from hf2ollama.network.ssl_fix import apply_ssl_fix
150
+
151
+ if ssl_fix:
152
+ apply_ssl_fix()
153
+
154
+ results = search_gguf_repos(query, limit=limit, sort=sort)
155
+
156
+ if not results:
157
+ console.print("[yellow]No GGUF repos found.[/yellow]")
158
+ return
159
+
160
+ table = Table(title=f"GGUF repos matching '{query}'")
161
+ table.add_column("Repository", style="bold")
162
+ table.add_column("Downloads", justify="right")
163
+ table.add_column("Likes", justify="right")
164
+
165
+ for r in results:
166
+ table.add_row(r.repo_id, f"{r.downloads:,}", str(r.likes))
167
+
168
+ console.print(table)
169
+
170
+
171
+ @main.command("list-files")
172
+ @click.argument("repo")
173
+ @click.option("--ssl-fix", is_flag=True, help="Use OS certificate store.")
174
+ def list_files(repo: str, ssl_fix: bool) -> None:
175
+ """List GGUF files in a HuggingFace repo."""
176
+ from hf2ollama.hf.api import list_gguf_files
177
+ from hf2ollama.network.ssl_fix import apply_ssl_fix
178
+
179
+ if ssl_fix:
180
+ apply_ssl_fix()
181
+
182
+ files = list_gguf_files(repo)
183
+
184
+ if not files:
185
+ console.print(f"[yellow]No GGUF files found in {repo}.[/yellow]")
186
+ return
187
+
188
+ table = Table(title=f"GGUF files in {repo}")
189
+ table.add_column("File", style="bold")
190
+ table.add_column("Size", justify="right")
191
+
192
+ for f in files:
193
+ table.add_row(f.filename, f.size_display)
194
+
195
+ console.print(table)
196
+
197
+
198
+ @main.command("list")
199
+ def list_models() -> None:
200
+ """List locally installed Ollama models."""
201
+ from hf2ollama.core.ollama import list_models as _list
202
+
203
+ try:
204
+ models = _list()
205
+ except FileNotFoundError as e:
206
+ console.print(f"[red]{e}[/red]")
207
+ raise SystemExit(1)
208
+
209
+ if not models:
210
+ console.print("[yellow]No Ollama models installed.[/yellow]")
211
+ return
212
+
213
+ table = Table(title="Ollama Models")
214
+ table.add_column("Name", style="bold")
215
+ table.add_column("Size", justify="right")
216
+ table.add_column("Modified")
217
+
218
+ for m in models:
219
+ table.add_row(m.name, m.size, m.modified)
220
+
221
+ console.print(table)
222
+
223
+
224
+ @main.command()
225
+ @click.argument("model_name")
226
+ def remove(model_name: str) -> None:
227
+ """Remove an Ollama model."""
228
+ from hf2ollama.core.ollama import remove_model
229
+
230
+ try:
231
+ remove_model(model_name)
232
+ console.print(f"[green]Removed: {model_name}[/green]")
233
+ except RuntimeError as e:
234
+ console.print(f"[red]{e}[/red]")
235
+ raise SystemExit(1)
236
+
237
+
238
+ @main.command()
239
+ @click.argument("model_name")
240
+ def info(model_name: str) -> None:
241
+ """Show details about an Ollama model."""
242
+ from hf2ollama.core.ollama import show_model
243
+
244
+ try:
245
+ output = show_model(model_name)
246
+ console.print(output)
247
+ except RuntimeError as e:
248
+ console.print(f"[red]{e}[/red]")
249
+ raise SystemExit(1)
250
+
251
+
252
+ @main.command()
253
+ @click.option(
254
+ "--vram", type=float, default=None, help="Available VRAM in GB. Auto-detected if not specified."
255
+ )
256
+ @click.option(
257
+ "--params", "-p", type=float, required=True,
258
+ help="Model parameter count in billions (e.g., 7 for 7B).",
259
+ )
260
+ def recommend(vram: float | None, params: float) -> None:
261
+ """Recommend quantization based on VRAM and model size."""
262
+ from hf2ollama.utils.vram import detect_vram, recommend_quant
263
+
264
+ if vram is None:
265
+ vram_info = detect_vram()
266
+ if vram_info:
267
+ vram = vram_info.available_gb
268
+ console.print(f"[dim]Detected: {vram_info.gpu_name} ({vram:.1f} GB)[/dim]")
269
+ else:
270
+ console.print("[yellow]Could not detect GPU. Specify --vram manually.[/yellow]")
271
+ raise SystemExit(1)
272
+
273
+ fits = recommend_quant(params, vram)
274
+
275
+ if not fits:
276
+ console.print(
277
+ f"[red]No quantization of a {params}B model fits in {vram:.1f} GB VRAM.[/red]"
278
+ )
279
+ console.print(f"[dim]Smallest (Q2_K) needs ~{params * 0.31 * 1.1:.1f} GB.[/dim]")
280
+ return
281
+
282
+ table = Table(title=f"Quantizations for {params}B model ({vram:.1f} GB VRAM)")
283
+ table.add_column("Quantization")
284
+ table.add_column("Est. Size", justify="right")
285
+ table.add_column("Fits?", justify="center")
286
+
287
+ from hf2ollama.utils.vram import QUANT_MULTIPLIERS
288
+
289
+ for quant, mult in sorted(QUANT_MULTIPLIERS.items(), key=lambda x: x[1]):
290
+ est = params * mult * 1.1
291
+ fits_str = "[green]Yes[/green]" if est <= vram else "[red]No[/red]"
292
+ table.add_row(quant, f"{est:.1f} GB", fits_str)
293
+
294
+ console.print(table)
295
+ console.print(f"\n[bold]Recommended:[/bold] {fits[-1]} (largest that fits)")
296
+
297
+
298
+ @main.command("network-check")
299
+ def network_check() -> None:
300
+ """Check HuggingFace reachability and SSL certificate status."""
301
+ from hf2ollama.network.detect import check_network
302
+
303
+ console.print("[bold]Checking network...[/bold]\n")
304
+ status = check_network()
305
+
306
+ table = Table(title="Network Diagnostics")
307
+ table.add_column("Check", style="bold")
308
+ table.add_column("Status")
309
+ table.add_column("Details")
310
+
311
+ hf_status = "[green]OK[/green]" if status.hf_reachable else "[red]UNREACHABLE[/red]"
312
+ table.add_row("HuggingFace reachable", hf_status, status.hf_error or "Connected")
313
+
314
+ ssl_status = "[green]OK[/green]" if status.hf_ssl_ok else "[red]FAIL[/red]"
315
+ table.add_row(
316
+ "SSL certificates",
317
+ ssl_status,
318
+ f"{status.cert_count} system certs" if status.system_certs_available else "Limited certs",
319
+ )
320
+
321
+ proxy_status = "[green]Set[/green]" if status.proxy_configured else "[dim]None[/dim]"
322
+ table.add_row("Proxy configured", proxy_status, status.proxy_url or "No proxy")
323
+
324
+ console.print(table)
325
+
326
+ if not status.hf_reachable:
327
+ console.print("\n[bold yellow]Suggestions:[/bold yellow]")
328
+ if not status.hf_ssl_ok:
329
+ console.print(" Try: [bold]hf2ollama pull --ssl-fix <repo>[/bold]")
@@ -0,0 +1 @@
1
+ """Core modules - Ollama binary integration and persistent configuration."""
@@ -0,0 +1,73 @@
1
+ """Configuration management - persistent settings in ~/.hf2ollama/config.toml."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from dataclasses import dataclass
7
+ from pathlib import Path
8
+
9
+ try:
10
+ import tomllib
11
+ except ImportError:
12
+ import tomli as tomllib # type: ignore[no-redef]
13
+
14
+
15
+ CONFIG_DIR = Path.home() / ".hf2ollama"
16
+ CONFIG_FILE = CONFIG_DIR / "config.toml"
17
+ DEFAULT_DOWNLOAD_DIR = CONFIG_DIR / "downloads"
18
+
19
+
20
+ @dataclass
21
+ class Config:
22
+ """Persistent user configuration for hf2ollama."""
23
+
24
+ download_dir: str = str(DEFAULT_DOWNLOAD_DIR)
25
+ proxy: str | None = None
26
+ no_proxy: str | None = None
27
+ hf_token: str | None = None
28
+ hf_endpoint: str | None = None
29
+ default_quant: str = "Q4_K_M"
30
+
31
+
32
+ def load_config() -> Config:
33
+ """Load config from file, falling back to defaults."""
34
+ if not CONFIG_FILE.exists():
35
+ return Config()
36
+
37
+ with open(CONFIG_FILE, "rb") as f:
38
+ data = tomllib.load(f)
39
+
40
+ return Config(
41
+ download_dir=data.get("download_dir", str(DEFAULT_DOWNLOAD_DIR)),
42
+ proxy=data.get("proxy"),
43
+ no_proxy=data.get("no_proxy"),
44
+ hf_token=data.get("hf_token") or os.environ.get("HF_TOKEN"),
45
+ hf_endpoint=data.get("hf_endpoint"),
46
+ default_quant=data.get("default_quant", "Q4_K_M"),
47
+ )
48
+
49
+
50
+ def save_config(config: Config) -> None:
51
+ """Save config to TOML file."""
52
+ CONFIG_DIR.mkdir(parents=True, exist_ok=True)
53
+
54
+ lines = []
55
+ lines.append(f'download_dir = "{config.download_dir}"')
56
+ lines.append(f'default_quant = "{config.default_quant}"')
57
+ if config.proxy:
58
+ lines.append(f'proxy = "{config.proxy}"')
59
+ if config.no_proxy:
60
+ lines.append(f'no_proxy = "{config.no_proxy}"')
61
+ if config.hf_token:
62
+ lines.append(f'hf_token = "{config.hf_token}"')
63
+ if config.hf_endpoint:
64
+ lines.append(f'hf_endpoint = "{config.hf_endpoint}"')
65
+
66
+ CONFIG_FILE.write_text("\n".join(lines) + "\n", encoding="utf-8")
67
+
68
+
69
+ def ensure_download_dir(config: Config) -> Path:
70
+ """Create and return the download directory."""
71
+ dl_dir = Path(config.download_dir)
72
+ dl_dir.mkdir(parents=True, exist_ok=True)
73
+ return dl_dir
@@ -0,0 +1,137 @@
1
+ """Ollama binary interaction - find, create, list, remove models."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import os
7
+ import shutil
8
+ import subprocess
9
+ from dataclasses import dataclass
10
+ from pathlib import Path
11
+
12
+ logger = logging.getLogger(__name__)
13
+
14
+
15
+ @dataclass
16
+ class OllamaModel:
17
+ """Metadata for a locally installed Ollama model."""
18
+
19
+ name: str
20
+ size: str
21
+ modified: str
22
+ model_id: str = ""
23
+
24
+
25
+ def find_ollama() -> str:
26
+ """Find the ollama executable."""
27
+ if os.name == "nt":
28
+ local_path = (
29
+ Path(os.environ.get("USERPROFILE", "")) / "AppData/Local/Programs/Ollama/ollama.exe"
30
+ )
31
+ if local_path.exists():
32
+ return str(local_path)
33
+
34
+ found = shutil.which("ollama")
35
+ if found:
36
+ return found
37
+
38
+ raise FileNotFoundError("Ollama not found. Install from https://ollama.com/download")
39
+
40
+
41
+ def create_model(
42
+ name: str,
43
+ modelfile_path: str,
44
+ working_dir: str | None = None,
45
+ ) -> str:
46
+ """Run `ollama create` with a Modelfile."""
47
+ ollama = find_ollama()
48
+ cwd = working_dir or str(Path(modelfile_path).parent)
49
+
50
+ result = subprocess.run(
51
+ [ollama, "create", name, "-f", "Modelfile"],
52
+ capture_output=True,
53
+ text=True,
54
+ cwd=cwd,
55
+ timeout=600,
56
+ )
57
+
58
+ if result.returncode != 0:
59
+ raise RuntimeError(f"ollama create failed: {result.stderr or result.stdout}")
60
+
61
+ return result.stdout.strip()
62
+
63
+
64
+ def list_models() -> list[OllamaModel]:
65
+ """List locally installed Ollama models."""
66
+ ollama = find_ollama()
67
+
68
+ result = subprocess.run(
69
+ [ollama, "list"],
70
+ capture_output=True,
71
+ text=True,
72
+ timeout=30,
73
+ )
74
+
75
+ if result.returncode != 0:
76
+ raise RuntimeError(f"ollama list failed: {result.stderr}")
77
+
78
+ models = []
79
+ lines = result.stdout.strip().splitlines()
80
+ for line in lines[1:]:
81
+ parts = line.split()
82
+ if len(parts) >= 3:
83
+ models.append(
84
+ OllamaModel(
85
+ name=parts[0],
86
+ model_id=parts[1] if len(parts) > 1 else "",
87
+ size=parts[2]
88
+ + (" " + parts[3] if len(parts) > 3 and parts[3] in ("MB", "GB", "KB") else ""),
89
+ modified=" ".join(parts[4:]) if len(parts) > 4 else "",
90
+ )
91
+ )
92
+ return models
93
+
94
+
95
+ def remove_model(name: str) -> str:
96
+ """Remove an Ollama model."""
97
+ ollama = find_ollama()
98
+
99
+ result = subprocess.run(
100
+ [ollama, "rm", name],
101
+ capture_output=True,
102
+ text=True,
103
+ timeout=60,
104
+ )
105
+
106
+ if result.returncode != 0:
107
+ raise RuntimeError(f"ollama rm failed: {result.stderr}")
108
+
109
+ return result.stdout.strip()
110
+
111
+
112
+ def show_model(name: str) -> str:
113
+ """Show model details."""
114
+ ollama = find_ollama()
115
+
116
+ result = subprocess.run(
117
+ [ollama, "show", name],
118
+ capture_output=True,
119
+ text=True,
120
+ timeout=30,
121
+ )
122
+
123
+ if result.returncode != 0:
124
+ raise RuntimeError(f"ollama show failed: {result.stderr}")
125
+
126
+ return result.stdout.strip()
127
+
128
+
129
+ def model_exists(name: str) -> bool:
130
+ """Check if a model is already installed."""
131
+ try:
132
+ models = list_models()
133
+ base_name = name.split(":")[0]
134
+ return any(base_name in m.name for m in models)
135
+ except (RuntimeError, FileNotFoundError, subprocess.SubprocessError) as e:
136
+ logger.debug("model_exists check failed: %s", e)
137
+ return False
@@ -0,0 +1 @@
1
+ """HuggingFace integration - API client and file downloads."""
@@ -0,0 +1,78 @@
1
+ """Alternative download method using embedded PowerShell script (Windows only)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import shutil
7
+ import subprocess
8
+ import sys
9
+ from pathlib import Path
10
+
11
+ logger = logging.getLogger(__name__)
12
+
13
+
14
+ def is_alt_download_available() -> bool:
15
+ """Check if the PowerShell-based download method is available."""
16
+ if sys.platform != "win32":
17
+ return False
18
+
19
+ pwsh = shutil.which("pwsh")
20
+ return pwsh is not None
21
+
22
+
23
+ def get_script_path() -> Path:
24
+ """Get path to the embedded HfDownload.ps1 script."""
25
+ script_dir = Path(__file__).parent.parent / "scripts"
26
+ return script_dir / "HfDownload.ps1"
27
+
28
+
29
+ def alt_download(
30
+ repo: str,
31
+ filename: str,
32
+ out_dir: str,
33
+ ) -> Path:
34
+ """Download a file using the PowerShell-based download method.
35
+
36
+ Requires Windows and PowerShell 7 (pwsh).
37
+
38
+ Raises RuntimeError if the download fails or prerequisites are not met.
39
+ """
40
+ if not is_alt_download_available():
41
+ raise RuntimeError(
42
+ "Alternative download requires Windows + PowerShell 7 (pwsh)."
43
+ )
44
+
45
+ script = get_script_path()
46
+ if not script.exists():
47
+ raise RuntimeError(f"HfDownload.ps1 not found at {script}")
48
+
49
+ cmd = [
50
+ "pwsh",
51
+ "-ExecutionPolicy",
52
+ "Bypass",
53
+ "-File",
54
+ str(script),
55
+ "-Repo",
56
+ repo,
57
+ "-File",
58
+ filename,
59
+ "-OutDir",
60
+ out_dir,
61
+ ]
62
+
63
+ result = subprocess.run(
64
+ cmd,
65
+ capture_output=True,
66
+ text=True,
67
+ timeout=3600,
68
+ )
69
+
70
+ if result.returncode != 0:
71
+ stderr = result.stderr.strip()
72
+ raise RuntimeError(f"Alternative download failed: {stderr or result.stdout}")
73
+
74
+ dest = Path(out_dir) / filename
75
+ if not dest.exists():
76
+ raise RuntimeError(f"Download completed but file not found at {dest}")
77
+
78
+ return dest