televault 0.1.0__py3-none-any.whl → 2.0.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.
televault/core.py CHANGED
@@ -1,19 +1,18 @@
1
1
  """Core TeleVault operations - upload, download, list."""
2
2
 
3
3
  import asyncio
4
+ import hashlib
4
5
  import os
5
- import tempfile
6
- from pathlib import Path
7
- from typing import Optional, Callable
6
+ from collections.abc import Callable
8
7
  from dataclasses import dataclass
9
- import hashlib
8
+ from pathlib import Path
10
9
 
11
- from .config import Config, get_data_dir
12
- from .models import FileMetadata, ChunkInfo, VaultIndex
13
- from .telegram import TelegramVault, TelegramConfig
14
- from .chunker import iter_chunks, hash_file, hash_data, ChunkWriter, DEFAULT_CHUNK_SIZE
15
- from .crypto import encrypt_chunk, decrypt_chunk
10
+ from .chunker import ChunkWriter, hash_data, hash_file, iter_chunks
16
11
  from .compress import compress_data, decompress_data, should_compress
12
+ from .config import Config
13
+ from .crypto import decrypt_chunk, encrypt_chunk
14
+ from .models import ChunkInfo, FileMetadata
15
+ from .telegram import TelegramConfig, TelegramVault
17
16
 
18
17
 
19
18
  def generate_file_id(name: str, size: int) -> str:
@@ -25,13 +24,14 @@ def generate_file_id(name: str, size: int) -> str:
25
24
  @dataclass
26
25
  class UploadProgress:
27
26
  """Progress information for upload."""
27
+
28
28
  file_name: str
29
29
  total_size: int
30
30
  uploaded_size: int
31
31
  total_chunks: int
32
32
  uploaded_chunks: int
33
33
  current_chunk: int
34
-
34
+
35
35
  @property
36
36
  def percent(self) -> float:
37
37
  if self.total_chunks == 0:
@@ -42,13 +42,14 @@ class UploadProgress:
42
42
  @dataclass
43
43
  class DownloadProgress:
44
44
  """Progress information for download."""
45
+
45
46
  file_name: str
46
47
  total_size: int
47
48
  downloaded_size: int
48
49
  total_chunks: int
49
50
  downloaded_chunks: int
50
51
  current_chunk: int
51
-
52
+
52
53
  @property
53
54
  def percent(self) -> float:
54
55
  if self.total_chunks == 0:
@@ -62,42 +63,44 @@ ProgressCallback = Callable[[UploadProgress | DownloadProgress], None]
62
63
  class TeleVault:
63
64
  """
64
65
  Main TeleVault interface.
65
-
66
+
66
67
  Handles file upload, download, listing with compression and encryption.
67
68
  """
68
-
69
+
69
70
  def __init__(
70
71
  self,
71
- config: Optional[Config] = None,
72
- telegram_config: Optional[TelegramConfig] = None,
73
- password: Optional[str] = None,
72
+ config: Config | None = None,
73
+ telegram_config: TelegramConfig | None = None,
74
+ password: str | None = None,
74
75
  ):
75
76
  self.config = config or Config.load_or_create()
76
77
  self.telegram = TelegramVault(telegram_config)
77
78
  self.password = password
78
79
  self._connected = False
79
-
80
+
81
+ async def is_authenticated(self) -> bool:
82
+ """Check if user is authenticated with Telegram."""
83
+ return await self.telegram._client.is_user_authorized()
84
+
80
85
  async def connect(self, skip_channel: bool = False) -> None:
81
86
  """Connect to Telegram."""
82
87
  await self.telegram.connect()
83
-
84
- if not skip_channel and self.config.channel_id:
85
- # Only set channel if we're already authenticated
86
- if await self.telegram._client.is_user_authorized():
87
- await self.telegram.set_channel(self.config.channel_id)
88
-
88
+
89
+ if not skip_channel and self.config.channel_id and await self.is_authenticated():
90
+ await self.telegram.set_channel(self.config.channel_id)
91
+
89
92
  self._connected = True
90
-
93
+
91
94
  async def disconnect(self) -> None:
92
95
  """Disconnect from Telegram."""
93
96
  await self.telegram.disconnect()
94
97
  self._connected = False
