From 1a8cae62cab1084dbfa4cf001413941fd76a8e5d Mon Sep 17 00:00:00 2001 From: Steve White Date: Fri, 28 Mar 2025 17:51:26 -0500 Subject: [PATCH] Made it a pip project. --- .gitignore | 2 + LICENSE | 21 ++ MANIFEST.in | 3 + README.md | 123 ++++++++ config.yaml | 24 ++ pynamer.py | 274 ++++++++++++++++++ pyproject.toml | 11 + requirements.txt | 2 + setup.py | 50 ++++ src/pynamer.egg-info/PKG-INFO | 138 +++++++++ src/pynamer.egg-info/SOURCES.txt | 15 + src/pynamer.egg-info/dependency_links.txt | 1 + src/pynamer.egg-info/entry_points.txt | 2 + src/pynamer.egg-info/requires.txt | 2 + src/pynamer.egg-info/top_level.txt | 1 + src/pynamer/__init__.py | 3 + .../__pycache__/__init__.cpython-310.pyc | Bin 0 -> 244 bytes src/pynamer/__pycache__/cli.cpython-310.pyc | Bin 0 -> 2493 bytes src/pynamer/__pycache__/core.cpython-310.pyc | Bin 0 -> 5912 bytes .../__pycache__/setup_utils.cpython-310.pyc | Bin 0 -> 1789 bytes src/pynamer/cli.py | 103 +++++++ src/pynamer/config.yaml | 24 ++ src/pynamer/core.py | 207 +++++++++++++ src/pynamer/setup_utils.py | 59 ++++ 24 files changed, 1065 insertions(+) create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 MANIFEST.in create mode 100644 README.md create mode 100644 config.yaml create mode 100755 pynamer.py create mode 100644 pyproject.toml create mode 100644 requirements.txt create mode 100644 setup.py create mode 100644 src/pynamer.egg-info/PKG-INFO create mode 100644 src/pynamer.egg-info/SOURCES.txt create mode 100644 src/pynamer.egg-info/dependency_links.txt create mode 100644 src/pynamer.egg-info/entry_points.txt create mode 100644 src/pynamer.egg-info/requires.txt create mode 100644 src/pynamer.egg-info/top_level.txt create mode 100644 src/pynamer/__init__.py create mode 100644 src/pynamer/__pycache__/__init__.cpython-310.pyc create mode 100644 src/pynamer/__pycache__/cli.cpython-310.pyc create mode 100644 src/pynamer/__pycache__/core.cpython-310.pyc create mode 100644 src/pynamer/__pycache__/setup_utils.cpython-310.pyc create mode 100644 src/pynamer/cli.py create mode 100644 src/pynamer/config.yaml create mode 100644 src/pynamer/core.py create mode 100644 src/pynamer/setup_utils.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a9c348f --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +.DS_Store +.venv/ diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..2be0553 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 0000000..278d0c8 --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1,3 @@ +include README.md +include LICENSE +include src/pynamer/config.yaml diff --git a/README.md b/README.md new file mode 100644 index 0000000..a9cad05 --- /dev/null +++ b/README.md @@ -0,0 +1,123 @@ +# PyNamer + +PyNamer is a command-line tool that uses AI vision models to generate descriptive filenames for images. It analyzes the content of images and renames them with meaningful, descriptive filenames in snake_case format. + +## Features + +- Uses LiteLLM to integrate with various vision-capable LLMs (default: GPT-4 Vision) +- Configurable via YAML config file +- Supports multiple image formats (jpg, jpeg, png, gif, webp) +- Dry-run mode to preview changes without renaming files +- Handles filename collisions automatically + +## Installation + +### Option 1: Install from PyPI (recommended) + +```bash +pip install pynamer +``` + +### Option 2: Install from source + +1. Clone this repository +2. Install the package in development mode: + +```bash +pip install -e . +``` + +### Set up your API key + +You need to set up your API key for the vision model: + +- Set the appropriate environment variable (e.g., `OPENAI_API_KEY`), or +- Create a custom config file with your API key + +## Configuration + +PyNamer uses the following configuration file locations (in order of precedence): + +1. Custom config file specified with `-c` or `--config` option +2. User config file at `~/.config/pynamer.yaml` (created automatically on first run) +3. Default config file included with the package + +You can customize the following settings: + +- LLM provider and model +- API key and endpoint +- Supported image formats +- Prompt templates for filename generation + +Example configuration file: + +```yaml +llm: + provider: "openai" + model: "gpt-4-vision-preview" + api_key: "your-api-key-here" + max_tokens: 100 + temperature: 0.7 +``` + +## Usage + +After installation, you can use PyNamer directly from the command line: + +Basic usage: + +```bash +pynamer path/to/image.jpg +``` + +Process multiple images: + +```bash +pynamer image1.jpg image2.png image3.jpg +``` + +Use a different config file: + +```bash +pynamer -c custom_config.yaml image.jpg +``` + +Preview changes without renaming (dry run): + +```bash +pynamer -d image.jpg +``` + +Enable verbose logging: + +```bash +pynamer -v image.jpg +``` + +## Example + +Input: `IMG_20230615_123456.jpg` (a photo of a cat sleeping on a window sill) + +Output: `orange_cat_sleeping_on_sunny_windowsill.jpg` + +## Development + +### Building the package + +```bash +pip install build +python -m build +``` + +### Installing in development mode + +```bash +pip install -e . +``` + +## Requirements + +- Python 3.7+ +- LiteLLM +- PyYAML +- Access to a vision-capable LLM API (OpenAI, Anthropic, etc.) diff --git a/config.yaml b/config.yaml new file mode 100644 index 0000000..2afe334 --- /dev/null +++ b/config.yaml @@ -0,0 +1,24 @@ +# PyNamer Configuration + +# LLM API Configuration +llm: + provider: "openai" # Provider name (openai, anthropic, etc.) + model: "gpt-4o-mini" # Model name + api_key: "" # Your API key (leave empty to use environment variable) + endpoint: "" # Custom endpoint URL (if using a proxy or alternative service) + max_tokens: 100 # Maximum tokens for response + temperature: 0.7 # Temperature for generation + +# Image Processing +image: + supported_formats: + - ".jpg" + - ".jpeg" + - ".png" + - ".gif" + - ".webp" + +# Prompt Configuration +prompt: + system_message: "You are a helpful assistant that generates concise, descriptive filenames for images. Focus on the main subject, key attributes, and context. Use snake_case format without special characters." + user_message: "Generate a descriptive filename for this image. The filename should be concise, specific, and use snake_case format. Do not include a file extension." diff --git a/pynamer.py b/pynamer.py new file mode 100755 index 0000000..a718831 --- /dev/null +++ b/pynamer.py @@ -0,0 +1,274 @@ +#!/Volumes/SAM2/CODE/pynamer/.venv/bin/python + +import argparse +import base64 +import os +import sys +from pathlib import Path +import yaml +from typing import Dict, List, Optional, Union +import litellm +from litellm import completion +import logging + +# Configure logging +logging.basicConfig( + level=logging.INFO, + format='%(asctime)s - %(name)s - %(levelname)s - %(message)s' +) +logger = logging.getLogger('pynamer') + +class PyNamer: + """A tool to generate descriptive filenames for images using LLMs.""" + + def __init__(self, config_path: str = 'config.yaml'): + """Initialize the PyNamer with configuration. + + Args: + config_path: Path to the YAML configuration file + """ + self.config = self._load_config(config_path) + self._setup_llm() + + def _load_config(self, config_path: str) -> Dict: + """Load configuration from YAML file. + + Args: + config_path: Path to the configuration file + + Returns: + Dict containing configuration + """ + try: + with open(config_path, 'r') as f: + config = yaml.safe_load(f) + logger.info(f"Loaded configuration from {config_path}") + return config + except Exception as e: + logger.error(f"Failed to load configuration: {e}") + sys.exit(1) + + def _setup_llm(self) -> None: + """Set up the LLM client based on configuration.""" + llm_config = self.config.get('llm', {}) + + # Set API key if provided in config + api_key = llm_config.get('api_key') + if api_key: + os.environ["OPENAI_API_KEY"] = api_key + + # Set custom endpoint if provided + endpoint = llm_config.get('endpoint') + if endpoint: + os.environ["OPENAI_API_BASE"] = endpoint + + self.model = llm_config.get('model', 'gpt-4-vision-preview') + self.max_tokens = llm_config.get('max_tokens', 100) + self.temperature = llm_config.get('temperature', 0.7) + + logger.info(f"LLM setup complete. Using model: {self.model}") + + def _encode_image(self, image_path: str) -> str: + """Encode image to base64 for API submission. + + Args: + image_path: Path to the image file + + Returns: + Base64 encoded image string + """ + with open(image_path, "rb") as image_file: + return base64.b64encode(image_file.read()).decode('utf-8') + + def _is_supported_format(self, file_path: str) -> bool: + """Check if the file format is supported. + + Args: + file_path: Path to the file + + Returns: + True if supported, False otherwise + """ + supported_formats = self.config.get('image', {}).get('supported_formats', []) + file_ext = os.path.splitext(file_path)[1].lower() + return file_ext in supported_formats + + def generate_filename(self, image_path: str) -> str: + """Generate a descriptive filename for the image using LLM. + + Args: + image_path: Path to the image file + + Returns: + Generated filename (without extension) + """ + if not os.path.exists(image_path): + logger.error(f"Image not found: {image_path}") + return None + + if not self._is_supported_format(image_path): + logger.error(f"Unsupported file format: {image_path}") + return None + + try: + # Encode image + base64_image = self._encode_image(image_path) + + # Prepare messages for LLM + system_message = self.config.get('prompt', {}).get('system_message', '') + user_message = self.config.get('prompt', {}).get('user_message', '') + + messages = [ + {"role": "system", "content": system_message}, + { + "role": "user", + "content": [ + {"type": "text", "text": user_message}, + { + "type": "image_url", + "image_url": {"url": f"data:image/jpeg;base64,{base64_image}"} + } + ] + } + ] + + # Call LLM + response = completion( + model=self.model, + messages=messages, + max_tokens=self.max_tokens, + temperature=self.temperature + ) + + # Extract filename from response + filename = response.choices[0].message.content.strip() + logger.info(f"Generated filename: {filename}") + return filename + + except Exception as e: + logger.error(f"Error generating filename: {e}") + return None + + def rename_image(self, image_path: str, dry_run: bool = False) -> Optional[str]: + """Rename the image with a generated descriptive filename. + + Args: + image_path: Path to the image file + dry_run: If True, don't actually rename the file + + Returns: + New path if successful, None otherwise + """ + # Generate filename + new_filename = self.generate_filename(image_path) + if not new_filename: + return None + + # Clean up the filename (ensure snake_case, no special chars) + new_filename = new_filename.lower() + new_filename = ''.join(c if c.isalnum() else '_' for c in new_filename) + new_filename = new_filename.replace('__', '_').strip('_') + + # Get original path components + path = Path(image_path) + directory = path.parent + extension = path.suffix + + # Create new path + new_path = directory / f"{new_filename}{extension}" + + # Rename file + if not dry_run: + try: + # Handle case where the new filename already exists + counter = 1 + while new_path.exists(): + new_path = directory / f"{new_filename}_{counter}{extension}" + counter += 1 + + path.rename(new_path) + logger.info(f"Renamed: {image_path} -> {new_path}") + except Exception as e: + logger.error(f"Error renaming file: {e}") + return None + else: + logger.info(f"[DRY RUN] Would rename: {image_path} -> {new_path}") + + return str(new_path) + +def main(): + """Main entry point for the script.""" + parser = argparse.ArgumentParser(description='Generate descriptive filenames for images using LLMs') + parser.add_argument('images', nargs='+', help='Paths to image files') + parser.add_argument('-c', '--config', default='config.yaml', help='Path to configuration file') + parser.add_argument('-d', '--dry-run', action='store_true', help='Preview changes without renaming files') + parser.add_argument('-v', '--verbose', action='store_true', help='Enable verbose output') + + args = parser.parse_args() + + # Set logging level + if args.verbose: + logger.setLevel(logging.DEBUG) + + # Initialize PyNamer + namer = PyNamer(config_path=args.config) + + # Process each image - handle image paths that might contain spaces + image_paths = [] + + # First, try to handle the case where the entire argument list is a single path with spaces + if len(args.images) > 1: + combined_path = ' '.join(args.images) + if os.path.exists(combined_path): + image_paths = [combined_path] + logger.info(f"Found image by combining all arguments: {combined_path}") + + # If that didn't work, try to handle each argument individually + if not image_paths: + # Use a set to avoid duplicate processing + processed_paths = set() + + for path in args.images: + # Check if the path exists as is + if os.path.exists(path) and path not in processed_paths: + image_paths.append(path) + processed_paths.add(path) + else: + # Try to find files that match the pattern + import glob + matching_files = glob.glob(f"*{path}*") + for file in matching_files: + if file not in processed_paths: + image_paths.append(file) + processed_paths.add(file) + + if not matching_files: + logger.debug(f"Could not find any file matching '{path}'") + + # Process each valid image path + if not image_paths: + print("Error: No valid image files found to process.") + return + + # Remove duplicates while preserving order + unique_image_paths = [] + seen = set() + for path in image_paths: + if path not in seen: + unique_image_paths.append(path) + seen.add(path) + + # Process each image + for image_path in unique_image_paths: + if not os.path.exists(image_path): + logger.warning(f"Skipping non-existent file: {image_path}") + continue + + new_path = namer.rename_image(image_path, dry_run=args.dry_run) + if new_path: + print(f"{'[DRY RUN] ' if args.dry_run else ''}Renamed: {image_path} -> {new_path}") + else: + print(f"Failed to process: {image_path}") + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..d58752d --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,11 @@ +[build-system] +requires = ["setuptools>=42", "wheel"] +build-backend = "setuptools.build_meta" + +[tool.black] +line-length = 88 +target-version = ['py37', 'py38', 'py39', 'py310'] + +[tool.isort] +profile = "black" +line_length = 88 diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..82f3050 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,2 @@ +litellm>=1.10.0 +pyyaml>=6.0 diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..e0adb3e --- /dev/null +++ b/setup.py @@ -0,0 +1,50 @@ +from setuptools import setup, find_packages +import os + +# Read the contents of README.md +with open("README.md", encoding="utf-8") as f: + long_description = f.read() + +# Read version from __init__.py +with open(os.path.join("src", "pynamer", "__init__.py"), encoding="utf-8") as f: + for line in f: + if line.startswith("__version__"): + version = line.split("=")[1].strip().strip('"').strip("'") + break + +setup( + name="pynamer", + version=version, + description="Generate descriptive filenames for images using LLMs", + long_description=long_description, + long_description_content_type="text/markdown", + author="Your Name", + author_email="your.email@example.com", + url="https://github.com/yourusername/pynamer", + package_dir={"": "src"}, + packages=find_packages(where="src"), + include_package_data=True, + package_data={ + "pynamer": ["config.yaml"], + }, + install_requires=[ + "litellm>=1.10.0", + "pyyaml>=6.0", + ], + python_requires=">=3.7", + entry_points={ + "console_scripts": [ + "pynamer=pynamer.cli:main", + ], + }, + classifiers=[ + "Development Status :: 3 - Alpha", + "Intended Audience :: Developers", + "License :: OSI Approved :: MIT License", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.7", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + ], +) diff --git a/src/pynamer.egg-info/PKG-INFO b/src/pynamer.egg-info/PKG-INFO new file mode 100644 index 0000000..42f9ca9 --- /dev/null +++ b/src/pynamer.egg-info/PKG-INFO @@ -0,0 +1,138 @@ +Metadata-Version: 2.1 +Name: pynamer +Version: 0.1.0 +Summary: Generate descriptive filenames for images using LLMs +Home-page: https://github.com/yourusername/pynamer +Author: Your Name +Author-email: your.email@example.com +Classifier: Development Status :: 3 - Alpha +Classifier: Intended Audience :: Developers +Classifier: License :: OSI Approved :: MIT License +Classifier: Programming Language :: Python :: 3 +Classifier: Programming Language :: Python :: 3.7 +Classifier: Programming Language :: Python :: 3.8 +Classifier: Programming Language :: Python :: 3.9 +Classifier: Programming Language :: Python :: 3.10 +Requires-Python: >=3.7 +Description-Content-Type: text/markdown +License-File: LICENSE +Requires-Dist: litellm>=1.10.0 +Requires-Dist: pyyaml>=6.0 + +# PyNamer + +PyNamer is a command-line tool that uses AI vision models to generate descriptive filenames for images. It analyzes the content of images and renames them with meaningful, descriptive filenames in snake_case format. + +## Features + +- Uses LiteLLM to integrate with various vision-capable LLMs (default: GPT-4 Vision) +- Configurable via YAML config file +- Supports multiple image formats (jpg, jpeg, png, gif, webp) +- Dry-run mode to preview changes without renaming files +- Handles filename collisions automatically + +## Installation + +### Option 1: Install from PyPI (recommended) + +```bash +pip install pynamer +``` + +### Option 2: Install from source + +1. Clone this repository +2. Install the package in development mode: + +```bash +pip install -e . +``` + +### Set up your API key + +You need to set up your API key for the vision model: + +- Set the appropriate environment variable (e.g., `OPENAI_API_KEY`), or +- Create a custom config file with your API key + +## Configuration + +PyNamer comes with a default configuration, but you can create a custom config file to customize: + +- LLM provider and model +- API key and endpoint +- Supported image formats +- Prompt templates for filename generation + +Example custom config file (config.yaml): + +```yaml +llm: + provider: "openai" + model: "gpt-4-vision-preview" + api_key: "your-api-key-here" + max_tokens: 100 + temperature: 0.7 +``` + +## Usage + +After installation, you can use PyNamer directly from the command line: + +Basic usage: + +```bash +pynamer path/to/image.jpg +``` + +Process multiple images: + +```bash +pynamer image1.jpg image2.png image3.jpg +``` + +Use a different config file: + +```bash +pynamer -c custom_config.yaml image.jpg +``` + +Preview changes without renaming (dry run): + +```bash +pynamer -d image.jpg +``` + +Enable verbose logging: + +```bash +pynamer -v image.jpg +``` + +## Example + +Input: `IMG_20230615_123456.jpg` (a photo of a cat sleeping on a window sill) + +Output: `orange_cat_sleeping_on_sunny_windowsill.jpg` + +## Development + +### Building the package + +```bash +pip install build +python -m build +``` + +### Installing in development mode + +```bash +pip install -e . +``` + +## Requirements + +- Python 3.7+ +- LiteLLM +- PyYAML +- Access to a vision-capable LLM API (OpenAI, Anthropic, etc.) diff --git a/src/pynamer.egg-info/SOURCES.txt b/src/pynamer.egg-info/SOURCES.txt new file mode 100644 index 0000000..0400e07 --- /dev/null +++ b/src/pynamer.egg-info/SOURCES.txt @@ -0,0 +1,15 @@ +LICENSE +MANIFEST.in +README.md +pyproject.toml +setup.py +src/pynamer/__init__.py +src/pynamer/cli.py +src/pynamer/config.yaml +src/pynamer/core.py +src/pynamer.egg-info/PKG-INFO +src/pynamer.egg-info/SOURCES.txt +src/pynamer.egg-info/dependency_links.txt +src/pynamer.egg-info/entry_points.txt +src/pynamer.egg-info/requires.txt +src/pynamer.egg-info/top_level.txt \ No newline at end of file diff --git a/src/pynamer.egg-info/dependency_links.txt b/src/pynamer.egg-info/dependency_links.txt new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/src/pynamer.egg-info/dependency_links.txt @@ -0,0 +1 @@ + diff --git a/src/pynamer.egg-info/entry_points.txt b/src/pynamer.egg-info/entry_points.txt new file mode 100644 index 0000000..75bdd5d --- /dev/null +++ b/src/pynamer.egg-info/entry_points.txt @@ -0,0 +1,2 @@ +[console_scripts] +pynamer = pynamer.cli:main diff --git a/src/pynamer.egg-info/requires.txt b/src/pynamer.egg-info/requires.txt new file mode 100644 index 0000000..82f3050 --- /dev/null +++ b/src/pynamer.egg-info/requires.txt @@ -0,0 +1,2 @@ +litellm>=1.10.0 +pyyaml>=6.0 diff --git a/src/pynamer.egg-info/top_level.txt b/src/pynamer.egg-info/top_level.txt new file mode 100644 index 0000000..9089872 --- /dev/null +++ b/src/pynamer.egg-info/top_level.txt @@ -0,0 +1 @@ +pynamer diff --git a/src/pynamer/__init__.py b/src/pynamer/__init__.py new file mode 100644 index 0000000..8e855ff --- /dev/null +++ b/src/pynamer/__init__.py @@ -0,0 +1,3 @@ +"""PyNamer - Generate descriptive filenames for images using LLMs.""" + +__version__ = "0.1.0" diff --git a/src/pynamer/__pycache__/__init__.cpython-310.pyc b/src/pynamer/__pycache__/__init__.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..abad37e5ab4891f767d96f8ced771850b8ef0f65 GIT binary patch literal 244 zcmd1j<>g`k0%4

