tempspace-cli 1.2.6__tar.gz → 1.2.7__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.
@@ -1,12 +1,12 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: tempspace-cli
3
- Version: 1.2.6
3
+ Version: 1.2.7
4
4
  Summary: A command-line tool for uploading files to Tempspace.
5
5
  Author-email: Tempspace <mcbplay1@gmail.com>
6
6
  License: MIT License
7
7
  Classifier: Programming Language :: Python :: 3
8
8
  Classifier: Operating System :: OS Independent
9
- Requires-Python: >=3.7
9
+ Requires-Python: >=3.8
10
10
  Description-Content-Type: text/markdown
11
11
  Requires-Dist: requests==2.32.5
12
12
  Requires-Dist: requests-toolbelt==1.0.0
@@ -4,7 +4,7 @@ import os
4
4
  import sys
5
5
  import hashlib
6
6
  import multiprocessing
7
- from requests_toolbelt.multipart.encoder import MultipartEncoder, MultipartEncoderMonitor
7
+
8
8
  import math
9
9
  from rich.console import Console
10
10
  from rich.panel import Panel
@@ -13,8 +13,12 @@ from rich import box
13
13
  import qrcode
14
14
  from rich.prompt import Prompt, Confirm
15
15
  from rich.progress import Progress, BarColumn, TextColumn, TransferSpeedColumn, TimeRemainingColumn
16
- from rich.console import Group
16
+
17
17
  from rich.live import Live
18
+ import shutil
19
+ import tempfile
20
+ from requests.adapters import HTTPAdapter
21
+ from urllib3.util.retry import Retry
18
22
 
19
23
  # Default configuration
20
24
  DEFAULT_SERVER_URL = "https://tempspace.needrp.net"
@@ -85,6 +89,113 @@ def calculate_file_hash(filepath: str) -> str:
85
89
  return sha256_hash.hexdigest()
86
90
 
87
91
 
