sideload 1.3.0__py3-none-any.whl → 1.4.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.

Potentially problematic release.


This version of sideload might be problematic. Click here for more details.

sideload/cli.py CHANGED
@@ -485,9 +485,14 @@ Examples:
485
485
 
486
486
  console.print(Rule("📦 Downloading Packages"))
487
487
 
488
- with tempfile.TemporaryDirectory() as temp_dir:
489
- temp_path = Path(temp_dir)
490
- wheel_files = client.download_packages(package_names, temp_path, args.debug)
488
+ # Use work_dir for downloads if provided, otherwise use temp directory
489
+ use_work_dir = args.work_dir is not None
490
+ if use_work_dir:
491
+ download_dir = args.work_dir / "wheels"
492
+ download_dir.mkdir(parents=True, exist_ok=True)
493
+ console.print(f"[yellow]📥 Downloading packages to: {download_dir}[/yellow]")
494
+
495
+ wheel_files = client.download_packages(package_names, download_dir, args.debug)
491
496
 
492
497
  if not wheel_files:
493
498
  console.print(
@@ -512,6 +517,35 @@ Examples:
512
517
  console.print(
513
518
  f"📊 File size: [cyan]{output_file.stat().st_size:,} bytes[/cyan]"
514
519
  )
520
+ else:
521
+ # Use temporary directory for downloads
522
+ with tempfile.TemporaryDirectory() as temp_dir:
523
+ temp_path = Path(temp_dir)
524
+ wheel_files = client.download_packages(package_names, temp_path, args.debug)
525
+
526
+ if not wheel_files:
527
+ console.print(
528
+ "❌ No packages were downloaded successfully", style="red"
529
+ )
530
+ return
531
+
532
+ # Extract and reassemble
533
+ console.print(Rule("🔧 Reassembling File"))
534
+ original_filename = data.get("filename", "downloaded_file")
535
+ output_file = args.output / original_filename
536
+
537
+ client.extract_and_reassemble(
538
+ wheel_files, package_names, original_filename, output_file, args.debug, args.work_dir
539
+ )
540
+
541
+ # Success!
542
+ console.print(Rule("✨ Complete"))
543
+ console.print(
544
+ f"🎉 File successfully downloaded to: [bold green]{output_file}[/bold green]"
545
+ )
546
+ console.print(
547
+ f"📊 File size: [cyan]{output_file.stat().st_size:,} bytes[/cyan]"
548
+ )
515
549
 
516
550
  except KeyboardInterrupt:
517
551
  console.print("\n⚠️ Download interrupted by user", style="yellow")
sideload/main.py CHANGED
@@ -11,7 +11,7 @@ from sideload.jsonbin_connector import JSONBinConnector
11
11
 
12
12
  JSONBIN_TOKEN = os.environ["JSONBIN_TOKEN"]
13
13
  PYPI_TOKEN = os.environ["PYPI_TOKEN"]
14
- MAX_PACKAGE_SIZE = 95 * 1024 * 1024 # 95 MB
14
+ MAX_PACKAGE_SIZE = 92 * 1024 * 1024 # 95 MB
15
15
 
16
16
  LAST_BINS: dict[str, str | None] = {}
17
17
 
@@ -6,16 +6,32 @@ Admin script to delete all sideload-* packages from PyPI using browser automatio
6
6
  import os
7
7
  import sys
8
8
  import asyncio
9
+ import random
9
10
  from playwright.async_api import async_playwright
11
+ import pyotp
10
12
 
11
13
  PYPI_USER = os.environ.get("PYPI_USER")
12
14
  PYPI_PASSWORD = os.environ.get("PYPI_PASSWORD")
15
+ PYPI_TOTP = os.environ.get("PYPI_TOTP") # Optional: TOTP secret key
13
16
 
14
17
  if not PYPI_USER or not PYPI_PASSWORD:
15
18
  print("❌ PYPI_USER and PYPI_PASSWORD environment variables must be set")
16
19
  sys.exit(1)
17
20
 
18
21
 
22
+ async def human_like_mouse_movement(page):
23
+ """Simulate human-like mouse movements across the page"""
24
+ viewport_size = page.viewport_size
25
+ width = viewport_size.get('width', 1280) if viewport_size else 1280
26
+ height = viewport_size.get('height', 720) if viewport_size else 720
27
+
28
+ for _ in range(random.randint(3, 6)):
29
+ x = random.randint(50, width - 50)
30
+ y = random.randint(50, height - 50)
31
+ await page.mouse.move(x, y)
32
+ await asyncio.sleep(random.uniform(0.15, 0.4))
33
+
34
+
19
35
  async def delete_project(page, package_name: str) -> bool:
20
36
  """Delete a project from PyPI using browser automation"""
21
37
  try:
@@ -35,8 +51,34 @@ async def delete_project(page, package_name: str) -> bool:
35
51
  await page.evaluate('window.scrollTo(0, document.body.scrollHeight)')
36
52
  await asyncio.sleep(0.5)
37
53
 
54
+ # Extract the exact project name from the page to ensure correct case
55
+ # Look for the project name in the delete section
56
+ exact_project_name = await page.evaluate('''() => {
57
+ // Find the label that mentions the project name
58
+ const labels = Array.from(document.querySelectorAll('label'));
59
+ for (const label of labels) {
60
+ const text = label.textContent;
61
+ if (text && text.includes('confirm by typing the project name')) {
62
+ // Extract the project name from something like "confirm by typing the project name (project-name) below"
63
+ const match = text.match(/\\(([^)]+)\\)/);
64
+ if (match) return match[1];
65
+ }
66
+ }
67
+ // Fallback: get from URL
68
+ const path = window.location.pathname;
69
+ const urlMatch = path.match(/\\/manage\\/project\\/([^\\/]+)/);
70
+ return urlMatch ? decodeURIComponent(urlMatch[1]) : null;
71
+ }''')
72
+
73
+ if not exact_project_name:
74
+ print(f" ⚠️ Could not extract exact project name from page, using: {package_name}")
75
+ exact_project_name = package_name
76
+ else:
77
+ print(f" 📝 Extracted exact project name: {exact_project_name}")
78
+
38
79
  # Find and check all "I understand..." checkboxes
39
80
  checkboxes = await page.query_selector_all('input[type="checkbox"]')
81
+ print(f" ✓ Found {len(checkboxes)} checkboxes")
40
82
  for checkbox in checkboxes:
41
83
  await checkbox.check()
42
84
 
@@ -46,29 +88,55 @@ async def delete_project(page, package_name: str) -> bool:
46
88
  print(f" ⚠️ Could not find confirmation input for {package_name}")
47
89
  return False
48
90
 
49
- await confirm_input.fill(package_name)
91
+ # Clear and type the exact project name with human-like behavior
92
+ await confirm_input.clear()
93
+ await asyncio.sleep(random.uniform(0.2, 0.5))
94
+
95
+ # Move mouse before typing
96
+ await human_like_mouse_movement(page)
97
+
98
+ # Type each character with varying delays
99
+ for char in exact_project_name:
100
+ await confirm_input.type(char, delay=random.randint(80, 200))
101
+ await asyncio.sleep(random.uniform(0.05, 0.15))
102
+
103
+ print(f" ✓ Typed project name: {exact_project_name}")
50
104
 
51
- # Find and click the delete link that opens the modal
52
- # This is the button with href="#project_name-modal"
53
- delete_link = await page.query_selector('a.button--danger[data-delete-confirm-target="button"]')
105
+ # Wait a moment for the button to become enabled (human-like pause)
106
+ await asyncio.sleep(random.uniform(1.0, 2.0))
107
+
108
+ # Move mouse naturally
109
+ await human_like_mouse_movement(page)
110
+
111
+ # Find and click the delete link
112
+ delete_link = await page.query_selector('[data-delete-confirm-target="button"]')
54
113
  if not delete_link:
55
- print(f" ⚠️ Could not find delete link for {package_name}")
114
+ print(f" ⚠️ Could not find delete button")
56
115
  return False
57
116
 
58
- # Wait a moment to ensure button is enabled
59
- await asyncio.sleep(0.5)
117
+ # Check if the button is enabled
118
+ is_disabled = await delete_link.evaluate('(el) => el.classList.contains("button--disabled") || el.hasAttribute("disabled")')
119
+ if is_disabled:
120
+ print(f" ⚠️ Delete button is still disabled")
121
+ # Take a screenshot for debugging
122
+ await page.screenshot(path=f"debug_{package_name}.png")
123
+ print(f" 📸 Screenshot saved as debug_{package_name}.png")
124
+ return False
60
125
 
61
- # Click the link to open the modal
62
- await delete_link.click()
63
- await asyncio.sleep(0.5)
126
+ print(f" 🖱️ Clicking delete button...")
64
127
 
65
- # Now find and click the actual delete button in the modal
66
- modal_delete_button = await page.query_selector('button[type="submit"]')
67
- if not modal_delete_button:
68
- print(f" ⚠️ Could not find modal delete button for {package_name}")
69
- return False
128
+ # Move mouse to the button area naturally
129
+ box = await delete_link.bounding_box()
130
+ if box:
131
+ # Move to a random point within the button
132
+ target_x = box['x'] + random.uniform(10, box['width'] - 10)
133
+ target_y = box['y'] + random.uniform(10, box['height'] - 10)
134
+ await page.mouse.move(target_x, target_y)
135
+ await asyncio.sleep(random.uniform(0.2, 0.5))
70
136
 
71
- await modal_delete_button.click()
137
+ # Click and wait for navigation
138
+ await delete_link.click()
139
+ await asyncio.sleep(random.uniform(0.5, 1.0))
72
140
  await page.wait_for_load_state("networkidle")
73
141
 
74
142
  print(f" ✅ Deleted {package_name}")
@@ -76,6 +144,14 @@ async def delete_project(page, package_name: str) -> bool:
76
144
 
77
145
  except Exception as e:
78
146
  print(f" ❌ Error deleting {package_name}: {e}")
147
+ import traceback
148
+ traceback.print_exc()
149
+ # Take screenshot on error
150
+ try:
151
+ await page.screenshot(path=f"error_{package_name}.png")
152
+ print(f" 📸 Error screenshot saved as error_{package_name}.png")
153
+ except:
154
+ pass
79
155
  return False
80
156
 
81
157
 
@@ -107,6 +183,10 @@ async def get_user_projects(page) -> list[str]:
107
183
  async def main():
108
184
  print("🧹 PyPI Sideload Package Cleanup Tool")
109
185
  print("=" * 50)
186
+ if PYPI_TOTP:
187
+ print("🔐 TOTP auto-generation enabled")
188
+ else:
189
+ print("⚠️ TOTP auto-generation disabled (set PYPI_TOTP to enable)")
110
190
  print()
111
191
 
112
192
  async with async_playwright() as p:
@@ -121,27 +201,128 @@ async def main():
121
201
  print(" Opening login page...")
122
202
  await page.goto('https://pypi.org/account/login/')
123
203
 
124
- # Fill in login credentials
204
+ # Fill in login credentials (fast, no need to be slow here)
125
205
  print(" Filling credentials...")
126
206
  await page.fill('#username', PYPI_USER)
207
+ await asyncio.sleep(random.uniform(0.2, 0.4))
127
208
  await page.fill('#password', PYPI_PASSWORD)
209
+ await asyncio.sleep(random.uniform(0.3, 0.6))
128
210
 
129
211
  # Submit login form
130
212
  await page.click('input[type="submit"]')
131
- await asyncio.sleep(1)
213
+ await asyncio.sleep(2)
132
214
 
133
215
  # Wait for user to complete TOTP if required
134
216
  current_url = page.url
135
217
  if '/account/two-factor/' in current_url:
136
- print(" ⚠️ TOTP required - please enter your 2FA code in the browser...")
137
- while True:
138
- current_url = page.url
139
- # Wait until we're not on TOTP page anymore
140
- if '/account/two-factor/' not in current_url:
141
- break
142
- await asyncio.sleep(1)
143
-
144
- print("✅ Login completed!")
218
+ if PYPI_TOTP:
219
+ print(" 🔐 Generating TOTP code...")
220
+
221
+ # Simulate human-like delay before interacting
222
+ await asyncio.sleep(random.uniform(1.5, 2.5))
223
+
224
+ # Move mouse around naturally across the page
225
+ print(" 🖱️ Moving mouse naturally...")
226
+ await human_like_mouse_movement(page)
227
+
228
+ totp = pyotp.TOTP(PYPI_TOTP)
229
+ code = totp.now()
230
+ print(f" ✓ Generated TOTP code: {code}")
231
+
232
+ # Find TOTP input field and enter the code
233
+ totp_input = await page.query_selector('input[name="totp_value"]')
234
+ if not totp_input:
235
+ totp_input = await page.query_selector('input[type="text"]')
236
+
237
+ if totp_input:
238
+ # Click on the input field naturally
239
+ await totp_input.click()
240
+ await asyncio.sleep(random.uniform(0.4, 0.8))
241
+
242
+ # Type each character slowly with human-like delays
243
+ print(" ⌨️ Typing TOTP code slowly...")
244
+ for i, char in enumerate(code):
245
+ await totp_input.type(char, delay=random.randint(100, 250))
246
+ await asyncio.sleep(random.uniform(0.1, 0.25))
247
+ # Occasionally move mouse during typing
248
+ if i % 2 == 0:
249
+ x = random.randint(200, 600)
250
+ y = random.randint(200, 500)
251
+ await page.mouse.move(x, y)
252
+
253
+ print(" ✓ Entered TOTP code")
254
+
255
+ # Wait a bit before submitting (like a human would)
256
+ await asyncio.sleep(random.uniform(0.8, 1.5))
257
+
258
+ # More mouse movements
259
+ print(" 🖱️ Moving mouse before submit...")
260
+ await human_like_mouse_movement(page)
261
+
262
+ # Submit the form
263
+ submit_button = await page.query_selector('button[type="submit"]')
264
+ if not submit_button:
265
+ submit_button = await page.query_selector('input[type="submit"]')
266
+
267
+ if submit_button:
268
+ await submit_button.click()
269
+ print(" ✓ Submitted TOTP, waiting for response...")
270
+
271
+ # Wait for navigation
272
+ await asyncio.sleep(4)
273
+
274
+ # Check if we've successfully logged in
275
+ current_url = page.url
276
+ if '/account/' in current_url or '/manage/' in current_url:
277
+ print(" ✓ Successfully logged in!")
278
+ elif '/account/two-factor/' in current_url:
279
+ # Still on 2FA page - likely a captcha
280
+ print("\n" + "=" * 60)
281
+ print(" 🤖 CAPTCHA DETECTED!")
282
+ print(" 👤 Please solve the captcha in the browser window")
283
+ print(" ⏳ Waiting for you to complete it...")
284
+ print("=" * 60 + "\n")
285
+
286
+ # Wait for user to solve captcha - keep checking
287
+ # We need to wait until we're actually logged in (on account page or similar)
288
+ while True:
289
+ await asyncio.sleep(2)
290
+ current_url = page.url
291
+ # Check if we've successfully logged in (not just left the 2FA page)
292
+ if ('/account/' in current_url or '/manage/' in current_url) and '/account/two-factor/' not in current_url:
293
+ break
294
+ # Print status every 2 seconds to show we're still waiting
295
+ print(" ⏳ Still waiting for captcha completion...", end='\r')
296
+
297
+ print("\n ✓ Captcha solved and logged in! Continuing...")
298
+ else:
299
+ print(f" ⚠️ Unexpected page: {current_url}")
300
+ print(" ⏳ Waiting for login to complete...")
301
+ # Wait until we're on a known good page
302
+ while True:
303
+ await asyncio.sleep(2)
304
+ current_url = page.url
305
+ if '/account/' in current_url or '/manage/' in current_url:
306
+ break
307
+ print(" ⏳ Still waiting...", end='\r')
308
+ print("\n ✓ Login completed!")
309
+ else:
310
+ print(" ⚠️ Could not find submit button")
311
+ else:
312
+ print(" ⚠️ Could not find TOTP input field")
313
+ else:
314
+ print(" ⚠️ TOTP required - please enter your 2FA code in the browser...")
315
+ print(" 👤 Waiting for you to complete 2FA (and captcha if present)...")
316
+ while True:
317
+ await asyncio.sleep(2)
318
+ current_url = page.url
319
+ # Wait until we're actually logged in
320
+ if '/account/two-factor/' not in current_url and ('/account/' in current_url or '/manage/' in current_url):
321
+ break
322
+ print(" ⏳ Still waiting for 2FA completion...", end='\r')
323
+ print("\n ✓ 2FA completed!")
324
+
325
+ print("\n✅ Login completed!")
145
326
 
146
327
  # Get all sideload projects
147
328
  projects = await get_user_projects(page)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: sideload
3
- Version: 1.3.0
3
+ Version: 1.4.0
4
4
  Summary: Download large files via PyPI packages
5
5
  Author: Sygmei
6
6
  Author-email: Sygmei <3835355+Sygmei@users.noreply.github.com>
@@ -11,6 +11,7 @@ Requires-Dist: rich>=13.0.0
11
11
  Requires-Dist: httpx>=0.28.1
12
12
  Requires-Dist: pip>=25.2
13
13
  Requires-Dist: playwright>=1.55.0
14
+ Requires-Dist: pyotp>=2.9.0
14
15
  Requires-Python: >=3.12
15
16
  Description-Content-Type: text/markdown
16
17
 
@@ -0,0 +1,11 @@
1
+ sideload/__init__.py,sha256=Y3rHLtR7n0sjLXrn-BEcrbIHx-9uE1tPZkooavo7xcA,222
2
+ sideload/cli.py,sha256=4nG0QLwcYypN2A7ENffEE4IEh0VNzwrAdL7cX4ZxjPM,21932
3
+ sideload/jsonbin.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
4
+ sideload/jsonbin_connector.py,sha256=IS7YdSBZeZMoTwOyA1I0T7aw36-KERYNnD324dRRI5A,2305
5
+ sideload/jsonbin_old.py,sha256=Rs657F3dTtdoxVYolByuyINABCIQx_VLxwxgdne1mws,8865
6
+ sideload/main.py,sha256=_YE0b93jhDFN8PcBfHLKbZp1EPQGfWq3wIYyX7nT7Qo,7600
7
+ sideload/scripts/cleanup_pypi.py,sha256=YPzYVsutFVsRoCi4qG6vZo8x5YiLzpAfJ7xKxZiZWgI,15264
8
+ sideload-1.4.0.dist-info/WHEEL,sha256=-neZj6nU9KAMg2CnCY6T3w8J53nx1kFGw_9HfoSzM60,79
9
+ sideload-1.4.0.dist-info/entry_points.txt,sha256=7ULrIjaVhrxMhuddTeoPjeIrqmIvVc9cSU3lZU2_YqE,44
10
+ sideload-1.4.0.dist-info/METADATA,sha256=VbhpNuxWsOFNVCvw9gBBUtT-mxqKvM626A5rGvzB4xE,4309
11
+ sideload-1.4.0.dist-info/RECORD,,
@@ -1,11 +0,0 @@
1
- sideload/__init__.py,sha256=Y3rHLtR7n0sjLXrn-BEcrbIHx-9uE1tPZkooavo7xcA,222
2
- sideload/cli.py,sha256=8CcoZf9_7XHzlFA9XBBZn19RRNAbrFfRshVIWWSaFhQ,20186
3
- sideload/jsonbin.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
4
- sideload/jsonbin_connector.py,sha256=IS7YdSBZeZMoTwOyA1I0T7aw36-KERYNnD324dRRI5A,2305
5
- sideload/jsonbin_old.py,sha256=Rs657F3dTtdoxVYolByuyINABCIQx_VLxwxgdne1mws,8865
6
- sideload/main.py,sha256=EiZguc4-ug8RM6vXkZuXW_ps8jFWoAfeKBBNy7FX-F0,7600
7
- sideload/scripts/cleanup_pypi.py,sha256=CouuOhnbpSbrFc1KhlOeZAuajVVpGF9d4qwFpIKkEvY,5906
8
- sideload-1.3.0.dist-info/WHEEL,sha256=-neZj6nU9KAMg2CnCY6T3w8J53nx1kFGw_9HfoSzM60,79
9
- sideload-1.3.0.dist-info/entry_points.txt,sha256=7ULrIjaVhrxMhuddTeoPjeIrqmIvVc9cSU3lZU2_YqE,44
10
- sideload-1.3.0.dist-info/METADATA,sha256=r0lod7DO3pFy-v0M9HQQFHKTiPih1jCApRnwZWVRZ8o,4281
11
- sideload-1.3.0.dist-info/RECORD,,