95
-
96
- async def login(self, phone: Optional[str] = None) -> str:
98
+
99
+ async def login(self, phone: str | None = None) -> str:
97
100
  """Interactive login flow."""
98
101
  return await self.telegram.login(phone)
99
-
100
- async def setup_channel(self, channel_id: Optional[int] = None) -> int:
102
+
103
+ async def setup_channel(self, channel_id: int | None = None) -> int:
101
104
  """Set up storage channel."""
102
105
  if channel_id:
103
106
  await self.telegram.set_channel(channel_id)
@@ -105,38 +108,38 @@ class TeleVault:
105
108
  else:
106
109
  channel_id = await self.telegram.create_channel()
107
110
  self.config.channel_id = channel_id
108
-
111
+
109
112
  self.config.save()
110
113
  return channel_id
111
-
114
+
112
115
  async def upload(
113
116
  self,
114
117
  file_path: str | Path,
115
- password: Optional[str] = None,
116
- progress_callback: Optional[ProgressCallback] = None,
118
+ password: str | None = None,
119
+ progress_callback: ProgressCallback | None = None,
117
120
  preserve_path: bool = False,
118
121
  ) -> FileMetadata:
119
122
  """
120
123
  Upload a file to TeleVault with parallel chunk uploads.
121
-
124
+
122
125
  Args:
123
126
  file_path: Path to file to upload
124
127
  password: Encryption password (uses instance password if not provided)
125
128
  progress_callback: Optional progress callback
126
129
  preserve_path: If True, include full path in filename (for directory uploads)
127
-
130
+
128
131
  Returns:
129
132
  FileMetadata of uploaded file
130
133
  """
131
134
  if not self._connected:
132
135
  raise RuntimeError("Not connected. Call connect() first.")
133
-
136
+
134
137
  file_path = Path(file_path)
135
138
  if not file_path.exists():
136
139
  raise FileNotFoundError(f"File not found: {file_path}")
137
-
140
+
138
141
  password = password or self.password
139
-
142
+
140
143
  # Get file info
141
144
  file_name = file_path.name
142
145
  if preserve_path:
@@ -144,17 +147,17 @@ class TeleVault:
144
147
  # For now, just use the full path
145
148
  file_name = str(file_path)
146
149
  file_name = file_name.replace("/", "_")
147
-
150
+
148
151
  file_size = file_path.stat().st_size
149
152
  file_hash = hash_file(file_path)
150
153
  file_id = generate_file_id(file_name, file_size)
151
-
154
+
152
155
  # Count chunks
153
156
  chunk_size = self.config.chunk_size
154
157
  total_chunks = (file_size + chunk_size - 1) // chunk_size
155
158
  if total_chunks == 0:
156
159
  total_chunks = 1 # Empty file = 1 empty chunk
157
-
160
+
158
161
  # Create initial metadata
159
162
  metadata = FileMetadata(
160
163
  id=file_id,
@@ -164,114 +167,36 @@ class TeleVault:
164
167
  encrypted=self.config.encryption and password is not None,
165
168
  compressed=self.config.compression and should_compress(file_name),
166
169
  )