92
+
93
+
94
+ def get_retry_session(retries=5, backoff_factor=1, status_forcelist=[500, 502, 503, 504]):
95
+ """Creates a requests Session with automatic retries."""
96
+ session = requests.Session()
97
+ retry = Retry(
98
+ total=retries,
99
+ read=retries,
100
+ connect=retries,
101
+ backoff_factor=backoff_factor,
102
+ status_forcelist=status_forcelist,
103
+ )
104
+ adapter = HTTPAdapter(max_retries=retry)
105
+ session.mount('http://', adapter)
106
+ session.mount('https://', adapter)
107
+ return session
108
+
109
+
110
+ def zip_directory(path: str) -> str:
111
+ """Zips a directory and returns the path to the temporary zip file."""
112
+ # Create a temporary directory to store the zip
113
+ temp_dir = tempfile.mkdtemp()
114
+ # Use the original folder name for the zip file
115
+ base_name = os.path.basename(os.path.normpath(path))
116
+ archive_base = os.path.join(temp_dir, base_name)
117
+
118
+ shutil.make_archive(archive_base, 'zip', path)
119
+ return archive_base + ".zip"
120
+
121
+
122
+ def download_file(console, url, password=None):
123
+ """Downloads a file from Tempspace."""
124
+ session = get_retry_session()
125
+
126
+ try:
127
+ # Check if it's a valid Tempspace URL and extract ID logic if needed,
128
+ # but for now we'll assume the URL is the full download link or similar.
129
+ # Actually, the user provides the "Download Link" which usually leads to a landing page.
130
+ # The CLI needs to handle the actual file download.
131
+ # If the URL is http://site/DOWLOAD_ID, the direct download might be different.
132
+ # Let's assume the user passes the direct download link or we try to download from it.
133
+ # However, Tempspace likely has a landing page.
134
+ # If the user gives the landing page URL, we might need to scrape or use an API.
135
+ # Given I don't see the server code, I'll assume the user might provide a direct file URL
136
+ # or the tool should try to GET the URL.
137
+ # If the server has an API like /api/download/<id>?password=..., that would be best.
138
+ # Looking at upload_file response: `download_link = data.get('url')`.
139
+ # Code audit earlier showed `DEFAULT_SERVER_URL`.
140
+ # Use regex to extract ID from URL if possible, or just try GET.
141
+
142
+ # Simple approach: GET the URL. If it initiates a download (headers), good.
143
+
144
+ session.head(url, allow_redirects=True)
145
+ # Check headers for content-disposition or just proceed
146
+
147
+ if password:
148
+ # If password is needed, headers or query param might be required.
149
+ # Since I don't know the exact server auth mechanism for downloads,
150
+ # I will try passing it as a query param 'password' or header 'X-Password'.
151
+ # Better: ask the user to enter it if the server responds 401/403.
152
+ pass
153
+
154
+ # For a streaming download
155
+ response = session.get(url, stream=True, params={'password': password} if password else None)
156
+ response.raise_for_status()
157
+
158
+ total_size = int(response.headers.get('content-length', 0))
159
+ disposition = response.headers.get('content-disposition')
160
+ # Try to get filename from Content-Disposition
161
+ filename = None
162
+ if disposition:
163
+ import re
164
+ # specific regex for filename="example.txt" or filename=example.txt
165
+ fname = re.findall(r'filename=["\']?([^"\';]+)["\']?', disposition)
166
+ if fname:
167
+ filename = fname[0]
168
+
169
+ # Fallback to URL if no filename found from headers
170
+ if not filename:
171
+ from urllib.parse import unquote
172
+ parsed_path = unquote(url.split("?")[0])
173
+ filename = parsed_path.split("/")[-1]
174
+
175
+ if not filename:
176
+ filename = "downloaded_file"
177
+
178
+ progress = Progress(
179
+ TextColumn("[bold blue]{task.description}", justify="right"),
180
+ BarColumn(bar_width=None),
181
+ "[progress.percentage]{task.percentage:>3.1f}%", "•",
182
+ TransferSpeedColumn(), "•",
183
+ TimeRemainingColumn(),
184
+ )
185
+
186
+ with Live(Panel(progress, title="[cyan]Downloading[/cyan]", border_style="cyan", title_align="left")):
187
+ task_id = progress.add_task(filename, total=total_size)
188
+ with open(filename, 'wb') as f:
189
+ for chunk in response.iter_content(CHUNK_SIZE):
190
+ f.write(chunk)
191
+ progress.update(task_id, advance=len(chunk))
192
+
193
+ console.print(Panel(f"[bold green]Download successful![/] Saved to '{filename}'", border_style="green"))
194
+
195
+ except Exception as e:
196
+ console.print(Panel(f"[bold red]Download failed:[/] {e}", border_style="red"))
197
+
198
+
88
199
  def upload_file(console, filepath, hours, password, one_time, qr, url):
