diff --git a/.github/workflows/mega-linter.yml b/.github/workflows/mega-linter.yml deleted file mode 100644 index dc4825b..0000000 --- a/.github/workflows/mega-linter.yml +++ /dev/null @@ -1,91 +0,0 @@ ---- -# MegaLinter GitHub Action configuration file -# More info at https://megalinter.io -name: MegaLinter - -on: - # Trigger mega-linter at every push. Action will also be visible from Pull Requests to main - push: # Comment this line to trigger action only on pull-requests (not recommended if you don't pay for GH Actions) - pull_request: - branches: [master, main] - -env: # Comment env block if you don't want to apply fixes - # Apply linter fixes configuration - APPLY_FIXES: all # When active, APPLY_FIXES must also be defined as environment variable (in github/workflows/mega-linter.yml or other CI tool) - APPLY_FIXES_EVENT: pull_request # Decide which event triggers application of fixes in a commit or a PR (pull_request, push, all) - APPLY_FIXES_MODE: commit # If APPLY_FIXES is used, defines if the fixes are directly committed (commit) or posted in a PR (pull_request) - -concurrency: - group: ${{ github.ref }}-${{ github.workflow }} - cancel-in-progress: true - -jobs: - megalinter: - name: MegaLinter - runs-on: ubuntu-latest - permissions: - # Give the default GITHUB_TOKEN write permission to commit and push, comment issues & post new PR - # Remove the ones you do not need - contents: write - issues: write - pull-requests: write - steps: - # Git Checkout - - name: Checkout Code - uses: actions/checkout@v4 - with: - token: ${{ secrets.PAT || secrets.GITHUB_TOKEN }} - fetch-depth: 0 # If you use VALIDATE_ALL_CODEBASE = true, you can remove this line to improve performances - - # MegaLinter - - name: MegaLinter - id: ml - # You can override MegaLinter flavor used to have faster performances - # More info at https://megalinter.io/flavors/ - uses: oxsecurity/megalinter@v8 - env: - # All available variables are described in documentation - # https://megalinter.io/configuration/ - VALIDATE_ALL_CODEBASE: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }} # Validates all source when push on main, else just the git diff with main. Override with true if you always want to lint all sources - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - # ADD YOUR CUSTOM ENV VARIABLES HERE OR DEFINE THEM IN A FILE .mega-linter.yml AT THE ROOT OF YOUR REPOSITORY - # DISABLE: COPYPASTE,SPELL # Uncomment to disable copy-paste and spell checks - - # Upload MegaLinter artifacts - - name: Archive production artifacts - if: success() || failure() - uses: actions/upload-artifact@v4 - with: - name: MegaLinter reports - path: | - megalinter-reports - mega-linter.log - - # Create pull request if applicable (for now works only on PR from same repository, not from forks) - - name: Create Pull Request with applied fixes - id: cpr - if: steps.ml.outputs.has_updated_sources == 1 && (env.APPLY_FIXES_EVENT == 'all' || env.APPLY_FIXES_EVENT == github.event_name) && env.APPLY_FIXES_MODE == 'pull_request' && (github.event_name == 'push' || github.event.pull_request.head.repo.full_name == github.repository) && !contains(github.event.head_commit.message, 'skip fix') - uses: peter-evans/create-pull-request@v6 - with: - token: ${{ secrets.PAT || secrets.GITHUB_TOKEN }} - commit-message: "[MegaLinter] Apply linters automatic fixes" - title: "[MegaLinter] Apply linters automatic fixes" - labels: bot - - name: Create PR output - if: steps.ml.outputs.has_updated_sources == 1 && (env.APPLY_FIXES_EVENT == 'all' || env.APPLY_FIXES_EVENT == github.event_name) && env.APPLY_FIXES_MODE == 'pull_request' && (github.event_name == 'push' || github.event.pull_request.head.repo.full_name == github.repository) && !contains(github.event.head_commit.message, 'skip fix') - run: | - echo "Pull Request Number - ${{ steps.cpr.outputs.pull-request-number }}" - echo "Pull Request URL - ${{ steps.cpr.outputs.pull-request-url }}" - - # Push new commit if applicable (for now works only on PR from same repository, not from forks) - - name: Prepare commit - if: steps.ml.outputs.has_updated_sources == 1 && (env.APPLY_FIXES_EVENT == 'all' || env.APPLY_FIXES_EVENT == github.event_name) && env.APPLY_FIXES_MODE == 'commit' && github.ref != 'refs/heads/main' && (github.event_name == 'push' || github.event.pull_request.head.repo.full_name == github.repository) && !contains(github.event.head_commit.message, 'skip fix') - run: sudo chown -Rc $UID .git/ - - name: Commit and push applied linter fixes - if: steps.ml.outputs.has_updated_sources == 1 && (env.APPLY_FIXES_EVENT == 'all' || env.APPLY_FIXES_EVENT == github.event_name) && env.APPLY_FIXES_MODE == 'commit' && github.ref != 'refs/heads/main' && (github.event_name == 'push' || github.event.pull_request.head.repo.full_name == github.repository) && !contains(github.event.head_commit.message, 'skip fix') - uses: stefanzweifel/git-auto-commit-action@v4 - with: - branch: ${{ github.event.pull_request.head.ref || github.head_ref || github.ref }} - commit_message: "[MegaLinter] Apply linters fixes" - commit_user_name: megalinter-bot - commit_user_email: 129584137+megalinter-bot@users.noreply.github.com diff --git a/.mega-linter.yml b/.mega-linter.yml deleted file mode 100644 index 26651c0..0000000 --- a/.mega-linter.yml +++ /dev/null @@ -1,73 +0,0 @@ -# .mega-linter.yml - -APPLY_FIXES: # Enable auto-fixes for safe linters - - PYTHON_BLACK - - PYTHON_ISORT - - YAML_PRETTIER - -#ENABLE: # Use DISABLE and selectively ENABLE, see below -DISABLE: - - ALL # Disable everything by default, then selectively enable -#Core Functionality -ENABLE: - - PYTHON - - YAML - - INI # Needed for config.ini - -#Optional Functionality -- Review before enabling -# - MARKDOWN # if you have markdown documentation - -# Security Linting (Critical) -ENABLE_LINTERS: - - PYTHON_BANDIT # Security vulnerability scanner for Python - - REPOSITORY_TRUFFLEHOG # High signal secrets scanner -# - REPOSITORY_SEMGREP # Pattern-based security scanner (requires configuration) - Enable once configured - -# Python Linters - Style and Quality -ENABLE_LINTERS: - - PYTHON_FLAKE8 # Style and error checker - - PYTHON_BLACK # Automatic code formatter (opinionated) - - PYTHON_ISORT # Import sorting -# - PYTHON_MYPY # Static type checker (requires type hints) - Consider enabling if you add type hints -# - PYTHON_PYLINT # More comprehensive linter (can be noisy) - -# YAML Linters -ENABLE_LINTERS: - - YAML_YAMLLINT # YAML Linter - - YAML_PRETTIER # YAML Formatter - -# INI Linter (for config.ini) -ENABLE_LINTERS: - - INI_CFGPARSE - -FILTER_REGEX_EXCLUDE: | - ( - \.automation/test| - \.automation/generated| - \.venv| - \.github/workflows| - __pycache__| - build| - dist| - \.mypy_cache| - \.pytest_cache| - htmlcov - ) - -PYTHON_FLAKE8_ARGUMENTS: "--max-line-length=120 --ignore=E203,W503" -PYTHON_BLACK_LINE_LENGTH: 120 # Ensure flake8, black, and isort stay in sync! -PYTHON_ISORT_ARGS: "--profile black" #Ensure flake8, black, and isort stay in sync! -INI_CFGPARSE_ARGUMENTS: "--no-duplicate-sections" #Example to help manage config.ini - -# Specific Exclusions (adjust paths as needed) -# For example, if you have automatically generated UI code: -# PYTHON_FLAKE8_FILTER_REGEX_EXCLUDE: (path/to/generated_file\.py) - -# GitHub Feedback Mechanisms (The "fun stuff") -GITHUB_STATUS_REPORTER: true # Enable status checks on pull requests -GITHUB_PR_COMMENT_REPORTER: true # Enable comments on pull requests. Use review reporter instead. -GITHUB_PR_REVIEW_REPORTER: true # Use the review reporter if you want a pull request review instead of comments - -# Reporter settings (adjust as needed) -FILEIO_REPORTER: false # Usually disabled in CI/CD -JSON_REPORTER: true # Keep this for generating a JSON report (useful for other tools) diff --git a/api_session.py b/api_session.py index 34162ab..52cdd9b 100644 --- a/api_session.py +++ b/api_session.py @@ -16,21 +16,18 @@ def create_session(): retry = Retry( total=5, # Maximum number of retries backoff_factor=0.5, # Exponential backoff factor (sleep longer between retries) - status_forcelist=[500, 502, 503, 504] # HTTP status codes to retry on + status_forcelist=[500, 502, 503, 504], # HTTP status codes to retry on ) adapter = HTTPAdapter(max_retries=retry) - session.mount('http://', adapter) - session.mount('https://', adapter) + session.mount("http://", adapter) + session.mount("https://", adapter) # Proxy configuration - if config.getboolean('Proxy', 'use_proxy'): - proxies = { - 'http': config['Proxy']['http'], - 'https': config['Proxy']['https'] - } + if config.getboolean("Proxy", "use_proxy"): + proxies = {"http": config["Proxy"]["http"], "https": config["Proxy"]["https"]} session.proxies.update(proxies) logger.info(f"Using proxy: {proxies}") else: logger.info("Not using a proxy.") - return session \ No newline at end of file + return session diff --git a/config.ini b/config.ini index c7ae397..cc7db09 100644 --- a/config.ini +++ b/config.ini @@ -1,6 +1,8 @@ [HuggingFace] -api_token_alias = Desktop -rate_limit_delay = 1 +api_token = mkdhyYZxh|JwYkrWWGsikRTgNYzMwZ~hWI{S +rate_limit_delay = 1.0 +org = +repo = [Zip] default_zip_name = my_archive diff --git a/config_dialog.py b/config_dialog.py index b37e15f..1a31d36 100644 --- a/config_dialog.py +++ b/config_dialog.py @@ -1,9 +1,26 @@ import logging -from PyQt6.QtWidgets import (QWidget, QLabel, QLineEdit, QPushButton, - QVBoxLayout, QHBoxLayout, QCheckBox, QMessageBox) +from PyQt6.QtWidgets import ( + QWidget, + QLabel, + QLineEdit, + QPushButton, + QVBoxLayout, + QHBoxLayout, + QCheckBox, + QMessageBox, +) from PyQt6.QtCore import Qt from custom_exceptions import ConfigError # Import ConfigError -from config_manager import config, save_config +from config_manager import ( + config, + set_api_token, + get_api_token, + get_rate_limit_delay, + set_rate_limit_delay, # Make sure this is imported + set_proxy, + get_proxy, + # save_config # You don't really need to import save_config directly in the ConfigDialog +) logger = logging.getLogger(__name__) @@ -11,13 +28,13 @@ def obfuscate_token(token): """A simple obfuscation function (DO NOT RELY ON THIS FOR SECURITY).""" - obfuscated = ''.join([chr(ord(c) + 5) for c in token]) # Shift each character by 5 + obfuscated = "".join([chr(ord(c) + 5) for c in token]) # Shift each character by 5 return obfuscated def deobfuscate_token(obfuscated): """Reverses the obfuscation (DO NOT RELY ON THIS FOR SECURITY).""" - original = ''.join([chr(ord(c) - 5) for c in obfuscated]) + original = "".join([chr(ord(c) - 5) for c in obfuscated]) return original @@ -25,23 +42,28 @@ class ConfigDialog(QWidget): """Dialog for configuring settings.""" def __init__(self): + """Initializes the configuration dialog.""" super().__init__() self.setWindowTitle("Configuration") + self.init_ui() + self.load_config_values() + def init_ui(self): + """Initializes the user interface elements and layout.""" # --- API Token Section --- self.api_token_label = QLabel("Hugging Face API Token:") self.api_token_input = QLineEdit() # No default - self.api_token_input.setEchoMode(QLineEdit.Password) # Mask the token + self.api_token_input.setEchoMode(QLineEdit.EchoMode.Password) # Mask the token # --- Proxy Section --- self.use_proxy_checkbox = QCheckBox("Use Proxy") - self.use_proxy_checkbox.setChecked(config.getboolean('Proxy', 'use_proxy')) + # self.use_proxy_checkbox.setChecked(config.getboolean("Proxy", "use_proxy")) self.http_proxy_label = QLabel("HTTP Proxy:") - self.http_proxy_input = QLineEdit(config['Proxy']['http']) + self.http_proxy_input = QLineEdit() # No default - filled from load self.https_proxy_label = QLabel("HTTPS Proxy:") - self.https_proxy_input = QLineEdit(config['Proxy']['https']) + self.https_proxy_input = QLineEdit() # No default - filled from load self.rate_limit_label = QLabel("Rate Limit Delay (seconds):") - self.rate_limit_input = QLineEdit(config['HuggingFace']['rate_limit_delay']) + self.rate_limit_input = QLineEdit() # --- Buttons --- self.save_button = QPushButton("Save") @@ -76,6 +98,20 @@ def __init__(self): self.save_button.clicked.connect(self.save_config) self.cancel_button.clicked.connect(self.close) + def load_config_values(self): + """Loads configuration values from the config manager and populates the UI.""" + # Load API token + self.api_token_input.setText(get_api_token() or "") # Load the API token + # Load Proxy settings + proxy_settings = get_proxy() + self.use_proxy_checkbox.setChecked( + proxy_settings.get("use_proxy", "False") == "True" + ) + self.http_proxy_input.setText(proxy_settings.get("http", "")) + self.https_proxy_input.setText(proxy_settings.get("https", "")) + # Load Rate limit delay + self.rate_limit_input.setText(str(get_rate_limit_delay())) + def save_config(self): """Saves the configuration settings.""" api_token = self.api_token_input.text() @@ -89,22 +125,27 @@ def save_config(self): QMessageBox.critical(self, "Error", str(e)) return - # Obfuscate the API token - obfuscated_token = obfuscate_token(api_token) - - # Save proxy settings - config['Proxy']['use_proxy'] = str(self.use_proxy_checkbox.isChecked()) - config['Proxy']['http'] = self.http_proxy_input.text() - config['Proxy']['https'] = self.https_proxy_input.text() - config['HuggingFace']['rate_limit_delay'] = str(rate_limit_delay) # Store as string - config['HuggingFace']['api_token'] = obfuscated_token # Obfuscated API token - - # Write to config file + # Save the config values try: - if save_config(): # Call save_config and check for success - QMessageBox.information(self, "Success", "Configuration saved successfully.") - self.close() - else: - QMessageBox.critical(self, "Error", "Failed to save configuration.") + # Set API token + set_api_token(api_token) # Store the api token in the manager + + # Save proxy settings + proxy_settings = { + "use_proxy": str(self.use_proxy_checkbox.isChecked()), + "http": self.http_proxy_input.text(), + "https": self.https_proxy_input.text(), + } + set_proxy(proxy_settings) # save proxy settings + # Save rate limit + # config["HuggingFace"]["rate_limit_delay"] = str(rate_limit_delay) + set_rate_limit_delay(rate_limit_delay) + QMessageBox.information( + self, "Success", "Configuration saved successfully." + ) + self.close() except ConfigError as e: - QMessageBox.critical(self, "Error", f"Failed to save configuration: {e}") \ No newline at end of file + QMessageBox.critical(self, "Error", f"Failed to save configuration: {e}") + except Exception as e: + # Catch any unexpected errors + QMessageBox.critical(self, "Error", f"An unexpected error occurred: {e}") diff --git a/config_manager.py b/config_manager.py index 097c688..ff703e3 100644 --- a/config_manager.py +++ b/config_manager.py @@ -1,3 +1,4 @@ +# config_manager.py import configparser import os import logging @@ -6,65 +7,115 @@ logger = logging.getLogger(__name__) config = configparser.ConfigParser() -config_path = "config.ini" +config_path = os.path.expanduser("~/.huggingface_uploader_config.ini") -# Default config -config['HuggingFace'] = { - 'api_token': '', # Store obfuscated API token - 'rate_limit_delay': '1', # Delay in seconds between API calls - 'org': '', #Store org or user - 'repo': '' # Store repo name +DEFAULT_CONFIG = { + "HuggingFace": { + "api_token": "", # Store obfuscated API token + "rate_limit_delay": "1", # Delay in seconds between API calls + "org": "", # Store org or user + "repo": "", # Store repo name + }, + "Zip": {"default_zip_name": "my_archive"}, + "Proxy": { + "use_proxy": "False", # Whether to use a proxy server + "http": "", # HTTP proxy URL + "https": "", # HTTPS proxy URL + }, } -config['Zip'] = { - 'default_zip_name': 'my_archive' -} - -config['Proxy'] = { - 'use_proxy': 'False', # Whether to use a proxy server - 'http': '', # HTTP proxy URL - 'https': '' # HTTPS proxy URL -} - - def load_config(): - """Loads the configuration from the config file.""" + """Loads the configuration from the config file. + + If the config file does not exist, it creates a default config. + Raises ConfigError if the config can't be loaded. + """ logger.info("Loading configuration...") if os.path.exists(config_path): logger.info(f"Configuration file found: {config_path}") try: config.read(config_path) - logger.info(f"Configuration loaded successfully.") + logger.info("Configuration loaded successfully.") + # *** DEBUGGING *** + logger.debug(f"Config contents after loading: {config.items()}") + except Exception as e: logger.error(f"Error reading configuration file: {e}", exc_info=True) - logger.warning("Creating default configuration.") - try: - save_config() # Create a new default config - logger.info("Default configuration created after load failure.") - except ConfigError as save_error: - logger.error(f"Failed to create default config after load failure: {save_error}", exc_info=True) - raise ConfigError("Failed to load or create default configuration.") from save_error - raise ConfigError(f"Failed to load configuration from {config_path}: {e}") from e #Raise the config error + raise ConfigError( + f"Failed to load configuration from {config_path}: {e}" + ) from e # Re-raise config error else: - logger.warning(f"Configuration file not found: {config_path}. Creating default config.") + logger.warning( + f"Configuration file not found: {config_path}. Creating default config." + ) + config.read_dict(DEFAULT_CONFIG) # Apply default config try: save_config() logger.info(f"Default configuration created: {config_path}") except ConfigError as save_error: - logger.error(f"Failed to create default config: {save_error}", exc_info=True) + logger.error( + f"Failed to create default config: {save_error}", exc_info=True + ) raise ConfigError("Failed to create default configuration.") from save_error return config + def save_config(): - """Saves the configuration to the config file.""" + """Saves the configuration to the config file. + + Raises ConfigError on failure. + """ logger.info("Saving configuration...") try: - with open(config_path, 'w') as configfile: + with open(config_path, "w") as configfile: config.write(configfile) logger.info("Configuration saved successfully.") + # *** DEBUGGING *** + logger.debug(f"Config contents after saving: {config.items()}") return True # Indicate success except Exception as e: logger.error(f"Error saving configuration: {e}", exc_info=True) - raise ConfigError(f"Failed to save configuration to {config_path}: {e}") from e #Raise the config error + raise ConfigError( + f"Failed to save configuration to {config_path}: {e}" + ) from e # Re-raise the config error + +# --- Convenience methods --- +def get_api_token(): + """Gets the Hugging Face API token from the configuration.""" + if token := os.environ.get("HF_API_TOKEN"): + return token + return config.get("HuggingFace", "api_token") # Corrected - Removed the extra default argument "" + +def set_api_token(token): + """Sets the Hugging Face API token in the configuration.""" + config.set("HuggingFace", "api_token", token) + save_config() + +def get_rate_limit_delay(): + """Gets the rate limit delay in seconds.""" + return float(config.get("HuggingFace", "rate_limit_delay", fallback="1")) + +def set_rate_limit_delay(delay): + """Sets the rate limit delay in seconds.""" + config.set("HuggingFace", "rate_limit_delay", str(delay)) # Save as string + save_config() + +def set_proxy(proxy_settings): + """Sets proxy settings in the configuration.""" + config.set("Proxy", "use_proxy", proxy_settings.get("use_proxy", "False")) + config.set("Proxy", "http", proxy_settings.get("http", "")) + config.set("Proxy", "https", proxy_settings.get("https", "")) + save_config() # save the config -config = load_config() \ No newline at end of file +def get_proxy(): + """Gets the proxy settings from the configuration.""" + return { + "use_proxy": config.get("Proxy", "use_proxy", fallback="False"), + "http": config.get("Proxy", "http", fallback=""), + "https": config.get("Proxy", "https", fallback=""), + } +# --- Load the configuration when the module is imported --- +try: + load_config() # Load config when module is imported +except ConfigError as e: + logger.error(f"Initial config load failed: {e}") \ No newline at end of file diff --git a/custom_exceptions.py b/custom_exceptions.py index 003d57c..b0bff0a 100644 --- a/custom_exceptions.py +++ b/custom_exceptions.py @@ -1,6 +1,7 @@ # hf_backup_tool/exceptions/custom_exceptions.py # Custom exception classes for the Hugging Face Backup Tool. + class APIKeyError(Exception): """ Raised when there's an issue with the API key. @@ -10,6 +11,7 @@ class APIKeyError(Exception): - API key does not have the required permissions. - API key has been revoked. """ + pass @@ -23,6 +25,7 @@ class UploadError(Exception): - File size exceeds the maximum allowed limit. - Invalid file format. """ + pass @@ -35,6 +38,7 @@ class ConfigError(Exception): - Required configuration settings are missing. - Invalid configuration values. """ + pass @@ -46,6 +50,7 @@ class RateLimitError(Exception): - Making too many API requests in a short period of time. - Not implementing proper rate limiting mechanisms. """ + pass @@ -58,6 +63,7 @@ class AuthenticationError(Exception): - Incorrect username or password (if applicable). - Account is locked or disabled. """ + pass @@ -70,4 +76,5 @@ class RepositoryError(Exception): - Insufficient permissions to access the repository. - Repository is corrupted or unavailable. """ - pass \ No newline at end of file + + pass diff --git a/download_app.py b/download_app.py index c9c0209..52e20b4 100644 --- a/download_app.py +++ b/download_app.py @@ -3,10 +3,19 @@ import os import subprocess -from PyQt6.QtWidgets import (QWidget, QLabel, QLineEdit, QPushButton, - QVBoxLayout, QHBoxLayout, QFileDialog, QTextEdit, QApplication) - -#from ..downloads.download_manager import create_download # Import the download function <- REMOVE THIS +from PyQt6.QtWidgets import ( + QWidget, + QLabel, + QLineEdit, + QPushButton, + QVBoxLayout, + QHBoxLayout, + QFileDialog, + QTextEdit, + QApplication, +) + +# from ..downloads.download_manager import create_download # Import the download function <- REMOVE THIS logger = logging.getLogger(__name__) @@ -60,23 +69,27 @@ def start_download(self): download_directory = self.download_dir_input.text() if not repo_url or not download_directory: - self.output_text.append("Please provide both repository URL and download directory.") + self.output_text.append( + "Please provide both repository URL and download directory." + ) return - #create_download(repo_url, download_directory, self.output_text) <-REMOVED + # create_download(repo_url, download_directory, self.output_text) <-REMOVED - downloader = download_manager(repo_url, download_directory, self.output_text) #Instantiate class + downloader = download_manager( + repo_url, download_directory, self.output_text + ) # Instantiate class downloader.start_download() - -class download_manager(): #Corrected! - def __init__(self, repo_url, download_directory, output_text): #Corrected! +class download_manager: # Corrected! + def __init__(self, repo_url, download_directory, output_text): # Corrected! self.repo_url = repo_url self.download_directory = download_directory self.output_text = output_text + def start_download(self): - self.download_with_aria2c() + self.download_with_aria2c() def download_with_aria2c(self): """Downloads files using aria2c.""" @@ -85,11 +98,13 @@ def download_with_aria2c(self): output_text = self.output_text try: command = ["aria2c", repo_url, "-d", download_directory] - process = subprocess.Popen(command, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True) + process = subprocess.Popen( + command, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True + ) while True: output = process.stdout.readline() - if output == '' and process.poll() is not None: + if output == "" and process.poll() is not None: break if output: output_text.append(output.strip()) @@ -99,26 +114,39 @@ def download_with_aria2c(self): if return_code == 0: output_text.append("Download completed successfully with aria2c.") else: - output_text.append(f"Download failed with aria2c. Return code: {return_code}") + output_text.append( + f"Download failed with aria2c. Return code: {return_code}" + ) except Exception as e: logger.error(f"Aria2 download error: {e}", exc_info=True) output_text.append(f"Error during aria2c download: {e}") self.download_with_wget() - def download_with_wget(self): #Corrected + def download_with_wget(self): # Corrected """Downloads files using wget.""" repo_url = self.repo_url download_directory = self.download_directory output_text = self.output_text try: - command = ["wget", "-r", "-np", "-nH", "--cut-dirs=1", "-P", download_directory, repo_url] - process = subprocess.Popen(command, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True) + command = [ + "wget", + "-r", + "-np", + "-nH", + "--cut-dirs=1", + "-P", + download_directory, + repo_url, + ] + process = subprocess.Popen( + command, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True + ) while True: output = process.stdout.readline() - if output == '' and process.poll() is not None: + if output == "" and process.poll() is not None: break if output: output_text.append(output.strip()) @@ -128,8 +156,10 @@ def download_with_wget(self): #Corrected if return_code == 0: output_text.append("Download completed successfully with wget.") else: - output_text.append(f"Download failed with wget. Return code: {return_code}") + output_text.append( + f"Download failed with wget. Return code: {return_code}" + ) except Exception as e: logger.error(f"Wget download error: {e}", exc_info=True) - output_text.append(f"Error during wget download: {e}") \ No newline at end of file + output_text.append(f"Error during wget download: {e}") diff --git a/file_handler.py b/file_handler.py index 627fddc..22fc2aa 100644 --- a/file_handler.py +++ b/file_handler.py @@ -24,4 +24,4 @@ def remove_directory(path): shutil.rmtree(path, ignore_errors=True) logger.info(f"Directory removed (if it existed): {path}") except Exception as e: - logger.error(f"Error removing directory {path}: {e}", exc_info=True) \ No newline at end of file + logger.error(f"Error removing directory {path}: {e}", exc_info=True) diff --git a/file_utils.py b/file_utils.py new file mode 100644 index 0000000..1a94a34 --- /dev/null +++ b/file_utils.py @@ -0,0 +1,21 @@ +import os +from datetime import datetime + + +def get_files_by_extension(directory, extension): + """Returns a list of files in a directory with a specific extension.""" + files = [] + for filename in os.listdir(directory): + if filename.endswith(f".{extension}"): + files.append(os.path.join(directory, filename)) + return files + + +def sort_files_by_date(files): + """Sorts a list of files by last modified date (most recent first).""" + return sorted(files, key=os.path.getmtime, reverse=True) + + +def sort_files_by_name(files): + """Sorts a list of files by name alphabetically.""" + return sorted(files) diff --git a/hf_upload.py b/hf_upload.py index b1d86c6..b499c6b 100644 --- a/hf_upload.py +++ b/hf_upload.py @@ -1,13 +1,25 @@ # hf_backup_tool/ui/hf_uploader.py import logging +import subprocess import os import glob import traceback -from PyQt6.QtWidgets import (QWidget, QLabel, QLineEdit, QPushButton, - QVBoxLayout, QHBoxLayout, QFileDialog, QTextEdit, - QCheckBox, QComboBox, QListWidget, QProgressBar) - +from PyQt6.QtWidgets import ( + QWidget, + QLabel, + QLineEdit, + QMessageBox, + QPushButton, + QVBoxLayout, + QHBoxLayout, + QFileDialog, + QTextEdit, + QCheckBox, + QComboBox, + QListWidget, + QProgressBar, +) from PyQt6.QtGui import QAction from hf_uploader_thread import HFUploaderThread from config_manager import config @@ -16,148 +28,195 @@ class HuggingFaceUploader(QWidget): - """Widget for uploading files to Hugging Face Hub.""" - def __init__(self): super().__init__() self.setWindowTitle("Hugging Face Uploader") - self.uploader_thread = None + self.init_ui() - # Widgets - self.config_button = QPushButton("Edit HF API Token") + def init_ui(self): + # Main vertical layout to stack all sections + # Set margins and spacing for the main layout + main_layout = QVBoxLayout() + main_layout.setContentsMargins( + 10, 10, 10, 10 + ) # Margins around the whole layout + main_layout.setSpacing(10) # Space between widgets + + # --- Config Section --- + header_config = QLabel("Configuration") + header_config.setStyleSheet("font-weight: bold; font-size: 14px;") + main_layout.addWidget(header_config) + # Button to edit API token + config_layout = QHBoxLayout() + self.edit_config_button = QPushButton("Edit HF API Token") + config_layout.addWidget(self.edit_config_button) + main_layout.addLayout(config_layout) + + # --- Repository Info --- + header_repo = QLabel("Repository Information") + header_repo.setStyleSheet("font-weight: bold; font-size: 14px;") + main_layout.addWidget(header_repo) + # Owner, Repo, and Repo Type + repo_layout = QGridLayout() self.org_label = QLabel("Owner:") self.org_input = QLineEdit() self.repo_label = QLabel("Repo:") self.repo_input = QLineEdit() self.repo_type_label = QLabel("Repo Type:") self.repo_type_dropdown = QComboBox() - self.repo_type_dropdown.addItems(['model', 'dataset', 'space']) - self.repo_folder_label = QLabel("Subfolder:") - self.repo_folder_input = QLineEdit() + self.repo_type_dropdown.addItems(["model", "dataset", "space"]) + + # Place widgets in grid + repo_layout.addWidget(self.org_label, 0, 0) + repo_layout.addWidget(self.org_input, 0, 1) + repo_layout.addWidget(self.repo_label, 0, 2) + repo_layout.addWidget(self.repo_input, 0, 3) + repo_layout.addWidget(self.repo_type_label, 1, 0) + repo_layout.addWidget(self.repo_type_dropdown, 1, 1) + main_layout.addLayout(repo_layout) + + # --- Directory Selection --- + header_dir = QLabel("Directory Selection") + header_dir.setStyleSheet("font-weight: bold; font-size: 14px;") + main_layout.addWidget(header_dir) + dir_layout = QHBoxLayout() + self.directory_label = QLabel("Current Directory:") + self.directory_input = QLineEdit(os.getcwd()) # Default to current dir + self.select_dir_button = QPushButton("Select Directory") + self.update_dir_button = QPushButton("Update Dir") + dir_layout.addWidget(self.directory_label) + dir_layout.addWidget(self.directory_input) + dir_layout.addWidget(self.select_dir_button) + dir_layout.addWidget(self.update_dir_button) + main_layout.addLayout(dir_layout) + + # --- File Type and Sorting --- + header_files = QLabel("File Settings") + header_files.setStyleSheet("font-weight: bold; font-size: 14px;") + main_layout.addWidget(header_files) + file_type_layout = QHBoxLayout() self.file_type_label = QLabel("File Type:") self.file_type_dropdown = QComboBox() + # Add file types (name, extension) self.file_types = [ - ('SafeTensors', 'safetensors'), - ('PyTorch Models', 'pt'), - ('PyTorch Legacy', 'pth'), - ('ONNX Models', 'onnx'), - ('TensorFlow Models', 'pb'), - ('Keras Models', 'h5'), - ('Checkpoints', 'ckpt'), - ('Binary Files', 'bin'), - ('JSON Files', 'json'), - ('YAML Files', 'yaml'), - ('YAML Alt', 'yml'), - ('Text Files', 'txt'), - ('CSV Files', 'csv'), - ('Pickle Files', 'pkl'), - ('PNG Images', 'png'), - ('JPEG Images', 'jpg'), - ('JPEG Alt', 'jpeg'), - ('WebP Images', 'webp'), - ('GIF Images', 'gif'), - ('ZIP Archives', 'zip'), - ('TAR Files', 'tar'), - ('GZ Archives', 'gz') + ("SafeTensors", "safetensors"), + ("PyTorch Models", "pt"), + ("PyTorch Legacy", "pth"), + ("ONNX Models", "onnx"), + ("TensorFlow Models", "pb"), + ("Keras Models", "h5"), + ("Checkpoints", "ckpt"), + ("Binary Files", "bin"), + ("JSON Files", "json"), + ("YAML Files", "yaml"), + ("YAML Alt", "yml"), + ("Text Files", "txt"), + ("CSV Files", "csv"), + ("Pickle Files", "pkl"), + ("PNG Images", "png"), + ("JPEG Images", "jpg"), + ("JPEG Alt", "jpeg"), + ("WebP Images", "webp"), + ("GIF Images", "gif"), + ("ZIP Archives", "zip"), + ("TAR Files", "tar"), + ("GZ Archives", "gz"), ] for name, ext in self.file_types: self.file_type_dropdown.addItem(name, ext) + self.sort_by_label = QLabel("Sort By:") self.sort_by_dropdown = QComboBox() - self.sort_by_dropdown.addItems(['name', 'date']) - - # Add directory selection button - self.directory_label = QLabel(f"Current Directory: {self.current_directory}") - self.directory_input = QLineEdit(self.current_directory) - self.directory_select_button = QPushButton("Select Directory") # New - self.directory_update_button = QPushButton("Update Dir") - - self.commit_message_label = QLabel("Commit Message:") - self.commit_message_input = QTextEdit("Upload with Earth & Dusk Huggingface 🤗 Backup") - self.create_pr_checkbox = QCheckBox("Create Pull Request") - self.clear_after_checkbox = QCheckBox("Clear output after upload") - self.clear_after_checkbox.setChecked(True) - self.update_files_button = QPushButton("Update Files") - self.upload_button = QPushButton("Upload") - self.cancel_upload_button = QPushButton("Cancel Upload") - self.cancel_upload_button.setEnabled(False) - self.clear_output_button = QPushButton("Clear Output") - self.file_list = QListWidget() - self.output_text = QTextEdit() - self.output_text.setReadOnly(True) - self.progress_bar = QProgressBar() - self.progress_label = QLabel("Ready.") - self.progress_percent_label = QLabel("0%") + self.sort_by_dropdown.addItems(["name", "date"]) - # Layout - config_layout = QHBoxLayout() - config_layout.addWidget(self.config_button) - - repo_layout = QHBoxLayout() - repo_layout.addWidget(self.org_label) - repo_layout.addWidget(self.org_input) - repo_layout.addWidget(self.repo_label) - repo_layout.addWidget(self.repo_input) - repo_layout.addWidget(self.repo_type_label) - repo_layout.addWidget(self.repo_type_dropdown) - - file_type_layout = QHBoxLayout() file_type_layout.addWidget(self.file_type_label) file_type_layout.addWidget(self.file_type_dropdown) file_type_layout.addWidget(self.sort_by_label) file_type_layout.addWidget(self.sort_by_dropdown) + main_layout.addLayout(file_type_layout) - # Update Directory Layout - directory_layout = QHBoxLayout() - directory_layout.addWidget(self.directory_label) - directory_layout.addWidget(self.directory_input) - directory_layout.addWidget(self.directory_select_button) # Added button - directory_layout.addWidget(self.directory_update_button) - + # --- Commit Message --- + header_commit = QLabel("Commit Message") + header_commit.setStyleSheet("font-weight: bold; font-size: 14px;") + main_layout.addWidget(header_commit) commit_layout = QVBoxLayout() + self.commit_message_label = QLabel("Commit Message:") + self.commit_message_input = QTextEdit( + "Upload with Earth & Dusk Huggingface 🤗 Backup" + ) commit_layout.addWidget(self.commit_message_label) commit_layout.addWidget(self.commit_message_input) + main_layout.addLayout(commit_layout) - upload_options_layout = QHBoxLayout() - upload_options_layout.addWidget(self.create_pr_checkbox) - upload_options_layout.addWidget(self.clear_after_checkbox) + # --- Options --- + header_options = QLabel("Options") + header_options.setStyleSheet("font-weight: bold; font-size: 14px;") + main_layout.addWidget(header_options) + options_layout = QHBoxLayout() + self.create_pr_checkbox = QCheckBox("Create Pull Request") + self.check_repo_exists_checkbox = QCheckBox("Check if Repo Exists") + self.create_repo_checkbox = QCheckBox("Create Repository if it doesn't exist") + self.create_repo_checkbox.setEnabled(False) + self.clear_after_checkbox = QCheckBox("Clear output after upload") + self.clear_after_checkbox.setChecked(True) + options_layout.addWidget(self.create_pr_checkbox) + options_layout.addWidget(self.check_repo_exists_checkbox) + options_layout.addWidget(self.create_repo_checkbox) + options_layout.addWidget(self.clear_after_checkbox) + main_layout.addLayout(options_layout) + + # --- File List --- + header_files_list = QLabel("Files to Upload") + header_files_list.setStyleSheet("font-weight: bold; font-size: 14px;") + main_layout.addWidget(header_files_list) + self.file_list = QListWidget() + main_layout.addWidget(QLabel("Files to Upload:")) + main_layout.addWidget(self.file_list) + # --- Output and Progress --- + header_output = QLabel("Output & Progress") + header_output.setStyleSheet("font-weight: bold; font-size: 14px;") + main_layout.addWidget(header_output) + output_layout = QVBoxLayout() + self.output_text = QTextEdit() + self.output_text.setReadOnly(True) + self.progress_bar = QProgressBar() + self.progress_label = QLabel("Status: Ready") + self.progress_percent_label = QLabel("0%") + output_layout.addWidget(self.output_text) + output_layout.addWidget(self.progress_bar) + output_layout.addWidget(self.progress_label) + output_layout.addWidget(self.progress_percent_label) + main_layout.addLayout(output_layout) + + # --- Buttons --- + header_buttons = QLabel("Buttons") + header_buttons.setStyleSheet("font-weight: bold; font-size: 14px;") + main_layout.addWidget(header_buttons) button_layout = QHBoxLayout() + self.update_files_button = QPushButton("Update Files") + self.upload_button = QPushButton("Upload") + self.cancel_button = QPushButton("Cancel") + self.clear_output_button = QPushButton("Clear Output") button_layout.addWidget(self.update_files_button) button_layout.addWidget(self.upload_button) - button_layout.addWidget(self.cancel_upload_button) + button_layout.addWidget(self.cancel_button) button_layout.addWidget(self.clear_output_button) - progress_layout = QHBoxLayout() - progress_layout.addWidget(self.progress_label) - progress_layout.addWidget(self.progress_percent_label) - - main_layout = QVBoxLayout() - main_layout.addLayout(config_layout) - main_layout.addLayout(repo_layout) - main_layout.addWidget(self.repo_folder_label) - main_layout.addWidget(self.repo_folder_input) - main_layout.addLayout(file_type_layout) - main_layout.addLayout(directory_layout) - main_layout.addLayout(commit_layout) - main_layout.addLayout(upload_options_layout) - main_layout.addWidget(self.update_files_button) - main_layout.addWidget(self.file_list) - main_layout.addLayout(button_layout) - main_layout.addLayout(progress_layout) - main_layout.addWidget(self.progress_bar) - main_layout.addWidget(self.output_text) - + # Set the main layout self.setLayout(main_layout) - # Connections - self.config_button.clicked.connect(self.edit_config) - self.directory_select_button.clicked.connect(self.select_directory) # Add connection for new button - self.directory_update_button.clicked.connect(self.update_directory) + # --- Connect signals to slots --- + self.edit_config_button.clicked.connect(self.edit_config) + self.select_dir_button.clicked.connect(self.select_directory) + self.update_dir_button.clicked.connect(self.update_directory) self.update_files_button.clicked.connect(self.update_files) self.upload_button.clicked.connect(self.start_upload) - self.cancel_upload_button.clicked.connect(self.cancel_upload) - self.file_type_dropdown.currentIndexChanged.connect(self.update_files) + self.cancel_button.clicked.connect(self.cancel_upload) + self.clear_output_button.clicked.connect(self.clear_output) + + # Additional initializations if needed + # e.g., self.file_types = [...] (already defined above) def edit_config(self): """Opens the configuration dialog.""" @@ -169,6 +228,9 @@ def select_directory(self): directory = QFileDialog.getExistingDirectory(self, "Select a Directory") if directory: self.directory_input.setText(directory) + self.current_directory = directory + self.directory_label.setText(f"Current Directory: {self.current_directory}") + self.update_files() def update_directory(self): """Updates the current directory and file list.""" @@ -180,12 +242,22 @@ def update_directory(self): else: self.output_text.append("❌ Invalid Directory") + def toggle_create_repo_checkbox(self, state): + """Enables/disables the create repo checkbox based on the check repo checkbox.""" + if state == 0: # Unchecked + self.create_repo_checkbox.setChecked(False) + self.create_repo_checkbox.setEnabled(False) + else: + self.create_repo_checkbox.setEnabled(True) + def update_files(self): """Updates the file list based on the selected file type.""" self.file_list.clear() file_extension = self.file_type_dropdown.currentData() try: - all_files = glob.glob(os.path.join(self.current_directory, f"*.{file_extension}")) + all_files = glob.glob( + os.path.join(self.current_directory, f"*.{file_extension}") + ) filtered_files = [] for file_path in all_files: if os.path.islink(file_path): @@ -198,11 +270,17 @@ def update_files(self): all_ckpts = sorted( filtered_files, - key=os.path.getmtime if self.sort_by_dropdown.currentText() == 'date' else str + key=( + os.path.getmtime + if self.sort_by_dropdown.currentText() == "date" + else str + ), ) self.file_list.addItems(all_ckpts) - self.output_text.append(f"✨ Found {len(all_ckpts)} {file_extension} files in {self.current_directory}") + self.output_text.append( + f"✨ Found {len(all_ckpts)} {file_extension} files in {self.current_directory}" + ) except Exception as e: logger.error(f"File listing error: {e}", exc_info=True) # Log @@ -211,26 +289,48 @@ def update_files(self): def start_upload(self): """Starts the upload process in a separate thread.""" if not self.org_input.text() or not self.repo_input.text(): - self.output_text.append("❗ Please fill in both Organization/Username and Repository name") + self.output_text.append( + "❗ Please fill in both Organization/Username and Repository name" + ) return + if self.check_repo_exists_checkbox.isChecked() and not self.repo_exists( + f"{self.org_input.text()}/{self.repo_input.text()}" + ): + if not self.create_repo_checkbox.isChecked(): + self.output_text.append( + "❗ Repository does not exist and creation is not enabled." + ) + return + repo_id = f"{self.org_input.text()}/{self.repo_input.text()}" selected_files = [item.text() for item in self.file_list.selectedItems()] repo_type = self.repo_type_dropdown.currentText() repo_folder = self.repo_folder_input.text().strip() current_directory = self.directory_input.text() commit_msg = self.commit_message_input.toPlainText() - rate_limit_delay = config['HuggingFace']['rate_limit_delay'] + rate_limit_delay = config["HuggingFace"]["rate_limit_delay"] if not selected_files: - self.output_text.append("📝 Nothing selected for upload. Please select files from the list.") + self.output_text.append( + "📝 Nothing selected for upload. Please select files from the list." + ) return self.upload_button.setEnabled(False) self.cancel_upload_button.setEnabled(True) - self.uploader_thread = HFUploaderThread(self.api, repo_id, selected_files, repo_type, repo_folder, - current_directory, commit_msg, self.create_pr_checkbox.isChecked(), rate_limit_delay) + self.uploader_thread = HFUploaderThread( + self.api, + repo_id, + selected_files, + repo_type, + repo_folder, + current_directory, + commit_msg, + self.create_pr_checkbox.isChecked(), + rate_limit_delay, + ) self.uploader_thread.signal_status.connect(self.update_status) self.uploader_thread.signal_progress.connect(self.update_progress) @@ -269,4 +369,33 @@ def upload_finished(self): def clear_output(self): """Clears the output text.""" - self.output_text.clear() \ No newline at end of file + self.output_text.clear() + + def repo_exists(self, repo_id): + """Checks if a repository exists on Hugging Face Hub.""" + try: + # Use the 'huggingface-cli' command to check repository existence. + result = subprocess.run( + ["huggingface-cli", "repo", "info", repo_id, "--json"], + capture_output=True, + text=True, + check=True, + ) + # If the command succeeds, the repo exists. We don't parse the JSON. + return True + except subprocess.CalledProcessError as e: + # Repo doesn't exist or other error. We'll check for 404 specifically. + if ( + "404 Client Error" in e.stderr + ): # Or check for a more specific error message + return False + else: + return False # Other errors, consider repo doesn't exist, or handle differently + except FileNotFoundError: + # huggingface-cli not found. Handle this case. + QMessageBox.critical( + self, + "Error", + "The 'huggingface-cli' command was not found. Please ensure you have the Hugging Face CLI installed and in your PATH.", + ) + return False diff --git a/hf_uploader.py b/hf_uploader.py index df084f1..673d5f8 100644 --- a/hf_uploader.py +++ b/hf_uploader.py @@ -1,265 +1,273 @@ +# hf_backup_tool/ui/hf_uploader.py import logging +import subprocess import os import glob import traceback - -from PyQt6.QtWidgets import (QWidget, QLabel, QLineEdit, QPushButton, - QVBoxLayout, QHBoxLayout, QFileDialog, QTextEdit, - QCheckBox, QComboBox, QListWidget, QProgressBar, QApplication) # Import QApplication +from PyQt6.QtWidgets import ( + QWidget, + QLabel, + QLineEdit, + QMessageBox, + QPushButton, + QVBoxLayout, + QHBoxLayout, + QFileDialog, + QTextEdit, + QCheckBox, + QComboBox, + QListWidget, + QProgressBar, + QGridLayout, +) from PyQt6.QtGui import QAction -from PyQt6.QtCore import QThread, pyqtSignal # Import QThread and pyqtSignal +from hf_uploader_thread import HFUploaderThread +from config_manager import config # Import config ONLY +from token_utils import deobfuscate_token # Import deobfuscate_token from token_utils +from config_dialog import ConfigDialog # Add this import statement -from huggingface_hub import HfApi -#from huggingface_hub import upload_folder #Removed -from config_manager import config # Needed for Config -from config_dialog import ConfigDialog # Needed for config -# from keyring_manager import get_api_token, set_api_token, delete_api_token logger = logging.getLogger(__name__) -def obfuscate_token(token): - """A simple obfuscation function (DO NOT RELY ON THIS FOR SECURITY).""" - obfuscated = ''.join([chr(ord(c) + 5) for c in token]) # Shift each character by 5 - return obfuscated - - -def deobfuscate_token(obfuscated): - """Reverses the obfuscation (DO NOT RELY ON THIS FOR SECURITY).""" - original = ''.join([chr(ord(c) - 5) for c in obfuscated]) - return original - - -class HFUploaderThread(QThread): - """Thread for uploading files to Hugging Face Hub.""" - signal_status = pyqtSignal(str) - signal_progress = pyqtSignal(int) - signal_output = pyqtSignal(str) - signal_finished = pyqtSignal() - - def __init__(self, api, repo_id, selected_files, repo_type, repo_folder, - current_directory, commit_msg, create_pr, rate_limit_delay): - super().__init__() - self.api = api - self.repo_id = repo_id - self.selected_files = selected_files - self.repo_type = repo_type - self.repo_folder = repo_folder - self.current_directory = current_directory - self.commit_msg = commit_msg - self.create_pr = create_pr - self.rate_limit_delay = rate_limit_delay - self.stop_flag = False - - def stop(self): - self.stop_flag = True - - def run(self): - try: - total_files = len(self.selected_files) - uploaded_files = 0 - - for file_path in self.selected_files: - if self.stop_flag: - break - - relative_path = os.path.relpath(file_path, self.current_directory) - repo_path = os.path.join(self.repo_folder, relative_path) if self.repo_folder else relative_path - - self.signal_status.emit(f"Uploading {file_path} to {repo_path}...") - - try: - # Simulate a delay for rate limiting - import time - time.sleep(float(self.rate_limit_delay)) #Rate limit is now a float - - self.api.upload_file( - path_or_fileobj=file_path, # The actual file path - path_in_repo=repo_path, # Where it goes in the repo - repo_id=self.repo_id, - repo_type=self.repo_type, - commit_message=self.commit_msg, - commit_info={"message": self.commit_msg} - ) - - uploaded_files += 1 - progress_percentage = int((uploaded_files / total_files) * 100) - self.signal_progress.emit(progress_percentage) - self.signal_output.emit(f"Uploaded {file_path} to {self.repo_id}/{repo_path}") - - except Exception as upload_error: - logger.error(f"Error uploading {file_path}: {upload_error}", exc_info=True) - self.signal_output.emit(f"❌ Error uploading {file_path}: {str(upload_error)}") - # Consider whether to stop the entire upload on an error or continue - - self.signal_status.emit("Upload completed.") - self.signal_finished.emit() - - except Exception as e: - logger.error(f"Upload error: {e}", exc_info=True) - self.signal_output.emit(f"❌ Upload error: {str(e)}") - self.signal_finished.emit() # Ensure finished signal is always emitted. - - class HuggingFaceUploader(QWidget): - """Widget for uploading files to Hugging Face Hub.""" + """ + UI for uploading files to Hugging Face Hub. + """ def __init__(self): + """ + Initializes the Hugging Face Uploader UI. + """ super().__init__() self.setWindowTitle("Hugging Face Uploader") - self.uploader_thread = None - self.config_dialog = None # Needed for it to be instantiated - - # Initialize current_directory here! - self.current_directory = "" # Initialize with a default value (empty string) or os.getcwd() + self.current_directory = os.getcwd() # Store the current directory + self.uploader_thread = None # To store the thread + self.config_dialog = None # Add this line + + # Call init_ui + self.init_ui() + + def init_ui(self): + """ + Initializes the UI elements and their layout. + """ + # Main vertical layout to stack all sections + main_layout = QVBoxLayout() + main_layout.setContentsMargins( + 10, 10, 10, 10 + ) # Margins around the whole layout + main_layout.setSpacing(10) # Space between widgets + + # --- Config Section --- + header_config = QLabel("Configuration") + header_config.setStyleSheet("font-weight: bold; font-size: 14px;") + main_layout.addWidget(header_config) + # Button to edit API token + config_layout = QHBoxLayout() + self.edit_config_button = QPushButton("Edit HF API Token") + config_layout.addWidget(self.edit_config_button) + main_layout.addLayout(config_layout) - # Widgets - self.config_button = QPushButton("Edit Config (API Token)") + # --- Repository Info --- + header_repo = QLabel("Repository Information") + header_repo.setStyleSheet("font-weight: bold; font-size: 14px;") + main_layout.addWidget(header_repo) + # Owner, Repo, and Repo Type + repo_layout = QGridLayout() self.org_label = QLabel("Owner:") self.org_input = QLineEdit() self.repo_label = QLabel("Repo:") self.repo_input = QLineEdit() self.repo_type_label = QLabel("Repo Type:") self.repo_type_dropdown = QComboBox() - self.repo_type_dropdown.addItems(['model', 'dataset', 'space']) - self.repo_folder_label = QLabel("Subfolder:") - self.repo_folder_input = QLineEdit() + self.repo_type_dropdown.addItems(["model", "dataset", "space"]) + + # Place widgets in grid + repo_layout.addWidget(self.org_label, 0, 0) + repo_layout.addWidget(self.org_input, 0, 1) + repo_layout.addWidget(self.repo_label, 0, 2) + repo_layout.addWidget(self.repo_input, 0, 3) + repo_layout.addWidget(self.repo_type_label, 1, 0) + repo_layout.addWidget(self.repo_type_dropdown, 1, 1) + main_layout.addLayout(repo_layout) + + # --- Directory Selection --- + header_dir = QLabel("Directory Selection") + header_dir.setStyleSheet("font-weight: bold; font-size: 14px;") + main_layout.addWidget(header_dir) + dir_layout = QVBoxLayout() # Changed to QVBoxLayout + dir_selection_layout = QHBoxLayout() # Layout for the input and buttons + + self.directory_label = QLabel(f"Current Directory: {self.current_directory}") + self.directory_input = QLineEdit( + self.current_directory + ) # Default to current dir + self.select_dir_button = QPushButton("Select Directory") + self.update_dir_button = QPushButton("Update Dir") + + dir_selection_layout.addWidget(self.directory_input) + dir_selection_layout.addWidget(self.select_dir_button) + dir_selection_layout.addWidget(self.update_dir_button) + + dir_layout.addLayout(dir_selection_layout) # Add the input/button layout + dir_layout.addWidget(self.directory_label) # Add the directory label + main_layout.addLayout(dir_layout) # Add the directory layout + + # --- File Type and Sorting --- + header_files = QLabel("File Settings") + header_files.setStyleSheet("font-weight: bold; font-size: 14px;") + main_layout.addWidget(header_files) + file_type_layout = QHBoxLayout() self.file_type_label = QLabel("File Type:") self.file_type_dropdown = QComboBox() + # Add file types (name, extension) self.file_types = [ - ('SafeTensors', 'safetensors'), - ('PyTorch Models', 'pt'), - ('PyTorch Legacy', 'pth'), - ('ONNX Models', 'onnx'), - ('TensorFlow Models', 'pb'), - ('Keras Models', 'h5'), - ('Checkpoints', 'ckpt'), - ('Binary Files', 'bin'), - ('JSON Files', 'json'), - ('YAML Files', 'yaml'), - ('YAML Alt', 'yml'), - ('Text Files', 'txt'), - ('CSV Files', 'csv'), - ('Pickle Files', 'pkl'), - ('PNG Images', 'png'), - ('JPEG Images', 'jpg'), - ('JPEG Alt', 'jpeg'), - ('WebP Images', 'webp'), - ('GIF Images', 'gif'), - ('ZIP Archives', 'zip'), - ('TAR Files', 'gz') + ("SafeTensors", "safetensors"), + ("PyTorch Models", "pt"), + ("PyTorch Legacy", "pth"), + ("ONNX Models", "onnx"), + ("TensorFlow Models", "pb"), + ("Keras Models", "h5"), + ("Checkpoints", "ckpt"), + ("Binary Files", "bin"), + ("JSON Files", "json"), + ("YAML Files", "yaml"), + ("YAML Alt", "yml"), + ("Text Files", "txt"), + ("CSV Files", "csv"), + ("Pickle Files", "pkl"), + ("PNG Images", "png"), + ("JPEG Images", "jpg"), + ("JPEG Alt", "jpeg"), + ("WebP Images", "webp"), + ("GIF Images", "gif"), + ("ZIP Archives", "zip"), + ("TAR Files", "tar"), + ("GZ Archives", "gz"), ] for name, ext in self.file_types: self.file_type_dropdown.addItem(name, ext) + self.sort_by_label = QLabel("Sort By:") self.sort_by_dropdown = QComboBox() - self.sort_by_dropdown.addItems(['name', 'date']) - - # Add directory selection button - self.directory_label = QLabel(f"Current Directory: {self.current_directory}") - self.directory_input = QLineEdit(self.current_directory) - self.directory_select_button = QPushButton("Select Directory") # New - self.directory_update_button = QPushButton("Update Dir") - - self.commit_message_label = QLabel("Commit Message:") - self.commit_message_input = QTextEdit("Upload with Earth & Dusk Huggingface 🤗 Backup") - self.create_pr_checkbox = QCheckBox("Create Pull Request") - self.clear_after_checkbox = QCheckBox("Clear output after upload") - self.clear_after_checkbox.setChecked(True) - self.update_files_button = QPushButton("Update Files") - self.upload_button = QPushButton("Upload") - self.cancel_upload_button = QPushButton("Cancel Upload") - self.cancel_upload_button.setEnabled(False) - self.clear_output_button = QPushButton("Clear Output") - self.file_list = QListWidget() - self.output_text = QTextEdit() - self.output_text.setReadOnly(True) - self.progress_bar = QProgressBar() - self.progress_label = QLabel("Ready.") - self.progress_percent_label = QLabel("0%") - - # Layout - config_layout = QHBoxLayout() - config_layout.addWidget(self.config_button) - - repo_layout = QHBoxLayout() - repo_layout.addWidget(self.org_label) - repo_layout.addWidget(self.org_input) - repo_layout.addWidget(self.repo_label) - repo_layout.addWidget(self.repo_input) - repo_layout.addWidget(self.repo_type_label) - repo_layout.addWidget(self.repo_type_dropdown) + self.sort_by_dropdown.addItems(["name", "date"]) - file_type_layout = QHBoxLayout() file_type_layout.addWidget(self.file_type_label) file_type_layout.addWidget(self.file_type_dropdown) file_type_layout.addWidget(self.sort_by_label) file_type_layout.addWidget(self.sort_by_dropdown) + main_layout.addLayout(file_type_layout) - # Update Directory Layout - directory_layout = QHBoxLayout() - directory_layout.addWidget(self.directory_label) - directory_layout.addWidget(self.directory_input) - directory_layout.addWidget(self.directory_select_button) # Added button - directory_layout.addWidget(self.directory_update_button) - + # --- Commit Message --- + header_commit = QLabel("Commit Message") + header_commit.setStyleSheet("font-weight: bold; font-size: 14px;") + main_layout.addWidget(header_commit) commit_layout = QVBoxLayout() + self.commit_message_label = QLabel("Commit Message:") + self.commit_message_input = QTextEdit( + "Upload with Earth & Dusk Huggingface 🤗 Backup" + ) commit_layout.addWidget(self.commit_message_label) commit_layout.addWidget(self.commit_message_input) + main_layout.addLayout(commit_layout) - upload_options_layout = QHBoxLayout() - upload_options_layout.addWidget(self.create_pr_checkbox) - upload_options_layout.addWidget(self.clear_after_checkbox) + # --- Options --- + header_options = QLabel("Options") + header_options.setStyleSheet("font-weight: bold; font-size: 14px;") + main_layout.addWidget(header_options) + options_layout = QHBoxLayout() + self.create_pr_checkbox = QCheckBox("Create Pull Request") + self.check_repo_exists_checkbox = QCheckBox("Check if Repo Exists") + self.create_repo_checkbox = QCheckBox("Create Repository if it doesn't exist") + self.create_repo_checkbox.setEnabled(False) + self.clear_after_checkbox = QCheckBox("Clear output after upload") + self.clear_after_checkbox.setChecked(True) + options_layout.addWidget(self.create_pr_checkbox) + options_layout.addWidget(self.check_repo_exists_checkbox) + options_layout.addWidget(self.create_repo_checkbox) + options_layout.addWidget(self.clear_after_checkbox) + main_layout.addLayout(options_layout) + + # --- File List --- + header_files_list = QLabel("Files to Upload") + header_files_list.setStyleSheet("font-weight: bold; font-size: 14px;") + main_layout.addWidget(header_files_list) + self.file_list = QListWidget() + main_layout.addWidget(QLabel("Files to Upload:")) + main_layout.addWidget(self.file_list) + # --- Output and Progress --- + header_output = QLabel("Output & Progress") + header_output.setStyleSheet("font-weight: bold; font-size: 14px;") + main_layout.addWidget(header_output) + output_layout = QVBoxLayout() + self.output_text = QTextEdit() + self.output_text.setReadOnly(True) + self.progress_bar = QProgressBar() + self.progress_label = QLabel("Status: Ready") + self.progress_percent_label = QLabel("0%") + output_layout.addWidget(self.output_text) + output_layout.addWidget(self.progress_bar) + output_layout.addWidget(self.progress_label) + output_layout.addWidget(self.progress_percent_label) + main_layout.addLayout(output_layout) + + # --- Buttons Section --- + header_buttons = QLabel("Actions") # Changed label + header_buttons.setStyleSheet("font-weight: bold; font-size: 14px;") + main_layout.addWidget(header_buttons) button_layout = QHBoxLayout() + self.update_files_button = QPushButton("Update Files") + self.upload_button = QPushButton("Upload") + self.cancel_button = QPushButton("Cancel") + self.clear_output_button = QPushButton("Clear Output") button_layout.addWidget(self.update_files_button) button_layout.addWidget(self.upload_button) - button_layout.addWidget(self.cancel_upload_button) + button_layout.addWidget(self.cancel_button) button_layout.addWidget(self.clear_output_button) + main_layout.addLayout(button_layout) # Add the button layout - progress_layout = QHBoxLayout() - progress_layout.addWidget(self.progress_label) - progress_layout.addWidget(self.progress_percent_label) - - main_layout = QVBoxLayout() - main_layout.addLayout(config_layout) - main_layout.addLayout(repo_layout) - main_layout.addWidget(self.repo_folder_label) - main_layout.addWidget(self.repo_folder_input) - main_layout.addLayout(file_type_layout) - main_layout.addLayout(directory_layout) - main_layout.addLayout(commit_layout) - main_layout.addLayout(upload_options_layout) - main_layout.addWidget(self.update_files_button) - main_layout.addWidget(self.file_list) - main_layout.addLayout(button_layout) - main_layout.addLayout(progress_layout) - main_layout.addWidget(self.progress_bar) - main_layout.addWidget(self.output_text) - + # Set the main layout self.setLayout(main_layout) - # Connections - self.config_button.clicked.connect(self.edit_config) - self.directory_select_button.clicked.connect(self.select_directory) # Add connection for new button - self.directory_update_button.clicked.connect(self.update_directory) + # --- Connect signals to slots --- + self.edit_config_button.clicked.connect(self.edit_config) + self.select_dir_button.clicked.connect(self.select_directory) + self.update_dir_button.clicked.connect(self.update_directory) self.update_files_button.clicked.connect(self.update_files) self.upload_button.clicked.connect(self.start_upload) - self.cancel_upload_button.clicked.connect(self.cancel_upload) - self.file_type_dropdown.currentIndexChanged.connect(self.update_files) + self.cancel_button.clicked.connect(self.cancel_upload) + self.clear_output_button.clicked.connect(self.clear_output) + self.check_repo_exists_checkbox.stateChanged.connect( + self.toggle_create_repo_checkbox + ) + + # Additional initializations if needed + # e.g., self.file_types = [...] (already defined above) def edit_config(self): - """Opens the configuration dialog.""" - self.config_dialog = ConfigDialog() # Initialized here + """ + Opens the configuration dialog. + """ + if not self.config_dialog: + self.config_dialog = ConfigDialog() # Make it a class member self.config_dialog.show() def select_directory(self): - """Opens a dialog to select a directory.""" + """ + Opens a dialog to select a directory. + """ directory = QFileDialog.getExistingDirectory(self, "Select a Directory") if directory: self.directory_input.setText(directory) + self.current_directory = directory + self.directory_label.setText(f"Current Directory: {self.current_directory}") + self.update_files() def update_directory(self): - """Updates the current directory and file list.""" + """ + Updates the current directory and file list. + """ new_dir = self.directory_input.text() if os.path.isdir(new_dir): self.current_directory = new_dir @@ -268,12 +276,27 @@ def update_directory(self): else: self.output_text.append("❌ Invalid Directory") + def toggle_create_repo_checkbox(self, state): + """ + Enables/disables the create repo checkbox based on the check repo checkbox. + """ + if state == 0: # Unchecked + self.create_repo_checkbox.setChecked(False) + self.create_repo_checkbox.setEnabled(False) + else: + self.create_repo_checkbox.setEnabled(True) + def update_files(self): - """Updates the file list based on the selected file type.""" + """ + Updates the file list based on the selected file type and directory. + """ self.file_list.clear() file_extension = self.file_type_dropdown.currentData() try: - all_files = glob.glob(os.path.join(self.current_directory, f"*.{file_extension}")) + # Build the file pattern + file_pattern = os.path.join(self.current_directory, f"*.{file_extension}") + # Use glob.glob to find files matching the pattern + all_files = glob.glob(file_pattern) filtered_files = [] for file_path in all_files: if os.path.islink(file_path): @@ -284,95 +307,203 @@ def update_files(self): continue filtered_files.append(file_path) + # Sort the files by date or name all_ckpts = sorted( filtered_files, - key=os.path.getmtime if self.sort_by_dropdown.currentText() == 'date' else str + key=( + os.path.getmtime + if self.sort_by_dropdown.currentText() == "date" + else str + ), + ) + # Add items to the list widget + for file_path in all_ckpts: + self.file_list.addItem(os.path.basename(file_path)) + # Add items to the list widget + # self.file_list.addItems(all_ckpts) # Use the filename + self.output_text.append( + f"✨ Found {len(all_ckpts)} {file_extension} files in {self.current_directory}" ) - - self.file_list.addItems(all_ckpts) - self.output_text.append(f"✨ Found {len(all_ckpts)} {file_extension} files in {self.current_directory}") except Exception as e: - logger.error(f"File listing error: {e}", exc_info=True) # Log + logger.error(f"File listing error: {e}", exc_info=True) self.output_text.append(f"❌ Error listing files: {str(e)}") def start_upload(self): - """Starts the upload process in a separate thread.""" - org = self.org_input.text() - repo = self.repo_input.text() - - if not org or not repo: - self.output_text.append("❗ Please fill in both Organization/Username and Repository name") + """ + Starts the upload process in a separate thread. + """ + # Input validation + if not self.org_input.text() or not self.repo_input.text(): + self.output_text.append( + "❗ Please fill in both Organization/Username and Repository name" + ) return - repo_id = f"{org}/{repo}" - selected_files = [item.text() for item in self.file_list.selectedItems()] + # Check if the repository exists and creation is enabled + if self.check_repo_exists_checkbox.isChecked() and not self.repo_exists( + f"{self.org_input.text()}/{self.repo_input.text()}" + ): + if not self.create_repo_checkbox.isChecked(): + self.output_text.append( + "❗ Repository does not exist and creation is not enabled." + ) + return + + # Get the values from the UI + repo_id = f"{self.org_input.text()}/{self.repo_input.text()}" + selected_files = [ + os.path.join(self.current_directory, item.text()) + for item in self.file_list.selectedItems() + ] repo_type = self.repo_type_dropdown.currentText() repo_folder = self.repo_folder_input.text().strip() current_directory = self.directory_input.text() commit_msg = self.commit_message_input.toPlainText() + create_pr = self.create_pr_checkbox.isChecked() + rate_limit_delay = float(config.get("HuggingFace", "rate_limit_delay", "1")) + if not selected_files: + self.output_text.append( + "📝 Nothing selected for upload. Please select files from the list." + ) + return - rate_limit_delay = config['HuggingFace']['rate_limit_delay'] # Pull from config - obfuscated_token = config['HuggingFace']['api_token'] # Obfuscated token from config - - # Deobfuscate: - api_token = deobfuscate_token(obfuscated_token) - - # Debug: Print the token (remove after verifying!) - print(f"Deobfuscated token: {api_token}") - + # Disable the UI elements during the upload + self.org_input.setEnabled(False) + self.repo_input.setEnabled(False) + self.repo_type_dropdown.setEnabled(False) + self.repo_folder_input.setEnabled(False) + self.directory_input.setEnabled(False) + self.select_dir_button.setEnabled(False) + self.file_type_dropdown.setEnabled(False) + self.sort_by_dropdown.setEnabled(False) + self.commit_message_input.setEnabled(False) + self.create_pr_checkbox.setEnabled(False) + self.check_repo_exists_checkbox.setEnabled(False) + self.create_repo_checkbox.setEnabled(False) + self.update_files_button.setEnabled(False) self.upload_button.setEnabled(False) - self.cancel_upload_button.setEnabled(True) # Enable cancel button - - # Initialize Hugging Face API (Moved here) - self.api = HfApi(token=api_token) # Pass the token here to HfApi - - self.uploader_thread = HFUploaderThread(self.api, repo_id, selected_files, repo_type, repo_folder, - current_directory, commit_msg, False, - rate_limit_delay) # Removed create_pr_checkbox - + self.cancel_button.setEnabled(True) + + # 2. Create the thread + self.uploader_thread = HFUploaderThread( + repo_id=repo_id, + selected_files=selected_files, + repo_type=repo_type, + repo_folder=repo_folder, + current_directory=current_directory, + commit_msg=commit_msg, + create_pr=create_pr, + rate_limit_delay=rate_limit_delay, + ) + + # 3. Connect signals self.uploader_thread.signal_status.connect(self.update_status) self.uploader_thread.signal_progress.connect(self.update_progress) self.uploader_thread.signal_output.connect(self.update_output) self.uploader_thread.signal_finished.connect(self.upload_finished) + + # 4. Start the thread self.uploader_thread.start() def cancel_upload(self): - """Cancels the upload process.""" + """ + Cancels the upload process. + """ if self.uploader_thread and self.uploader_thread.isRunning(): - self.uploader_thread.stop() - self.uploader_thread.wait() - self.upload_finished() + # Confirmation message before cancelling + reply = QMessageBox.question( + self, + "Cancel Upload", + "Are you sure you want to cancel the upload?", + QMessageBox.Yes | QMessageBox.No, + QMessageBox.No, + ) + if reply == QMessageBox.Yes: + self.uploader_thread.stop() # Set the stop flag + # Optionally, wait for the thread to finish. + self.uploader_thread.wait() + self.upload_finished() # reset the UI + + self.append_output("Upload cancelled.") def update_status(self, message): - """Updates the status label.""" + """ + Updates the status label. + """ self.progress_label.setText(message) def update_progress(self, value): - """Updates the progress bar.""" + """ + Updates the progress bar. + """ self.progress_bar.setValue(value) self.progress_percent_label.setText(f"{value}%") def update_output(self, message): - """Appends a message to the output text.""" + """ + Appends a message to the output text. + """ self.output_text.append(message) def upload_finished(self): - """Resets the UI after the upload is finished.""" + """ + Resets the UI after the upload is finished. + """ + # Re-enable the UI elements + self.org_input.setEnabled(True) + self.repo_input.setEnabled(True) + self.repo_type_dropdown.setEnabled(True) + self.repo_folder_input.setEnabled(True) + self.directory_input.setEnabled(True) + self.select_dir_button.setEnabled(True) + self.file_type_dropdown.setEnabled(True) + self.sort_by_dropdown.setEnabled(True) + self.commit_message_input.setEnabled(True) + self.create_pr_checkbox.setEnabled(True) + self.check_repo_exists_checkbox.setEnabled(True) + self.create_repo_checkbox.setEnabled(True) + self.update_files_button.setEnabled(True) self.upload_button.setEnabled(True) - self.cancel_upload_button.setEnabled(False) + self.cancel_button.setEnabled(False) self.progress_label.setText("Ready.") self.progress_percent_label.setText("0%") if self.clear_after_checkbox.isChecked(): self.clear_output() def clear_output(self): - """Clears the output text.""" + """ + Clears the output text. + """ self.output_text.clear() - -if __name__ == '__main__': - app = QApplication([]) - window = HuggingFaceUploader() - window.show() - app.exec() \ No newline at end of file + def repo_exists(self, repo_id): + """ + Checks if a repository exists on Hugging Face Hub using the 'huggingface-cli' command. + """ + try: + # Use the 'huggingface-cli' command to check repository existence. + result = subprocess.run( + ["huggingface-cli", "repo", "info", repo_id, "--json"], + capture_output=True, + text=True, + check=True, + ) + # If the command succeeds, the repo exists. We don't parse the JSON. + return True + except subprocess.CalledProcessError as e: + # Repo doesn't exist or other error. We'll check for 404 specifically. + if ( + "404 Client Error" in e.stderr + ): # Or check for a more specific error message + return False + else: + return False # Other errors, consider repo doesn't exist, or handle differently + except FileNotFoundError: + # huggingface-cli not found. Handle this case. + QMessageBox.critical( + self, + "Error", + "The 'huggingface-cli' command was not found. Please ensure you have the Hugging Face CLI installed and in your PATH.", + ) + return False \ No newline at end of file diff --git a/hf_uploader_thread.py b/hf_uploader_thread.py index 9388568..86d6bea 100644 --- a/hf_uploader_thread.py +++ b/hf_uploader_thread.py @@ -1,39 +1,42 @@ +import glob import logging import os -import glob -import traceback import time +import traceback from pathlib import Path -from PyQt6.QtWidgets import (QWidget, QLabel, QLineEdit, QPushButton, - QVBoxLayout, QHBoxLayout, QFileDialog, QTextEdit, - QCheckBox, QComboBox, QListWidget, QProgressBar, QApplication) # Import QApplication -from PyQt6.QtGui import QAction +# from config_dialog import ConfigDialog # Not needed here - but good for ref. +# hf_uploader_thread.py +# import huggingface_hub +# from huggingface_hub.utils import get_hf_home_dir # OLD +from huggingface_hub import ( # Import the library + HfApi, + upload_folder, +) from PyQt6.QtCore import QThread, pyqtSignal # Import QThread and pyqtSignal -from huggingface_hub import HfApi, upload_folder -from config_manager import config, deobfuscate_token # Needed for Config -from config_dialog import ConfigDialog # Needed for config -from custom_exceptions import UploadError, APIKeyError - +from PyQt6.QtGui import QAction +from PyQt6.QtWidgets import ( + QApplication, + QCheckBox, + QComboBox, + QFileDialog, + QHBoxLayout, + QLabel, + QLineEdit, + QListWidget, + QProgressBar, + QPushButton, + QTextEdit, + QVBoxLayout, + QWidget, +) # Import QApplication logger = logging.getLogger(__name__) -def get_api_token(api_token_alias): - """Retrieves the API token from the configuration file.""" - try: - obfuscated_token = config['HuggingFace'][api_token_alias] - api_token = deobfuscate_token(obfuscated_token) - if not api_token: - raise APIKeyError("API token is empty after deobfuscation.") - return api_token - except KeyError: - raise APIKeyError(f"API token alias '{api_token_alias}' not found in configuration.") - except Exception as e: - raise APIKeyError(f"Error retrieving API token: {e}") from e def format_size(size): """Formats the file size into a human-readable string.""" - for unit in ['bytes', 'KB', 'MB', 'GB', 'TB']: + for unit in ["bytes", "KB", "MB", "GB", "TB"]: if size < 1024.0: break size /= 1024.0 @@ -42,15 +45,37 @@ def format_size(size): class HFUploaderThread(QThread): """Thread for uploading files to Hugging Face Hub.""" + signal_status = pyqtSignal(str) signal_progress = pyqtSignal(int) signal_output = pyqtSignal(str) signal_finished = pyqtSignal() - def __init__(self, api, repo_id, selected_files, repo_type, repo_folder, - current_directory, commit_msg, create_pr, rate_limit_delay): + def __init__( + self, + repo_id, + selected_files, + repo_type, + repo_folder, + current_directory, + commit_msg, + create_pr, + rate_limit_delay, + ): + """ + Initializes the HFUploaderThread. + + Args: + repo_id (str): The ID of the Hugging Face repository (e.g., "username/repo_name"). + selected_files (list): A list of file paths to upload. + repo_type (str): The type of the repository (e.g., "model", "dataset"). + repo_folder (str): The subfolder within the repository (optional). + current_directory (str): The current working directory. + commit_msg (str): The commit message for the upload. + create_pr (bool): Whether to create a pull request. + rate_limit_delay (float): The delay in seconds between uploads. + """ super().__init__() - self.api = api self.repo_id = repo_id self.selected_files = selected_files self.repo_type = repo_type @@ -61,52 +86,65 @@ def __init__(self, api, repo_id, selected_files, repo_type, repo_folder, self.rate_limit_delay = rate_limit_delay self.stop_flag = False self.is_stopped = False # Add this line + self.api = HfApi() # Initialize the API def stop(self): + """Sets the stop flag to request the thread to stop.""" self.stop_flag = True - self.is_stopped = True # Add this line + self.is_stopped = True # Add this line def run(self): + """ + Executes the upload process in a separate thread. + """ try: logger.info("HFUploaderThread: run() called") total_files = len(self.selected_files) self.signal_status.emit("Starting upload...") self.signal_progress.emit(0) - api_token_alias = config['HuggingFace']['api_token'] - logger.info("api_token_alias: " + api_token_alias) - try: # Wrap the get_api_token call - api_token = get_api_token(api_token_alias) + api_token = get_api_token() # Use the function from config_manager except APIKeyError as e: self.signal_output.emit(f"❌ API Key Error: {e}") self.signal_finished.emit() return - if not api_token: # This check is now redundant, but leave it for safety + if not api_token: self.signal_output.emit("❌ API token not found. Please configure it.") self.signal_finished.emit() return + # Initialize the API with the token + self.api = HfApi( + token=api_token + ) # Initialize here, after the token is retrieved + logger.info("Api Token Found") try: for idx, ckpt in enumerate(self.selected_files, 1): - if self.is_stopped: + if self.stop_flag: self.signal_status.emit("Upload cancelled.") - return + break # Exit the loop if the stop flag is set self.signal_status.emit(f"Uploading: {ckpt}") file_size = os.path.getsize(ckpt) - self.signal_output.emit(f"📦 File {idx}/{total_files}: {ckpt} ({format_size(file_size)})") + self.signal_output.emit( + f"📦 File {idx}/{total_files}: {ckpt} ({format_size(file_size)})" + ) start_time = time.time() path_in_repo = os.path.basename(ckpt) path_parts = Path(ckpt).parts if len(path_parts) > 1: - folder_path_parts = path_parts[len(Path(self.current_directory).parts):-1] + folder_path_parts = path_parts[ + len(Path(self.current_directory).parts) : -1 + ] if folder_path_parts: - path_in_repo = os.path.join(*folder_path_parts, os.path.basename(ckpt)) + path_in_repo = os.path.join( + *folder_path_parts, os.path.basename(ckpt) + ) if self.repo_folder: path_in_repo = os.path.join(self.repo_folder, path_in_repo) @@ -121,19 +159,25 @@ def run(self): path_in_repo=path_in_repo, repo_id=self.repo_id, repo_type=self.repo_type, - create_pr=self.create_pr, # Use the create_pr attribute + create_pr=self.create_pr, # Use the create_pr attribute commit_message=self.commit_msg, - #token=api_token, #Remove the token argument + # token=api_token, #Remove the token argument ) logger.info("FINISHED TO UPLOAD") duration = time.time() - start_time - self.signal_output.emit(f"✅ Upload completed in {duration:.1f} seconds") + self.signal_output.emit( + f"✅ Upload completed in {duration:.1f} seconds" + ) self.signal_output.emit(str(response)) except Exception as e: - logger.error(f"Upload error: {e}", exc_info=True) # Log the full exception - self.signal_output.emit(f"❌ Error uploading {ckpt}: {type(e).__name__} - {str(e)}") + logger.error( + f"Upload error: {e}", exc_info=True + ) # Log the full exception + self.signal_output.emit( + f"❌ Error uploading {ckpt}: {type(e).__name__} - {str(e)}" + ) self.signal_output.emit(traceback.format_exc()) # Re-raise the exception as an UploadError raise UploadError(f"Failed to upload {ckpt}: {e}") from e @@ -141,17 +185,21 @@ def run(self): # Rate limiting time.sleep(float(self.rate_limit_delay)) # Delay between API calls - except UploadError as e: #Catch the upload error + except UploadError as e: # Catch the upload error logger.exception("An unexpected error occurred during upload.") - self.signal_output.emit(f"❌ An unexpected error occurred: {type(e).__name__} - {str(e)}") + self.signal_output.emit( + f"❌ An unexpected error occurred: {type(e).__name__} - {str(e)}" + ) self.signal_output.emit(traceback.format_exc()) except Exception as e: logger.exception("An unexpected error occurred during upload.") - self.signal_output.emit(f"❌ An unexpected error occurred: {type(e).__name__} - {str(e)}") + self.signal_output.emit( + f"❌ An unexpected error occurred: {type(e).__name__} - {str(e)}" + ) self.signal_output.emit(traceback.format_exc()) finally: self.signal_progress.emit(100) self.signal_finished.emit() - logger.info("HFUploaderThread: finished") \ No newline at end of file + logger.info("HFUploaderThread: finished") diff --git a/keyring_manager.py b/keyring_manager.py index 6f84fa5..50228f6 100644 --- a/keyring_manager.py +++ b/keyring_manager.py @@ -5,6 +5,7 @@ KEYRING_SERVICE_NAME = "huggingface_backup" # Consistent service name + def get_api_token(alias): """Retrieves the API token from the system keyring.""" try: @@ -16,7 +17,9 @@ def get_api_token(alias): logger.warning(f"No API token found for alias: {alias}") return None except Exception as e: - logger.error(f"Error retrieving API token for alias {alias}: {e}", exc_info=True) + logger.error( + f"Error retrieving API token for alias {alias}: {e}", exc_info=True + ) return None @@ -35,4 +38,4 @@ def delete_api_token(alias): keyring.delete_password(KEYRING_SERVICE_NAME, alias) logger.info(f"Successfully deleted API token for alias: {alias}") except Exception as e: - logger.error(f"Error deleting API token for alias {alias}: {e}", exc_info=True) \ No newline at end of file + logger.error(f"Error deleting API token for alias {alias}: {e}", exc_info=True) diff --git a/main.py b/main.py index 5e02272..3daa1d3 100644 --- a/main.py +++ b/main.py @@ -16,7 +16,9 @@ # Set up basic logging -logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s') +logging.basicConfig( + level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s" +) logger = logging.getLogger(__name__) @@ -25,16 +27,16 @@ app = QApplication(sys.argv) logger.info("QApplication created.") - try: #Add try statement - #Check if qt_material is installed + try: # Add try statement + # Check if qt_material is installed if check_qt_material(): - apply_theme(app, theme_name='dark_teal.xml') # Apply the theme + apply_theme(app, theme_name="dark_teal.xml") # Apply the theme logger.info("Stylesheet applied.") else: QMessageBox.critical(None, "Error", "qt_material library is not installed.") sys.exit(1) - window = MainWindow(app) #Passed the app + window = MainWindow(app) # Passed the app logger.info("MainWindow created.") window.show() @@ -43,12 +45,12 @@ logger.info("Starting QApplication event loop...") sys.exit(app.exec()) - except ConfigError as e: #Catch config error + except ConfigError as e: # Catch config error logger.error(f"Configuration error: {e}", exc_info=True) QMessageBox.critical(None, "Error", f"A configuration error occurred: {e}") sys.exit(1) - except Exception as e: #Catch any other error + except Exception as e: # Catch any other error logger.error(f"An unhandled exception occurred: {e}", exc_info=True) QMessageBox.critical(None, "Error", f"An unexpected error occurred: {e}") - sys.exit(1) \ No newline at end of file + sys.exit(1) diff --git a/main_window.py b/main_window.py index ea4fc3d..5c65819 100644 --- a/main_window.py +++ b/main_window.py @@ -1,120 +1,158 @@ import logging -from PyQt6.QtWidgets import (QWidget, QVBoxLayout, QTabWidget, QPushButton, - QMessageBox, QSizePolicy, QMenuBar, QMenu, QApplication) -from PyQt6.QtGui import QAction -from zip_app import ZipApp -from hf_uploader import HuggingFaceUploader + +from config_dialog import ConfigDialog +from config_manager import config +from custom_exceptions import APIKeyError, ConfigError from download_app import DownloadApp +from hf_uploader import HuggingFaceUploader +from huggingface_hub import HfApi, upload_folder + +# from huggingface_hub.utils import get_hf_home_dir +from PyQt6.QtGui import QAction, QIcon +from PyQt6.QtWidgets import ( + QMenu, + QMenuBar, + QMessageBox, + QPushButton, + QSizePolicy, + QTabWidget, + QVBoxLayout, + QWidget, + QApplication, +) from theme_handler import apply_theme, get_available_themes -from custom_exceptions import ConfigError +from zip_app import ZipApp + logger = logging.getLogger(__name__) + class MainWindow(QWidget): """Main application window.""" - def __init__(self, app): # Accept the QApplication instance + def __init__(self, app: QApplication): # Accept the QApplication instance + """Initializes the main window.""" super().__init__() logger.info("MainWindow initializing...") self.setWindowTitle("Hugging Face Backup Tool") self.app = app # Store the QApplication instance + self.uploader_thread = None # Store the upload thread - # Create instances of your widgets - logger.info("Creating widget instances...") + # Create widget instances + logger.debug("Creating widget instances") self.zip_app = ZipApp() - logger.info("ZipApp created") self.hf_uploader = HuggingFaceUploader() - logger.info("HuggingFaceUploader created") self.download_app = DownloadApp() - logger.info("DownloadApp created") self.config_dialog = None - # Tab widget - logger.info("Creating tab widget...") + # Create Tab Widget and add tabs + logger.debug("Creating tab widget") self.tab_widget = QTabWidget() self.tab_widget.addTab(self.hf_uploader, "Hugging Face Uploader") - logger.info("HF Uploader tab added") - self.tab_widget.addTab(self.zip_app, "Zip Folder") - logger.info("ZipApp tab added") - self.tab_widget.addTab(self.download_app, "Download") - logger.info("DownloadApp Tab Added") - - # Set the initial tab - self.tab_widget.setCurrentIndex(0) - logger.info("Initial tab set") + # Uncomment if you add these tabs later + # self.tab_widget.addTab(self.zip_app, "Zip Folder") + # self.tab_widget.addTab(self.download_app, "Download") # Exit Button - logger.info("Creating exit button...") + logger.debug("Creating exit button") self.exit_button = QPushButton("Exit") self.exit_button.clicked.connect(self.close) - logger.info("Exit button created and connected") - # Create Menu Bar - logger.info("Creating menu bar...") + # Apply the layouts + main_layout = self.create_layout() + self.setLayout(main_layout) + + # Set initial size + self.resize(800, 600) + + # Apply default theme + try: + apply_theme(self.app, theme_name="dark_teal.xml") + except Exception as e: # Handle errors during theme application + logger.error(f"Error applying initial theme: {e}") + QMessageBox.critical(self, "Error", f"Failed to apply default theme: {e}") + + logger.info("MainWindow initialized") + + def create_layout(self): + """Creates and returns the main layout of the window.""" + # Create Menu Bar for theme selection + logger.debug("Creating menu bar and theme menu") self.menu_bar = QMenuBar(self) self.theme_menu = QMenu("Theme", self) self.menu_bar.addMenu(self.theme_menu) - logger.info("Menu bar and theme menu created") + + # Create File Menu + self.file_menu = QMenu("File", self) # Create the File menu + self.menu_bar.addMenu(self.file_menu) + + # Add Configure Action (Config Dialog) + self.config_action = QAction("Configure", self) + self.config_action.triggered.connect(self.show_config_dialog) + self.file_menu.addAction(self.config_action) # Add the action to the File menu # List available themes - logger.info("Listing available themes...") + logger.debug("Listing available themes") available_themes = get_available_themes() self.theme_actions = {} - - logger.info(f"Found themes: {available_themes}") - - logger.info("Creating theme actions...") for theme in available_themes: - logger.info(f"Processing theme: {theme}") + logger.debug(f"Creating action for theme: {theme}") action = QAction(theme, self) - action.triggered.connect(lambda checked=False, theme_name=theme: self.change_theme(theme_name)) + # Use lambda with default argument to capture current theme + action.triggered.connect( + lambda checked=False, theme_name=theme: self.change_theme(theme_name) + ) self.theme_menu.addAction(action) self.theme_actions[theme] = action - logger.info(f"Action created for theme: {theme}") - - logger.info("Theme actions created") - # Layout Setup - # Create a layout for the main window - logger.info("Creating main layout...") + # Create main layout + logger.debug("Creating main layout") main_layout = QVBoxLayout() - main_layout.setMenuBar(self.menu_bar) - main_layout.addWidget(self.tab_widget) - main_layout.addWidget(self.exit_button) - logger.info("Main layout created") + main_layout.setContentsMargins( + 15, 15, 15, 15 + ) # Margins around the whole layout + main_layout.setSpacing(12) # Space between widgets - # Make the window resizable - logger.info("Setting size policy...") - self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding) - logger.info("Size policy set") + # Add menu bar at the top + main_layout.setMenuBar(self.menu_bar) - # Set the layout for the main window - logger.info("Setting layout...") - self.setLayout(main_layout) - logger.info("Layout set") + # Add tab widget with stretch factor (more space) + main_layout.addWidget(self.tab_widget, stretch=8) - # Set the initial size of the window - logger.info("Setting initial size...") - self.resize(800, 600) - logger.info("Initial size set") + # Add Exit button with less stretch + main_layout.addWidget(self.exit_button, stretch=1) + return main_layout - logger.info("MainWindow initialized") - - # Load Theme - apply_theme(self.app, theme_name='dark_teal.xml') # Apply the theme + def show_config_dialog(self): + """Shows the configuration dialog.""" + if not self.config_dialog: + self.config_dialog = ConfigDialog() # Create the dialog + self.config_dialog.exec() # Show the dialog + # Optional: You could refresh some UI elements here if config changes affect them + # self.hf_uploader.refresh_ui() # example def change_theme(self, theme_name): - """Changes the application theme at runtime.""" - apply_theme(self.app, theme_name=theme_name) - logger.info(f"Theme changed to {theme_name}") + """Change the application's theme at runtime.""" + try: + apply_theme(self.app, theme_name) + logger.info(f"Theme changed to {theme_name}") + except Exception as e: + logger.error(f"Error changing theme: {e}") + QMessageBox.critical(self, "Error", f"Failed to apply theme: {e}") def closeEvent(self, event): - """Handles the window close event.""" - reply = QMessageBox.question(self, 'Exit', - "Are you sure you want to exit?", - QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, - QMessageBox.StandardButton.No) - + """Handle window close with confirmation.""" + reply = QMessageBox.question( + self, + "Exit", + "Are you sure you want to exit?", + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, + QMessageBox.StandardButton.No, + ) if reply == QMessageBox.StandardButton.Yes: + logger.debug("Close event accepted") event.accept() else: - event.ignore() \ No newline at end of file + event.ignore() + + def __del__(self): + logger.debug("MainWindow is being destroyed") diff --git a/setup.py b/setup.py index d07f4f9..54fb924 100644 --- a/setup.py +++ b/setup.py @@ -1,13 +1,13 @@ from setuptools import setup, find_packages setup( - name='hf_backup_tool', - version='0.1.0', + name="hf_backup_tool", + version="0.1.0", packages=find_packages(), install_requires=[ - 'PyQt6', - 'qt_material', - 'huggingface_hub', - 'requests', # Added requests + "PyQt6", + "qt_material", + "huggingface_hub", + "requests", # Added requests ], -) \ No newline at end of file +) diff --git a/theme_handler.py b/theme_handler.py index 62db5ef..82789b7 100644 --- a/theme_handler.py +++ b/theme_handler.py @@ -5,14 +5,17 @@ logger = logging.getLogger(__name__) -def apply_theme(widget, theme_name='dark_teal.xml'): + +def apply_theme(widget, theme_name="dark_teal.xml"): """Applies the specified theme to the given widget.""" try: apply_stylesheet(widget, theme=theme_name) logger.info(f"Successfully applied theme: {theme_name}") except Exception as e: logger.error(f"Error applying theme {theme_name}: {e}", exc_info=True) - QMessageBox.critical(widget, "Error", f"Failed to apply theme {theme_name}: {e}") + QMessageBox.critical( + widget, "Error", f"Failed to apply theme {theme_name}: {e}" + ) def get_available_themes(): @@ -25,17 +28,19 @@ def get_available_themes(): logger.error(f"Error listing themes: {e}", exc_info=True) return [] # Return an empty list in case of error + def check_qt_material(): """Checks if the qt_material library is installed.""" try: - importlib.import_module('qt_material') + importlib.import_module("qt_material") return True except ImportError: logger.error("qt_material library is not installed.") return False + # Example usage in MainWindow: # if check_qt_material(): # apply_theme(self.app, theme_name='dark_teal.xml') # else: -# QMessageBox.critical(self, "Error", "qt_material library is not installed.") \ No newline at end of file +# QMessageBox.critical(self, "Error", "qt_material library is not installed.") diff --git a/token_utils.py b/token_utils.py new file mode 100644 index 0000000..fa4dd90 --- /dev/null +++ b/token_utils.py @@ -0,0 +1,9 @@ +# token_utils.py +def deobfuscate_token(obfuscated_token): + """Deobfuscates the API token.""" + # Your deobfuscation logic here (example: simple XOR) + key = 123 # Replace with your actual key + try: + return "".join(chr(ord(c) ^ key) for c in obfuscated_token) + except TypeError: + return "" # Return empty on error diff --git a/upload_worker.py b/upload_worker.py new file mode 100644 index 0000000..8056567 --- /dev/null +++ b/upload_worker.py @@ -0,0 +1,133 @@ +from PyQt6.QtCore import QThread, pyqtSignal, Qt +from huggingface_hub import HfApi, create_repo, upload_file, upload_folder +import os +from custom_exceptions import UploadError, APIKeyError + + +class UploadWorker(QThread): # Separate class for background upload + progress_signal = pyqtSignal(int) + output_signal = pyqtSignal(str) + finished_signal = pyqtSignal(bool) # Added signal for finish status + + def __init__( + self, + api_token, + repo_owner, + repo_name, + file_path=None, + folder_path=None, + commit_message=None, + repo_type="model", + repo_folder=None, + upload_type="File", + create_repo=False, + repo_exists=False, + ): + super().__init__() + self.api_token = api_token + self.repo_owner = repo_owner + self.repo_name = repo_name + self.file_path = file_path + self.folder_path = folder_path + self.commit_message = commit_message + self.repo_type = repo_type + self.repo_folder = repo_folder + self.upload_type = upload_type + self.create_repo = create_repo + self.repo_exists = repo_exists + + def run(self): + try: + if not self.api_token: + raise APIKeyError("API token not found in configuration.") + + api = HfApi(token=self.api_token) + repo_id = f"{self.repo_owner}/{self.repo_name}" + + if self.repo_exists: + try: + api.repo_info(repo_id) + self.output_signal.emit(f"✅ Repository '{repo_id}' found.") + except Exception as e: + self.output_signal.emit( + f"❌ Repository '{repo_id}' not found. Error: {str(e)}" + ) + self.finished_signal.emit(False) # Signal upload failed + return + + if self.create_repo: + try: + create_repo( + repo_id, + repo_type=self.repo_type, + token=self.api_token, + private=False, + ) # or True if user selects private + self.output_signal.emit( + f"✅ Repository '{repo_id}' created successfully." + ) + except Exception as e: + self.output_signal.emit( + f"❌ Failed to create repository '{repo_id}'. Error: {str(e)}" + ) + self.finished_signal.emit(False) # Signal upload failed + return + + if self.upload_type == "File": + if not self.file_path: + raise UploadError("No file selected for upload.") + try: + filename = os.path.basename(self.file_path) + if self.repo_folder: + upload_path = os.path.join(self.repo_folder, filename) + else: + upload_path = filename + + upload_file( + path_or_fileobj=self.file_path, + path_in_repo=upload_path, + repo_id=repo_id, + repo_type=self.repo_type, + commit_message=self.commit_message, + token=self.api_token, + create_pr=False, + ) + self.output_signal.emit( + f"✅ File '{filename}' uploaded to '{repo_id}' successfully." + ) + except Exception as e: + self.output_signal.emit(f"❌ File upload failed. Error: {str(e)}") + self.finished_signal.emit(False) + return + + elif self.upload_type == "Folder": + if not self.folder_path: + raise UploadError("No folder selected for upload.") + + try: + upload_folder( + folder_path=self.folder_path, + repo_id=repo_id, + repo_type=self.repo_type, + commit_message=self.commit_message, + token=self.api_token, + ) + self.output_signal.emit( + f"✅ Folder '{self.folder_path}' uploaded to '{repo_id}' successfully." + ) + except Exception as e: + self.output_signal.emit(f"❌ Folder upload failed. Error: {str(e)}") + self.finished_signal.emit(False) + return + + self.finished_signal.emit(True) # Signal upload completed successfully + + except APIKeyError as e: + self.output_signal.emit(f"❌ API Key Error: {str(e)}") + self.finished_signal.emit(False) # Signal upload failed + except UploadError as e: + self.output_signal.emit(f"❌ Upload Error: {str(e)}") + self.finished_signal.emit(False) # Signal upload failed + except Exception as e: + self.output_signal.emit(f"❌ An unexpected error occurred: {str(e)}") + self.finished_signal.emit(False) # Signal upload failed diff --git a/zip_app.py b/zip_app.py index 6d22c0b..8323106 100644 --- a/zip_app.py +++ b/zip_app.py @@ -3,8 +3,16 @@ import shutil import zipfile -from PyQt6.QtWidgets import (QWidget, QLabel, QLineEdit, QPushButton, - QVBoxLayout, QHBoxLayout, QFileDialog, QTextEdit) +from PyQt6.QtWidgets import ( + QWidget, + QLabel, + QLineEdit, + QPushButton, + QVBoxLayout, + QHBoxLayout, + QFileDialog, + QTextEdit, +) from config_manager import config @@ -24,7 +32,9 @@ def __init__(self): self.folder_input = QLineEdit() self.folder_button = QPushButton("Select Folder") self.zip_name_label = QLabel("Zip Name:") - self.zip_name_input = QLineEdit(config['Zip']['default_zip_name']) # From config + self.zip_name_input = QLineEdit( + config["Zip"]["default_zip_name"] + ) # From config self.zip_button = QPushButton("Zip and Save") self.output_text = QTextEdit() self.output_text.setReadOnly(True) @@ -65,17 +75,19 @@ def zip_and_save(self): self.output_text.append("Please enter a zip file name.") return if not os.path.isdir(folder_path): - self.output_text.append("Invalid folder path. Please provide a valid directory") + self.output_text.append( + "Invalid folder path. Please provide a valid directory" + ) return - zip_file_path = zip_file_name + '.zip' + zip_file_path = zip_file_name + ".zip" try: temp_dir = "temp_zip_dir" os.makedirs(temp_dir, exist_ok=True) temp_zip_path = os.path.join(temp_dir, zip_file_path) - with zipfile.ZipFile(temp_zip_path, 'w', zipfile.ZIP_DEFLATED) as zipf: + with zipfile.ZipFile(temp_zip_path, "w", zipfile.ZIP_DEFLATED) as zipf: for root, _, files in os.walk(folder_path): for file in files: file_path = os.path.join(root, file) @@ -83,11 +95,15 @@ def zip_and_save(self): zipf.write(file_path, relative_path) # Get the directory to save the zip file to - save_path, _ = QFileDialog.getSaveFileName(self, "Save Zip File", zip_file_path, "Zip files (*.zip)") + save_path, _ = QFileDialog.getSaveFileName( + self, "Save Zip File", zip_file_path, "Zip files (*.zip)" + ) if save_path: shutil.copy2(temp_zip_path, save_path) - self.output_text.append(f"Successfully created and saved {zip_file_path} to {save_path}") + self.output_text.append( + f"Successfully created and saved {zip_file_path} to {save_path}" + ) else: self.output_text.append("Zip file creation cancelled.") @@ -96,4 +112,4 @@ def zip_and_save(self): self.output_text.append(f"Error creating zip file: {e}") finally: - shutil.rmtree(temp_dir, ignore_errors=True) \ No newline at end of file + shutil.rmtree(temp_dir, ignore_errors=True)