167
-
168
- # Upload metadata message first
169
- metadata_msg_id = await self.telegram.upload_metadata(metadata)
170
- metadata.message_id = metadata_msg_id
171
-
172
- # Prepare chunks for parallel upload
173
- chunk_results: dict[int, ChunkInfo] = {}
174
- uploaded_count = 0
175
- lock = asyncio.Lock()
176
-
177
- async def upload_single_chunk(chunk):
178
- nonlocal uploaded_count
179
-
180
- data = chunk.data
181
-
182
- # Compress if enabled
183
- if metadata.compressed:
184
- data = compress_data(data)
185
-
186
- # Encrypt if enabled
187
- if metadata.encrypted and password:
188
- data = encrypt_chunk(data, password)
189
-
190
- # Upload chunk
191
- chunk_msg_id = await self.telegram.upload_chunk(
192
- data=data,
193
- filename=f"{file_id}_{chunk.index:04d}.chunk",
194
- reply_to=metadata_msg_id,
195
- )
196
-
197
- # Track chunk info
198
- chunk_info = ChunkInfo(
199
- index=chunk.index,
200
- message_id=chunk_msg_id,
201
- size=len(data),
202
- hash=hash_data(data),
203
- )
204
-
205
- async with lock:
206
- chunk_results[chunk.index] = chunk_info
207
- uploaded_count += 1
208
-
209
- # Progress callback
210
- if progress_callback:
211
- progress_callback(UploadProgress(
212
- file_name=file_name,
213
- total_size=file_size,
214
- uploaded_size=int(file_size * uploaded_count / total_chunks),
215
- total_chunks=total_chunks,
216
- uploaded_chunks=uploaded_count,
217
- current_chunk=chunk.index,
218
- ))
219
-
220
- # Upload chunks in parallel (limited concurrency)
221
- semaphore = asyncio.Semaphore(self.config.parallel_uploads)
222
-
223
- async def upload_with_limit(chunk):
224
- async with semaphore:
225
- await upload_single_chunk(chunk)
226
-
227
- # Collect all chunks first for parallel processing
228
- chunks = list(iter_chunks(file_path, chunk_size))
229
-
230
- if chunks:
231
- await asyncio.gather(*[upload_with_limit(c) for c in chunks])
232
-
233
- # Sort chunks by index
234
- metadata.chunks = [chunk_results[i] for i in sorted(chunk_results.keys())]
235
-
236
- # Update metadata with chunk info
237
- await self.telegram.update_metadata(metadata_msg_id, metadata)
238
-
239
- # Update index
240
- index = await self.telegram.get_index()
241
- index.add_file(file_id, metadata_msg_id)
242
- await self.telegram.save_index(index)
243
-
244
- return metadata
245
-
170
+
246
171
  # Upload metadata message first
247
172
  metadata_msg_id = await self.telegram.upload_metadata(metadata)
248
173
  metadata.message_id = metadata_msg_id
249
-
174
+
250
175
  # Prepare chunks for parallel upload
251
176
  chunk_results: dict[int, ChunkInfo] = {}
252
177
  uploaded_count = 0
253
178
  lock = asyncio.Lock()
254
-
179
+
255
180
  async def upload_single_chunk(chunk):
256
181
  nonlocal uploaded_count
257
-
182
+
258
183
  data = chunk.data
259
-
184
+
260
185
  # Compress if enabled
261
186
  if metadata.compressed:
262
187
  data = compress_data(data)
263
-
188
+
264
189
  # Encrypt if enabled
265
190
  if metadata.encrypted and password:
266
191
  data = encrypt_chunk(data, password)
267
-
192
+
268
193
  # Upload chunk
269
194
  chunk_msg_id = await self.telegram.upload_chunk(
270
195
  data=data,
271
196
  filename=f"{file_id}_{chunk.index:04d}.chunk",
272
197
  reply_to=metadata_msg_id,
273
198
  )
274
-
199
+
275
200
  # Track chunk info
276
201
  chunk_info = ChunkInfo(
277
202
  index=chunk.index,
@@ -279,75 +204,77 @@ class TeleVault:
279
204
  size=len(data),
280
205
  hash=hash_data(data),
281
206
  )
282
-
207
+
283
208
  async with lock:
284
209
  chunk_results[chunk.index] = chunk_info
285
210
  uploaded_count += 1
286
-
211
+
287
212
  # Progress callback
288
213
  if progress_callback:
289
- progress_callback(UploadProgress(
290
- file_name=file_name,
291
- total_size=file_size,
292
- uploaded_size=int(file_size * uploaded_count / total_chunks),
293
- total_chunks=total_chunks,
294
- uploaded_chunks=uploaded_count,
295
- current_chunk=chunk.index,
296
- ))
297
-
214
+ progress_callback(
215
+ UploadProgress(
216
+ file_name=file_name,
217
+ total_size=file_size,
218
+ uploaded_size=int(file_size * uploaded_count / total_chunks),
219
+ total_chunks=total_chunks,
220
+ uploaded_chunks=uploaded_count,
221
+ current_chunk=chunk.index,
222
+ )
223
+ )
224
+
298
225
  # Upload chunks in parallel (limited concurrency)
299
226
  semaphore = asyncio.Semaphore(self.config.parallel_uploads)
300
-
227
+
301
228
  async def upload_with_limit(chunk):