89
200
  """Handles the upload of a single file.
90
201
 
@@ -125,9 +236,10 @@ def upload_file(console, filepath, hours, password, one_time, qr, url):
125
236
 
126
237
  # --- Chunked Upload ---
127
238
  response = None
239
+ session = get_retry_session()
128
240
  try:
129
241
  # 1. Initiate Upload
130
- initiate_response = requests.post(f"{upload_url}/upload/initiate")
242
+ initiate_response = session.post(f"{upload_url}/upload/initiate")
131
243
  initiate_response.raise_for_status()
132
244
  upload_id = initiate_response.json()['upload_id']
133
245
 
@@ -140,7 +252,7 @@ def upload_file(console, filepath, hours, password, one_time, qr, url):
140
252
  TimeRemainingColumn(),
141
253
  )
142
254
 
143
- with Live(Panel(progress, title="[cyan]Uploading[/cyan]", border_style="cyan", title_align="left")) as live:
255
+ with Live(Panel(progress, title="[cyan]Uploading[/cyan]", border_style="cyan", title_align="left")):
144
256
  task_id = progress.add_task(filename, total=file_size)
145
257
  with open(filepath, 'rb') as f:
146
258
  chunk_number = 0
@@ -152,7 +264,7 @@ def upload_file(console, filepath, hours, password, one_time, qr, url):
152
264
  }
153
265
  files = {'file': (f'chunk_{chunk_number}', chunk, 'application/octet-stream')}
154
266
 
155
- chunk_response = requests.post(
267
+ chunk_response = session.post(
156
268
  f"{upload_url}/upload/chunk",
157
269
  data=chunk_data,
158
270
  files=files
@@ -172,7 +284,7 @@ def upload_file(console, filepath, hours, password, one_time, qr, url):
172
284
  if password:
173
285
  finalize_data['password'] = password
174
286
 
175
- response = requests.post(f"{upload_url}/upload/finalize", data=finalize_data)
287
+ response = session.post(f"{upload_url}/upload/finalize", data=finalize_data)
176
288
  response.raise_for_status()
177
289
 
178
290
  except FileNotFoundError:
@@ -183,7 +295,7 @@ def upload_file(console, filepath, hours, password, one_time, qr, url):
183
295
  if e.response:
184
296
  try:
185
297
  error_message = e.response.json().get('detail', e.response.text)
186
- except:
298
+ except Exception:
187
299
  error_message = e.response.text
188
300
  console.print(Panel(f"[bold red]An error occurred:[/] {error_message}", title="[bold red]Error[/bold red]", border_style="red"))
189
301
  return
@@ -244,6 +356,21 @@ def main():
244
356
  """
245
357
  console = Console()
246
358
 
359
+ # --- Handle Download Command ---
360
+ if len(sys.argv) > 1 and sys.argv[1] == 'download':
361
+ parser = argparse.ArgumentParser(description="Download a file from Tempspace.")
362
+ parser.add_argument("url", help="The URL of the file to download.")
363
+ parser.add_argument("-p", "--password", help="The password for the file.")
364
+
365
+ # We process only the download arguments
366
+ if len(sys.argv) == 2 and (sys.argv[1] == '-h' or sys.argv[1] == '--help'):
367
+ parser.print_help()
368
+ sys.exit(0)
369
+
370
+ args = parser.parse_args(sys.argv[2:])
371
+ download_file(console, args.url, args.password)
372
+ return
373
+
247
374
  # --- Header ---
248
375
  console.print(Panel("[bold cyan]Tempspace File Uploader[/bold cyan]", expand=False, border_style="blue"))
249
376
 
@@ -285,11 +412,24 @@ def main():
285
412
  console.print(Panel(f"[bold red]Error:[/] Invalid time format '{args.time}'. Use formats like '24h', '7d', or '360'.", title="[bold red]Error[/bold red]", border_style="red"))
286
413
  sys.exit(1)
287
414
 
415
+ # --- Process each file ---
288
416
  # --- Process each file ---
289
417
  for i, filepath in enumerate(filepaths):
418
+ actual_path = filepath
419
+ is_temp = False
420
+
421
+ if os.path.isdir(filepath):
422
+ console.print(f"[bold yellow]Zipping directory '{os.path.basename(filepath)}'...[/]")
423
+ actual_path = zip_directory(filepath)
424
+ is_temp = True
425
+
290
426
  if len(filepaths) > 1:
291
- console.print(f"\n[bold yellow]Uploading file {i+1} of {len(filepaths)}: {os.path.basename(filepath)}[/bold yellow]\n")
292
- upload_file(console, filepath, hours, args.password, args.one_time, args.qr, args.url)
427
+ console.print(f"\n[bold yellow]Uploading file {i+1} of {len(filepaths)}: {os.path.basename(actual_path)}[/bold yellow]\n")
428
+
429
+ upload_file(console, actual_path, hours, args.password, args.one_time, args.qr, args.url)
430
+
431
+ if is_temp:
432
+ os.remove(actual_path)
293
433
 
