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/hf/api.py ADDED
@@ -0,0 +1,197 @@
1
+ """HuggingFace API client - search repos and list GGUF files."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ from contextlib import contextmanager
7
+ from dataclasses import dataclass
8
+ from typing import Iterator
9
+
10
+ import httpx
11
+
12
+ logger = logging.getLogger(__name__)
13
+
14
+ HF_API_BASE = "https://huggingface.co/api"
15
+
16
+
17
+ @dataclass
18
+ class RepoInfo:
19
+ """Metadata for a HuggingFace model repository."""
20
+
21
+ repo_id: str
22
+ downloads: int
23
+ likes: int
24
+ last_modified: str
25
+
26
+
27
+ @dataclass
28
+ class GGUFFile:
29
+ """A GGUF file in a HuggingFace repository."""
30
+
31
+ filename: str
32
+ size_bytes: int
33
+ repo_id: str
34
+
35
+ @property
36
+ def size_gb(self) -> float:
37
+ """Return file size in gigabytes."""
38
+ return self.size_bytes / (1024**3)
39
+
40
+ @property
41
+ def size_display(self) -> str:
42
+ """Return human-readable size string."""
43
+ if self.size_bytes >= 1024**3:
44
+ return f"{self.size_gb:.1f} GB"
45
+ return f"{self.size_bytes / (1024**2):.0f} MB"
46
+
47
+
48
+ @contextmanager
49
+ def _get_client(client: httpx.Client | None) -> Iterator[httpx.Client]:
50
+ """Yield an httpx client, creating one if not provided."""
51
+ if client is not None:
52
+ yield client
53
+ else:
54
+ owned = httpx.Client(timeout=15.0)
55
+ try:
56
+ yield owned
57
+ finally:
58
+ owned.close()
59
+
60
+
61
+ def search_gguf_repos(
62
+ query: str,
63
+ limit: int = 20,
64
+ sort: str = "downloads",
65
+ client: httpx.Client | None = None,
66
+ ) -> list[RepoInfo]:
67
+ """Search HuggingFace for GGUF model repos.
68
+
69
+ Parameters
70
+ ----------
71
+ query : str
72
+ Search query string.
73
+ limit : int
74
+ Maximum number of results.
75
+ sort : str
76
+ Sort field (downloads, likes, lastModified).
77
+ client : httpx.Client | None
78
+ Optional shared HTTP client.
79
+
80
+ Returns
81
+ -------
82
+ list[RepoInfo]
83
+ Matching repositories sorted by the given field.
84
+
85
+ """
86
+ with _get_client(client) as c:
87
+ params = {
88
+ "search": query,
89
+ "filter": "gguf",
90
+ "sort": sort,
91
+ "direction": "-1",
92
+ "limit": str(limit),
93
+ }
94
+ resp = c.get(f"{HF_API_BASE}/models", params=params)
95
+ resp.raise_for_status()
96
+
97
+ results: list[RepoInfo] = []
98
+ for item in resp.json():
99
+ results.append(
100
+ RepoInfo(
101
+ repo_id=item.get("id", ""),
102
+ downloads=item.get("downloads", 0),
103
+ likes=item.get("likes", 0),
104
+ last_modified=item.get("lastModified", ""),
105
+ )
106
+ )
107
+ logger.debug("Search '%s' returned %d results", query, len(results))
108
+ return results
109
+
110
+
111
+ def list_gguf_files(
112
+ repo_id: str,
113
+ client: httpx.Client | None = None,
114
+ ) -> list[GGUFFile]:
115
+ """List GGUF files in a HuggingFace repo with their sizes.
116
+
117
+ Parameters
118
+ ----------
119
+ repo_id : str
120
+ HuggingFace repo (e.g., bartowski/Qwen2.5-Coder-7B-Instruct-GGUF).
121
+ client : httpx.Client | None
122
+ Optional shared HTTP client.
123
+
124
+ Returns
125
+ -------
126
+ list[GGUFFile]
127
+ GGUF files sorted by size ascending.
128
+
129
+ """
130
+ with _get_client(client) as c:
131
+ resp = c.get(f"{HF_API_BASE}/models/{repo_id}/tree/main")
132
+ resp.raise_for_status()
133
+
134
+ files: list[GGUFFile] = []
135
+ for item in resp.json():
136
+ path: str = item.get("path", "")
137
+ if path.lower().endswith(".gguf"):
138
+ files.append(
139
+ GGUFFile(
140
+ filename=path,
141
+ size_bytes=item.get("size", 0),
142
+ repo_id=repo_id,
143
+ )
144
+ )
145
+ logger.debug("Repo '%s' has %d GGUF files", repo_id, len(files))
146
+ return sorted(files, key=lambda f: f.size_bytes)
147
+
148
+
149
+ def find_gguf_by_quant(
150
+ repo_id: str,
151
+ quant: str = "Q4_K_M",
152
+ client: httpx.Client | None = None,
153
+ ) -> GGUFFile | None:
154
+ """Find a specific quantization in a repo.
155
+
156
+ Parameters
157
+ ----------
158
+ repo_id : str
159
+ HuggingFace repo ID.
160
+ quant : str
161
+ Quantization level to match in the filename.
162
+ client : httpx.Client | None
163
+ Optional shared HTTP client.
164
+
165
+ Returns
166
+ -------
167
+ GGUFFile | None
168
+ Matching file, or None if not found.
169
+
170
+ """
171
+ files = list_gguf_files(repo_id, client=client)
172
+ quant_lower = quant.lower()
173
+
174
+ for f in files:
175
+ if quant_lower in f.filename.lower():
176
+ return f
177
+
178
+ return None
179
+
180
+
181
+ def get_download_url(repo_id: str, filename: str) -> str:
182
+ """Build the direct download URL for a file.
183
+
184
+ Parameters
185
+ ----------
186
+ repo_id : str
187
+ HuggingFace repo ID.
188
+ filename : str
189
+ Name of the file to download.
190
+
191
+ Returns
192
+ -------
193
+ str
194
+ Direct download URL.
195
+
196
+ """
197
+ return f"https://huggingface.co/{repo_id}/resolve/main/{filename}"
@@ -0,0 +1,121 @@
1
+ """Standard download with httpx - resumable, with progress bar."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ from pathlib import Path
7
+
8
+ import httpx
9
+ from rich.progress import (
10
+ BarColumn,
11
+ DownloadColumn,
12
+ Progress,
13
+ TextColumn,
14
+ TimeRemainingColumn,
15
+ TransferSpeedColumn,
16
+ )
17
+
18
+ from hf2ollama.hf.api import _get_client
19
+
20
+ logger = logging.getLogger(__name__)
21
+
22
+ _DOWNLOAD_TIMEOUT = httpx.Timeout(connect=15.0, read=300.0, write=30.0, pool=10.0)
23
+
24
+
25
+ def download_file(
26
+ url: str,
27
+ dest_path: str,
28
+ client: httpx.Client | None = None,
29
+ chunk_size: int = 4 * 1024 * 1024,
30
+ ) -> Path:
31
+ """Download a file with resume support and progress bar.
32
+
33
+ Parameters
34
+ ----------
35
+ url : str
36
+ Direct download URL.
37
+ dest_path : str
38
+ Local path to write the file.
39
+ client : httpx.Client | None
40
+ Optional shared HTTP client. Creates one if not provided.
41
+ chunk_size : int
42
+ Download chunk size in bytes (default 4MB).
43
+
44
+ Returns
45
+ -------
46
+ Path
47
+ Path to the downloaded file.
48
+
49
+ """
50
+ dest = Path(dest_path)
51
+ dest.parent.mkdir(parents=True, exist_ok=True)
52
+
53
+ with _get_client(client) as c:
54
+ existing_size: int = dest.stat().st_size if dest.exists() else 0
55
+
56
+ headers: dict[str, str] = {}
57
+ if existing_size > 0:
58
+ headers["Range"] = f"bytes={existing_size}-"
59
+ logger.info("Resuming download from byte %d", existing_size)
60
+
61
+ with c.stream("GET", url, headers=headers, follow_redirects=True) as resp:
62
+ if resp.status_code == 416:
63
+ logger.info("File already fully downloaded: %s", dest)
64
+ return dest
65
+
66
+ resp.raise_for_status()
67
+
68
+ total_size: int | None = None
69
+ if resp.status_code == 206:
70
+ content_range = resp.headers.get("content-range", "")
71
+ if "/" in content_range:
72
+ total_size = int(content_range.split("/")[-1])
73
+ mode = "ab"
74
+ else:
75
+ cl = resp.headers.get("content-length")
76
+ total_size = int(cl) if cl else None
77
+ existing_size = 0
78
+ mode = "wb"
79
+
80
+ progress = Progress(
81
+ TextColumn("[bold blue]{task.description}"),
82
+ BarColumn(),
83
+ DownloadColumn(),
84
+ TransferSpeedColumn(),
85
+ TimeRemainingColumn(),
86
+ )
87
+
88
+ filename = Path(url.split("/")[-1].split("?")[0]).name
89
+ with progress:
90
+ task = progress.add_task(
91
+ filename[:40],
92
+ total=total_size,
93
+ completed=existing_size,
94
+ )
95
+
96
+ with open(dest, mode) as f:
97
+ for chunk in resp.iter_bytes(chunk_size=chunk_size):
98
+ f.write(chunk)
99
+ progress.advance(task, len(chunk))
100
+
101
+ return dest
102
+
103
+
104
+ def validate_file_size(path: str, expected_size: int) -> bool:
105
+ """Check if downloaded file matches expected size.
106
+
107
+ Parameters
108
+ ----------
109
+ path : str
110
+ Path to the downloaded file.
111
+ expected_size : int
112
+ Expected file size in bytes.
113
+
114
+ Returns
115
+ -------
116
+ bool
117
+ True if file size matches.
118
+
119
+ """
120
+ actual = Path(path).stat().st_size
121
+ return actual == expected_size
@@ -0,0 +1 @@
1
+ """Modelfile generation - chat templates and model family detection."""
@@ -0,0 +1,90 @@
1
+ """Model family detection and Modelfile generation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from pathlib import Path
7
+
8
+ from hf2ollama.modelfile.templates import TEMPLATES
9
+
10
+
11
+ def detect_family(filename: str) -> str:
12
+ """Detect model family from GGUF filename."""
13
+ lower = filename.lower()
14
+
15
+ if re.search(r"deepseek|ds[-_]?r1", lower):
16
+ return "deepseek"
17
+ if re.search(r"llama[-_]?4", lower):
18
+ return "llama4"
19
+ if re.search(r"llama[-_]?3", lower):
20
+ return "llama3"
21
+ if re.search(r"gemma[-_]?[234]?", lower) and "gemma" in lower:
22
+ return "gemma"
23
+ if re.search(r"qwen|qwq", lower):
24
+ return "qwen"
25
+ if re.search(r"mistral|mixtral|nemo", lower):
26
+ return "mistral"
27
+ if re.search(r"phi[-_]?[34]?", lower) and "phi" in lower:
28
+ return "phi"
29
+ if "granite" in lower:
30
+ return "granite"
31
+ if re.search(r"command[-_]?r|c4ai", lower):
32
+ return "command-r"
33
+ if re.search(r"starcoder|codellama", lower):
34
+ return "qwen"
35
+
36
+ return "chatml"
37
+
38
+
39
+ def sanitize_model_name(filename: str) -> str:
40
+ """Generate an Ollama model name from a GGUF filename."""
41
+ base = re.sub(r"\.gguf$", "", filename, flags=re.IGNORECASE)
42
+
43
+ quant_match = re.search(r"[-_](Q\d[^.-]*)", base, re.IGNORECASE)
44
+ tag = ""
45
+ if quant_match:
46
+ tag = quant_match.group(1).lower()
47
+ base = base[: quant_match.start()]
48
+
49
+ name = re.sub(r"[^a-z0-9._-]", "-", base.lower())
50
+ name = re.sub(r"-+", "-", name).strip("-")
51
+
52
+ if tag:
53
+ name = f"{name}:{tag}"
54
+
55
+ return name
56
+
57
+
58
+ def generate_modelfile(
59
+ gguf_path: str,
60
+ family: str | None = None,
61
+ temperature: float = 0.7,
62
+ top_p: float = 0.9,
63
+ ) -> str:
64
+ """Generate an Ollama Modelfile for a GGUF file."""
65
+ filename = Path(gguf_path).name
66
+
67
+ if family is None:
68
+ family = detect_family(filename)
69
+
70
+ tmpl = TEMPLATES.get(family, TEMPLATES["chatml"])
71
+
72
+ lines = [
73
+ f"FROM ./{filename}",
74
+ "",
75
+ f'TEMPLATE """{tmpl.template}"""',
76
+ "",
77
+ ]
78
+
79
+ for stop in tmpl.stop_tokens:
80
+ lines.append(f'PARAMETER stop "{stop}"')
81
+
82
+ lines.extend(
83
+ [
84
+ "",
85
+ f"PARAMETER temperature {temperature}",
86
+ f"PARAMETER top_p {top_p}",
87
+ ]
88
+ )
89
+
90
+ return "\n".join(lines)
@@ -0,0 +1,134 @@
1
+ """Chat templates and stop tokens for each model family.
2
+
3
+ Ported from ConvertToOllama.ps1. These are Go template strings that Ollama
4
+ interprets at runtime. The {{ .System }}, {{ .Prompt }}, {{ .Response }}
5
+ placeholders are Ollama's template variables.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from dataclasses import dataclass
11
+
12
+
13
+ @dataclass(frozen=True)
14
+ class ChatTemplate:
15
+ """Go template string and stop tokens for an Ollama Modelfile."""
16
+
17
+ template: str
18
+ stop_tokens: list[str]
19
+
20
+
21
+ TEMPLATES: dict[str, ChatTemplate] = {
22
+ "llama4": ChatTemplate(
23
+ template=(
24
+ "{{- if .System }}<|header_start|>system<|header_end|>\n\n"
25
+ "{{ .System }}<|eot|>{{- end }}\n"
26
+ "<|header_start|>user<|header_end|>\n\n"
27
+ "{{ .Prompt }}<|eot|>\n"
28
+ "<|header_start|>assistant<|header_end|>\n\n"
29
+ "{{ .Response }}<|eot|>"
30
+ ),
31
+ stop_tokens=["<|eot|>", "<|end_of_text|>"],
32
+ ),
33
+ "llama3": ChatTemplate(
34
+ template=(
35
+ "{{- if .System }}<|start_header_id|>system<|end_header_id|>\n\n"
36
+ "{{ .System }}<|eot_id|>{{- end }}\n"
37
+ "<|start_header_id|>user<|end_header_id|>\n\n"
38
+ "{{ .Prompt }}<|eot_id|>\n"
39
+ "<|start_header_id|>assistant<|end_header_id|>\n\n"
40
+ "{{ .Response }}<|eot_id|>"
41
+ ),
42
+ stop_tokens=["<|eot_id|>", "<|end_of_text|>"],
43
+ ),
44
+ "gemma": ChatTemplate(
45
+ template=(
46
+ "{{- if .System }}<start_of_turn>user\n"
47
+ "{{ .System }}\n\n"
48
+ "{{ .Prompt }}<end_of_turn>\n"
49
+ "<start_of_turn>model\n"
50
+ "{{ .Response }}<end_of_turn>{{- else }}\n"
51
+ "<start_of_turn>user\n"
52
+ "{{ .Prompt }}<end_of_turn>\n"
53
+ "<start_of_turn>model\n"
54
+ "{{ .Response }}<end_of_turn>{{- end }}"
55
+ ),
56
+ stop_tokens=["<end_of_turn>"],
57
+ ),
58
+ "qwen": ChatTemplate(
59
+ template=(
60
+ "{{- if .System }}<|im_start|>system\n"
61
+ "{{ .System }}<|im_end|>\n"
62
+ "{{- end }}\n"
63
+ "<|im_start|>user\n"
64
+ "{{ .Prompt }}<|im_end|>\n"
65
+ "<|im_start|>assistant\n"
66
+ "{{ .Response }}<|im_end|>"
67
+ ),
68
+ stop_tokens=["<|im_end|>", "<|endoftext|>"],
69
+ ),
70
+ "mistral": ChatTemplate(
71
+ template=(
72
+ "{{- if .System }}[INST] {{ .System }}\n\n"
73
+ "{{ .Prompt }} [/INST]{{ .Response }}"
74
+ "{{- else }}[INST] {{ .Prompt }} [/INST]{{ .Response }}{{- end }}"
75
+ ),
76
+ stop_tokens=["[INST]", "</s>"],
77
+ ),
78
+ "phi": ChatTemplate(
79
+ template=(
80
+ "{{- if .System }}<|system|>\n"
81
+ "{{ .System }}<|end|>\n"
82
+ "{{- end }}\n"
83
+ "<|user|>\n"
84
+ "{{ .Prompt }}<|end|>\n"
85
+ "<|assistant|>\n"
86
+ "{{ .Response }}<|end|>"
87
+ ),
88
+ stop_tokens=["<|end|>", "<|endoftext|>"],
89
+ ),
90
+ "deepseek": ChatTemplate(
91
+ template=(
92
+ "{{- if .System }}<|begin▁of▁sentence|>"
93
+ "{{ .System }}\n{{- end }}\n"
94
+ "<|User|>{{ .Prompt }}\n"
95
+ "<|Assistant|>{{ .Response }}<|end▁of▁sentence|>"
96
+ ),
97
+ stop_tokens=["<|end▁of▁sentence|>"],
98
+ ),
99
+ "granite": ChatTemplate(
100
+ template=(
101
+ "{{- if .System }}<|start_of_role|>system<|end_of_role|>\n"
102
+ "{{ .System }}<|end_of_text|>\n"
103
+ "{{- end }}\n"
104
+ "<|start_of_role|>user<|end_of_role|>\n"
105
+ "{{ .Prompt }}<|end_of_text|>\n"
106
+ "<|start_of_role|>assistant<|end_of_role|>\n"
107
+ "{{ .Response }}<|end_of_text|>"
108
+ ),
109
+ stop_tokens=["<|end_of_text|>"],
110
+ ),
111
+ "command-r": ChatTemplate(
112
+ template=(
113
+ "{{- if .System }}<|START_OF_TURN_TOKEN|><|SYSTEM_TOKEN|>"
114
+ "{{ .System }}<|END_OF_TURN_TOKEN|>{{- end }}\n"
115
+ "<|START_OF_TURN_TOKEN|><|USER_TOKEN|>{{ .Prompt }}<|END_OF_TURN_TOKEN|>\n"
116
+ "<|START_OF_TURN_TOKEN|><|CHATBOT_TOKEN|>{{ .Response }}<|END_OF_TURN_TOKEN|>"
117
+ ),
118
+ stop_tokens=["<|END_OF_TURN_TOKEN|>"],
119
+ ),
120
+ "chatml": ChatTemplate(
121
+ template=(
122
+ "{{- if .System }}<|im_start|>system\n"
123
+ "{{ .System }}<|im_end|>\n"
124
+ "{{- end }}\n"
125
+ "<|im_start|>user\n"
126
+ "{{ .Prompt }}<|im_end|>\n"
127
+ "<|im_start|>assistant\n"
128
+ "{{ .Response }}<|im_end|>"
129
+ ),
130
+ stop_tokens=["<|im_end|>"],
131
+ ),
132
+ }
133
+
134
+ SUPPORTED_FAMILIES = list(TEMPLATES.keys())
@@ -0,0 +1 @@
1
+ """Network modules - environment detection and SSL certificate patching."""
@@ -0,0 +1,58 @@
1
+ """Network environment detection - SSL and HuggingFace reachability checks."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import os
7
+ import ssl
8
+ from dataclasses import dataclass
9
+
10
+ import httpx
11
+
12
+ logger = logging.getLogger(__name__)
13
+
14
+
15
+ @dataclass
16
+ class NetworkStatus:
17
+ """Result of a network diagnostic check."""
18
+
19
+ hf_reachable: bool = False
20
+ hf_ssl_ok: bool = False
21
+ hf_error: str = ""
22
+ proxy_configured: bool = False
23
+ proxy_url: str = ""
24
+ system_certs_available: bool = False
25
+ cert_count: int = 0
26
+
27
+
28
+ def check_network() -> NetworkStatus:
29
+ """Check HuggingFace reachability and SSL certificate status."""
30
+ status = NetworkStatus()
31
+
32
+ status.proxy_url = os.environ.get("HTTPS_PROXY", os.environ.get("https_proxy", ""))
33
+ status.proxy_configured = bool(status.proxy_url)
34
+
35
+ try:
36
+ ctx = ssl.create_default_context()
37
+ certs = ctx.get_ca_certs(binary_form=True)
38
+ status.cert_count = len(certs)
39
+ status.system_certs_available = status.cert_count > 50
40
+ except (ssl.SSLError, OSError) as e:
41
+ logger.debug("Failed to read system certs: %s", e)
42
+
43
+ try:
44
+ httpx.get("https://huggingface.co/api/models?limit=1", timeout=10.0)
45
+ status.hf_reachable = True
46
+ status.hf_ssl_ok = True
47
+ except httpx.ConnectError as e:
48
+ err = str(e).lower()
49
+ if "ssl" in err or "certificate" in err:
50
+ status.hf_reachable = False
51
+ status.hf_ssl_ok = False
52
+ status.hf_error = "SSL certificate verification failed"
53
+ else:
54
+ status.hf_error = str(e)[:100]
55
+ except (httpx.TimeoutException, httpx.HTTPStatusError, OSError) as e:
56
+ status.hf_error = str(e)[:100]
57
+
58
+ return status
@@ -0,0 +1,76 @@
1
+ """SSL certificate configuration - use OS trust store instead of certifi defaults."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import os
7
+ import ssl
8
+ import tempfile
9
+ from pathlib import Path
10
+
11
+ logger = logging.getLogger(__name__)
12
+ _PATCHED = False
13
+
14
+
15
+ def apply_ssl_fix(
16
+ ca_bundle: str | None = None,
17
+ proxy: str | None = None,
18
+ ) -> str:
19
+ """Configure Python to use the OS certificate store for SSL verification.
20
+
21
+ Exports system CA certificates and sets environment variables so that
22
+ httpx, requests, and urllib3 use the OS trust store.
23
+
24
+ Returns the path to the CA bundle being used.
25
+ """
26
+ global _PATCHED
27
+ if _PATCHED:
28
+ return os.environ.get("SSL_CERT_FILE", "")
29
+
30
+ bundle = ca_bundle or _export_system_certs()
31
+ os.environ["SSL_CERT_FILE"] = bundle
32
+ os.environ["REQUESTS_CA_BUNDLE"] = bundle
33
+ os.environ["CURL_CA_BUNDLE"] = bundle
34
+
35
+ if proxy:
36
+ os.environ["HTTPS_PROXY"] = proxy
37
+ os.environ["HTTP_PROXY"] = proxy
38
+
39
+ _PATCHED = True
40
+ return bundle
41
+
42
+
43
+ def _export_system_certs() -> str:
44
+ """Export system certificates to a PEM file."""
45
+ certs: set[str] = set()
46
+
47
+ ctx = ssl.create_default_context()
48
+ for cert_der in ctx.get_ca_certs(binary_form=True):
49
+ pem = ssl.DER_cert_to_PEM_cert(cert_der)
50
+ certs.add(pem)
51
+
52
+ try:
53
+ import certifi
54
+
55
+ with open(certifi.where(), "r", encoding="utf-8") as f:
56
+ content = f.read()
57
+ in_cert = False
58
+ current: list[str] = []
59
+ for line in content.splitlines(True):
60
+ if "BEGIN CERTIFICATE" in line:
61
+ in_cert = True
62
+ current = [line]
63
+ elif "END CERTIFICATE" in line:
64
+ current.append(line)
65
+ certs.add("".join(current))
66
+ in_cert = False
67
+ elif in_cert:
68
+ current.append(line)
69
+ except ImportError:
70
+ pass
71
+
72
+ cert_dir = Path(tempfile.gettempdir()) / "hf2ollama"
73
+ cert_dir.mkdir(exist_ok=True)
74
+ bundle = cert_dir / "ca_bundle.pem"
75
+ bundle.write_text("\n".join(sorted(certs)), encoding="utf-8")
76
+ return str(bundle)