simplex-chat 6.5.1__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.
@@ -0,0 +1,59 @@
1
+ """SimpleX Chat — Python client library for chat bots."""
2
+
3
+ from ._version import __version__
4
+ from .api import ChatApi, ChatCommandError, ConnReqType, Db, PostgresDb, SqliteDb
5
+ from .bot import (
6
+ Bot,
7
+ BotCommand,
8
+ BotProfile,
9
+ ChatMessage,
10
+ CommandHandler,
11
+ EventHandler,
12
+ FileMessage,
13
+ ImageMessage,
14
+ LinkMessage,
15
+ Message,
16
+ MessageHandler,
17
+ Middleware,
18
+ ParsedCommand,
19
+ ReportMessage,
20
+ TextMessage,
21
+ UnknownMessage,
22
+ VideoMessage,
23
+ VoiceMessage,
24
+ )
25
+ from .core import ChatAPIError, ChatInitError, CryptoArgs, MigrationConfirmation
26
+ from . import util as util # re-export the util namespace
27
+
28
+ __all__ = [
29
+ "__version__",
30
+ "Bot",
31
+ "BotCommand",
32
+ "BotProfile",
33
+ "ChatAPIError",
34
+ "ChatApi",
35
+ "ChatCommandError",
36
+ "ChatInitError",
37
+ "ChatMessage",
38
+ "CommandHandler",
39
+ "ConnReqType",
40
+ "CryptoArgs",
41
+ "Db",
42
+ "EventHandler",
43
+ "FileMessage",
44
+ "ImageMessage",
45
+ "LinkMessage",
46
+ "Message",
47
+ "MessageHandler",
48
+ "Middleware",
49
+ "MigrationConfirmation",
50
+ "ParsedCommand",
51
+ "PostgresDb",
52
+ "ReportMessage",
53
+ "SqliteDb",
54
+ "TextMessage",
55
+ "UnknownMessage",
56
+ "VideoMessage",
57
+ "VoiceMessage",
58
+ "util",
59
+ ]
@@ -0,0 +1,35 @@
1
+ """CLI: ``python -m simplex_chat install [--backend=sqlite|postgres]``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import sys
7
+
8
+ from . import _native
9
+
10
+
11
+ def main(argv: list[str] | None = None) -> int:
12
+ p = argparse.ArgumentParser(prog="simplex_chat")
13
+ sub = p.add_subparsers(dest="command", required=True)
14
+ install = sub.add_parser("install", help="Pre-fetch libsimplex into the user cache")
15
+ install.add_argument(
16
+ "--backend",
17
+ choices=["sqlite", "postgres"],
18
+ default="sqlite",
19
+ help="which libsimplex variant to download (default: sqlite)",
20
+ )
21
+ args = p.parse_args(argv)
22
+ # `args.command` is always set: `add_subparsers(required=True)` makes
23
+ # argparse exit before reaching this point if no subcommand is given.
24
+ assert args.command == "install"
25
+ try:
26
+ path = _native._resolve_libs_dir(args.backend)
27
+ print(f"libsimplex installed at: {path}")
28
+ return 0
29
+ except Exception as e:
30
+ print(f"install failed: {e}", file=sys.stderr)
31
+ return 1
32
+
33
+
34
+ if __name__ == "__main__":
35
+ sys.exit(main())
@@ -0,0 +1,257 @@
1
+ """Native libsimplex loader: platform detection, lazy download, ctypes setup.
2
+
3
+ Internal — users interact with `Bot` / `ChatApi`, never with this module.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import ctypes
9
+ import errno
10
+ import os
11
+ import platform
12
+ import sys
13
+ import tempfile
14
+ import threading
15
+ import urllib.request
16
+ import zipfile
17
+ from ctypes import POINTER, c_char_p, c_int, c_uint8, c_void_p
18
+ from pathlib import Path
19
+ from typing import Literal
20
+
21
+ from ._version import LIBS_VERSION
22
+
23
+ Backend = Literal["sqlite", "postgres"]
24
+
25
+ _GITHUB_REPO = "simplex-chat/simplex-chat-libs"
26
+
27
+ _PLATFORM_MAP = {
28
+ "linux": ("linux", {"x86_64": "x86_64", "aarch64": "aarch64"}),
29
+ "darwin": ("macos", {"x86_64": "x86_64", "arm64": "aarch64"}),
30
+ "win32": ("windows", {"AMD64": "x86_64", "x86_64": "x86_64"}),
31
+ }
32
+
33
+ _LIBNAME = {"linux": "libsimplex.so", "darwin": "libsimplex.dylib", "win32": "libsimplex.dll"}
34
+
35
+ SUPPORTED = (
36
+ "linux-x86_64",
37
+ "linux-aarch64",
38
+ "macos-x86_64",
39
+ "macos-aarch64",
40
+ "windows-x86_64",
41
+ )
42
+
43
+
44
+ def _platform_tag() -> str:
45
+ info = _PLATFORM_MAP.get(sys.platform)
46
+ if not info:
47
+ raise RuntimeError(f"Unsupported platform: {sys.platform}")
48
+ sysname, archs = info
49
+ arch = archs.get(platform.machine())
50
+ if not arch:
51
+ raise RuntimeError(f"Unsupported architecture: {sys.platform}/{platform.machine()}")
52
+ tag = f"{sysname}-{arch}"
53
+ if tag not in SUPPORTED:
54
+ raise RuntimeError(f"Unsupported combination: {tag}; supported: {SUPPORTED}")
55
+ return tag
56
+
57
+
58
+ def _libname() -> str:
59
+ return _LIBNAME[sys.platform]
60
+
61
+
62
+ def _libs_url(backend: Backend) -> str:
63
+ suffix = "-postgres" if backend == "postgres" else ""
64
+ return (
65
+ f"https://github.com/{_GITHUB_REPO}/releases/download/"
66
+ f"v{LIBS_VERSION}/simplex-chat-libs-{_platform_tag()}{suffix}.zip"
67
+ )
68
+
69
+
70
+ def _cache_root() -> Path:
71
+ if sys.platform == "darwin":
72
+ return Path.home() / "Library" / "Caches" / "simplex-chat"
73
+ if sys.platform == "win32":
74
+ return Path(os.environ["LOCALAPPDATA"]) / "simplex-chat"
75
+ base = os.environ.get("XDG_CACHE_HOME") or str(Path.home() / ".cache")
76
+ return Path(base) / "simplex-chat"
77
+
78
+
79
+ def _resolve_libs_dir(backend: Backend) -> Path:
80
+ if override := os.environ.get("SIMPLEX_LIBS_DIR"):
81
+ return Path(override)
82
+ if backend == "postgres" and _platform_tag() != "linux-x86_64":
83
+ raise RuntimeError(
84
+ "postgres backend is only supported on linux-x86_64; "
85
+ f"current platform is {_platform_tag()}"
86
+ )
87
+ target = _cache_root() / f"v{LIBS_VERSION}" / backend
88
+ if not (target / _libname()).exists():
89
+ _download(target, backend)
90
+ return target
91
+
92
+
93
+ _DOWNLOAD_CHUNK = 1 << 16 # 64 KiB
94
+
95
+
96
+ def _stream_to_file(url: str, dest: Path, *, timeout: float = 60.0) -> None:
97
+ """Stream `url` → `dest`, printing a carriage-return progress bar.
98
+
99
+ `timeout` is per-request; we don't touch `socket.setdefaulttimeout`
100
+ so other socket users in the same process aren't affected.
101
+ """
102
+ with urllib.request.urlopen(url, timeout=timeout) as resp: # noqa: S310 - https://github.com/...
103
+ total = int(resp.headers.get("Content-Length") or 0)
104
+ received = 0
105
+ with dest.open("wb") as out:
106
+ while chunk := resp.read(_DOWNLOAD_CHUNK):
107
+ out.write(chunk)
108
+ received += len(chunk)
109
+ if total > 0:
110
+ pct = min(100, received * 100 // total)
111
+ msg = f"\r download: {received >> 20} / {total >> 20} MiB ({pct}%)"
112
+ else:
113
+ msg = f"\r download: {received >> 20} MiB"
114
+ print(msg, end="", file=sys.stderr, flush=True)
115
+ print("", file=sys.stderr, flush=True) # newline after final progress line
116
+
117
+
118
+ def _download(target: Path, backend: Backend) -> None:
119
+ """Download libs zip → atomic rename into `target`. Concurrent processes safe.
120
+
121
+ Atomicity strategy: each process extracts to its own sibling tempdir on the same
122
+ filesystem, then `os.rename` the `libs/` subdir to `target`. POSIX `os.rename`
123
+ onto a NON-EXISTENT path is atomic; if the target exists (another process won
124
+ the race), `os.rename` fails on most platforms — we then verify the winner has
125
+ what we need and proceed. NEVER rmtree the target: that creates a TOCTOU
126
+ window where another process is reading/loading the file we're deleting.
127
+ """
128
+ target.parent.mkdir(parents=True, exist_ok=True)
129
+ url = _libs_url(backend)
130
+ print(
131
+ f"Downloading libsimplex ({_platform_tag()}, {backend}) v{LIBS_VERSION} from {url} ...",
132
+ file=sys.stderr,
133
+ flush=True,
134
+ )
135
+ with tempfile.TemporaryDirectory(dir=target.parent) as tmp:
136
+ zip_path = Path(tmp) / "libs.zip"
137
+ _stream_to_file(url, zip_path, timeout=60.0)
138
+ with zipfile.ZipFile(zip_path) as zf:
139
+ zf.extractall(tmp)
140
+ # zip layout: <tmp>/libs/libsimplex.* + libHS*.*
141
+ extracted_libs = Path(tmp) / "libs"
142
+ if not extracted_libs.is_dir():
143
+ raise RuntimeError(f"libs/ missing from {_libs_url(backend)}")
144
+ try:
145
+ os.rename(extracted_libs, target)
146
+ except OSError as e:
147
+ # EEXIST / ENOTEMPTY mean another process won the race — fall through
148
+ # and check that the winner left a usable libsimplex behind. Anything
149
+ # else (ENOSPC, EACCES, EROFS, Windows codes mapped to None) is a real
150
+ # failure and must propagate. Same VERSION cached → same content →
151
+ # safe to proceed once we've confirmed the file is there.
152
+ if e.errno not in (errno.EEXIST, errno.ENOTEMPTY):
153
+ raise
154
+ if not (target / _libname()).exists():
155
+ raise RuntimeError(
156
+ f"another process partially populated {target} but libsimplex "
157
+ f"is missing; remove the directory manually and retry"
158
+ ) from e
159
+
160
+
161
+ _lock = threading.Lock()
162
+ _lib: ctypes.CDLL | None = None
163
+ _libc: ctypes.CDLL | None = None
164
+ _backend: Backend | None = None
165
+
166
+
167
+ def _load_libc() -> ctypes.CDLL:
168
+ if sys.platform == "win32":
169
+ return ctypes.CDLL("msvcrt")
170
+ return ctypes.CDLL(None) # libc on POSIX is the process's own symbol table
171
+
172
+
173
+ def _setup_signatures(lib: ctypes.CDLL) -> None:
174
+ """Declare argtypes/restype for the 8 chat_* functions exported by libsimplex.
175
+
176
+ All result strings come back as raw c_void_p so the caller can free them
177
+ after copying — matches HandleCResult in cpp/simplex.cc:157-165.
178
+ """
179
+ lib.chat_migrate_init.argtypes = [c_char_p, c_char_p, c_char_p, POINTER(c_void_p)]
180
+ lib.chat_migrate_init.restype = c_void_p
181
+ lib.chat_close_store.argtypes = [c_void_p]
182
+ lib.chat_close_store.restype = c_void_p
183
+ lib.chat_send_cmd.argtypes = [c_void_p, c_char_p]
184
+ lib.chat_send_cmd.restype = c_void_p
185
+ lib.chat_recv_msg_wait.argtypes = [c_void_p, c_int]
186
+ lib.chat_recv_msg_wait.restype = c_void_p
187
+ # chat_write_file's payload is treated read-only by libsimplex; passing
188
+ # `bytes` via c_char_p avoids the from_buffer_copy doubling. ctypes pins
189
+ # the bytes buffer for the duration of the call.
190
+ lib.chat_write_file.argtypes = [c_void_p, c_char_p, c_char_p, c_int]
191
+ lib.chat_write_file.restype = c_void_p
192
+ lib.chat_read_file.argtypes = [c_char_p, c_char_p, c_char_p]
193
+ lib.chat_read_file.restype = POINTER(c_uint8)
194
+ lib.chat_encrypt_file.argtypes = [c_void_p, c_char_p, c_char_p]
195
+ lib.chat_encrypt_file.restype = c_void_p
196
+ lib.chat_decrypt_file.argtypes = [c_char_p, c_char_p, c_char_p, c_char_p]
197
+ lib.chat_decrypt_file.restype = c_void_p
198
+
199
+
200
+ def _hs_init(lib: ctypes.CDLL) -> None:
201
+ """Initialize the Haskell runtime exactly once. Mirrors cpp/simplex.cc:13-32."""
202
+ if sys.platform == "win32":
203
+ argv_strs = [b"simplex", b"+RTS", b"-A64m", b"-H64m", b"--install-signal-handlers=no"]
204
+ else:
205
+ argv_strs = [
206
+ b"simplex",
207
+ b"+RTS",
208
+ b"-A64m",
209
+ b"-H64m",
210
+ b"-xn",
211
+ b"--install-signal-handlers=no",
212
+ ]
213
+ argc = c_int(len(argv_strs))
214
+ arr = (c_char_p * (len(argv_strs) + 1))(*argv_strs, None)
215
+ arr_ptr = ctypes.byref(ctypes.cast(arr, POINTER(c_char_p)))
216
+ lib.hs_init_with_rtsopts.argtypes = [POINTER(c_int), POINTER(POINTER(c_char_p))]
217
+ lib.hs_init_with_rtsopts.restype = None
218
+ lib.hs_init_with_rtsopts(ctypes.byref(argc), arr_ptr)
219
+
220
+
221
+ def lib_for(backend: Backend) -> ctypes.CDLL:
222
+ """Resolve, load, and initialize libsimplex for the given backend.
223
+
224
+ Idempotent for the same backend; raises if called with a different backend.
225
+ Concurrent calls serialize on the module-level lock.
226
+ """
227
+ global _lib, _libc, _backend
228
+ with _lock:
229
+ if _lib is not None:
230
+ if _backend != backend:
231
+ raise RuntimeError(
232
+ f"libsimplex already loaded with backend={_backend!r}; "
233
+ f"cannot switch to {backend!r} in the same process"
234
+ )
235
+ return _lib
236
+ libs_dir = _resolve_libs_dir(backend)
237
+ lib = ctypes.CDLL(str(libs_dir / _libname()))
238
+ _setup_signatures(lib)
239
+ _hs_init(lib)
240
+ _libc = _load_libc()
241
+ _lib = lib
242
+ _backend = backend
243
+ return lib
244
+
245
+
246
+ def libc() -> ctypes.CDLL:
247
+ """libc — needed by `core` to free Haskell-allocated result strings."""
248
+ if _libc is None:
249
+ raise RuntimeError("lib_for() must be called before libc()")
250
+ return _libc
251
+
252
+
253
+ def lib() -> ctypes.CDLL:
254
+ """Loaded libsimplex handle. Raises if `lib_for()` has not been called."""
255
+ if _lib is None:
256
+ raise RuntimeError("lib_for() must be called before lib()")
257
+ return _lib
@@ -0,0 +1,9 @@
1
+ """Single source of truth for both the Python package version and the
2
+ simplex-chat-libs release tag we depend on.
3
+
4
+ Bump both together for normal releases. For wrapper-only fixes use a PEP 440
5
+ post-release: __version__ = "6.5.1.post1", LIBS_VERSION unchanged.
6
+ """
7
+
8
+ __version__ = "6.5.1" # PEP 440 — read by hatchling for wheel metadata
9
+ LIBS_VERSION = "6.5.1" # simplex-chat-libs release tag (no 'v' prefix)