From 00696fc4498a7d1e2fc4ba175a94154a826e00e3 Mon Sep 17 00:00:00 2001 From: Fabrice Quenneville Date: Sat, 12 Jul 2025 13:58:34 -0400 Subject: [PATCH] feat(scripts): add reusable venv utilities and file case renaming script - Added `scripts/library/venv_utils.py`: - Utilities for creating, activating, and managing Python virtual environments. - Supports automatic requirement installation and optional --verbose mode. - Added `scripts/change_case.py`: - CLI script to recursively rename files and directories based on case transformation (lower, upper, capitalize). - Supports --recursive and --case flags, and integrates with `venv_utils.py`. - Updated README with usage instructions and documentation for new components. --- README.md | 3 + requirements.txt | 1 + scripts/change_case.py | 125 ++++++++++++++++++++++++++++++++ scripts/library/__init__.py | 5 ++ scripts/library/venv_utils.py | 130 ++++++++++++++++++++++++++++++++++ 5 files changed, 264 insertions(+) create mode 100644 requirements.txt create mode 100755 scripts/change_case.py create mode 100644 scripts/library/__init__.py create mode 100644 scripts/library/venv_utils.py diff --git a/README.md b/README.md index 1f78551..504d30a 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,9 @@ This repository is structured into several key directories: - **scripts/**: Contains individual scripts for various tasks. Currently, it includes: + - `scripts/library/`: Libraries used by Python scripts. + - `venv_utils.py`: Utility functions for creating, activating, and managing Python virtual environments. + - `change_case.py`: A script for renaming files and directories by changing their case. - `video_manage_audio.py`: A script for removing audio from video files. - `video_manage_subtitles.py`: A script for removing subtitles from video files. - `video_autoreduce.py`: A script for automatic resolution reduction of video files. diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..e60624f --- /dev/null +++ b/requirements.txt @@ -0,0 +1 @@ +argcomplete diff --git a/scripts/change_case.py b/scripts/change_case.py new file mode 100755 index 0000000..b7f8325 --- /dev/null +++ b/scripts/change_case.py @@ -0,0 +1,125 @@ +#!/usr/bin/env python3 + +# === Standard library === +import sys +import os +from pathlib import Path +from typing import Optional +from argparse import ArgumentParser, Namespace + +# Allow importing from scripts/library even when run directly +project_root = str(Path(__file__).resolve().parent.parent) +if project_root not in sys.path: + sys.path.append(project_root) + +# === Local import === +from scripts.library import parse_verbose, run_in_venv + + +def rename_by_case(root_dir: str, case: str, recursive: bool = False) -> None: + """ + Renames all files and directories in a given directory by changing their case. + + Args: + root_dir (str): Root path to start renaming. + case (str): One of "lower", "upper", "capitalize". + recursive (bool): Whether to apply renaming recursively. + + Returns: + None + """ + + def apply_case(name: str) -> str: + if case == "lower": + return name.lower() + elif case == "upper": + return name.upper() + elif case == "capitalize": + return name.capitalize() + else: + raise ValueError(f"Unsupported case transformation: {case}") + + for current_dir, dirs, files in os.walk(root_dir, topdown=False): + # Skip descending into subdirectories if not recursive + if not recursive and current_dir != root_dir: + continue + + # Rename files + for name in files: + old_path = os.path.join(current_dir, name) + new_name = apply_case(name) + new_path = os.path.join(current_dir, new_name) + if old_path != new_path: + if not os.path.exists(new_path): + os.rename(old_path, new_path) + else: + print( + f"Cannot rename {old_path} to {new_path}: target already exists." + ) + + # Rename directories + for name in dirs: + old_path = os.path.join(current_dir, name) + new_name = apply_case(name) + new_path = os.path.join(current_dir, new_name) + if old_path != new_path: + if not os.path.exists(new_path): + os.rename(old_path, new_path) + else: + print( + f"Cannot rename {old_path} to {new_path}: target already exists." + ) + + +def parse_args() -> Namespace: + """ + Parse and validate command-line arguments. + + Returns: + Namespace: Parsed arguments with 'case' and 'recursive' options. + """ + import argcomplete + + parser = ArgumentParser( + description="Rename files and directories by changing case (lower, upper, capitalize)." + ) + + parser.add_argument( + "--case", + "-c", + type=str, + choices=["lower", "upper", "capitalize"], + default="lower", + help="Case transformation to apply: 'lower', 'upper', or 'capitalize'. Default is 'lower'.", + ) + + parser.add_argument( + "--recursive", + "-r", + action="store_true", + help="Apply the transformation recursively in all subdirectories.", + ) + + parser.add_argument( + "-v", + "--verbose", + action="store_true", + help="Enable verbose output (debug information, warnings)", + ) + + argcomplete.autocomplete(parser) + return parser.parse_args() + + +def main() -> None: + """ + Main entry point. Parses arguments and runs case renaming in current working directory. + """ + args = parse_args() + cwd: str = os.getcwd() + rename_by_case(cwd, case=args.case, recursive=args.recursive) + + +if __name__ == "__main__": + args = parse_verbose() + run_in_venv(main, verbose=args.verbose) diff --git a/scripts/library/__init__.py b/scripts/library/__init__.py new file mode 100644 index 0000000..4cff4b1 --- /dev/null +++ b/scripts/library/__init__.py @@ -0,0 +1,5 @@ +# scripts/library/__init__.py + +from .venv_utils import parse_verbose, run_in_venv + +__all__ = ["parse_verbose", "run_in_venv"] diff --git a/scripts/library/venv_utils.py b/scripts/library/venv_utils.py new file mode 100644 index 0000000..511edc3 --- /dev/null +++ b/scripts/library/venv_utils.py @@ -0,0 +1,130 @@ +#!/usr/bin/env python3 + +# === Standard library === +import os +import sys +import subprocess +import venv +from pathlib import Path +from typing import Callable +from argparse import ArgumentParser, Namespace + +# === Configuration === +# Define the virtual environment directory in the user's home folder +VENV_DIR: Path = Path.home() / ".scripts_fabq_venv" + +# Define the path to the requirements.txt file in the project root +REQUIREMENTS_FILE: Path = Path(__file__).resolve().parents[2] / "requirements.txt" + + +def parse_verbose() -> Namespace: + """ + Parse the --verbose flag before virtual environment setup. + + This is useful when argument parsing requires packages that may + only be available after the virtual environment is initialized. + + Returns: + Namespace: Contains a single attribute 'verbose' (bool). + """ + parser = ArgumentParser(add_help=False) + parser.add_argument( + "--verbose", + "-v", + action="store_true", + help="Enable verbose output.", + ) + return parser.parse_known_args()[0] + + +def is_in_venv() -> bool: + """ + Determine whether the script is currently running inside a virtual environment. + + Returns: + bool: True if running inside a virtual environment, False otherwise. + """ + return hasattr(sys, "real_prefix") or sys.prefix != getattr( + sys, "base_prefix", sys.prefix + ) + + +def create_venv(verbose: bool = False) -> None: + """ + Create a virtual environment in the predefined VENV_DIR location. + + Args: + verbose (bool): If True, prints progress messages. + """ + if verbose: + print(f"Creating virtual environment in {VENV_DIR}...") + venv.create(VENV_DIR, with_pip=True) + + +def install_requirements(verbose: bool = False) -> None: + """ + Install Python dependencies from requirements.txt into the venv. + + Args: + verbose (bool): If True, prints progress and pip output. + """ + if not REQUIREMENTS_FILE.exists(): + print(f"Error: requirements.txt not found at {REQUIREMENTS_FILE}") + sys.exit(1) + + if verbose: + print(f"Installing/updating requirements from {REQUIREMENTS_FILE}...") + + pip_cmd = [str(VENV_DIR / "bin" / "pip")] + + try: + subprocess.check_call( + pip_cmd + ["install", "-r", str(REQUIREMENTS_FILE)], + stdout=None if verbose else subprocess.DEVNULL, + stderr=None if verbose else subprocess.DEVNULL, + ) + except subprocess.CalledProcessError as e: + print(f"Failed to install requirements: {e}") + sys.exit(1) + + try: + subprocess.check_call( + pip_cmd + ["check"], + stdout=None if verbose else subprocess.DEVNULL, + stderr=None if verbose else subprocess.DEVNULL, + ) + except subprocess.CalledProcessError: + print("Dependency conflict detected — your environment may be broken.") + raise + + +def run_in_venv(main_func: Callable[[], None], verbose: bool = False) -> None: + """ + Ensure the script is running inside the expected virtual environment. + + If not already in the venv: + - Create it if needed + - Install requirements + - Relaunch the script inside the venv + + If already in the venv: + - Ensure requirements are installed + - Execute the given main function + + Args: + main_func (Callable[[], None]): The function to execute after setup. + verbose (bool): If True, enables progress messages and pip output. + """ + if not is_in_venv(): + if not VENV_DIR.exists(): + create_venv(verbose) + install_requirements(verbose) + + # Relaunch the script inside the virtual environment + os.execv( + str(VENV_DIR / "bin" / "python"), + [str(VENV_DIR / "bin" / "python")] + sys.argv, + ) + else: + install_requirements(verbose) + main_func()