sideload 1.3.0__py3-none-any.whl → 1.5.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
@@ -174,12 +174,18 @@ class SideloadClient:
174
174
  check=True,
175
175
  )
176
176
 
177
- # Find the downloaded wheel file
178
- wheel_files = list(output_dir.glob(f"{package_name}*.whl"))
177
+ # Find the downloaded wheel file (case-insensitive)
178
+ package_name_lower = package_name.lower()
179
+ wheel_files = [
180
+ f for f in output_dir.glob("*.whl")
181
+ if f.name.lower().startswith(package_name_lower)
182
+ ]
179
183
  if wheel_files:
180
184
  downloaded_files.append(wheel_files[0])
181
185
  if debug:
182
186
  console.print(f"[green]✓ Downloaded: {wheel_files[0].name}[/green]")
187
+ elif debug:
188
+ console.print(f"[yellow]⚠ No wheel file found for {package_name}[/yellow]")
183
189
 
184
190
  except subprocess.CalledProcessError as e:
185
191
  error_msg = e.stderr if hasattr(e, 'stderr') and e.stderr else str(e)
@@ -250,17 +256,23 @@ class SideloadClient:
250
256
  console.print(f"[dim] {name}[/dim]")
251
257
  zip_ref.extractall(temp_path)
252
258
 
253
- # Find the part file using the exact package name
259
+ # Find the part file using the exact package name (case-insensitive)
254
260
  # Pattern: pkgname-version.data/data/share/pkgname/pkgname
255
261
  # Need to find the .data directory that starts with package_name
262
+ package_name_lower = package_name.lower()
256
263
  data_dir = None
264
+ actual_package_name = None
265
+
257
266
  for d in temp_path.iterdir():
258
- if d.is_dir() and d.name.startswith(f"{package_name}-") and d.name.endswith(".data"):
267
+ if d.is_dir() and d.name.lower().startswith(f"{package_name_lower}-") and d.name.endswith(".data"):
259
268
  data_dir = d
269
+ # Extract the actual package name from the directory name (before -version)
270
+ actual_package_name = d.name.split('-')[0]
260
271
  break
261
272
 
262
- if data_dir:
263
- part_file_path = data_dir / "data" / "share" / package_name / package_name
273
+ if data_dir and actual_package_name:
274
+ # Use actual package name as it appears in the filesystem
275
+ part_file_path = data_dir / "data" / "share" / actual_package_name / actual_package_name
264
276
  if debug:
265
277
  console.print(f"[dim]Looking for: {part_file_path}[/dim]")
266
278
  console.print(f"[dim]Exists: {part_file_path.exists()}, Is file: {part_file_path.is_file() if part_file_path.exists() else 'N/A'}[/dim]")
@@ -268,9 +280,13 @@ class SideloadClient:
268
280
  part_files.append(part_file_path)
269
281
  if debug:
270
282
  console.print(f"[green]✓ Found part file: {part_file_path.name} (size: {part_file_path.stat().st_size:,} bytes)[/green]")
283
+ else:
284
+ if debug:
285
+ console.print(f"[yellow]⚠ Part file not found at expected path[/yellow]")
271
286
  else:
272
287
  if debug:
273
288
  console.print(f"[yellow]⚠ Could not find .data directory for {package_name}[/yellow]")
289
+ console.print(f"[dim]Available directories: {[d.name for d in temp_path.iterdir() if d.is_dir()]}[/dim]")
274
290
 
275
291
  progress.update(
276
292
  extract_task,
@@ -485,9 +501,14 @@ Examples:
485
501
 
486
502
  console.print(Rule("📦 Downloading Packages"))
487
503
 
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)
504
+ # Use work_dir for downloads if provided, otherwise use temp directory
505
+ use_work_dir = args.work_dir is not None
506
+ if use_work_dir:
507
+ download_dir = args.work_dir / "wheels"
508
+ download_dir.mkdir(parents=True, exist_ok=True)
509
+ console.print(f"[yellow]📥 Downloading packages to: {download_dir}[/yellow]")
510
+
511
+ wheel_files = client.download_packages(package_names, download_dir, args.debug)
491
512
 
492
513
  if not wheel_files:
493
514
  console.print(
@@ -512,6 +533,35 @@ Examples:
512
533
  console.print(
513
534
  f"📊 File size: [cyan]{output_file.stat().st_size:,} bytes[/cyan]"
514
535
  )
536
+ else:
537
+ # Use temporary directory for downloads
538
+ with tempfile.TemporaryDirectory() as temp_dir:
539
+ temp_path = Path(temp_dir)
540
+ wheel_files = client.download_packages(package_names, temp_path, args.debug)
541
+
542
+ if not wheel_files:
543
+ console.print(
544
+ "❌ No packages were downloaded successfully", style="red"
545
+ )
546
+ return
547
+
548
+ # Extract and reassemble
549
+ console.print(Rule("🔧 Reassembling File"))
550
+ original_filename = data.get("filename", "downloaded_file")
551
+ output_file = args.output / original_filename
552
+
553
+ client.extract_and_reassemble(
554
+ wheel_files, package_names, original_filename, output_file, args.debug, args.work_dir
555
+ )
556
+
557
+ # Success!
558
+ console.print(Rule("✨ Complete"))
559
+ console.print(
560
+ f"🎉 File successfully downloaded to: [bold green]{output_file}[/bold green]"
561
+ )
562
+ console.print(
563
+ f"📊 File size: [cyan]{output_file.stat().st_size:,} bytes[/cyan]"
564
+ )
515
565
 
516
566
  except KeyboardInterrupt:
517
567
  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.5.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=_bvcZGikzoNFaOWPWxSmlxDHOZFQTm708wHwRcd2-9c,23006
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.5.0.dist-info/WHEEL,sha256=-neZj6nU9KAMg2CnCY6T3w8J53nx1kFGw_9HfoSzM60,79
9
+ sideload-1.5.0.dist-info/entry_points.txt,sha256=7ULrIjaVhrxMhuddTeoPjeIrqmIvVc9cSU3lZU2_YqE,44
10
+ sideload-1.5.0.dist-info/METADATA,sha256=LomGs8ccyjYMbE9AmQZDmbKt0vsjy-3_C4UUGHEiTp4,4309
11
+ sideload-1.5.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,,