Skip to content

Commit b5d59cd

Browse files
committed
release: версия 1.4.2 — многопоточная загрузка, --only-new, логирование времени
- Реализована многопоточная загрузка файлов (параметр --threads) - Добавлена опция --only-new для загрузки только новых файлов без проверки размера - Добавлено логирование времени на каждый файл при загрузке - Улучшена производительность синхронизации - Обновлены README.md и CHANGELOG.md
1 parent 0e9db9a commit b5d59cd

File tree

10 files changed

+577
-1
lines changed

10 files changed

+577
-1
lines changed

CONTRIBUTING.md

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,4 +11,3 @@
1111
Сообщения пишем **на русском языке**.
1212
5. Отправьте изменения в свой форк (`git push origin feature/AmazingFeature`).
1313
6. Создайте Pull Request.
14-
- Начиная с версии 1.4.0, sync поддерживает рекурсивное создание директорий в облаке при загрузке (push).

build/lib/api.py

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
from network import get_client
2+
3+
4+
def list_files(path: str = "/") -> list[str]:
5+
"""Возвращает список файлов/папок в указанной директории.
6+
Работает через WebDAV.
7+
"""
8+
client = get_client()
9+
try:
10+
return client.list(path) # type: ignore[arg-type]
11+
except Exception as exc:
12+
print(f"Ошибка при получении списка файлов: {exc}")
13+
return []
14+
15+
16+
# --- Дополнительные операции ---------------------------------------------------
17+
18+
19+
def delete_file(remote_path: str) -> bool:
20+
"""Удаляет файл или папку *remote_path* в облаке."""
21+
client = get_client()
22+
try:
23+
client.clean(remote_path) # type: ignore[arg-type]
24+
return True
25+
except Exception as exc:
26+
print(f"Ошибка удаления: {exc}")
27+
return False
28+
29+
30+
def move_file(src_path: str, dst_path: str) -> bool:
31+
"""Переименовывает/перемещает ресурс в облаке."""
32+
client = get_client()
33+
try:
34+
client.move(src_path, dst_path) # type: ignore[arg-type]
35+
return True
36+
except Exception as exc:
37+
print(f"Ошибка перемещения: {exc}")
38+
return False
39+
40+
41+
def file_info(remote_path: str) -> dict:
42+
"""Возвращает словарь информации о файле (size, modified и т.п.)."""
43+
client = get_client()
44+
try:
45+
return client.info(remote_path) # type: ignore[arg-type]
46+
except Exception as exc:
47+
print(f"Ошибка info: {exc}")
48+
return {}

build/lib/auth.py

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
# mailrucloud/auth.py
2+
"""Модуль авторизации для WebDAV-доступа к Облаку Mail.ru.
3+
Схема проста: сохраняем в файл email и *пароль для внешнего приложения*.
4+
Никаких сетевых запросов при логине не требуется – WebDAV обрабатывает
5+
Basic-авторизацию самостоятельно.
6+
"""
7+
8+
import json
9+
from pathlib import Path
10+
from typing import Optional
11+
12+
CRED_FILE = Path.home() / ".mailru_token.json"
13+
14+
15+
def login(username: str, app_password: str) -> bool:
16+
"""Сохраняет учётные данные в файл.
17+
18+
Parameters
19+
----------
20+
username : str
21+
Полный e-mail пользователя (например, `user@mail.ru`).
22+
app_password : str
23+
Пароль для внешнего приложения, созданный в настройках безопасности
24+
Mail.ru. *Не* обычный пароль от почты!
25+
"""
26+
if "@" not in username:
27+
print("❌ Введите полный e-mail, например user@mail.ru")
28+
return False
29+
30+
data = {"email": username, "password": app_password}
31+
try:
32+
CRED_FILE.write_text(json.dumps(data))
33+
print(f"✅ Данные сохранены в {CRED_FILE}")
34+
return True
35+
except OSError as exc:
36+
print(f"❌ Не удалось сохранить файл учётных данных: {exc}")
37+
return False
38+
39+
40+
def load_credentials() -> Optional[dict]:
41+
"""Читает файл учётных данных и возвращает словарь с ключами
42+
`email` и `password`. Если файл отсутствует – ``None``.
43+
"""
44+
if not CRED_FILE.exists():
45+
return None
46+
try:
47+
return json.loads(CRED_FILE.read_text())
48+
except (OSError, json.JSONDecodeError):
49+
return None

