Skip to content

Commit 497c52b

Browse files
harrypmoz-agent
andcommitted
Add download support and binary build automation
Introduce repository download functionality in the CLI, add local PyInstaller build tooling, and add a GitHub Actions workflow for cross-platform binary artifacts. Co-Authored-By: Oz <oz-agent@warp.dev>
1 parent 9521d7d commit 497c52b

7 files changed

Lines changed: 235 additions & 14 deletions

File tree

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
name: Build Cross-Platform Binaries
2+
3+
on:
4+
workflow_dispatch:
5+
pull_request:
6+
push:
7+
branches:
8+
- main
9+
10+
jobs:
11+
build-binaries:
12+
name: Build on ${{ matrix.os }}
13+
runs-on: ${{ matrix.os }}
14+
strategy:
15+
fail-fast: false
16+
matrix:
17+
os:
18+
- ubuntu-latest
19+
- macos-latest
20+
- windows-latest
21+
defaults:
22+
run:
23+
shell: bash
24+
steps:
25+
- name: Checkout
26+
uses: actions/checkout@v4
27+
28+
- name: Set up Python
29+
uses: actions/setup-python@v5
30+
with:
31+
python-version: "3.11"
32+
33+
- name: Install build dependencies
34+
run: |
35+
python -m pip install --upgrade pip
36+
python -m pip install -r requirements-build.txt
37+
38+
- name: Build binary
39+
run: pyinstaller --clean --onefile --name ia-interact ia-interact.py
40+
41+
- name: Prepare platform artifact
42+
run: |
43+
python - <<'PY'
44+
import os
45+
import pathlib
46+
import shutil
47+
48+
runner_os = os.environ["RUNNER_OS"]
49+
ext = ".exe" if runner_os == "Windows" else ""
50+
source_path = pathlib.Path("dist") / f"ia-interact{ext}"
51+
if not source_path.exists():
52+
raise SystemExit(f"Build output not found: {source_path}")
53+
54+
suffix_map = {"Linux": "linux", "Windows": "windows", "macOS": "macos"}
55+
suffix = suffix_map.get(runner_os, runner_os.lower())
56+
artifacts_dir = pathlib.Path("artifacts")
57+
artifacts_dir.mkdir(parents=True, exist_ok=True)
58+
destination_path = artifacts_dir / f"ia-interact-{suffix}{ext}"
59+
shutil.copy2(source_path, destination_path)
60+
print(f"Prepared artifact: {destination_path}")
61+
PY
62+
63+
- name: Upload binary artifact
64+
uses: actions/upload-artifact@v4
65+
with:
66+
name: ia-interact-${{ runner.os }}
67+
path: artifacts/*
68+
if-no-files-found: error

.gitignore

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
__pycache__/
2+
*.py[cod]
3+
build/
4+
dist/
5+
*.spec
6+
.venv/
7+
.venv-build/

README.md

Lines changed: 37 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33

44
An interactive command-line tool for managing Internet Archive repositories.
55

6-
Use this script to list files, upload files, delete files, move files, and create new repositories with detailed metadata input.
6+
Use this script to list files, upload files, download files, delete files, move files, and create new repositories with detailed metadata input.
77

88

99
## Table of Contents
@@ -38,8 +38,7 @@ Use this script to list files, upload files, delete files, move files, and creat
3838

3939

4040
## Features
41-
42-
- **Interactive Menu:** Choose options to list files, upload files, delete or move files, or create a new repository.
41+
- **Interactive Menu:** Choose options to list files, upload files, download files, delete or move files, or create a new repository.
4342
- **Test Mode & Permanent Mode:** Run in simulation (Test Mode, where no changes are made) or execute actual changes (Permanent Mode).
4443
- **Metadata Support:** Input metadata including title, description, creator, date, language, license URL, collection, subject tags, and test item status.
4544
- **Collection Options:** Supports collections such as `community`, `opensource`, `texts`, `movies`, `audio`, `image`, `etree`, `folksoundomy`, `games`, and `software`.
@@ -138,6 +137,36 @@ Replace `"your-access-key"` and `"your-secret-key"` with your actual keys.
138137

139138
echo $S3_ACCESS_KEY
140139
echo $S3_SECRET_KEY
140+
### 5. Build a Local Binary
141+
142+
Install build dependencies:
143+
144+
pip3 install -r requirements-build.txt
145+
146+
Build a single-file executable:
147+
148+
pyinstaller --clean --onefile --name ia-interact ia-interact.py
149+
150+
The binary will be output to:
151+
152+
dist/ia-interact
153+
154+
On Windows, the file will be:
155+
156+
dist/ia-interact.exe
157+
158+
# GitHub Actions: Cross-Platform Binaries
159+
160+
This repository includes:
161+
162+
.github/workflows/build-binaries.yml
163+
164+
The workflow builds platform binaries for:
165+
- Linux (`ubuntu-latest`)
166+
- macOS (`macos-latest`)
167+
- Windows (`windows-latest`)
168+
169+
Each run uploads build artifacts with platform-specific names.
141170

142171

143172
# Usage
@@ -165,6 +194,7 @@ When the script runs, it displays an interactive menu with the following options
165194

166195
- **List Files:** Display the contents of an existing repository.
167196
- **Upload Files:** Add files to a repository.
197+
- **Download Files:** Download one file or all files from a repository to a local folder.
168198
- **Delete/Move Files:** Manage files within a repository.
169199
- **Create a New Repository:** Upload an entire folder and configure repository metadata.
170200

@@ -212,6 +242,7 @@ During repository creation, you will be prompted to:
212242
- **Listing Repository Contents:** Retrieve and display the contents of a repository using the metadata API.
213243
- **Deleting Files:** Remove specified files from a repository.
214244
- **Moving Files:** Change a file’s location within a repository by copying it and then deleting the original.
245+
- **Downloading Files:** Download a single file or all files from a repository to a local path.
215246
- **Creating a New Repository:** Upload a folder as a new repository and submit metadata.
216247
- **User Interaction:** Offers an interactive menu with a help option, test mode (simulation) vs. permanent mode, and filtering to avoid showing files from ".thumbs" directories.
217248

@@ -320,10 +351,10 @@ This script uses the Internet Archive’s S3-compatible interface and Metadata A
320351
- **Purpose:**
321352
Serves as the entry point of the script with an interactive menu.
322353
- **Key Features:**
323-
- **Main Menu:** Displays options for uploading, listing, deleting, moving files, creating a repository, or viewing help.
354+
- **Main Menu:** Displays options for uploading, listing, deleting, moving, and downloading files, creating a repository, or viewing help.
324355
- **Conditional Prompts:**
325-
- **For Existing Repositories (options 1–4):** Prompts for the repository URL after the option selection.
326-
- **For Folder-based Repository Creation (option 5):** Gathers folder path, mode, and metadata.
356+
- **For Existing Repositories (options 1–5):** Prompts for the repository URL after the option selection.
357+
- **For Folder-based Repository Creation (option 6):** Gathers folder path, mode, and metadata.
327358
- **Action Dispatch:** Calls the corresponding function based on the user’s selection.
328359

329360

build-binary.sh

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
#!/usr/bin/env bash
2+
set -euo pipefail
3+
4+
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
5+
VENV_PATH="$SCRIPT_DIR/.venv-build"
6+
DIST_PATH="$SCRIPT_DIR/dist"
7+
BUILD_PATH="$SCRIPT_DIR/build"
8+
9+
if [ ! -d "$VENV_PATH" ]; then
10+
python3 -m venv "$VENV_PATH"
11+
fi
12+
"$VENV_PATH/bin/python" -m pip install --upgrade pip
13+
"$VENV_PATH/bin/python" -m pip install -r "$SCRIPT_DIR/requirements-build.txt"
14+
"$VENV_PATH/bin/pyinstaller" \
15+
--clean \
16+
--onefile \
17+
--name ia-interact \
18+
--distpath "$DIST_PATH" \
19+
--workpath "$BUILD_PATH" \
20+
--specpath "$SCRIPT_DIR" \
21+
"$SCRIPT_DIR/ia-interact.py"
22+
23+
printf 'Binary created at %s\n' "$SCRIPT_DIR/dist/ia-interact"

ia-interact.py

Lines changed: 96 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
from tqdm import tqdm
55
from requests.adapters import HTTPAdapter
66
from urllib3.util.retry import Retry
7+
from urllib.parse import quote
78

89
def get_repo_identifier(repo_link):
910
"""
@@ -166,6 +167,88 @@ def move_file(identifier, file_name, source_dir, target_dir):
166167
except Exception as e:
167168
print("Error during file move (copy-delete):", e)
168169
return False
170+
def download_file_with_progress(identifier, file_name, destination_dir):
171+
"""
172+
Downloads a file from an Internet Archive repository with progress tracking.
173+
"""
174+
safe_relative_path = os.path.normpath(file_name).lstrip("/\\")
175+
if safe_relative_path.startswith(".."):
176+
print(f"Skipping unsafe file path: {file_name}")
177+
return False
178+
179+
download_url = f"https://archive.org/download/{identifier}/{quote(file_name, safe='/')}"
180+
output_path = os.path.join(destination_dir, safe_relative_path)
181+
output_folder = os.path.dirname(output_path)
182+
183+
if output_folder:
184+
os.makedirs(output_folder, exist_ok=True)
185+
186+
try:
187+
response = requests.get(download_url, stream=True, timeout=(60, 600))
188+
if response.status_code != 200:
189+
print(f"Error downloading {file_name}: {response.status_code} {response.reason}")
190+
return False
191+
192+
total_size = int(response.headers.get("content-length", 0))
193+
progress_total = total_size if total_size > 0 else None
194+
195+
with tqdm(total=progress_total, unit="B", unit_scale=True, desc=f"Downloading {os.path.basename(file_name)}") as pbar:
196+
with open(output_path, "wb") as f:
197+
for chunk in response.iter_content(chunk_size=1024 * 1024):
198+
if not chunk:
199+
continue
200+
f.write(chunk)
201+
pbar.update(len(chunk))
202+
203+
print(f"Downloaded '{file_name}' to '{output_path}'")
204+
return True
205+
except requests.exceptions.RequestException as e:
206+
print(f"Request error during download: {e}")
207+
return False
208+
except Exception as e:
209+
print(f"Unexpected download error: {e}")
210+
return False
211+
212+
def download_repository_files(identifier):
213+
"""
214+
Downloads one or all files from a repository.
215+
"""
216+
file_list = list_repository_files(identifier)
217+
if not file_list:
218+
return
219+
220+
destination_dir_input = input("Enter destination folder (leave blank for current directory): ").strip().strip('\"').strip("'")
221+
destination_dir = os.path.abspath(os.path.expanduser(destination_dir_input)) if destination_dir_input else os.getcwd()
222+
223+
try:
224+
os.makedirs(destination_dir, exist_ok=True)
225+
except OSError as e:
226+
print(f"Unable to create/access destination folder: {e}")
227+
return
228+
229+
print("\nDownload Options:")
230+
print("1. Download a single file")
231+
print("2. Download all files")
232+
download_choice = input("Enter your choice (1 or 2): ").strip()
233+
234+
if download_choice == "1":
235+
print("Enter the number of the file to download:")
236+
try:
237+
index = int(input().strip()) - 1
238+
if 0 <= index < len(file_list):
239+
download_file_with_progress(identifier, file_list[index], destination_dir)
240+
else:
241+
print("Invalid index.")
242+
except ValueError:
243+
print("Invalid input.")
244+
elif download_choice == "2":
245+
successful_downloads = 0
246+
for file_name in file_list:
247+
if download_file_with_progress(identifier, file_name, destination_dir):
248+
successful_downloads += 1
249+
print(f"\nDownloaded {successful_downloads} of {len(file_list)} files.")
250+
else:
251+
print("Invalid choice. Exiting download menu.")
169252

170253
def create_rules_file(folder_path):
171254
"""
@@ -293,9 +376,11 @@ def print_help():
293376
- List files and choose one to delete.
294377
4. Move a file within a repository:
295378
- Copy a file to a new directory using x-amz-copy-source, then delete the original.
296-
5. Create or access a repository from a folder:
379+
5. Download files from a repository:
380+
- Download one or all files to a local destination folder.
381+
6. Create or access a repository from a folder:
297382
- Upload an entire folder as a new repository with metadata and mode selection.
298-
6. Help:
383+
7. Help:
299384
- Display this help information.
300385
301386
Instructions:
@@ -315,14 +400,15 @@ def main():
315400
print("2. List files in a repository")
316401
print("3. Delete a file from a repository")
317402
print("4. Move a file within a repository")
318-
print("5. Create or access a repository from a folder")
319-
print("6. Help")
320-
choice = input("Enter your choice (1-6): ").strip()
403+
print("5. Download files from a repository")
404+
print("6. Create or access a repository from a folder")
405+
print("7. Help")
406+
choice = input("Enter your choice (1-7): ").strip()
321407

322-
if choice == "6":
408+
if choice == "7":
323409
print_help()
324410
main()
325-
elif choice == "5":
411+
elif choice == "6":
326412
folder_path = input("Enter the folder path to upload as a repository: ").strip().strip('\"').strip('\'')
327413
if not os.path.isdir(folder_path):
328414
print("Invalid folder path. Please check if the directory exists and is accessible.")
@@ -345,7 +431,7 @@ def main():
345431
print("Repository identifier is required.")
346432
return
347433
initialize_repository(folder_path, identifier, metadata, mode)
348-
elif choice in ("1", "2", "3", "4"):
434+
elif choice in ("1", "2", "3", "4", "5"):
349435
repo_link = input("Enter the Internet Archive repository link: ").strip()
350436
identifier = get_repo_identifier(repo_link)
351437
if not identifier:
@@ -408,6 +494,8 @@ def main():
408494
print("Invalid index.")
409495
except ValueError:
410496
print("Invalid input.")
497+
elif choice == "5":
498+
download_repository_files(identifier)
411499
else:
412500
print("Invalid choice. Exiting.")
413501

requirements-build.txt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
-r requirements.txt
2+
pyinstaller>=6.0.0

requirements.txt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
requests>=2.31.0
2+
tqdm>=4.66.0

0 commit comments

Comments
 (0)