rottenstuff commited on
Commit
26e73ea
·
verified ·
1 Parent(s): cfcf19e

Upload 7 files

Browse files
Files changed (7) hide show
  1. .gitignore +6 -0
  2. README.md +45 -0
  3. pyproject.toml +17 -0
  4. src/__init__.py +75 -0
  5. src/_config.py +47 -0
  6. src/_tunnels.py +168 -0
  7. src/_utils.py +256 -0
.gitignore ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ __pycache__/
2
+ *.py[cod]
3
+ *.egg-info/
4
+ dist/
5
+ build/
6
+ .eggs/
README.md ADDED
@@ -0,0 +1,45 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # colab-proxy
2
+
3
+ Утилиты для получения публичных ссылок на локальные веб-интерфейсы в Google Colab
4
+ через популярные reverse-proxy сервисы.
5
+
6
+ ## Установка
7
+
8
+ ```python
9
+ !pip install -q git+https://github.com/ВАШ_ЮЗЕР/colab-proxy.git
10
+ ```
11
+
12
+ ## Использование
13
+
14
+ ```python
15
+ from colab_proxy import get_share_link
16
+
17
+ port = 7860 # порт вашего веб-интерфейса
18
+
19
+ link = get_share_link('cloudflared', port)
20
+ print(link)
21
+ ```
22
+
23
+ ## Доступные провайдеры
24
+
25
+ | Имя | Описание |
26
+ |---------------|----------------------------------|
27
+ | `cloudflared` | Cloudflare Tunnel (рекомендуется)|
28
+ | `gradio` | Gradio share tunnel |
29
+ | `tmole` | Tunnelmole |
30
+ | `tunwg` | tunwg |
31
+ | `localt` | localt (go_localt) |
32
+ | `native` | Google Colab встроенный прокси |
33
+ | `all` | Попробовать все сразу |
34
+
35
+ ## Конфигурация
36
+
37
+ По умолчанию бинарники туннелей скачиваются в `/content`.
38
+ Для переопределения установите переменную окружения до импорта пакета:
39
+
40
+ ```python
41
+ import os
42
+ os.environ['COLAB_PROXY_WORK_DIR'] = '/tmp/proxy'
43
+
44
+ from colab_proxy import get_share_link
45
+ ```
pyproject.toml ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [build-system]
2
+ requires = ["setuptools>=61.0", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "colab-tunnel"
7
+ version = "1.0.0"
8
+ description = "Утилиты для туннелирования веб-интерфейсов в Google Colab"
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = { text = "MIT" }
12
+ dependencies = [
13
+ "requests",
14
+ ]
15
+
16
+ [tool.setuptools.packages.find]
17
+ include = ["src*"]
src/__init__.py ADDED
@@ -0,0 +1,75 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ colab-proxy — утилиты для туннелирования веб-интерфейсов в Google Colab.
3
+
4
+ Быстрый старт:
5
+ from colab_proxy import get_share_link
6
+ link = get_share_link('cloudflared', 7860)
7
+ print(link)
8
+ """
9
+
10
+ from ._config import WORK_FOLDER
11
+ from ._utils import run, download
12
+ from ._tunnels import (
13
+ get_tmole_url,
14
+ get_tunwg_url,
15
+ get_cloudflared_url,
16
+ get_localt_url,
17
+ get_gradio_url,
18
+ get_native_url,
19
+ )
20
+
21
+ __version__ = '1.0.0'
22
+ __all__ = [
23
+ 'get_share_link',
24
+ 'try_all',
25
+ 'proxies_functions',
26
+ 'get_tmole_url',
27
+ 'get_tunwg_url',
28
+ 'get_cloudflared_url',
29
+ 'get_localt_url',
30
+ 'get_gradio_url',
31
+ 'get_native_url',
32
+ 'download',
33
+ 'run',
34
+ 'WORK_FOLDER',
35
+ ]
36
+
37
+ proxies_functions: dict = {
38
+ 'tmole': get_tmole_url,
39
+ 'tunwg': get_tunwg_url,
40
+ 'cloudflared': get_cloudflared_url,
41
+ 'localt': get_localt_url,
42
+ 'gradio': get_gradio_url,
43
+ 'native': get_native_url,
44
+ }
45
+
46
+
47
+ def try_all(port: int) -> str:
48
+ """Запускает все доступные туннели и возвращает все полученные ссылки."""
49
+ results: list[str] = []
50
+ for name, func in proxies_functions.items():
51
+ try:
52
+ results.append(func(port))
53
+ except Exception as e:
54
+ print(f'{name}: {e}')
55
+ return '\n'.join(results)
56
+
57
+
58
+ def get_share_link(host: str, port: int) -> str:
59
+ """
60
+ Получить публичную ссылку на локальный порт через указанный туннель.
61
+
62
+ Args:
63
+ host: Имя провайдера: 'cloudflared', 'tmole', 'tunwg',
64
+ 'localt', 'gradio', 'native' — или 'all' для всех сразу.
65
+ port: Локальный порт веб-интерфейса.
66
+
67
+ Returns:
68
+ Публичная URL-ссылка (или несколько, если host='all').
69
+ """
70
+ if host in proxies_functions:
71
+ return proxies_functions[host](port)
72
+ if host == 'all':
73
+ return try_all(port)
74
+ available = ', '.join(proxies_functions.keys())
75
+ return f'провайдер "{host}" не найден. Доступные: {available}'
src/_config.py ADDED
@@ -0,0 +1,47 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from os import environ
2
+ from pathlib import Path
3
+
4
+ # Переопределяется переменной окружения COLAB_PROXY_WORK_DIR
5
+ WORK_FOLDER = Path(environ.get('COLAB_PROXY_WORK_DIR', '/content/.config'))
6
+
7
+ claudflare_bin = WORK_FOLDER / 'claudflared'
8
+ tmole_bin = WORK_FOLDER / 'tmole'
9
+ tunwg_bin = WORK_FOLDER / 'tunwg'
10
+ go_localt_bin = WORK_FOLDER / 'go_localt'
11
+ gradio_bin = WORK_FOLDER / 'frpc_linux_amd64'
12
+ colab_native_url = WORK_FOLDER / 'colab_url.txt'
13
+ links_file = WORK_FOLDER / 'links.txt'
14
+
15
+ HEADERS = {
16
+ 'accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7',
17
+ 'accept-language': 'ru-RU,ru;q=0.9,en-US;q=0.8,en;q=0.7',
18
+ 'cache-control': 'no-cache',
19
+ 'device-memory': '8',
20
+ 'downlink': '10.0',
21
+ 'dpr': '1.5',
22
+ 'ect': '4g',
23
+ 'origin': 'https://colab.research.google.com',
24
+ 'pragma': 'no-cache',
25
+ 'priority': 'u=0, i',
26
+ 'referer': 'https://colab.research.google.com',
27
+ 'rtt': '200',
28
+ 'sec-ch-ua': '"Google Chrome";v="148", "Not?A)Brand";v="24", "Chromium";v="148"',
29
+ 'sec-ch-ua-arch': '"x86"',
30
+ 'sec-ch-ua-bitness': '"64"',
31
+ 'sec-ch-ua-full-version': '"148.0.7778.168"',
32
+ 'sec-ch-ua-full-version-list': '"Google Chrome";v="148.0.7778.168", "Not?A)Brand";v="24.0.0.0", "Chromium";v="148.0.7778.168"',
33
+ 'sec-ch-ua-mobile': '?0',
34
+ 'sec-ch-ua-model': '""',
35
+ 'sec-ch-ua-platform': '"Windows"',
36
+ 'sec-ch-ua-platform-version': '"19.0.0"',
37
+ 'sec-ch-ua-wow64': '?0',
38
+ 'sec-ch-viewport-height': '592',
39
+ 'sec-ch-viewport-width': '1216',
40
+ 'sec-fetch-dest': 'document',
41
+ 'sec-fetch-mode': 'navigate',
42
+ 'sec-fetch-site': 'same-origin',
43
+ 'sec-fetch-user': '?1',
44
+ 'upgrade-insecure-requests': '1',
45
+ 'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.7778.168 Safari/537.36',
46
+ 'viewport-width': '1216'
47
+ }
src/_tunnels.py ADDED
@@ -0,0 +1,168 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from atexit import register as exit_register
2
+ from pathlib import Path
3
+ from re import search
4
+ from subprocess import PIPE, Popen
5
+ from time import sleep
6
+
7
+ from requests import get as get_url
8
+
9
+ from ._config import (
10
+ WORK_FOLDER,
11
+ claudflare_bin, tmole_bin, tunwg_bin,
12
+ go_localt_bin, gradio_bin, links_file,
13
+ )
14
+ from ._utils import (
15
+ download, unpack_archive, run,
16
+ is_process_running, terminate_process,
17
+ move_path, is_ipv4,
18
+ )
19
+
20
+
21
+ def get_revproxy_url(
22
+ bin_url: str,
23
+ need_unpack: bool,
24
+ bin_path: Path,
25
+ start_commands: list,
26
+ read_from_stderr: bool,
27
+ lines_to_read: int,
28
+ url_pattern: str = r'https://\S+',
29
+ write_link: bool = False,
30
+ ) -> str:
31
+ if not bin_path.exists():
32
+ files_before = {f.name for f in WORK_FOLDER.iterdir() if f.is_file()}
33
+
34
+ if need_unpack:
35
+ unpack_archive(download(bin_url, progress=False), WORK_FOLDER, rm_archive=True)
36
+ else:
37
+ download(bin_url, save_path=bin_path.parent, progress=False)
38
+
39
+ files_after = {f.name for f in WORK_FOLDER.iterdir() if f.is_file()}
40
+ new_files = files_after - files_before
41
+
42
+ if len(new_files) == 1:
43
+ move_path(WORK_FOLDER / new_files.pop(), bin_path)
44
+ else:
45
+ raise RuntimeError(
46
+ f'ошибка при определении нового файла после распаковки!\n'
47
+ f'ожидался один новый файл, а появились: {", ".join(new_files)}'
48
+ )
49
+
50
+ bin_path.chmod(0o777)
51
+
52
+ if is_process_running(bin_path.name):
53
+ run(f'pkill -f {bin_path.name}')
54
+
55
+ process = Popen(start_commands, stdout=PIPE, stderr=PIPE, text=True)
56
+ stream = process.stderr if read_from_stderr else process.stdout
57
+ lines: list[str] = []
58
+
59
+ for _ in range(lines_to_read):
60
+ line = stream.readline()
61
+ if not line:
62
+ break
63
+ lines.append(line.strip())
64
+
65
+ ipv4 = next((ln for ln in lines if is_ipv4(ln)), None)
66
+ urls = [search(url_pattern, ln).group() for ln in lines if search(url_pattern, ln)]
67
+
68
+ if urls:
69
+ exit_register(terminate_process, bin_path.name, process)
70
+ if write_link:
71
+ links_file.write_text(urls[0])
72
+ return f'{urls[0]}\n пароль(IP): {ipv4}' if ipv4 else urls[0]
73
+
74
+ run(f'pkill -f {bin_path.name}')
75
+ raise RuntimeError(
76
+ f'ссылку получить не удалось, вывод работы бинарника:\n' + '\n'.join(lines)
77
+ )
78
+
79
+
80
+ # ---------------------------------------------------------------------------
81
+ # Конкретные провайдеры
82
+ # ---------------------------------------------------------------------------
83
+
84
+ def get_tmole_url(port: int) -> str:
85
+ return get_revproxy_url(
86
+ bin_url='https://tunnelmole.com/downloads/tmole-linux.gz',
87
+ need_unpack=True,
88
+ bin_path=tmole_bin,
89
+ start_commands=[str(tmole_bin), str(port)],
90
+ read_from_stderr=False,
91
+ lines_to_read=5,
92
+ )
93
+
94
+
95
+ def get_tunwg_url(port: int) -> str:
96
+ return get_revproxy_url(
97
+ bin_url='https://github.com/ntnj/tunwg/releases/latest/download/tunwg',
98
+ need_unpack=False,
99
+ bin_path=tunwg_bin,
100
+ start_commands=[str(tunwg_bin), f'--forward=http://127.0.0.1:{port}'],
101
+ read_from_stderr=True,
102
+ lines_to_read=2,
103
+ )
104
+
105
+
106
+ def get_cloudflared_url(port: int) -> str:
107
+ return get_revproxy_url(
108
+ bin_url='https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-amd64',
109
+ need_unpack=False,
110
+ bin_path=claudflare_bin,
111
+ start_commands=[str(claudflare_bin), 'tunnel', '--url', f'http://127.0.0.1:{port}'],
112
+ read_from_stderr=True,
113
+ lines_to_read=6,
114
+ url_pattern=r'(?P<url>https?://\S+\.trycloudflare\.com)',
115
+ )
116
+
117
+
118
+ def get_localt_url(port: int) -> str:
119
+ return get_revproxy_url(
120
+ bin_url='https://huggingface.co/prolapse/go_localt/resolve/main/go_localt',
121
+ need_unpack=False,
122
+ bin_path=go_localt_bin,
123
+ start_commands=[str(go_localt_bin), str(port)],
124
+ read_from_stderr=False,
125
+ lines_to_read=2,
126
+ )
127
+
128
+
129
+ def get_gradio_url(port: int) -> str:
130
+ max_attempts = 3
131
+ last_error: Exception | None = None
132
+
133
+ for attempt in range(max_attempts):
134
+ try:
135
+ resp = get_url('https://api.gradio.app/v2/tunnel-request', timeout=10)
136
+ resp.raise_for_status()
137
+ info = resp.json()[0]
138
+ remote_host, remote_port = info['host'], int(info['port'])
139
+ cmd = [
140
+ str(gradio_bin), 'http', '-n', 'random',
141
+ '-l', str(port), '-i', '127.0.0.1',
142
+ '--uc', '--sd', 'random', '--ue',
143
+ '--server_addr', f'{remote_host}:{remote_port}',
144
+ '--disable_log_color',
145
+ ]
146
+ return get_revproxy_url(
147
+ bin_url='https://cdn-media.huggingface.co/frpc-gradio-0.1/frpc_linux_amd64',
148
+ need_unpack=False,
149
+ bin_path=gradio_bin,
150
+ start_commands=cmd,
151
+ read_from_stderr=False,
152
+ lines_to_read=3,
153
+ )
154
+ except Exception as e:
155
+ last_error = e
156
+ if attempt < max_attempts - 1:
157
+ print(f'попытка {attempt + 1} получить ссылку градио провалилась: {e}. пробуем ещё...')
158
+ sleep(5)
159
+
160
+ raise RuntimeError(f'после {max_attempts} попыток поднять туннель градио не удалось: {last_error}')
161
+
162
+
163
+ def get_native_url(port: int) -> str:
164
+ try:
165
+ from google.colab.output import eval_js # type: ignore[import]
166
+ except ImportError:
167
+ raise RuntimeError('get_native_url доступен только в среде Google Colab.')
168
+ return eval_js(f'google.colab.kernel.proxyPort({port})')
src/_utils.py ADDED
@@ -0,0 +1,256 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from os import name as os_name
2
+ from pathlib import Path
3
+ from re import compile, search
4
+ from shutil import move
5
+ from subprocess import PIPE, Popen, STDOUT, check_output, CalledProcessError
6
+ from sys import stdout
7
+ from time import time
8
+ from urllib.parse import unquote, urlparse
9
+
10
+ from requests import get as get_url, head as get_head
11
+ from requests.structures import CaseInsensitiveDict
12
+
13
+ from ._config import WORK_FOLDER, HEADERS
14
+
15
+
16
+ # ---------------------------------------------------------------------------
17
+ # Архивы
18
+ # ---------------------------------------------------------------------------
19
+
20
+ def determine_archive_format(filepath: str | Path) -> str | None:
21
+ filepath = Path(filepath)
22
+ zip_sig = bytes([0x50, 0x4B, 0x03, 0x04])
23
+ seven_z = bytes([0x37, 0x7A, 0xBC, 0xAF, 0x27, 0x1C])
24
+ lzma_xz = bytes([0xFD, 0x37, 0x7A, 0x58, 0x5A])
25
+ tgz = bytes([0x1F, 0x8B])
26
+ tbz = bytes([0x42, 0x5A, 0x68])
27
+ ustar = bytes([0x75, 0x73, 0x74, 0x61, 0x72])
28
+
29
+ with filepath.open('rb') as fh:
30
+ header = fh.read(262)
31
+
32
+ if header.startswith(zip_sig):
33
+ return 'zip'
34
+ if header.startswith(seven_z) or header.startswith(lzma_xz):
35
+ return '7z'
36
+ if header.startswith(tgz):
37
+ return 'tar.gz'
38
+ if header.startswith(tbz):
39
+ return 'tar.bz2'
40
+ if header[0x101:0x101 + len(ustar)] == ustar:
41
+ return 'tar'
42
+ return None
43
+
44
+
45
+ def unpack_archive(archive_path: str | Path, dest_path: str | Path, rm_archive: bool = True) -> None:
46
+ if str(archive_path).startswith(('https://', 'http://')):
47
+ archive_path = download(archive_path, save_path=WORK_FOLDER, progress=False)
48
+
49
+ archive_path = Path(archive_path)
50
+ dest_path = Path(dest_path)
51
+
52
+ if not archive_path.exists():
53
+ raise RuntimeError(f'архив {archive_path} не найден.')
54
+
55
+ fmt = determine_archive_format(archive_path)
56
+
57
+ def _rm() -> None:
58
+ if rm_archive:
59
+ archive_path.unlink(missing_ok=True)
60
+
61
+ try:
62
+ if fmt == '7z' or archive_path.suffix == '.7z':
63
+ run(f'7z -bso0 -bd -slp -y x "{archive_path}" -o"{dest_path}"')
64
+ elif fmt == 'tar' or archive_path.suffix == '.tar':
65
+ run(f'tar -xvpf "{archive_path}" -C "{dest_path}"')
66
+ elif fmt in ('tar.gz', 'tar.bz2') or archive_path.suffix in ('.tar.gz', '.tar.bz2', '.tar.xz'):
67
+ result = run(f'tar -xvzpf "{archive_path}" -C "{dest_path}"')
68
+ if result['status_code'] != 0:
69
+ run(f'gzip -d "{archive_path}"', dest_path)
70
+ elif fmt == 'zip' or archive_path.suffix == '.zip':
71
+ run(f'unzip "{archive_path}" -d "{dest_path}"')
72
+ else:
73
+ run(f'7z -bso0 -bd -slp -y x "{archive_path}" -o"{dest_path}"')
74
+ _rm()
75
+ except Exception:
76
+ try:
77
+ run(f'7z -bso0 -bd -mmt4 -slp -y x "{archive_path}" -o"{dest_path}"')
78
+ _rm()
79
+ except Exception:
80
+ raise RuntimeError(
81
+ f'формат архива {archive_path.suffix} не определён. '
82
+ f'Задайте формат вручную через determine_archive_format.'
83
+ )
84
+
85
+
86
+ # ---------------------------------------------------------------------------
87
+ # Процессы
88
+ # ---------------------------------------------------------------------------
89
+
90
+ def run(command: str, cwd: str | Path | None = None, live_output: bool = False) -> dict:
91
+ process = Popen(command, shell=True, cwd=cwd, stdout=PIPE, stderr=STDOUT)
92
+ _encodings = ['utf-8', 'iso-8859-5', 'windows-1251', 'cp866', 'koi8-r', 'mac_cyrillic']
93
+ _progress_pattern = r'\d+%|\d+/\d+'
94
+
95
+ def _decode(data: bytes) -> str:
96
+ for enc in _encodings:
97
+ try:
98
+ return data.decode(enc)
99
+ except UnicodeDecodeError:
100
+ continue
101
+ return data.decode('utf-8', errors='replace')
102
+
103
+ final_output: list[str] = []
104
+ last_progress = ''
105
+
106
+ for raw_line in iter(process.stdout.readline, b''):
107
+ line = _decode(raw_line.strip())
108
+ if search(_progress_pattern, line):
109
+ last_progress = line
110
+ if live_output:
111
+ stdout.write('\r' + line)
112
+ else:
113
+ final_output.append(line)
114
+ if live_output:
115
+ stdout.write(line + '\n')
116
+ if live_output:
117
+ stdout.flush()
118
+
119
+ process.wait()
120
+ if last_progress:
121
+ final_output.append(last_progress)
122
+
123
+ return {
124
+ 'status_code': process.returncode,
125
+ 'output': '\n'.join(final_output),
126
+ }
127
+
128
+
129
+ def is_process_running(process_name: str | Path) -> bool:
130
+ try:
131
+ output = check_output(['pgrep', '-f', str(process_name)], text=True)
132
+ return bool(output.strip())
133
+ except CalledProcessError:
134
+ return False
135
+
136
+
137
+ def terminate_process(process_name: str, process_obj: Popen) -> None:
138
+ try:
139
+ process_obj.stdout.close()
140
+ except Exception:
141
+ pass
142
+ try:
143
+ process_obj.stderr.close()
144
+ except Exception:
145
+ pass
146
+ process_obj.terminate()
147
+ run(f'pkill -f {process_name}')
148
+
149
+
150
+ def move_path(old_path: Path | str, new_path: Path | str) -> None:
151
+ old, new = Path(old_path), Path(new_path)
152
+ # BUGFIX: оригинал проверял old_path.exists() — падало если old_path это str
153
+ if not old.exists():
154
+ raise RuntimeError(f'не найден исходный путь для перемещения: {old}')
155
+ try:
156
+ old.replace(new)
157
+ except Exception:
158
+ try:
159
+ move(old, new)
160
+ except Exception:
161
+ if os_name == 'posix':
162
+ run(f'mv "{old}" "{new}"')
163
+ else:
164
+ run(f'move "{old}" "{new}"')
165
+
166
+
167
+ # ---------------------------------------------------------------------------
168
+ # Сеть
169
+ # ---------------------------------------------------------------------------
170
+
171
+ def is_ipv4(address: str) -> bool:
172
+ pattern = compile(
173
+ r'^((25[0-5]|2[0-4]\d|[01]?\d\d?)\.){3}(25[0-5]|2[0-4]\d|[01]?\d\d?)$'
174
+ )
175
+ return bool(pattern.match(address))
176
+
177
+
178
+ def is_valid_url(url: str) -> bool:
179
+ for attempt in range(2):
180
+ try:
181
+ with get_url(url, headers=HEADERS, allow_redirects=True, stream=True, timeout=10) as resp:
182
+ if 200 <= resp.status_code < 300:
183
+ return True
184
+ except Exception:
185
+ try:
186
+ resp = get_head(url, headers=HEADERS, allow_redirects=True, timeout=10)
187
+ if 200 <= resp.status_code < 300:
188
+ return True
189
+ except Exception:
190
+ pass
191
+ else:
192
+ break
193
+ return False
194
+
195
+
196
+ def get_filename_from_headers(headers: CaseInsensitiveDict) -> str | None:
197
+ cd = headers.get('content-disposition')
198
+ if not cd:
199
+ return headers.get('filename')
200
+
201
+ for part in cd.split(';'):
202
+ part = part.strip()
203
+ if part.startswith('filename*='):
204
+ encoding, _, encoded = part[len('filename*='):].partition("''")
205
+ return unquote(encoded, encoding=encoding)
206
+ if part.startswith('filename='):
207
+ return part[len('filename='):].strip('"')
208
+ return None
209
+
210
+
211
+ def download(
212
+ url: str,
213
+ filename: str | Path | None = None,
214
+ save_path: str | Path | None = None,
215
+ progress: bool = True,
216
+ ) -> Path:
217
+ # Поддержка --header= суффикса
218
+ extra_headers: dict | None = None
219
+ url_parts = url.replace('"', '').replace("'", '').split('--header=')
220
+ if len(url_parts) > 1:
221
+ url = url_parts[0].strip()
222
+ key, _, value = url_parts[1].partition(':') # BUGFIX: partition вместо split(':')[1]
223
+ extra_headers = {key.strip(): value.strip()}
224
+
225
+ if not is_valid_url(url):
226
+ raise RuntimeError(f'недействительная ссылка на файл: {url}')
227
+
228
+ save_path = Path(save_path) if save_path else Path.cwd()
229
+ save_path.mkdir(parents=True, exist_ok=True)
230
+
231
+ with get_url(url, stream=True, allow_redirects=True, headers=extra_headers) as req:
232
+ file_size = int(req.headers.get('content-length', 0))
233
+ file_name = filename or get_filename_from_headers(req.headers) or Path(urlparse(req.url).path).name
234
+ file_path = save_path / file_name
235
+ chunk_size = max(4096, file_size // 2000) if file_size else 4096
236
+
237
+ downloaded = 0
238
+ start = time()
239
+ try:
240
+ with open(file_path, 'wb') as fp:
241
+ for chunk in req.iter_content(chunk_size=chunk_size):
242
+ if chunk:
243
+ fp.write(chunk)
244
+ if progress and file_size: # BUGFIX: guard деления на 0
245
+ downloaded += len(chunk)
246
+ elapsed = time() - start
247
+ print(
248
+ f'\rзагрузка {file_name}: {downloaded / file_size * 100:.2f}% | {elapsed:.2f} сек.',
249
+ end='',
250
+ )
251
+ except Exception as e:
252
+ raise RuntimeError(f'не удалось загрузить файл по ссылке {url}:\n{e}')
253
+
254
+ if progress and file_size:
255
+ print() # перевод строки после прогресс-бара
256
+ return file_path