commit 1a8cae62cab1084dbfa4cf001413941fd76a8e5d Author: Steve White Date: Fri Mar 28 17:51:26 2025 -0500 Made it a pip project. 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 0000000..abad37e Binary files /dev/null and b/src/pynamer/__pycache__/__init__.cpython-310.pyc differ diff --git a/src/pynamer/__pycache__/cli.cpython-310.pyc b/src/pynamer/__pycache__/cli.cpython-310.pyc new file mode 100644 index 0000000..0cac9de Binary files /dev/null and b/src/pynamer/__pycache__/cli.cpython-310.pyc differ diff --git a/src/pynamer/__pycache__/core.cpython-310.pyc b/src/pynamer/__pycache__/core.cpython-310.pyc new file mode 100644 index 0000000..fd2b710 Binary files /dev/null and b/src/pynamer/__pycache__/core.cpython-310.pyc differ diff --git a/src/pynamer/__pycache__/setup_utils.cpython-310.pyc b/src/pynamer/__pycache__/setup_utils.cpython-310.pyc new file mode 100644 index 0000000..84a6df4 Binary files /dev/null and b/src/pynamer/__pycache__/setup_utils.cpython-310.pyc differ 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}")