build/lib/cli.py

Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,180 @@
1+
import click
2+
from auth import login
3+
from api import list_files, delete_file, move_file, file_info
4+
from upload import upload_file
5+
from download import download_file
6+
from sync import sync_directories
7+
8+
@click.group()
9+
def cli():
10+
"""mailru-cloud: неофициальный Python-клиент для Mail.ru Облака"""
11+
pass
12+
13+
@cli.command()
14+
@click.option("--username", prompt=True)
15+
@click.option("--password", prompt=True, hide_input=True)
16+
def login_cmd(username, password):
17+
"""Авторизация в Mail.ru Cloud"""
18+
success = login(username, password)
19+
if success:
20+
click.echo("✅ Успешная авторизация.")
21+
else:
22+
click.echo("❌ Ошибка авторизации.")
23+
24+
25+
@cli.command()
26+
@click.argument('remote_dir', default='/', required=False)
27+
def ls(remote_dir):
28+
"""Показать содержимое каталога в облаке (по умолчанию «/»)."""
29+
files = list_files(remote_dir)
30+
for f in files:
31+
click.echo(f)
32+
33+
34+
@cli.command()
35+
@click.argument('local_path', type=click.Path(exists=True))
36+
@click.option('--remote-path', default=None, help='Целевой путь в облаке (по умолчанию /<имя_файла>)')
37+
def upload(local_path, remote_path):
38+
"""
39+
Загрузка одного файла в облако.
40+
41+
LOCAL_PATH - путь к файлу на вашем компьютере.
42+
"""
43+
target = remote_path or f"/(auto)"
44+
click.echo(f"⏳ Загружаю {local_path}{target}")
45+
success = upload_file(local_path, remote_path)
46+
if success:
47+
click.secho("✅ Файл успешно загружен.", fg="green")
48+
else:
49+
click.secho("❌ Ошибка при загрузке файла.", fg="red")
50+
51+
52+
@cli.command()
53+
@click.argument('remote_path')
54+
@click.argument('local_path', required=False)
55+
def download(remote_path, local_path):
56+
"""Скачивание файла из облака.
57+
58+
REMOTE_PATH – путь в облаке (например /docs/report.pdf).
59+
LOCAL_PATH – куда сохранить (по умолчанию тек. каталог и исходное имя).
60+
"""
61+
dst = local_path or '(текущая папка)'
62+
click.echo(f"⏳ Скачиваю {remote_path}{dst}")
63+
success = download_file(remote_path, local_path)
64+
if success:
65+
click.secho("✅ Файл скачан.", fg="green")
66+
else:
67+
click.secho("❌ Ошибка скачивания.", fg="red")
68+
69+
70+
@cli.command()
71+
@click.argument('local_dir', default='.')
72+
@click.argument('remote_dir', default='/')
73+
@click.option('--direction', '-d', type=click.Choice(['push', 'pull', 'both'], case_sensitive=False),
74+
default='both', show_default=True,
75+
help="Направление: push (локальное → облако), pull (облако → локальное), both (двусторонняя)")
76+
def sync(local_dir, remote_dir, direction):
77+
"""Синхронизация каталогов LOCAL_DIR и REMOTE_DIR."""
78+
arrow = {
79+
'push': '→',
80+
'pull': '←',
81+
'both': '↔',
82+
}[direction.lower()]
83+
click.echo(f"⏳ Синхронизация {local_dir} {arrow} {remote_dir} (mode: {direction})")
84+
sync_directories(local_dir, remote_dir, direction.lower())
85+
click.secho("✅ Синхронизация завершена.", fg="green")
86+
87+
88+
@cli.command(name='rm')
89+
@click.argument('remote_path')
90+
def cmd_rm(remote_path):
91+
"""Удалить файл/папку в облаке."""
92+
click.echo(f"⏳ Удаляю {remote_path} …")
93+
if delete_file(remote_path):
94+
click.secho("✅ Удалено.", fg="green")
95+
else:
96+
click.secho("❌ Ошибка удаления.", fg="red")
97+
98+
99+
@cli.command(name='mv')
100+
@click.argument('src_path')
101+
@click.argument('dst_path')
102+
def cmd_mv(src_path, dst_path):
103+
"""Переименовать/переместить файл в облаке."""
104+
click.echo(f"⏳ Перемещаю {src_path}{dst_path}")
105+
if move_file(src_path, dst_path):
106+
click.secho("✅ Готово.", fg="green")
107+
else:
108+
click.secho("❌ Ошибка перемещения.", fg="red")
109+
110+
111+
@cli.command()
112+
@click.argument('remote_path')
113+
def info(remote_path):
114+
"""Показать информацию о файле (size, modified и т.п.)."""
115+
data = file_info(remote_path)
116+
if not data:
117+
click.secho("❌ Не удалось получить информацию.", fg="red")
118+
return
119+
for k, v in data.items():
120+
click.echo(f"{k}: {v}")
121+
122+
123+
@cli.command()
124+
def start():
125+
"""Запустить фоновую синхронизацию каталога ~/Mail.Cloud ↔ /."""
126+
import subprocess
127+
import sys
128+
import os
129+
from pathlib import Path
130+
131+
local_dir = Path.home() / "Mail.Cloud"
132+
local_dir.mkdir(parents=True, exist_ok=True)
133+
134+
pid_file = Path.home() / ".mailrucloud-daemon.pid"
135+
# Проверяем, не запущен ли демон уже
136+
if pid_file.exists():
137+
try:
138+
pid = int(pid_file.read_text().strip())
139+
os.kill(pid, 0) # Проверка существования процесса
140+
click.secho("⚠️ Демон уже запущен.", fg="yellow")
141+
return
142+
except (ProcessLookupError, ValueError):
143+
# PID-файл устарел – удаляем
144+
pid_file.unlink(missing_ok=True)
145+
except PermissionError:
146+
click.secho("❌ Нет прав проверить состояние демона.", fg="red")
147+
return
148+
149+
daemon_script = Path(__file__).with_name("sync_daemon.py")
150+
try:
151+
subprocess.Popen(
152+
[sys.executable, str(daemon_script)],
153+
stdout=subprocess.DEVNULL,
154+
stderr=subprocess.DEVNULL,
155+
start_new_session=True,
156+
)
157+
click.secho("✅ Демон синхронизации запущен в фоне.", fg="green")
158+
except Exception as exc:
159+
click.secho(f"❌ Не удалось запустить демон: {exc}", fg="red")
160+
161+
162+
@cli.command()
163+
def stop():
164+
"""Остановить фоновый демон синхронизации."""
165+
import os
166+
import signal
167+
from pathlib import Path
168+
pid_file = Path.home() / ".mailrucloud-daemon.pid"
169+
if not pid_file.exists():
170+
click.secho("❌ PID-файл не найден. Демон не запущен?", fg="red")
171+
return
172+
try:
173+
with open(pid_file) as f:
174+
pid = int(f.read().strip())
175+
os.kill(pid, signal.SIGTERM)
176+
click.secho(f"✅ Отправлен сигнал SIGTERM процессу {pid}.", fg="green")
177+
# Удаляем PID-файл сразу, система всё равно отдаст сигнал процессу
178+
pid_file.unlink(missing_ok=True)
179+
except Exception as exc:
180+
click.secho(f"❌ Не удалось остановить демон: {exc}", fg="red")

