Files
gh-christianlouis-docuelevate/app/utils/filename_utils.py
T
copilot-swe-agent[bot] 204000aabc fix: merge main branch and renumber migration 027→037
Resolve 3 merge conflicts and renumber the automation_hooks migration
to follow main's migration chain (036_add_document_translation_fields).

Conflicts resolved:
- app/api/__init__.py: add automation_router alongside main's new routers
- app/utils/settings_service.py: add automation_hooks_enabled alongside compliance_enabled
- tests/conftest.py: add AutomationHook alongside AuditLog/ComplianceTemplate imports

Migration renumbered:
- 027_add_automation_hooks → 037_add_automation_hooks
- down_revision: 026_add_scheduled_jobs → 036_add_document_translation_fields

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-16 22:40:15 +00:00

210 lines
7.9 KiB
Python

import logging
import os
import re
import uuid
from collections.abc import Callable
from datetime import datetime
from pathlib import Path
logger = logging.getLogger(__name__)
# Pattern for valid filenames (alphanumeric, dash, underscore, period, and space)
# Used for validating GPT-provided filenames and other inputs
VALID_FILENAME_PATTERN = r"^[\w\-\. ]+$"
VALID_FILENAME_RE = re.compile(VALID_FILENAME_PATTERN)
def get_unique_filename(original_path: str, check_exists_func: Callable[[str], bool] | None = None) -> str:
"""
Generates a unique filename by appending a timestamp or counter when a collision occurs.
Args:
original_path (str): The original file path
check_exists_func (callable): Function that checks if file exists in target system.
Takes a path string and returns True if exists, False otherwise.
If None, will use local filesystem check.
Returns:
str: A unique filename that doesn't collide with existing files
"""
if check_exists_func is None:
check_exists_func = os.path.exists
path = Path(original_path)
directory = str(path.parent)
filename = path.name
name, ext = os.path.splitext(filename)
# If file doesn't exist, return the original
if not check_exists_func(original_path):
return original_path
# Try timestamp-based suffix first (more user-friendly)
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
new_filename = f"{name}_{timestamp}{ext}"
new_path = os.path.join(directory, new_filename)
if not check_exists_func(new_path):
logger.info(f"Renamed '{filename}' to '{new_filename}' to avoid collision")
return new_path
# If timestamp-based name also exists, try random UUID
uuid_str = str(uuid.uuid4())[:8] # Use first 8 chars of UUID for brevity
new_filename = f"{name}_{uuid_str}{ext}"
new_path = os.path.join(directory, new_filename)
if not check_exists_func(new_path):
logger.info(f"Renamed '{filename}' to '{new_filename}' using UUID to avoid collision")
return new_path
# If that still exists (very unlikely), use incremental numbering
counter = 1
while counter < 1000: # Limit to avoid infinite loop
new_filename = f"{name}_{counter}{ext}"
new_path = os.path.join(directory, new_filename)
if not check_exists_func(new_path):
logger.info(f"Renamed '{filename}' to '{new_filename}' using counter to avoid collision")
return new_path
counter += 1
# If we got here, something is weird - just use a full UUID
new_filename = f"{name}_{str(uuid.uuid4())}{ext}"
new_path = os.path.join(directory, new_filename)
logger.warning(f"Had to use full UUID to rename '{filename}' to '{new_filename}'")
return new_path
def get_unique_filepath_with_counter(directory: str, base_filename: str, extension: str = ".pdf") -> str:
"""
Returns a unique filepath in the specified directory using a numeric counter suffix.
If 'base_filename.pdf' exists, it will append '-0001', '-0002', etc.
This function implements robust collision handling with zero-padded numeric suffixes
as required for document storage organization.
Args:
directory (str): Directory path where the file will be stored
base_filename (str): Base name for the file (without extension)
extension (str): File extension including the dot (default: ".pdf")
Returns:
str: Full path to a unique filename
Examples:
>>> get_unique_filepath_with_counter("/workdir/original", "2024-01-01_Invoice")
"/workdir/original/2024-01-01_Invoice.pdf" # If doesn't exist
>>> get_unique_filepath_with_counter("/workdir/original", "2024-01-01_Invoice")
"/workdir/original/2024-01-01_Invoice-0001.pdf" # If original exists
"""
# Try the base filename first
candidate = os.path.join(directory, base_filename + extension)
if not os.path.exists(candidate):
return candidate
# If base exists, try with counter suffix
counter = 1
while True:
# Use zero-padded 4-digit counter: -0001, -0002, etc.
suffix = f"-{counter:04d}"
candidate = os.path.join(directory, f"{base_filename}{suffix}{extension}")
if not os.path.exists(candidate):
return candidate
counter += 1
# Sanity check to prevent infinite loops (very unlikely to reach)
if counter > 9999:
# Fall back to timestamp + UUID if somehow we have 10000 collisions
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
uuid_str = str(uuid.uuid4())[:8]
candidate = os.path.join(directory, f"{base_filename}-{timestamp}-{uuid_str}{extension}")
logger.warning(
f"Exceeded 9999 file collisions for {base_filename}, "
f"using timestamp+UUID: {os.path.basename(candidate)}"
)
return candidate
def sanitize_filename(filename: str) -> str:
r"""
Sanitize a filename to ensure it's valid across different file systems
and prevent path traversal attacks.
Args:
filename (str): The filename to sanitize
Returns:
str: A sanitized filename
Security:
- Removes path separators (/ and \)
- Prevents path traversal patterns (..)
- Replaces problematic characters with underscores
- Ensures compatibility across Windows, Linux, and macOS
"""
# First, replace all path separators (both Unix and Windows style) with underscores
sanitized = filename.replace("/", "_").replace("\\", "_")
# Replace characters that are problematic in various filesystems
# Keep only alphanumeric, dash, underscore, period, and space
sanitized = re.sub(r"[^\w\-\. ]", "_", sanitized)
# Remove or replace path traversal patterns
# Replace specifically '..' to prevent path traversal while preserving single dots
sanitized = sanitized.replace("..", "_")
# Replace multiple spaces/underscores with single ones
sanitized = re.sub(r"__+", "_", sanitized)
sanitized = re.sub(r" +", " ", sanitized)
# Trim leading/trailing spaces, periods, and underscores which cause issues in Windows
sanitized = sanitized.strip(". _")
# Ensure the filename isn't empty after sanitization
if not sanitized or sanitized == ".":
sanitized = f"document_{datetime.now().strftime('%Y%m%d_%H%M%S')}"
return sanitized
def extract_remote_path(file_path: str, base_dir: str, remote_base: str = "") -> str:
"""
Extract a remote path for a file by preserving its directory structure
relative to the base directory, but with a new remote base path.
Modified to skip 'processed' directory in the remote path.
"""
# Normalize paths for consistent handling across platforms
file_path = os.path.normpath(file_path)
base_dir = os.path.normpath(base_dir)
# Get relative path from base directory
if file_path.startswith(base_dir):
rel_path = os.path.relpath(file_path, base_dir)
else:
# If not a subdirectory of base_dir, just use the filename
rel_path = os.path.basename(file_path)
# Skip 'processed' directory if it's in the path
path_parts = rel_path.split(os.sep)
if "processed" in path_parts:
# Remove 'processed' from the path
path_parts.remove("processed")
rel_path = os.path.join(*path_parts)
# Combine with remote base path
if remote_base:
if remote_base.startswith("/"):
# Handle absolute path for services like Dropbox
remote_path = os.path.join(remote_base[1:], rel_path)
else:
remote_path = os.path.join(remote_base, rel_path)
else:
remote_path = rel_path
# Convert to forward slashes for compatibility with most cloud services
remote_path = remote_path.replace(os.sep, "/")
return remote_path