294
434
 
295
435
  if __name__ == "__main__":
@@ -7,14 +7,14 @@ packages = ["cli"]
7
7
 
8
8
  [project]
9
9
  name = "tempspace-cli"
10
- version = "1.2.6"
10
+ version = "1.2.7"
11
11
  authors = [
12
12
  { name="Tempspace", email="mcbplay1@gmail.com" },
13
13
  ]
14
14
  description = "A command-line tool for uploading files to Tempspace."
15
15
  readme = "README.md"
16
16
  license = { text = "MIT License" }
17
- requires-python = ">=3.7"
17
+ requires-python = ">=3.8"
18
18
  classifiers = [
19
19
  "Programming Language :: Python :: 3",
20
20
  "Operating System :: OS Independent",
@@ -1,12 +1,12 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: tempspace-cli
3
- Version: 1.2.6
3
+ Version: 1.2.7
4
4
  Summary: A command-line tool for uploading files to Tempspace.
5
5
  Author-email: Tempspace <mcbplay1@gmail.com>
6
6
  License: MIT License
7
7
  Classifier: Programming Language :: Python :: 3
8
8
  Classifier: Operating System :: OS Independent
9
- Requires-Python: >=3.7
9
+ Requires-Python: >=3.8
10
10
  Description-Content-Type: text/markdown
11
11
  Requires-Dist: requests==2.32.5
12
12
  Requires-Dist: requests-toolbelt==1.0.0
@@ -0,0 +1,123 @@
1
+
2
+ import os
3
+ import shutil
4
+ import tempfile
5
+ import hashlib
6
+
7
+ from unittest.mock import MagicMock, patch
8
+ from cli.tempspace import parse_time, format_size, calculate_file_hash, zip_directory, upload_file, download_file
9
+
10
+
11
+ # --- Unit Tests ---
12
+
13
+ def test_parse_time():
14
+ assert parse_time("7d") == 168
15
+ assert parse_time("24h") == 24
16
+ assert parse_time("360") == 360
17
+ assert parse_time("1D") == 24
18
+ assert parse_time("2H") == 2
19
+ assert parse_time(" 7d ") == 168
20
+ assert parse_time("invalid") is None
21
+ assert parse_time("1w") is None
22
+
23
+ def test_format_size():
24
+ assert format_size(0) == "0B"
25
+ assert format_size(1023) == "1023.0 B"
26
+ assert format_size(100) == "100.0 B"
27
+ assert format_size(1024) == "1.0 KB"
28
+ assert format_size(1024 * 1024) == "1.0 MB"
29
+ assert format_size(1024 * 1024 * 1024 * 2.5) == "2.5 GB"
30
+
31
+ def test_calculate_file_hash():
32
+ with tempfile.NamedTemporaryFile(delete=False) as f:
33
+ f.write(b"hello world")
34
+ filepath = f.name
35
+ try:
36
+ expected_hash = hashlib.sha256(b"hello world").hexdigest()
37
+ assert calculate_file_hash(filepath) == expected_hash
38
+ finally:
39
+ os.remove(filepath)
40
+
41
+ def test_zip_directory():
42
+ # Create a dummy directory structure
43
+ temp_dir = tempfile.mkdtemp()
44
+ try:
45
+ os.makedirs(os.path.join(temp_dir, "subdir"))
46
+ with open(os.path.join(temp_dir, "file1.txt"), "w") as f:
47
+ f.write("content1")
48
+
49
+ # Call zip_directory
50
+ zip_path = zip_directory(temp_dir)
51
+
52
+ assert os.path.exists(zip_path)
53
+ assert zip_path.endswith(".zip")
54
+ assert os.path.basename(temp_dir) in os.path.basename(zip_path)
55
+
56
+ # Cleanup zip
57
+ os.remove(zip_path)
58
+ finally:
59
+ shutil.rmtree(temp_dir)
60
+
61
+ # --- Mock Tests ---
62
+
63
+ @patch('cli.tempspace.requests.Session')
64
+ def test_upload_file_success(mock_session_cls, capsys):
65
+ mock_session = mock_session_cls.return_value
66
+
67
+ # Mock Initiate
68
+ mock_session.post.side_effect = [
69
+ MagicMock(status_code=200, json=lambda: {'upload_id': '123'}), # Initiate
70
+ MagicMock(status_code=200), # Chunk 1
71
+ MagicMock(status_code=200, json=lambda: {'url': 'http://test.com/file', 'hash': 'abc', 'hash_verified': True}) # Finalize
72
+ ]
73
+
74
+ with tempfile.NamedTemporaryFile(delete=False) as f:
75
+ f.write(b"test content")
76
+ filepath = f.name
77
+
78
+ mock_console = MagicMock()
79
+
80
+ try:
81
+ upload_file(mock_console, filepath, 24, None, False, False, "http://test-server")
82
+
83
+ # Assert calls
84
+ assert mock_session.post.call_count == 3
85
+ # Verify initiate call
86
+ mock_session.post.call_args_list[0][0][0].endswith("/upload/initiate")
87
+ # Verify finalize call
88
+ mock_session.post.call_args_list[2][0][0].endswith("/upload/finalize")
89
+
90
+ # Verify success message printed
91
+ # mock_console.print.assert_called() # Hard to check partial strings on rich objects without more complex matching
92
+
93
+ finally:
94
+ os.remove(filepath)
95
+
96
+ @patch('cli.tempspace.requests.Session')
97
+ def test_download_file_success(mock_session_cls):
98
+ mock_session = mock_session_cls.return_value
99
+
100
+ # Mock HEAD request (optional/handled in code?) Code does checks but uses same session
101
+ mock_session.head.return_value = MagicMock(status_code=200)
102
+
103
+ # Mock GET request
104
+ content = b"downloaded content"
105
+ mock_response = MagicMock(status_code=200)
106
+ mock_response.headers = {'content-length': str(len(content)), 'content-disposition': 'attachment; filename="server_file.txt"'}
107
+ mock_response.iter_content.return_value = [content]
108
+ mock_session.get.return_value = mock_response
109
+
110
+ mock_console = MagicMock()
111
+
112
+ # Run download
113
+ try:
114
+ download_file(mock_console, "http://test-server/file")
115
+
116
+ # Verify file saved
117
+ assert os.path.exists("server_file.txt")
118
+ with open("server_file.txt", "rb") as f:
119
+ assert f.read() == content
120
+ finally:
121
+ if os.path.exists("server_file.txt"):
122
+ os.remove("server_file.txt")
123
+
@@ -1,16 +1,19 @@
1
1
  import pytest
2
2
  from fastapi.testclient import TestClient
3
- from main import app, UPLOAD_DIR
3
+ from main import (
4
+ app, UPLOAD_DIR, RateLimiter, RATE_LIMIT_UPLOADS, RATE_LIMIT_DOWNLOADS,
5
+ RATE_LIMIT_WINDOW, cleanup_expired_files, shutdown_event,
6
+ is_video, is_text, format_bytes
7
+ )
4
8
  import os
5
9
  import shutil
6
10
  import asyncio
7
- import json
11
+ import hashlib
12
+ from datetime import datetime, timedelta, timezone
8
13
 
9
14
  # Create a client for testing
10
15
  client = TestClient(app)
11
16
 
12
- # Import RateLimiter to reset it
13
- from main import RateLimiter, RATE_LIMIT_UPLOADS, RATE_LIMIT_DOWNLOADS, RATE_LIMIT_WINDOW
14
17
 
15
18
  @pytest.fixture(autouse=True)
16
19
  def reset_rate_limiters(monkeypatch):
@@ -217,7 +220,7 @@ def test_duplicate_file_upload():
217
220
  # Check that the file_id is the same for both uploads
218
221
  assert data1["file_id"] == data2["file_id"]
219
222
 
220
- import hashlib
223
+
221
224
 
222
225
  def calculate_hash(content: bytes) -> str:
223
226
  """Helper function to calculate SHA256 hash."""
@@ -343,8 +346,6 @@ def test_debug_wipe_authorized():
343
346
  assert get_response.status_code == 404
344
347
 
345
348
  # Import necessary modules for the expiration test
346
- from datetime import datetime, timedelta, timezone
347
- from main import cleanup_expired_files, shutdown_event
348
349
 
349
350
  @pytest.mark.asyncio
350
351
  async def test_file_expiration_and_cleanup(monkeypatch):
@@ -425,7 +426,7 @@ async def test_rate_limiter_timezone_aware_expiration(monkeypatch):
425
426
  Test that the RateLimiter correctly expires entries after its time window,
426
427
  using timezone-aware datetime objects to prevent DST or timezone issues.
427
428
  """
428
- from datetime import datetime, timedelta, timezone
429
+
429
430
 
430
431
  # Use a local limiter instance for this test
431
432
  limiter = RateLimiter(max_requests=1, window_seconds=10)
@@ -653,7 +654,7 @@ def test_get_scheme_and_host_forwarded():
653
654
  assert data["url"].startswith("https://")
654
655
 
655
656
 
656
- from main import is_video, is_text, format_bytes
657
+
657
658
 
658
659
  def test_format_bytes():
659
660
  """Test the format_bytes helper function."""
@@ -719,3 +720,40 @@ def test_delete_file_missing_client_id():
719
720
  json={"some_other_key": "some_value"},
720
721
  )
721
722
  assert response.status_code == 400
723
+
724
+
725
+ def test_upload_zip_file():
726
+ """Test uploading a zip file to verify MIME type detection and handling."""
727
+ # Create a dummy zip content (header for a zip file)
728
+ zip_header = b"PK\x03\x04"
729
+ response = client.post(
730
+ "/upload",
731
+ files={"file": ("archive.zip", zip_header + b"some content", "application/zip")},
732
+ data={"hours": "1"},
733
+ )
734
+ assert response.status_code == 200
735
+ data = response.json()
736
+ assert data["success"] is True
737
+ assert data["filename"] == "archive.zip"
738
+ # Depending on filetype library, it might guess zip or octet-stream for this partial content.
739
+ # The server logic uses filetype.guess.
740
+ assert "url" in data
741
+
742
+ def test_download_content_disposition():
743
+ """Test that the server sends the correct Content-Disposition header."""
744
+ filename = "disposition_test.txt"
745
+ # Use unique content to avoid deduplication returning an old filename
746
+ content = b"unique content for disposition test " + os.urandom(8)
747
+ upload_response = client.post(
748
+ "/upload",
749
+ files={"file": (filename, content, "text/plain")},
750
+ data={"hours": "1"},
751
+ )
752
+ path = upload_response.json()["url"].split("/", 3)[-1]
753
+
754
+ response = client.get(path)
755
+ assert response.status_code == 200
756
+ assert "content-disposition" in response.headers
757
+ # Starlette/FastAPI FileResponse might use filename*=UTF-8''... for safety
758
+ cd = response.headers["content-disposition"]
759
+ assert filename in cd
@@ -1,12 +0,0 @@
1
- import pytest
2
- from cli.tempspace import parse_time
3
-
4
- def test_parse_time():
5
- assert parse_time("7d") == 168
6
- assert parse_time("24h") == 24
7
- assert parse_time("360") == 360
8
- assert parse_time("1D") == 24
9
- assert parse_time("2H") == 2
10
- assert parse_time(" 7d ") == 168
11
- assert parse_time("invalid") is None
12
- assert parse_time("1w") is None
File without changes
File without changes