Made it a pip project.
This commit is contained in:
commit
1a8cae62ca
|
@ -0,0 +1,2 @@
|
|||
.DS_Store
|
||||
.venv/
|
|
@ -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.
|
|
@ -0,0 +1,3 @@
|
|||
include README.md
|
||||
include LICENSE
|
||||
include src/pynamer/config.yaml
|
|
@ -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.)
|
|
@ -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."
|
|
@ -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()
|
|
@ -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
|
|
@ -0,0 +1,2 @@
|
|||
litellm>=1.10.0
|
||||
pyyaml>=6.0
|
|
@ -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",
|
||||
],
|
||||
)
|
|
@ -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.)
|
|
@ -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
|
|
@ -0,0 +1 @@
|
|||
|
|
@ -0,0 +1,2 @@
|
|||
[console_scripts]
|
||||
pynamer = pynamer.cli:main
|
|
@ -0,0 +1,2 @@
|
|||
litellm>=1.10.0
|
||||
pyyaml>=6.0
|
|
@ -0,0 +1 @@
|
|||
pynamer
|
|
@ -0,0 +1,3 @@
|
|||
"""PyNamer - Generate descriptive filenames for images using LLMs."""
|
||||
|
||||
__version__ = "0.1.0"
|
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
@ -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()
|
|
@ -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."
|
|
@ -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)
|
|
@ -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}")
|
Loading…
Reference in New Issue