build/lib/download.py

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
from pathlib import Path
2+
from network import get_client
3+
4+
5+
def download_file(remote_path: str, local_path: str | None = None) -> bool:
6+
"""Скачивает файл `remote_path` из облака.
7+
8+
Параметры
9+
---------
10+
remote_path : str
11+
Путь к файлу в облаке (например, "/docs/report.pdf").
12+
local_path : str | None
13+
Куда сохранить файл на диске. Если *None*, используется имя файла
14+
из `remote_path` и текущий каталог.
15+
"""
16+
if local_path is None:
17+
local_path = Path(remote_path).name
18+
19+
client = get_client()
20+
try:
21+
# Основной путь: штатная реализация библиотеки
22+
client.download_sync(remote_path=remote_path, local_path=str(local_path))
23+
print(f"Файл сохранён в: {local_path}")
24+
return True
25+
except Exception as exc:
26+
# Для некоторых (особенно пустых или очень маленьких) файлов у облака
27+
# отсутствует заголовок ``Content-Length``. Штатная реализация
28+
# `webdavclient3` в таком случае падает с KeyError. Пытаемся обойти
29+
# проблему: скачиваем файл «вручную» тем же клиентом, но напрямую через
30+
# низкоуровневый `execute_request`, игнорируя прогресс.
31+
32+
if "content-length" in str(exc).lower():
33+
try:
34+
from webdav3.urn import Urn # импорт здесь, чтобы избежать лишней зависимости при обычной работе
35+
36+
# Получаем поток ответа на GET.
37+
response = client.execute_request(action="download", path=Urn(remote_path).quote())
38+
39+
# Стримингом пишем в файл.
40+
with open(local_path, "wb") as fp:
41+
for chunk in response.iter_content(chunk_size=client.chunk_size):
42+
if chunk: # пропускаем keep-alive
43+
fp.write(chunk)
44+
45+
print(f"Файл сохранён в: {local_path} (fallback)")
46+
return True
47+
except Exception as exc_fallback:
48+
print(f"Ошибка скачивания (fallback): {exc_fallback}")
49+
return False
50+
51+
# Иная ошибка – просто выводим и возвращаем False
52+
print(f"Ошибка скачивания: {exc}")
53+
return False

build/lib/main.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
from cli import cli
2+
3+
if __name__ == "__main__":
4+
cli()

build/lib/network.py

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
"""mailrucloud/network.py
2+
Поставщик WebDAV-клиента, настроенного с учётными данными из auth.py.
3+
Использует библиотеку `webdavclient3`.
4+
"""
5+
6+
from typing import Optional
7+
from webdav3.client import Client # type: ignore
8+
from auth import load_credentials
9+
10+
11+
_cached_client: Optional[Client] = None # Singleton, чтобы не создавать несколько раз
12+
13+
14+
def get_client() -> Client:
15+
"""Возвращает настроенный экземпляр WebDAV-клиента.
16+
17+
Raises
18+
------
19+
RuntimeError
20+
Если учётные данные отсутствуют.
21+
"""
22+
global _cached_client
23+
if _cached_client is not None:
24+
return _cached_client
25+
26+
creds = load_credentials()
27+
if creds is None:
28+
raise RuntimeError("Учётные данные не найдены. Выполните команду login сначала.")
29+
30+
options = {
31+
"webdav_hostname": "https://webdav.cloud.mail.ru",
32+
"webdav_login": creds["email"],
33+
"webdav_password": creds["password"],
34+
}
35+
_cached_client = Client(options)
36+
return _cached_client

0 commit comments

Comments
 (0)