302
229
  async with semaphore:
303
230
  await upload_single_chunk(chunk)
304
-
231
+
305
232
  # Collect all chunks first for parallel processing
306
233
  chunks = list(iter_chunks(file_path, chunk_size))
307
-
234
+
308
235
  if chunks:
309
236
  await asyncio.gather(*[upload_with_limit(c) for c in chunks])
310
-
237
+
311
238
  # Sort chunks by index
312
239
  metadata.chunks = [chunk_results[i] for i in sorted(chunk_results.keys())]
313
-
240
+
314
241
  # Update metadata with chunk info
315
242
  await self.telegram.update_metadata(metadata_msg_id, metadata)
316
-
243
+
317
244
  # Update index
318
245
  index = await self.telegram.get_index()
319
246
  index.add_file(file_id, metadata_msg_id)
320
247
  await self.telegram.save_index(index)
321
-
248
+
322
249
  return metadata
323
-
250
+
324
251
  async def download(
325
252
  self,
326
253
  file_id_or_name: str,
327
- output_path: Optional[str | Path] = None,
328
- password: Optional[str] = None,
329
- progress_callback: Optional[ProgressCallback] = None,
254
+ output_path: str | Path | None = None,
255
+ password: str | None = None,
256
+ progress_callback: ProgressCallback | None = None,
330
257
  ) -> Path:
331
258
  """
332
259
  Download a file from TeleVault.
333
-
260
+
334
261
  Args:
335
262
  file_id_or_name: File ID or name to download
336
263
  output_path: Output path (uses original filename in current dir if not provided)
337
264
  password: Decryption password
338
265
  progress_callback: Optional progress callback
339
-
266
+
340
267
  Returns:
341
268
  Path to downloaded file
342
269
  """
343
270
  if not self._connected:
344
271
  raise RuntimeError("Not connected. Call connect() first.")
345
-
272
+
346
273
  password = password or self.password
347
-
274
+
348
275
  # Find file
349
276
  index = await self.telegram.get_index()
350
-
277
+
351
278
  # Try as file ID first
352
279
  if file_id_or_name in index.files:
353
280
  metadata_msg_id = index.files[file_id_or_name]
@@ -355,121 +282,131 @@ class TeleVault:
355
282
  # Search by name
356
283
  files = await self.telegram.list_files()
357
284
  matches = [f for f in files if f.name == file_id_or_name or file_id_or_name in f.name]
358
-
285
+
359
286
  if not matches:
360
287
  raise FileNotFoundError(f"File not found: {file_id_or_name}")
361
288
  if len(matches) > 1:
362
- raise ValueError(f"Multiple files match '{file_id_or_name}': {[f.name for f in matches]}")
363
-
289
+ raise ValueError(
290
+ f"Multiple files match '{file_id_or_name}': {[f.name for f in matches]}"
291
+ )
292
+
364
293
  metadata_msg_id = matches[0].message_id
365
-
294
+
366
295
  # Get metadata
367
296
  metadata = await self.telegram.get_metadata(metadata_msg_id)
368
-
297
+
369
298
  # Determine output path
370
- if output_path:
371
- output_path = Path(output_path)
372
- else:
373
- output_path = Path.cwd() / metadata.name
374
-
299
+ output_path = Path(output_path) if output_path else Path.cwd() / metadata.name
300
+
375
301
  # Create chunk writer
376
302
  writer = ChunkWriter(output_path, metadata.size, self.config.chunk_size)
377
-
303
+
378
304
  downloaded_size = 0
379
-
305
+ total_chunks = len(metadata.chunks)
306
+
380
307
  # Download chunks in order
381
- for chunk_info in sorted(metadata.chunks, key=lambda c: c.index):
308
+ for downloaded_chunks, chunk_info in enumerate(
309
+ sorted(metadata.chunks, key=lambda c: c.index), start=1
310
+ ):
382
311
  # Download chunk
383
312
  data = await self.telegram.download_chunk(chunk_info.message_id)
384
-
313
+
385
314
  # Verify hash
386
315
  if hash_data(data) != chunk_info.hash:
387
316
  raise ValueError(f"Chunk {chunk_info.index} hash mismatch - data corrupted")
388
-
317
+
389
318
  # Decrypt if needed
