pylolm 0.1.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.
pylolm/__init__.py ADDED
@@ -0,0 +1,36 @@
1
+ """pylolm (LoLm): run GGUF language models on-device on Android, from Python.
2
+
3
+ An Android-friendly take on llama-cpp-python, built on llama.cpp and designed for
4
+ Kivy, Flet and Termux.
5
+ """
6
+
7
+ from .errors import (
8
+ ContextOverflowError,
9
+ DownloadError,
10
+ GenerationError,
11
+ LibraryNotFoundError,
12
+ ModelLoadError,
13
+ PyLoLmError,
14
+ )
15
+ from .llama import Llama
16
+
17
+ try:
18
+ from importlib.metadata import PackageNotFoundError, version as _version
19
+
20
+ try:
21
+ __version__ = _version("pylolm")
22
+ except PackageNotFoundError: # running from a source checkout
23
+ __version__ = "0.0.0+local"
24
+ except ImportError: # pragma: no cover
25
+ __version__ = "0.0.0+local"
26
+
27
+ __all__ = [
28
+ "Llama",
29
+ "PyLoLmError",
30
+ "LibraryNotFoundError",
31
+ "ModelLoadError",
32
+ "ContextOverflowError",
33
+ "DownloadError",
34
+ "GenerationError",
35
+ "__version__",
36
+ ]
pylolm/__main__.py ADDED
@@ -0,0 +1,168 @@
1
+ """Command line: ``python -m pylolm {doctor,get-libs,build-native,chat}`` (also ``pylolm``)."""
2
+
3
+ import argparse
4
+ import platform
5
+ import subprocess
6
+ import sys
7
+ import time
8
+ from pathlib import Path
9
+
10
+ from . import __version__, _lib
11
+ from .errors import DownloadError, PyLoLmError
12
+
13
+
14
+ def cmd_doctor(_args) -> int:
15
+ print("pylolm %s" % __version__)
16
+ print("python %s on %s (%s)" % (platform.python_version(), platform.system(), platform.machine()))
17
+ print("search order for native libraries:")
18
+ for d in _lib.candidate_dirs():
19
+ found = (d / _lib.SHIM_NAME).is_file()
20
+ print(" [%s] %s" % ("x" if found else " ", d))
21
+ try:
22
+ _lib.load()
23
+ except PyLoLmError as exc:
24
+ print("\nNOT READY: %s" % exc)
25
+ return 1
26
+ print("\nOK: native libraries loaded from %s" % _lib.loaded_dir())
27
+ return 0
28
+
29
+
30
+ def cmd_get_libs(args) -> int:
31
+ from . import _download
32
+
33
+ pending = [False] # a progress line is on screen without a trailing newline
34
+
35
+ def progress(done, total):
36
+ pending[0] = True
37
+ mb = done / 1e6
38
+ text = " %.1f / %.1f MB" % (mb, total / 1e6) if total else " %.1f MB" % mb
39
+ print("\r" + text, end="", flush=True)
40
+
41
+ def log(message):
42
+ if pending[0]:
43
+ print()
44
+ pending[0] = False
45
+ print(message)
46
+
47
+ try:
48
+ _download.get_libs(
49
+ version=args.version,
50
+ repo=args.repo,
51
+ base_url=args.base_url,
52
+ force=args.force,
53
+ progress=progress,
54
+ log=log,
55
+ )
56
+ except DownloadError as exc:
57
+ if pending[0]:
58
+ print()
59
+ print("error: %s" % exc, file=sys.stderr)
60
+ return 1
61
+ print("\nNext: pylolm doctor")
62
+ return 0
63
+
64
+
65
+ def cmd_build_native(_args) -> int:
66
+ script = Path(__file__).resolve().parent / "native" / "build_termux.sh"
67
+ if not script.is_file():
68
+ print("build script not found: %s" % script, file=sys.stderr)
69
+ return 1
70
+ return subprocess.call(["bash", str(script)])
71
+
72
+
73
+ def cmd_chat(args) -> int:
74
+ from .llama import Llama
75
+
76
+ try:
77
+ llm = Llama(args.model, n_ctx=args.n_ctx, n_threads=args.threads)
78
+ except (PyLoLmError, FileNotFoundError) as exc:
79
+ print("error: %s" % exc, file=sys.stderr)
80
+ return 1
81
+
82
+ history = []
83
+ if args.system:
84
+ history.append({"role": "system", "content": args.system})
85
+ print("pylolm chat - /reset clears history, /exit quits.\n")
86
+
87
+ while True:
88
+ try:
89
+ user = input("you> ").strip()
90
+ except (EOFError, KeyboardInterrupt):
91
+ print()
92
+ break
93
+ if not user:
94
+ continue
95
+ if user == "/exit":
96
+ break
97
+ if user == "/reset":
98
+ history = history[:1] if args.system else []
99
+ llm.reset()
100
+ print("(history cleared)")
101
+ continue
102
+
103
+ history.append({"role": "user", "content": user})
104
+ reply = []
105
+ t0 = time.time()
106
+ n = 0
107
+ try:
108
+ print("bot> ", end="", flush=True)
109
+ for chunk in llm.create_chat_completion(
110
+ history, max_tokens=args.max_tokens, temperature=args.temp, stream=True
111
+ ):
112
+ text = chunk["choices"][0]["delta"].get("content")
113
+ if text:
114
+ n += 1
115
+ reply.append(text)
116
+ print(text, end="", flush=True)
117
+ except KeyboardInterrupt:
118
+ llm.stop()
119
+ print("\n(interrupted)")
120
+ except PyLoLmError as exc:
121
+ print("\nerror: %s" % exc)
122
+ history.pop()
123
+ continue
124
+ dt = max(time.time() - t0, 1e-6)
125
+ print("\n[%d chunks, %.1f/s]\n" % (n, n / dt))
126
+ history.append({"role": "assistant", "content": "".join(reply)})
127
+
128
+ llm.close()
129
+ return 0
130
+
131
+
132
+ def main(argv=None) -> int:
133
+ parser = argparse.ArgumentParser(prog="pylolm", description="Local LLMs on Android, from Python")
134
+ parser.add_argument("--version", action="version", version="pylolm " + __version__)
135
+ sub = parser.add_subparsers(dest="command")
136
+
137
+ sub.add_parser("doctor", help="check that the native libraries can be found and loaded")
138
+ sub.add_parser("build-native", help="build llama.cpp + the shim on this device (slow, Termux)")
139
+
140
+ get = sub.add_parser("get-libs", help="download the prebuilt native libraries (fast)")
141
+ get.add_argument("--version", default=None, help="release to download (default: this pylolm)")
142
+ get.add_argument("--repo", default=None, help="GitHub repo as owner/name")
143
+ get.add_argument("--base-url", default=None, help="download from this URL instead of GitHub")
144
+ get.add_argument("--force", action="store_true", help="replace existing libraries")
145
+
146
+ chat = sub.add_parser("chat", help="interactive chat in the terminal")
147
+ chat.add_argument("model", help="path to a .gguf file")
148
+ chat.add_argument("--n-ctx", type=int, default=2048)
149
+ chat.add_argument("--threads", type=int, default=None)
150
+ chat.add_argument("--system", default=None, help="system prompt")
151
+ chat.add_argument("--max-tokens", type=int, default=512)
152
+ chat.add_argument("--temp", type=float, default=0.7)
153
+
154
+ args = parser.parse_args(argv)
155
+ handlers = {
156
+ "doctor": cmd_doctor,
157
+ "get-libs": cmd_get_libs,
158
+ "build-native": cmd_build_native,
159
+ "chat": cmd_chat,
160
+ }
161
+ if args.command not in handlers:
162
+ parser.print_help()
163
+ return 0
164
+ return handlers[args.command](args)
165
+
166
+
167
+ if __name__ == "__main__":
168
+ sys.exit(main())
pylolm/_download.py ADDED
@@ -0,0 +1,184 @@
1
+ """Download the prebuilt native libraries from a GitHub Release (``pylolm get-libs``).
2
+
3
+ Safety: the archive is only used if its SHA-256 matches the checksum file published next to
4
+ it, and extraction only accepts plain ``*.so`` files (no paths, links or other entry types).
5
+ """
6
+
7
+ import hashlib
8
+ import os
9
+ import platform
10
+ import shutil
11
+ import tarfile
12
+ import tempfile
13
+ import urllib.error
14
+ import urllib.request
15
+ from pathlib import Path
16
+ from typing import Callable, List, Optional
17
+
18
+ from .errors import DownloadError
19
+
20
+ ASSET = "pylolm-libs-android-arm64-v8a.tar.gz"
21
+ DEFAULT_REPO = "auraway/pylolm"
22
+ SHIM_NAME = "libpylolm_shim.so"
23
+ MAX_BYTES = 300 * 1024 * 1024 # refuse absurdly large downloads
24
+
25
+ Progress = Callable[[int, int], None]
26
+
27
+
28
+ def default_dest() -> Path:
29
+ """Same place the loader and `pylolm build-native` use."""
30
+ env = os.environ.get("PYLOLM_LIB_DIR")
31
+ return Path(env).expanduser() if env else Path.home() / ".pylolm" / "lib"
32
+
33
+
34
+ def release_base_url(version: str, repo: Optional[str] = None) -> str:
35
+ repo = repo or os.environ.get("PYLOLM_RELEASE_REPO") or DEFAULT_REPO
36
+ return "https://github.com/%s/releases/download/v%s" % (repo, version)
37
+
38
+
39
+ def _fetch(url: str, target: Path, progress: Optional[Progress] = None) -> None:
40
+ req = urllib.request.Request(url, headers={"User-Agent": "pylolm-get-libs"})
41
+ try:
42
+ with urllib.request.urlopen(req, timeout=30) as resp, open(str(target), "wb") as out:
43
+ total = int(resp.headers.get("Content-Length") or 0)
44
+ done = 0
45
+ while True:
46
+ block = resp.read(1 << 16)
47
+ if not block:
48
+ break
49
+ done += len(block)
50
+ if done > MAX_BYTES:
51
+ raise DownloadError("download is larger than expected; aborting")
52
+ out.write(block)
53
+ if progress:
54
+ progress(done, total)
55
+ except urllib.error.HTTPError as exc:
56
+ if exc.code == 404:
57
+ raise DownloadError(
58
+ "not found (404): %s\n"
59
+ "Has this version been released with prebuilt libraries yet? "
60
+ "Use `pylolm build-native` instead." % url
61
+ )
62
+ raise DownloadError("HTTP error %d for %s" % (exc.code, url))
63
+ except (urllib.error.URLError, OSError) as exc:
64
+ raise DownloadError("could not download %s: %s" % (url, exc))
65
+
66
+
67
+ def _sha256_of(path: Path) -> str:
68
+ h = hashlib.sha256()
69
+ with open(str(path), "rb") as f:
70
+ for block in iter(lambda: f.read(1 << 20), b""):
71
+ h.update(block)
72
+ return h.hexdigest()
73
+
74
+
75
+ def _expected_sha256(path: Path) -> str:
76
+ try:
77
+ token = path.read_text().split()[0].lower()
78
+ except (IndexError, UnicodeDecodeError, OSError):
79
+ raise DownloadError("checksum file is empty or unreadable")
80
+ if len(token) != 64 or any(c not in "0123456789abcdef" for c in token):
81
+ raise DownloadError("checksum file does not contain a SHA-256 hash")
82
+ return token
83
+
84
+
85
+ def _safe_extract(archive: Path, out_dir: Path) -> List[str]:
86
+ """Extract plain *.so files only, writing them ourselves (no tar path handling)."""
87
+ names: List[str] = []
88
+ try:
89
+ with tarfile.open(str(archive), "r:gz") as tar:
90
+ members = tar.getmembers()
91
+ if not members:
92
+ raise DownloadError("archive is empty")
93
+ for m in members:
94
+ name = m.name
95
+ if (
96
+ not m.isfile()
97
+ or os.path.basename(name) != name
98
+ or name.startswith(".")
99
+ or not name.endswith(".so")
100
+ ):
101
+ raise DownloadError("unexpected entry in archive: %r" % name)
102
+ for m in members:
103
+ src = tar.extractfile(m)
104
+ if src is None:
105
+ raise DownloadError("could not read %r from archive" % m.name)
106
+ target = out_dir / m.name
107
+ with open(str(target), "wb") as dst:
108
+ shutil.copyfileobj(src, dst)
109
+ os.chmod(str(target), 0o755)
110
+ names.append(m.name)
111
+ except (tarfile.TarError, EOFError, OSError) as exc:
112
+ raise DownloadError("archive is corrupt: %s" % exc)
113
+ if SHIM_NAME not in names:
114
+ raise DownloadError("archive does not contain %s" % SHIM_NAME)
115
+ return names
116
+
117
+
118
+ def get_libs(
119
+ version: Optional[str] = None,
120
+ repo: Optional[str] = None,
121
+ base_url: Optional[str] = None,
122
+ dest: Optional[Path] = None,
123
+ force: bool = False,
124
+ progress: Optional[Progress] = None,
125
+ log: Callable[[str], None] = print,
126
+ ) -> Path:
127
+ """Download, verify and install the prebuilt libraries. Returns the install directory."""
128
+ machine = platform.machine().lower()
129
+ if machine not in ("aarch64", "arm64") and not force:
130
+ raise DownloadError(
131
+ "the prebuilt libraries are for Android arm64 (aarch64), but this device "
132
+ "reports %r. Use --force to install anyway, or run `pylolm build-native`." % machine
133
+ )
134
+
135
+ if base_url is None:
136
+ if version is None:
137
+ from . import __version__
138
+
139
+ if "+" in __version__:
140
+ raise DownloadError(
141
+ "this looks like a source checkout (version %s); pass --version X.Y.Z"
142
+ % __version__
143
+ )
144
+ version = __version__
145
+ base_url = release_base_url(version, repo)
146
+ base_url = base_url.rstrip("/")
147
+
148
+ dest = Path(dest) if dest else default_dest()
149
+ if (dest / SHIM_NAME).is_file() and not force:
150
+ raise DownloadError(
151
+ "native libraries are already installed in %s. Use --force to replace them." % dest
152
+ )
153
+
154
+ dest.parent.mkdir(parents=True, exist_ok=True)
155
+ staging = Path(tempfile.mkdtemp(prefix=".pylolm-get-libs-", dir=str(dest.parent)))
156
+ try:
157
+ archive = staging / ASSET
158
+ checksum = staging / (ASSET + ".sha256")
159
+
160
+ log("Downloading %s ..." % ASSET)
161
+ _fetch("%s/%s" % (base_url, ASSET), archive, progress)
162
+ _fetch("%s/%s.sha256" % (base_url, ASSET), checksum)
163
+
164
+ log("Verifying checksum ...")
165
+ expected, actual = _expected_sha256(checksum), _sha256_of(archive)
166
+ if expected != actual:
167
+ raise DownloadError(
168
+ "checksum mismatch (expected %s..., got %s...); the file was NOT installed"
169
+ % (expected[:12], actual[:12])
170
+ )
171
+
172
+ unpacked = staging / "unpacked"
173
+ unpacked.mkdir()
174
+ names = _safe_extract(archive, unpacked)
175
+
176
+ dest.mkdir(parents=True, exist_ok=True)
177
+ for old in dest.glob("*.so"):
178
+ old.unlink()
179
+ for name in names:
180
+ shutil.move(str(unpacked / name), str(dest / name))
181
+ log("Installed %d libraries to %s" % (len(names), dest))
182
+ return dest
183
+ finally:
184
+ shutil.rmtree(str(staging), ignore_errors=True)
pylolm/_lib.py ADDED
@@ -0,0 +1,155 @@
1
+ """Locate and load the native libraries, and declare the ctypes signatures of the shim."""
2
+
3
+ import ctypes
4
+ import os
5
+ import threading
6
+ from pathlib import Path
7
+ from typing import Iterator, List
8
+
9
+ from .errors import LibraryNotFoundError
10
+
11
+ SHIM_ABI_VERSION = 1
12
+ SHIM_NAME = "libpylolm_shim.so"
13
+
14
+ # Loaded in this order with RTLD_GLOBAL so the dynamic linker finds them by soname.
15
+ # (name, required)
16
+ _DEPENDENCIES = [
17
+ ("libggml-base.so", True),
18
+ ("libggml-cpu.so", False), # only present when CPU variants are NOT used
19
+ ("libggml.so", True),
20
+ ("libllama.so", True),
21
+ ]
22
+
23
+ # void callback(const char *piece, int len, void *user) -> int (non-zero = stop)
24
+ TOKEN_CB = ctypes.CFUNCTYPE(ctypes.c_int, ctypes.c_void_p, ctypes.c_int, ctypes.c_void_p)
25
+
26
+ _lock = threading.Lock()
27
+ _shim = None
28
+ _lib_dir = None
29
+
30
+
31
+ def candidate_dirs() -> Iterator[Path]:
32
+ """Directories searched for the native libraries, in priority order."""
33
+ env = os.environ.get("PYLOLM_LIB_DIR")
34
+ if env:
35
+ yield Path(env).expanduser()
36
+ yield Path(__file__).resolve().parent / "lib"
37
+ try:
38
+ yield Path.home() / ".pylolm" / "lib"
39
+ except RuntimeError: # no resolvable home directory
40
+ pass
41
+
42
+
43
+ def find_lib_dir() -> Path:
44
+ searched: List[str] = []
45
+ for d in candidate_dirs():
46
+ searched.append(str(d))
47
+ if (d / SHIM_NAME).is_file():
48
+ return d
49
+ raise LibraryNotFoundError(
50
+ "pylolm native libraries not found (looked for %s in: %s).\n"
51
+ " - Download the prebuilt libraries (fast): pylolm get-libs\n"
52
+ " - Or build them on this device (slow, ~30 min): pylolm build-native\n"
53
+ " - Or point PYLOLM_LIB_DIR at a folder containing the .so files."
54
+ % (SHIM_NAME, ", ".join(searched))
55
+ )
56
+
57
+
58
+ def _bind(shim: ctypes.CDLL) -> None:
59
+ c = ctypes
60
+ sampling = [
61
+ c.c_float, # temperature
62
+ c.c_float, # top_p
63
+ c.c_int, # top_k
64
+ c.c_float, # min_p
65
+ c.c_float, # repeat_penalty
66
+ c.c_uint32, # seed
67
+ ]
68
+
69
+ shim.shim_abi_version.argtypes = []
70
+ shim.shim_abi_version.restype = c.c_int
71
+
72
+ shim.shim_set_verbose.argtypes = [c.c_int]
73
+ shim.shim_set_verbose.restype = None
74
+
75
+ shim.shim_init.argtypes = [c.c_char_p]
76
+ shim.shim_init.restype = c.c_int
77
+
78
+ shim.shim_load.argtypes = [c.c_char_p, c.c_int, c.c_int]
79
+ shim.shim_load.restype = c.c_void_p
80
+
81
+ shim.shim_stop.argtypes = [c.c_void_p]
82
+ shim.shim_stop.restype = None
83
+
84
+ shim.shim_reset.argtypes = [c.c_void_p]
85
+ shim.shim_reset.restype = None
86
+
87
+ shim.shim_n_ctx.argtypes = [c.c_void_p]
88
+ shim.shim_n_ctx.restype = c.c_int
89
+
90
+ shim.shim_count_tokens.argtypes = [c.c_void_p, c.c_char_p, c.c_int]
91
+ shim.shim_count_tokens.restype = c.c_int
92
+
93
+ shim.shim_last_stats.argtypes = [c.c_void_p, c.POINTER(c.c_int)]
94
+ shim.shim_last_stats.restype = None
95
+
96
+ shim.shim_chat.argtypes = (
97
+ [
98
+ c.c_void_p, # handle
99
+ c.POINTER(c.c_char_p), # roles
100
+ c.POINTER(c.c_char_p), # contents
101
+ c.c_int, # n_msgs
102
+ c.c_char_p, # template name or NULL
103
+ c.c_int, # max_tokens
104
+ ]
105
+ + sampling
106
+ + [TOKEN_CB, c.c_void_p]
107
+ )
108
+ shim.shim_chat.restype = c.c_int
109
+
110
+ shim.shim_complete.argtypes = (
111
+ [c.c_void_p, c.c_char_p, c.c_int] + sampling + [TOKEN_CB, c.c_void_p]
112
+ )
113
+ shim.shim_complete.restype = c.c_int
114
+
115
+ shim.shim_free.argtypes = [c.c_void_p]
116
+ shim.shim_free.restype = None
117
+
118
+
119
+ def load() -> ctypes.CDLL:
120
+ """Load (once) and return the shim library."""
121
+ global _shim, _lib_dir
122
+ with _lock:
123
+ if _shim is not None:
124
+ return _shim
125
+
126
+ d = find_lib_dir()
127
+ try:
128
+ for name, required in _DEPENDENCIES:
129
+ path = d / name
130
+ if path.is_file():
131
+ ctypes.CDLL(str(path), mode=ctypes.RTLD_GLOBAL)
132
+ elif required:
133
+ raise LibraryNotFoundError("%s is missing from %s" % (name, d))
134
+ shim = ctypes.CDLL(str(d / SHIM_NAME))
135
+ except OSError as exc:
136
+ raise LibraryNotFoundError("failed to load native libraries from %s: %s" % (d, exc))
137
+
138
+ _bind(shim)
139
+ abi = shim.shim_abi_version()
140
+ if abi != SHIM_ABI_VERSION:
141
+ raise LibraryNotFoundError(
142
+ "native shim ABI %d does not match pylolm (expects %d); rebuild it with "
143
+ "`pylolm build-native`" % (abi, SHIM_ABI_VERSION)
144
+ )
145
+
146
+ shim.shim_set_verbose(0)
147
+ shim.shim_init(str(d).encode())
148
+ _shim = shim
149
+ _lib_dir = d
150
+ return shim
151
+
152
+
153
+ def loaded_dir():
154
+ """Directory the libraries were loaded from, or None if not loaded yet."""
155
+ return _lib_dir
pylolm/errors.py ADDED
@@ -0,0 +1,25 @@
1
+ """Exceptions raised by pylolm."""
2
+
3
+
4
+ class PyLoLmError(Exception):
5
+ """Base class for all pylolm errors."""
6
+
7
+
8
+ class LibraryNotFoundError(PyLoLmError):
9
+ """The native libraries (libllama / libpylolm_shim) could not be found or loaded."""
10
+
11
+
12
+ class ModelLoadError(PyLoLmError):
13
+ """The GGUF model could not be loaded."""
14
+
15
+
16
+ class ContextOverflowError(PyLoLmError):
17
+ """The prompt does not fit in the context window (n_ctx)."""
18
+
19
+
20
+ class GenerationError(PyLoLmError):
21
+ """llama.cpp failed while generating."""
22
+
23
+
24
+ class DownloadError(PyLoLmError):
25
+ """Downloading or installing the prebuilt native libraries failed."""