dreamtunnel 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.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 dreamcatchered
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.
@@ -0,0 +1,193 @@
1
+ Metadata-Version: 2.4
2
+ Name: dreamtunnel
3
+ Version: 0.1.0
4
+ Summary: Python SDK for dreamTunnel — instant public HTTPS tunnels
5
+ License: MIT
6
+ Project-URL: Homepage, https://tunnel.swag.best
7
+ Project-URL: Repository, https://github.com/dreamcatchered/dreamTunnel
8
+ Keywords: tunnel,ngrok,https,webhook,localhost,self-hosted
9
+ Classifier: Development Status :: 4 - Beta
10
+ Classifier: Intended Audience :: Developers
11
+ Classifier: License :: OSI Approved :: MIT License
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Programming Language :: Python :: 3.10
14
+ Classifier: Programming Language :: Python :: 3.11
15
+ Classifier: Programming Language :: Python :: 3.12
16
+ Classifier: Topic :: Internet :: WWW/HTTP
17
+ Classifier: Topic :: Software Development :: Libraries
18
+ Requires-Python: >=3.10
19
+ Description-Content-Type: text/markdown
20
+ License-File: LICENSE
21
+ Provides-Extra: test
22
+ Requires-Dist: pytest>=7; extra == "test"
23
+ Dynamic: license-file
24
+
25
+ # dreamtunnel (Python SDK)
26
+
27
+ Python-обёртка над CLI `dt`: запускает туннель **из кода**, отдаёт публичный
28
+ URL, присылает события (включая переподключения) и корректно всё останавливает.
29
+
30
+ Токен уже встроен в бинарник — после установки ничего настраивать не нужно.
31
+
32
+ ```bash
33
+ pip install "git+https://github.com/dreamcatchered/dreamTunnel.git#subdirectory=sdk/python"
34
+ ```
35
+
36
+ Из локальной копии репозитория — так же просто:
37
+
38
+ ```bash
39
+ git clone https://github.com/dreamcatchered/dreamTunnel
40
+ pip install -e dreamTunnel/sdk/python
41
+ ```
42
+
43
+ Если `dt` ещё нет, SDK скачает его сам при первом запуске.
44
+
45
+ ## Быстрый старт
46
+
47
+ ```python
48
+ from dreamtunnel import Tunnel
49
+
50
+ with Tunnel(port=8080) as tun:
51
+ print(tun.url) # https://k7f3m9ab.maiuni.online
52
+ ... # туннель живёт, пока мы внутри блока
53
+ # выход из блока — туннель закрыт
54
+ ```
55
+
56
+ Свой поддомен:
57
+
58
+ ```python
59
+ with Tunnel(port=8080, subdomain="my-api") as tun:
60
+ print(tun.url) # https://my-api.maiuni.online
61
+ ```
62
+
63
+ ## Управление вручную
64
+
65
+ ```python
66
+ tun = Tunnel(port=3000, subdomain="dev")
67
+ tun.start() # ждёт, пока релей подтвердит туннель
68
+ print(tun.url, tun.host, tun.local_url, tun.pid)
69
+
70
+ tun.stop() # SIGTERM, при необходимости SIGKILL
71
+ tun.restart() # перезапуск с тем же поддоменом
72
+
73
+ tun.connect() # синоним start()
74
+ tun.disconnect() # синоним stop()
75
+ ```
76
+
77
+ ## События и переподключение
78
+
79
+ `dt` сам держит соединение и переподключается при обрыве. SDK отдаёт это наружу:
80
+
81
+ ```python
82
+ def on_event(event: dict) -> None:
83
+ # event["event"]: ready | reconnected | disconnected | error | request | response
84
+ print(event)
85
+
86
+ tun = Tunnel(port=8080, on_event=on_event)
87
+ tun.start()
88
+
89
+ tun.events # все события, что видели
90
+ tun.connected # True, пока туннель на связи
91
+ tun.stderr # что писал dt в stderr
92
+ ```
93
+
94
+ ## Запуск приложения вместе с туннелем
95
+
96
+ ```python
97
+ import threading, time
98
+ from flask import Flask
99
+ from dreamtunnel import Tunnel
100
+
101
+ app = Flask(__name__)
102
+
103
+ @app.route("/")
104
+ def index():
105
+ return "привет"
106
+
107
+ threading.Thread(
108
+ target=lambda: app.run(host="127.0.0.1", port=8080, use_reloader=False),
109
+ daemon=True,
110
+ ).start()
111
+ time.sleep(0.5)
112
+
113
+ with Tunnel(port=8080, subdomain="my-api") as tun:
114
+ print(tun.url)
115
+ while True:
116
+ time.sleep(1)
117
+ ```
118
+
119
+ Рабочие примеры — в [`examples/`](examples): `flask_app.py`, `fastapi_app.py`.
120
+
121
+ ## CLI
122
+
123
+ ```bash
124
+ dreamtunnel http 8080 # как `dt http`, но через SDK
125
+ dreamtunnel http 8080 my-api --json # машинный вывод (NDJSON)
126
+ dreamtunnel tcp 25565
127
+ python -m dreamtunnel http 8080
128
+ ```
129
+
130
+ ## Параметры `Tunnel`
131
+
132
+ | Параметр | По умолчанию | Смысл |
133
+ |---|---|---|
134
+ | `port` | — | локальный порт |
135
+ | `subdomain` | случайный | желаемое имя: `my-api` → `my-api.maiuni.online` |
136
+ | `mode` | `"http"` | `"http"` или `"tcp"` |
137
+ | `server` | `tunnel.swag.best:444` | адрес control-канала |
138
+ | `token` | встроенный | свой токен, если нужен приватный доступ |
139
+ | `dt_binary` | авто | путь к `dt` |
140
+ | `auto_install` | `True` | скачивать `dt`, если не найден |
141
+ | `ready_timeout` | `30` | сколько ждать подтверждения |
142
+ | `on_event` | `None` | колбэк на каждое событие |
143
+ | `session` | `True` | свой файл настроек и сессия для этого туннеля |
144
+ | `config_path` | — | явный путь к файлу настроек `dt` |
145
+
146
+ ## Исключения
147
+
148
+ | Исключение | Когда |
149
+ |---|---|
150
+ | `BinaryNotFound` | `dt` не найден и автоустановка выключена |
151
+ | `TunnelTimeout` | релей не ответил за `ready_timeout` |
152
+ | `TunnelExited` | процесс `dt` умер до подключения (в `.output` — его вывод) |
153
+ | `TunnelError` | релей отказал (плохой токен, занятый поддомен) |
154
+
155
+ ## Переменные окружения
156
+
157
+ | Переменная | Смысл |
158
+ |---|---|
159
+ | `DT_BINARY` | путь к `dt` |
160
+ | `DT_TOKEN` | токен доступа |
161
+ | `DT_SERVER` | адрес control-канала |
162
+ | `DT_SUB` | поддомен |
163
+ | `DT_CONFIG` | файл настроек `dt` (SDK задаёт свой на каждый туннель) |
164
+ | `DREAMTUNNEL_BASE_URL` | откуда скачивать бинарник (по умолчанию `https://tunnel.swag.best`) |
165
+ | `DREAMTUNNEL_HOME` | куда класть скачанный бинарник и сессии (по умолчанию `~/.dreamtunnel`) |
166
+
167
+ ## Как это устроено
168
+
169
+ SDK не переписывает протокол: он запускает `dt <mode> <port> --json` и
170
+ разбирает поток NDJSON-событий (`ready`, `disconnected`, `error`, …). Если
171
+ попался старый бинарник без `--json`, строки вида
172
+ `Forwarding https://… -> http://…` разбираются регуляркой — поддержка
173
+ обратной совместимости встроена.
174
+
175
+ Каждому туннелю SDK выдаёт **свой** файл настроек `dt` (переменная
176
+ `DT_CONFIG`), поэтому сессии не пересекаются: несколько туннелей с одной
177
+ машины (и даже из одного процесса) работают одновременно и не выбивают друг
178
+ друга. `tun.stop()` сохраняет файл — перезапуск получает тот же адрес,
179
+ `tun.close()` его удаляет.
180
+
181
+ Практический смысл: `dt` хранит session-id в `~/.dreamtunnel/config.env`, и
182
+ если два процесса используют один файл, второй забирает сессию себе, а первый
183
+ теряет туннель.
184
+
185
+ ## Тесты
186
+
187
+ ```bash
188
+ pip install -e ".[test]"
189
+ pytest # юнит-тесты на фейковом dt
190
+ DREAMTUNNEL_LIVE=1 pytest tests/test_live.py -v # боевой релей
191
+ ```
192
+
193
+ Лицензия: MIT.
@@ -0,0 +1,169 @@
1
+ # dreamtunnel (Python SDK)
2
+
3
+ Python-обёртка над CLI `dt`: запускает туннель **из кода**, отдаёт публичный
4
+ URL, присылает события (включая переподключения) и корректно всё останавливает.
5
+
6
+ Токен уже встроен в бинарник — после установки ничего настраивать не нужно.
7
+
8
+ ```bash
9
+ pip install "git+https://github.com/dreamcatchered/dreamTunnel.git#subdirectory=sdk/python"
10
+ ```
11
+
12
+ Из локальной копии репозитория — так же просто:
13
+
14
+ ```bash
15
+ git clone https://github.com/dreamcatchered/dreamTunnel
16
+ pip install -e dreamTunnel/sdk/python
17
+ ```
18
+
19
+ Если `dt` ещё нет, SDK скачает его сам при первом запуске.
20
+
21
+ ## Быстрый старт
22
+
23
+ ```python
24
+ from dreamtunnel import Tunnel
25
+
26
+ with Tunnel(port=8080) as tun:
27
+ print(tun.url) # https://k7f3m9ab.maiuni.online
28
+ ... # туннель живёт, пока мы внутри блока
29
+ # выход из блока — туннель закрыт
30
+ ```
31
+
32
+ Свой поддомен:
33
+
34
+ ```python
35
+ with Tunnel(port=8080, subdomain="my-api") as tun:
36
+ print(tun.url) # https://my-api.maiuni.online
37
+ ```
38
+
39
+ ## Управление вручную
40
+
41
+ ```python
42
+ tun = Tunnel(port=3000, subdomain="dev")
43
+ tun.start() # ждёт, пока релей подтвердит туннель
44
+ print(tun.url, tun.host, tun.local_url, tun.pid)
45
+
46
+ tun.stop() # SIGTERM, при необходимости SIGKILL
47
+ tun.restart() # перезапуск с тем же поддоменом
48
+
49
+ tun.connect() # синоним start()
50
+ tun.disconnect() # синоним stop()
51
+ ```
52
+
53
+ ## События и переподключение
54
+
55
+ `dt` сам держит соединение и переподключается при обрыве. SDK отдаёт это наружу:
56
+
57
+ ```python
58
+ def on_event(event: dict) -> None:
59
+ # event["event"]: ready | reconnected | disconnected | error | request | response
60
+ print(event)
61
+
62
+ tun = Tunnel(port=8080, on_event=on_event)
63
+ tun.start()
64
+
65
+ tun.events # все события, что видели
66
+ tun.connected # True, пока туннель на связи
67
+ tun.stderr # что писал dt в stderr
68
+ ```
69
+
70
+ ## Запуск приложения вместе с туннелем
71
+
72
+ ```python
73
+ import threading, time
74
+ from flask import Flask
75
+ from dreamtunnel import Tunnel
76
+
77
+ app = Flask(__name__)
78
+
79
+ @app.route("/")
80
+ def index():
81
+ return "привет"
82
+
83
+ threading.Thread(
84
+ target=lambda: app.run(host="127.0.0.1", port=8080, use_reloader=False),
85
+ daemon=True,
86
+ ).start()
87
+ time.sleep(0.5)
88
+
89
+ with Tunnel(port=8080, subdomain="my-api") as tun:
90
+ print(tun.url)
91
+ while True:
92
+ time.sleep(1)
93
+ ```
94
+
95
+ Рабочие примеры — в [`examples/`](examples): `flask_app.py`, `fastapi_app.py`.
96
+
97
+ ## CLI
98
+
99
+ ```bash
100
+ dreamtunnel http 8080 # как `dt http`, но через SDK
101
+ dreamtunnel http 8080 my-api --json # машинный вывод (NDJSON)
102
+ dreamtunnel tcp 25565
103
+ python -m dreamtunnel http 8080
104
+ ```
105
+
106
+ ## Параметры `Tunnel`
107
+
108
+ | Параметр | По умолчанию | Смысл |
109
+ |---|---|---|
110
+ | `port` | — | локальный порт |
111
+ | `subdomain` | случайный | желаемое имя: `my-api` → `my-api.maiuni.online` |
112
+ | `mode` | `"http"` | `"http"` или `"tcp"` |
113
+ | `server` | `tunnel.swag.best:444` | адрес control-канала |
114
+ | `token` | встроенный | свой токен, если нужен приватный доступ |
115
+ | `dt_binary` | авто | путь к `dt` |
116
+ | `auto_install` | `True` | скачивать `dt`, если не найден |
117
+ | `ready_timeout` | `30` | сколько ждать подтверждения |
118
+ | `on_event` | `None` | колбэк на каждое событие |
119
+ | `session` | `True` | свой файл настроек и сессия для этого туннеля |
120
+ | `config_path` | — | явный путь к файлу настроек `dt` |
121
+
122
+ ## Исключения
123
+
124
+ | Исключение | Когда |
125
+ |---|---|
126
+ | `BinaryNotFound` | `dt` не найден и автоустановка выключена |
127
+ | `TunnelTimeout` | релей не ответил за `ready_timeout` |
128
+ | `TunnelExited` | процесс `dt` умер до подключения (в `.output` — его вывод) |
129
+ | `TunnelError` | релей отказал (плохой токен, занятый поддомен) |
130
+
131
+ ## Переменные окружения
132
+
133
+ | Переменная | Смысл |
134
+ |---|---|
135
+ | `DT_BINARY` | путь к `dt` |
136
+ | `DT_TOKEN` | токен доступа |
137
+ | `DT_SERVER` | адрес control-канала |
138
+ | `DT_SUB` | поддомен |
139
+ | `DT_CONFIG` | файл настроек `dt` (SDK задаёт свой на каждый туннель) |
140
+ | `DREAMTUNNEL_BASE_URL` | откуда скачивать бинарник (по умолчанию `https://tunnel.swag.best`) |
141
+ | `DREAMTUNNEL_HOME` | куда класть скачанный бинарник и сессии (по умолчанию `~/.dreamtunnel`) |
142
+
143
+ ## Как это устроено
144
+
145
+ SDK не переписывает протокол: он запускает `dt <mode> <port> --json` и
146
+ разбирает поток NDJSON-событий (`ready`, `disconnected`, `error`, …). Если
147
+ попался старый бинарник без `--json`, строки вида
148
+ `Forwarding https://… -> http://…` разбираются регуляркой — поддержка
149
+ обратной совместимости встроена.
150
+
151
+ Каждому туннелю SDK выдаёт **свой** файл настроек `dt` (переменная
152
+ `DT_CONFIG`), поэтому сессии не пересекаются: несколько туннелей с одной
153
+ машины (и даже из одного процесса) работают одновременно и не выбивают друг
154
+ друга. `tun.stop()` сохраняет файл — перезапуск получает тот же адрес,
155
+ `tun.close()` его удаляет.
156
+
157
+ Практический смысл: `dt` хранит session-id в `~/.dreamtunnel/config.env`, и
158
+ если два процесса используют один файл, второй забирает сессию себе, а первый
159
+ теряет туннель.
160
+
161
+ ## Тесты
162
+
163
+ ```bash
164
+ pip install -e ".[test]"
165
+ pytest # юнит-тесты на фейковом dt
166
+ DREAMTUNNEL_LIVE=1 pytest tests/test_live.py -v # боевой релей
167
+ ```
168
+
169
+ Лицензия: MIT.
@@ -0,0 +1,39 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "dreamtunnel"
7
+ version = "0.1.0"
8
+ description = "Python SDK for dreamTunnel — instant public HTTPS tunnels"
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = { text = "MIT" }
12
+ keywords = ["tunnel", "ngrok", "https", "webhook", "localhost", "self-hosted"]
13
+ classifiers = [
14
+ "Development Status :: 4 - Beta",
15
+ "Intended Audience :: Developers",
16
+ "License :: OSI Approved :: MIT License",
17
+ "Programming Language :: Python :: 3",
18
+ "Programming Language :: Python :: 3.10",
19
+ "Programming Language :: Python :: 3.11",
20
+ "Programming Language :: Python :: 3.12",
21
+ "Topic :: Internet :: WWW/HTTP",
22
+ "Topic :: Software Development :: Libraries",
23
+ ]
24
+
25
+ [project.urls]
26
+ Homepage = "https://tunnel.swag.best"
27
+ Repository = "https://github.com/dreamcatchered/dreamTunnel"
28
+
29
+ [project.optional-dependencies]
30
+ test = ["pytest>=7"]
31
+
32
+ [project.scripts]
33
+ dreamtunnel = "dreamtunnel.cli:main"
34
+
35
+ [tool.setuptools.packages.find]
36
+ where = ["src"]
37
+
38
+ [tool.pytest.ini_options]
39
+ testpaths = ["tests"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,40 @@
1
+ """Python SDK for dreamTunnel — instant public HTTPS tunnels.
2
+
3
+ Typical use:
4
+
5
+ from dreamtunnel import Tunnel
6
+
7
+ with Tunnel(port=8080) as tun:
8
+ print(tun.url) # https://k7f3m9ab.maiuni.online
9
+ ...
10
+
11
+ The SDK supervises the existing ``dt`` CLI process: it starts the tunnel,
12
+ waits until the relay reports it online, exposes the public URL, forwards the
13
+ relay's events (including reconnects) to your code, and shuts the process down
14
+ cleanly on exit.
15
+ """
16
+
17
+ from .binary import ensure_dt, install_dt
18
+ from .errors import (
19
+ BinaryNotFound,
20
+ DreamTunnelError,
21
+ TunnelError,
22
+ TunnelExited,
23
+ TunnelTimeout,
24
+ )
25
+ from .tunnel import DEFAULT_SERVER, Tunnel
26
+
27
+ __version__ = "0.1.0"
28
+
29
+ __all__ = [
30
+ "Tunnel",
31
+ "DEFAULT_SERVER",
32
+ "ensure_dt",
33
+ "install_dt",
34
+ "DreamTunnelError",
35
+ "BinaryNotFound",
36
+ "TunnelTimeout",
37
+ "TunnelExited",
38
+ "TunnelError",
39
+ "__version__",
40
+ ]
@@ -0,0 +1,6 @@
1
+ """Allow ``python -m dreamtunnel``."""
2
+
3
+ from .cli import main
4
+
5
+ if __name__ == "__main__":
6
+ raise SystemExit(main())
@@ -0,0 +1,152 @@
1
+ """Locate or install the ``dt`` binary.
2
+
3
+ Lookup order:
4
+
5
+ 1. the ``dt_binary`` argument passed to :class:`~dreamtunnel.Tunnel`
6
+ 2. the ``DT_BINARY`` environment variable
7
+ 3. ``dt`` on ``PATH``
8
+ 4. the SDK cache (``~/.dreamtunnel/bin``)
9
+ 5. an automatic download from the public release endpoint, unless disabled
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import os
15
+ import platform
16
+ import shutil
17
+ import stat
18
+ import sys
19
+ import urllib.error
20
+ import urllib.request
21
+ from pathlib import Path
22
+
23
+ from .errors import BinaryNotFound
24
+
25
+ DEFAULT_BASE_URL = os.environ.get("DREAMTUNNEL_BASE_URL", "https://tunnel.swag.best")
26
+
27
+ # machine() spellings we accept
28
+ _AMD64 = {"x86_64", "amd64", "x64", "i686", "i386", "x86"}
29
+ _ARM64 = {"aarch64", "arm64"}
30
+
31
+
32
+ def cache_root() -> Path:
33
+ """Root directory used by the SDK (``~/.dreamtunnel`` by default)."""
34
+ override = os.environ.get("DREAMTUNNEL_HOME")
35
+ return Path(override).expanduser() if override else Path.home() / ".dreamtunnel"
36
+
37
+
38
+ def cache_dir() -> Path:
39
+ """Directory where downloaded binaries are kept."""
40
+ return cache_root() / "bin"
41
+
42
+
43
+ def platform_binary_name(
44
+ system: str | None = None, machine: str | None = None
45
+ ) -> str:
46
+ """Return the release file name for the current (or given) platform.
47
+
48
+ Raises:
49
+ BinaryNotFound: for platforms dreamTunnel does not build for.
50
+ """
51
+ system = (system or platform.system()).lower()
52
+ machine = (machine or platform.machine()).lower()
53
+
54
+ if machine in _AMD64:
55
+ arch = "386" if machine in {"i686", "i386", "x86"} else "amd64"
56
+ elif machine in _ARM64:
57
+ arch = "arm64"
58
+ else:
59
+ raise BinaryNotFound(f"unsupported CPU architecture: {machine!r}")
60
+
61
+ if system.startswith("linux"):
62
+ return f"dt-linux-{arch}"
63
+ if system.startswith(("darwin", "mac")):
64
+ return f"dt-darwin-{arch}"
65
+ if system.startswith("win"):
66
+ return f"dt-windows-{arch}.exe"
67
+ raise BinaryNotFound(f"unsupported platform: {system!r}")
68
+
69
+
70
+ def _executable_name() -> str:
71
+ return "dt.exe" if os.name == "nt" else "dt"
72
+
73
+
74
+ def _make_executable(path: Path) -> None:
75
+ if os.name == "nt":
76
+ return
77
+ mode = path.stat().st_mode
78
+ path.chmod(mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
79
+
80
+
81
+ def download_dt(dest: Path | None = None, *, base_url: str = DEFAULT_BASE_URL) -> Path:
82
+ """Download the ``dt`` binary for this platform into the SDK cache."""
83
+ name = platform_binary_name()
84
+ dest = dest or (cache_dir() / _executable_name())
85
+ dest.parent.mkdir(parents=True, exist_ok=True)
86
+
87
+ url = f"{base_url.rstrip('/')}/dl/{name}"
88
+ tmp = dest.with_suffix(dest.suffix + ".part")
89
+ try:
90
+ with urllib.request.urlopen(url, timeout=60) as resp: # noqa: S310
91
+ data = resp.read()
92
+ except urllib.error.URLError as exc: # pragma: no cover - network path
93
+ raise BinaryNotFound(f"could not download {url}: {exc}") from exc
94
+
95
+ if len(data) < 1024:
96
+ raise BinaryNotFound(f"download from {url} looks truncated ({len(data)} bytes)")
97
+
98
+ tmp.write_bytes(data)
99
+ tmp.replace(dest)
100
+ _make_executable(dest)
101
+ return dest
102
+
103
+
104
+ def install_dt(
105
+ dest: Path | None = None, *, base_url: str = DEFAULT_BASE_URL, force: bool = False
106
+ ) -> Path:
107
+ """Install ``dt`` into the cache directory and return its path."""
108
+ dest = dest or (cache_dir() / _executable_name())
109
+ if dest.exists() and not force:
110
+ return dest
111
+ return download_dt(dest, base_url=base_url)
112
+
113
+
114
+ def ensure_dt(
115
+ explicit: str | os.PathLike[str] | None = None,
116
+ *,
117
+ auto_install: bool = True,
118
+ base_url: str = DEFAULT_BASE_URL,
119
+ ) -> str:
120
+ """Return a usable path to ``dt``, installing it if necessary."""
121
+ if explicit:
122
+ path = Path(explicit).expanduser()
123
+ if path.exists():
124
+ return str(path)
125
+ raise BinaryNotFound(f"dt binary not found at {path}")
126
+
127
+ env = os.environ.get("DT_BINARY")
128
+ if env:
129
+ path = Path(env).expanduser()
130
+ if path.exists():
131
+ return str(path)
132
+
133
+ found = shutil.which("dt")
134
+ if found:
135
+ return found
136
+
137
+ cached = cache_dir() / _executable_name()
138
+ if cached.exists():
139
+ return str(cached)
140
+
141
+ if not auto_install:
142
+ raise BinaryNotFound(
143
+ "dt not found on PATH; install it with "
144
+ "`curl -fsSL https://tunnel.swag.best/install.sh | bash` "
145
+ "or pass dt_binary=..."
146
+ )
147
+
148
+ return str(download_dt(base_url=base_url))
149
+
150
+
151
+ def python_supported() -> bool:
152
+ return sys.version_info >= (3, 10)