390
319
  if metadata.encrypted:
391
320
  if not password:
392
321
  raise ValueError("File is encrypted but no password provided")
393
322
  data = decrypt_chunk(data, password)
394
-
323
+
395
324
  # Decompress if needed
396
325
  if metadata.compressed:
397
326
  data = decompress_data(data)
398
-
327
+
399
328
  # Write chunk
400
329
  from .chunker import Chunk
401
- writer.write_chunk(Chunk(
402
- index=chunk_info.index,
403
- data=data,
404
- hash="", # Already verified
405
- size=len(data),
406
- ))
407
-
330
+
331
+ writer.write_chunk(
332
+ Chunk(
333
+ index=chunk_info.index,
334
+ data=data,
335
+ hash="", # Already verified
336
+ size=len(data),
337
+ )
338
+ )
339
+
408
340
  downloaded_size += len(data)
409
-
341
+
410
342
  # Progress callback
411
343
  if progress_callback:
412
- progress_callback(DownloadProgress(
413
- file_name=metadata.name,
414
- total_size=metadata.size,
415
- downloaded_size=downloaded_size,
416
- total_chunks=len(metadata.chunks),
417
- downloaded_chunks=chunk_info.index + 1,
418
- current_chunk=chunk_info.index,
419
- ))
420
-
344
+ progress_callback(
345
+ DownloadProgress(
346
+ file_name=metadata.name,
347
+ total_size=metadata.size,
348
+ downloaded_size=downloaded_size,
349
+ total_chunks=total_chunks,
350
+ downloaded_chunks=downloaded_chunks,
351
+ current_chunk=chunk_info.index,
352
+ )
353
+ )
354
+
421
355
  # Verify final hash
422
356
  if hash_file(output_path) != metadata.hash:
423
- output_path.unlink() # Delete corrupted file
424
- raise ValueError("Downloaded file hash mismatch - file corrupted")
425
-
357
+ output_path.unlink(missing_ok=True) # Delete corrupted file if it exists
358
+ raise ValueError(
359
+ "Downloaded file hash mismatch - downloaded data is corrupted; "
360
+ "try re-downloading or checking your network/Telegram storage."
361
+ )
362
+
426
363
  return output_path
427
-
364
+
428
365
  async def list_files(self) -> list[FileMetadata]:
429
366
  """List all files in the vault."""
430
367
  if not self._connected:
431
368
  raise RuntimeError("Not connected. Call connect() first.")
432
-
369
+
433
370
  return await self.telegram.list_files()
434
-
371
+
435
372
  async def search(self, query: str) -> list[FileMetadata]:
436
373
  """Search files by name."""
437
374
  if not self._connected:
438
375
  raise RuntimeError("Not connected. Call connect() first.")
439
-
376
+
440
377
  return await self.telegram.search_files(query)
441
-
378
+
442
379
  async def delete(self, file_id_or_name: str) -> bool:
443
380
  """Delete a file."""
444
381
  if not self._connected:
445
382
  raise RuntimeError("Not connected. Call connect() first.")
446
-
383
+
447
384
  index = await self.telegram.get_index()
448
-
385
+
449
386
  # Try as file ID first
450
387
  if file_id_or_name in index.files:
451
388
  return await self.telegram.delete_file(file_id_or_name)
452
-
389
+
453
390
  # Search by name
454
391
  files = await self.telegram.list_files()
455
392
  matches = [f for f in files if f.name == file_id_or_name]
456
-
393
+
457
394
  if not matches:
458
395
  return False
459
396
  if len(matches) > 1:
460
397
  raise ValueError(f"Multiple files match '{file_id_or_name}'")
461
-
398
+
462
399
  return await self.telegram.delete_file(matches[0].id)
463
-
400
+
464
401
  async def get_status(self) -> dict:
465
402
  """Get vault status."""
466
403
  if not self._connected:
467
404
  raise RuntimeError("Not connected. Call connect() first.")
468
-
405
+
469
406
  files = await self.list_files()
470
407
  total_size = sum(f.size for f in files)
471
408
  stored_size = sum(f.total_stored_size for f in files)
472
-
409
+
473
410
  return {
474
411
  "channel_id": self.config.channel_id,
475
412
  "file_count": len(files),