9IiiF^Gc<7=auIATH(s5-AK(3@MCJj44dP44TYU_5qcCiMgpo z3c3pJsd=eIi6yBDDXGQDMVSR9nPsU8X_+~xc|Zll3TgR83Yocy=|E;_ab{k+f{%}H zv0fFcfu5nBfuAPRE%x~Ml>FrQ_*>lZ@nxw+#hLke@$oAeikN}6f{9;7`eFGwr9i{< zgB^X1^qu`(T=feoLB<#97Z)YN+41q2d6^~g@p=W7w>WHa^HWN5Qtd##C}sf?EDRh> E08C0k1ONa4 literal 0 HcmV?d00001 diff --git a/src/pynamer/__pycache__/cli.cpython-310.pyc b/src/pynamer/__pycache__/cli.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..0cac9de0a52e66050a2c279e64b4d8470ffeb792 GIT binary patch literal 2493 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 literal 0 HcmV?d00001 diff --git a/src/pynamer/cli.py b/src/pynamer/cli.py new file mode 100644 index 0000000..9a92020 --- /dev/null +++ b/src/pynamer/cli.py @@ -0,0 +1,103 @@ +"""Command-line interface for PyNamer.""" + +import argparse +import os +import glob +import logging +from pathlib import Path +from typing import List, Optional + +from .core import PyNamer, logger +from .setup_utils import ensure_user_config_exists + +def find_image_files(args_images: List[str]) -> List[str]: + """Find image files from command line arguments. + + Args: + args_images: List of image paths from command line + + Returns: + List of valid image paths + """ + image_paths = [] + + # First, try to handle the case where the entire argument list is a single path with spaces + if len(args_images) > 1: + combined_path = ' '.join(args_images) + if os.path.exists(combined_path): + image_paths = [combined_path] + logger.info(f"Found image by combining all arguments: {combined_path}") + + # If that didn't work, try to handle each argument individually + if not image_paths: + # Use a set to avoid duplicate processing + processed_paths = set() + + for path in args_images: + # Check if the path exists as is + if os.path.exists(path) and path not in processed_paths: + image_paths.append(path) + processed_paths.add(path) + else: + # Try to find files that match the pattern + matching_files = glob.glob(f"*{path}*") + for file in matching_files: + if file not in processed_paths: + image_paths.append(file) + processed_paths.add(file) + + if not matching_files: + logger.debug(f"Could not find any file matching '{path}'") + + # Remove duplicates while preserving order + unique_image_paths = [] + seen = set() + for path in image_paths: + if path not in seen: + unique_image_paths.append(path) + seen.add(path) + + return unique_image_paths + +def main(): + """Main entry point for the script.""" + # Ensure the user config exists + ensure_user_config_exists() + + parser = argparse.ArgumentParser(description='Generate descriptive filenames for images using LLMs') + parser.add_argument('images', nargs='+', help='Paths to image files') + parser.add_argument('-c', '--config', help='Path to configuration file') + parser.add_argument('-d', '--dry-run', action='store_true', help='Preview changes without renaming files') + parser.add_argument('-v', '--verbose', action='store_true', help='Enable verbose output') + + args = parser.parse_args() + + # Set logging level + if args.verbose: + logger.setLevel(logging.DEBUG) + + # Initialize PyNamer + namer = PyNamer(config_path=args.config) + + # Find image files + image_paths = find_image_files(args.images) + + # Process each valid image path + if not image_paths: + print("Error: No valid image files found to process.") + return + + # Process each image + for image_path in image_paths: + if not os.path.exists(image_path): + logger.warning(f"Skipping non-existent file: {image_path}") + continue + + new_path = namer.rename_image(image_path, dry_run=args.dry_run) + if new_path: + print(f"{'[DRY RUN] ' if args.dry_run else ''}Renamed: {image_path} -> {new_path}") + else: + print(f"Failed to process: {image_path}") + +if __name__ == "__main__": + main() diff --git a/src/pynamer/config.yaml b/src/pynamer/config.yaml new file mode 100644 index 0000000..2afe334 --- /dev/null +++ b/src/pynamer/config.yaml @@ -0,0 +1,24 @@ +# PyNamer Configuration + +# LLM API Configuration +llm: + provider: "openai" # Provider name (openai, anthropic, etc.) + model: "gpt-4o-mini" # Model name + api_key: "" # Your API key (leave empty to use environment variable) + endpoint: "" # Custom endpoint URL (if using a proxy or alternative service) + max_tokens: 100 # Maximum tokens for response + temperature: 0.7 # Temperature for generation + +# Image Processing +image: + supported_formats: + - ".jpg" + - ".jpeg" + - ".png" + - ".gif" + - ".webp" + +# Prompt Configuration +prompt: + system_message: "You are a helpful assistant that generates concise, descriptive filenames for images. Focus on the main subject, key attributes, and context. Use snake_case format without special characters." + user_message: "Generate a descriptive filename for this image. The filename should be concise, specific, and use snake_case format. Do not include a file extension." diff --git a/src/pynamer/core.py b/src/pynamer/core.py new file mode 100644 index 0000000..d3a5962 --- /dev/null +++ b/src/pynamer/core.py @@ -0,0 +1,207 @@ +"""Core functionality for PyNamer.""" + +import base64 +import os +import sys +from pathlib import Path +import yaml +from typing import Dict, List, Optional, Union +import litellm +from litellm import completion +import logging + +# Configure logging +logging.basicConfig( + level=logging.INFO, + format='%(asctime)s - %(name)s - %(levelname)s - %(message)s' +) +logger = logging.getLogger('pynamer') + +class PyNamer: + """A tool to generate descriptive filenames for images using LLMs.""" + + def __init__(self, config_path: str = None): + """Initialize the PyNamer with configuration. + + Args: + config_path: Path to the YAML configuration file + """ + if config_path is None: + # Look for config in user's home directory first + user_config_path = os.path.expanduser("~/.config/pynamer.yaml") + if os.path.exists(user_config_path): + config_path = user_config_path + logger.info(f"Using user config from {user_config_path}") + else: + # Fall back to default config in package + config_path = os.path.join(os.path.dirname(__file__), 'config.yaml') + logger.info(f"Using default config from {config_path}") + + self.config = self._load_config(config_path) + self._setup_llm() + + def _load_config(self, config_path: str) -> Dict: + """Load configuration from YAML file. + + Args: + config_path: Path to the configuration file + + Returns: + Dict containing configuration + """ + try: + with open(config_path, 'r') as f: + config = yaml.safe_load(f) + logger.info(f"Loaded configuration from {config_path}") + return config + except Exception as e: + logger.error(f"Failed to load configuration: {e}") + sys.exit(1) + + def _setup_llm(self) -> None: + """Set up the LLM client based on configuration.""" + llm_config = self.config.get('llm', {}) + + # Set API key if provided in config + api_key = llm_config.get('api_key') + if api_key: + os.environ["OPENAI_API_KEY"] = api_key + + # Set custom endpoint if provided + endpoint = llm_config.get('endpoint') + if endpoint: + os.environ["OPENAI_API_BASE"] = endpoint + + self.model = llm_config.get('model', 'gpt-4-vision-preview') + self.max_tokens = llm_config.get('max_tokens', 100) + self.temperature = llm_config.get('temperature', 0.7) + + logger.info(f"LLM setup complete. Using model: {self.model}") + + def _encode_image(self, image_path: str) -> str: + """Encode image to base64 for API submission. + + Args: + image_path: Path to the image file + + Returns: + Base64 encoded image string + """ + with open(image_path, "rb") as image_file: + return base64.b64encode(image_file.read()).decode('utf-8') + + def _is_supported_format(self, file_path: str) -> bool: + """Check if the file format is supported. + + Args: + file_path: Path to the file + + Returns: + True if supported, False otherwise + """ + supported_formats = self.config.get('image', {}).get('supported_formats', []) + file_ext = os.path.splitext(file_path)[1].lower() + return file_ext in supported_formats + + def generate_filename(self, image_path: str) -> str: + """Generate a descriptive filename for the image using LLM. + + Args: + image_path: Path to the image file + + Returns: + Generated filename (without extension) + """ + if not os.path.exists(image_path): + logger.error(f"Image not found: {image_path}") + return None + + if not self._is_supported_format(image_path): + logger.error(f"Unsupported file format: {image_path}") + return None + + try: + # Encode image + base64_image = self._encode_image(image_path) + + # Prepare messages for LLM + system_message = self.config.get('prompt', {}).get('system_message', '') + user_message = self.config.get('prompt', {}).get('user_message', '') + + messages = [ + {"role": "system", "content": system_message}, + { + "role": "user", + "content": [ + {"type": "text", "text": user_message}, + { + "type": "image_url", + "image_url": {"url": f"data:image/jpeg;base64,{base64_image}"} + } + ] + } + ] + + # Call LLM + response = completion( + model=self.model, + messages=messages, + max_tokens=self.max_tokens, + temperature=self.temperature + ) + + # Extract filename from response + filename = response.choices[0].message.content.strip() + logger.info(f"Generated filename: {filename}") + return filename + + except Exception as e: + logger.error(f"Error generating filename: {e}") + return None + + def rename_image(self, image_path: str, dry_run: bool = False) -> Optional[str]: + """Rename the image with a generated descriptive filename. + + Args: + image_path: Path to the image file + dry_run: If True, don't actually rename the file + + Returns: + New path if successful, None otherwise + """ + # Generate filename + new_filename = self.generate_filename(image_path) + if not new_filename: + return None + + # Clean up the filename (ensure snake_case, no special chars) + new_filename = new_filename.lower() + new_filename = ''.join(c if c.isalnum() else '_' for c in new_filename) + new_filename = new_filename.replace('__', '_').strip('_') + + # Get original path components + path = Path(image_path) + directory = path.parent + extension = path.suffix + + # Create new path + new_path = directory / f"{new_filename}{extension}" + + # Rename file + if not dry_run: + try: + # Handle case where the new filename already exists + counter = 1 + while new_path.exists(): + new_path = directory / f"{new_filename}_{counter}{extension}" + counter += 1 + + path.rename(new_path) + logger.info(f"Renamed: {image_path} -> {new_path}") + except Exception as e: + logger.error(f"Error renaming file: {e}") + return None + else: + logger.info(f"[DRY RUN] Would rename: {image_path} -> {new_path}") + + return str(new_path) diff --git a/src/pynamer/setup_utils.py b/src/pynamer/setup_utils.py new file mode 100644 index 0000000..1b85d05 --- /dev/null +++ b/src/pynamer/setup_utils.py @@ -0,0 +1,59 @@ +"""Utilities for setting up PyNamer.""" + +import os +import shutil +import logging +import yaml + +logger = logging.getLogger('pynamer') + +def ensure_user_config_exists(): + """ + Ensure that the user config file exists in ~/.config/pynamer.yaml. + If it doesn't exist, copy the default config there. + """ + # Get the default config path from the package + default_config_path = os.path.join(os.path.dirname(__file__), 'config.yaml') + + # Define the user config path + user_config_dir = os.path.expanduser("~/.config") + user_config_path = os.path.join(user_config_dir, "pynamer.yaml") + + # Check if the user config already exists + if os.path.exists(user_config_path): + logger.info(f"User config already exists at {user_config_path}") + return + + # Create the ~/.config directory if it doesn't exist + if not os.path.exists(user_config_dir): + try: + os.makedirs(user_config_dir, exist_ok=True) + logger.info(f"Created directory: {user_config_dir}") + except Exception as e: + logger.error(f"Failed to create directory {user_config_dir}: {e}") + return + + # Copy the default config to the user config path + try: + shutil.copy2(default_config_path, user_config_path) + logger.info(f"Copied default config to {user_config_path}") + except Exception as e: + logger.error(f"Failed to copy default config to {user_config_path}: {e}") + return + + # Optionally, you can modify the user config here + # For example, to add a comment about it being a user-specific config + try: + with open(user_config_path, 'r') as f: + config = yaml.safe_load(f) + + # Add a comment at the top of the file + with open(user_config_path, 'w') as f: + f.write("# PyNamer User Configuration\n") + f.write("# This file was automatically created during installation\n") + f.write("# You can modify this file to customize PyNamer's behavior\n\n") + yaml.dump(config, f, default_flow_style=False) + + logger.info(f"Updated user config with comments") + except Exception as e: + logger.error(f"Failed to update user config with comments: {e}")