XeFM provides comprehensive AWS S3 support through an extended Path implementation, allowing users to navigate, browse, and manipulate S3 buckets and objects using the same interface as local file operations. The system includes intelligent caching for performance optimization and modular architecture for maintainability.
s3://bucket-name/key/paths3://my-bucket/read_text(), read_bytes(), open()write_text(), write_bytes(), open('w')stat(), exists(), is_file(), is_dir()unlink(), rename(), touch()joinpath(), / operatorwith_name(), with_suffix(), with_stem()name, stem, suffix, parent, partsiterdir(), glob(), rglob()mkdir() (creates directory markers)rmdir() (removes empty directories)The S3 support is implemented using a clean modular architecture:
src/
├── xefm/path.py # Core path implementation (PathImpl, LocalPathImpl, Path)
├── xefm/s3.py # S3 implementation (S3PathImpl, S3Cache, utilities)
└── _config.py # Configuration (includes S3 tools)
xefm/s3.py)class S3Cache:
def __init__(self, default_ttl: int = 60, max_entries: int = 1000)
def get(self, operation: str, bucket: str, key: str = "", **kwargs) -> Optional[Any]
def put(self, operation: str, bucket: str, key: str = "", data: Any = None, ttl: Optional[int] = None, **kwargs)
def invalidate_bucket(self, bucket: str)
def invalidate_key(self, bucket: str, key: str)
def invalidate_prefix(self, bucket: str, prefix: str)
def clear(self)
def get_stats(self) -> Dict[str, Any]
xefm/path.py)def _create_implementation(self, path_str: str) -> PathImpl:
if path_str.startswith('s3://'):
try:
from .xefm.s3 import S3PathImpl # Dynamic import
except ImportError:
from xefm.s3 import S3PathImpl # Fallback for direct execution
return S3PathImpl(path_str)
return LocalPathImpl(PathlibPath(path_str))
S3Cache
├── _cache: Dict[str, Dict[str, Any]] # Main cache storage
├── _lock: threading.RLock # Thread safety
├── default_ttl: int # Default cache TTL
└── max_entries: int # Maximum cache entries
pip install boto3
# Option 1: AWS CLI
aws configure
# Option 2: Environment Variables
export AWS_ACCESS_KEY_ID=your_access_key
export AWS_SECRET_ACCESS_KEY=your_secret_key
export AWS_DEFAULT_REGION=us-west-2
# Option 3: IAM Roles (for EC2 instances)
# Automatically detected when running on AWS infrastructure
from xefm.s3 import configure_s3_cache, get_s3_cache_stats
# Configure cache with custom settings
configure_s3_cache(ttl=120, max_entries=2000)
# Check cache statistics
stats = get_s3_cache_stats()
print(f"Cache entries: {stats['total_entries']}")
from xefm.path import Path
# Create S3 paths
bucket = Path('s3://my-bucket/')
file_path = Path('s3://my-bucket/documents/report.pdf')
# Path properties
print(file_path.name) # 'report.pdf'
print(file_path.suffix) # '.pdf'
print(file_path.parent) # 's3://my-bucket/documents/'
# Path manipulation
new_file = file_path.with_name('summary.pdf')
csv_file = file_path.with_suffix('.csv')
# All operations automatically use caching
s3_path = Path('s3://my-bucket/my-file.txt')
# First call hits API and caches result
exists1 = s3_path.exists() # API call made
# Second call uses cached result
exists2 = s3_path.exists() # No API call
# Write operation invalidates cache
s3_path.write_text("new content") # Cache invalidated
# Next call hits API again
exists3 = s3_path.exists() # API call made
# List bucket contents (cached)
bucket = Path('s3://my-bucket/')
for item in bucket.iterdir():
print(f"Found: {item}")
# Create directory (marker)
new_dir = Path('s3://my-bucket/new-folder/')
new_dir.mkdir()
# Search with patterns (uses cached listings)
for pdf_file in bucket.glob('*.pdf'):
print(f"PDF: {pdf_file}")
from xefm.s3 import clear_s3_cache, get_s3_cache
# Clear all cache entries
clear_s3_cache()
# Get cache instance for advanced operations
cache = get_s3_cache()
cache.invalidate_bucket('my-bucket')
# Manual invalidation
cache.invalidate_key('bucket', 'path/to/file.txt')
cache.invalidate_prefix('bucket', 'path/to/')
s3://bucket-name/XEFM_THIS_DIR can be an S3 path: s3://bucket/folder/XEFM_THIS_SELECTED can include S3 objectsBased on typical S3 operations:
| Operation | Without Cache | With Cache | Improvement |
|---|---|---|---|
| exists() | 150-300ms | 1-5ms | 95-98% |
| stat() | 150-300ms | 1-5ms | 95-98% |
| iterdir() | 200-500ms | 10-50ms | 80-95% |
| read_text() | 200-400ms | 50-100ms | 50-75% |
# Global configuration
configure_s3_cache(
ttl=60, # Default TTL in seconds
max_entries=1000 # Maximum cache entries
)
# Per-operation TTL
s3_path._cached_api_call(
'head_object',
ttl=300, # 5 minutes
Bucket='my-bucket',
Key='my-key'
)
The entire system is thread-safe:
threading.RLock for all cache operationsdef _invalidate_cache_for_write(self, key: Optional[str] = None):
target_key = key or self._key
# Invalidate the specific key
self._cache.invalidate_key(self._bucket, target_key)
# Invalidate parent directory listings
if '/' in target_key:
parent_key = '/'.join(target_key.split('/')[:-1]) + '/'
self._cache.invalidate_key(self._bucket, parent_key)
# Invalidate bucket root listing if top-level key
if '/' not in target_key.strip('/'):
self._cache.invalidate_key(self._bucket, '')
stats = get_s3_cache_stats()
# Returns:
{
'total_entries': int, # Current cache entries
'expired_entries': int, # Expired but not cleaned entries
'max_entries': int, # Maximum allowed entries
'default_ttl': int # Default TTL in seconds
}
expired_entries for cache efficiencyError: AWS credentials not found
Solution: Configure AWS credentials using 'aws configure' or environment variables
Error: Access Denied
Solution: Check IAM permissions for the S3 bucket and objects
# Reduce cache size
configure_s3_cache(max_entries=500)
# Or clear cache periodically
clear_s3_cache()
# Reduce TTL for frequently changing data
configure_s3_cache(ttl=30)
# Or manually invalidate
cache = get_s3_cache()
cache.invalidate_bucket('frequently-changing-bucket')
Enable debug logging to monitor cache behavior:
import logging
logging.basicConfig(level=logging.DEBUG)
# Cache operations will be logged
s3_path.exists() # Logs cache hit/miss information
# Unit tests (no AWS credentials required)
python test/test_s3_path.py
python test/test_s3_caching.py
# Integration tests (requires AWS credentials)
python test/test_s3_integration.py
The modular architecture could enable addition of new storage backends in the future. These are architectural possibilities, not currently implemented or planned features:
Note: These backends do not currently exist. The architecture’s modularity means they could be added if needed, following the same patterns established by the S3 implementation.
XeFM prevents users from renaming directories on S3 storage to avoid confusion and expensive operations. Unlike local file systems where directory renaming is a simple metadata operation, S3 directory renaming would require copying all objects within the directory and then deleting the originals, which can be:
The restriction is implemented at two levels:
Dialog Prevention (Primary UX):
def enter_rename_mode(self):
# Check if this storage implementation supports directory renaming
try:
if selected_file.is_dir() and not selected_file.supports_directory_rename():
print("Directory renaming is not supported on this storage type due to performance and cost considerations")
return
except Exception as e:
print(f"Warning: Could not check directory rename capability: {e}")
Backend Protection (Fallback):
def rename(self, target) -> 'Path':
"""Rename this file or directory to the given target"""
# Check if this is a directory - S3 directory renaming is not supported
if self.is_dir():
raise OSError("Directory renaming is not supported on S3 due to performance and cost considerations")
/) and virtual directories are blockedXeFM provides a capability indicator for S3 file editing operations through the supports_file_editing() method. This allows applications to check whether a storage implementation supports file editing characteristics, without blocking the operations.
# S3PathImpl returns False to indicate different editing characteristics
def supports_file_editing(self) -> bool:
return False
# LocalPathImpl returns True for full editing support
def supports_file_editing(self) -> bool:
return True
open(), write_text(), write_bytes(), etc.path.supports_file_editing() to understand storage characteristicspath = Path('s3://bucket/file.txt')
if path.supports_file_editing():
# Local file system - full editing support expected
path.write_text("new content")
else:
# S3 or other storage - editing works but may have different characteristics
print("Note: This storage type has different editing characteristics")
path.write_text("new content") # Still works
XeFM provides meaningful size and timestamp information for S3 virtual directories instead of showing “—” for both values.
Virtual directories in S3 (directories that exist only because there are S3 objects with that prefix) previously showed:
def _get_virtual_directory_stats(self) -> Tuple[int, float]:
"""Get generated stats for virtual directories."""
# Lists objects under the directory prefix
# Finds the latest LastModified timestamp among all children
# Handles pagination for large directories (>1000 objects)
# Uses caching to optimize performance
# Returns (size=0, latest_timestamp)
Before:
s3://bucket/reports/2024/ --- ---
s3://bucket/data/processed/ --- ---
After:
s3://bucket/reports/2024/ 0B 2024-06-30 17:45:30
s3://bucket/data/processed/ 0B 2024-09-15 09:22:15
Problem: get_file_info() calls were not hitting the cache and causing 404 errors from HeadObject API calls during directory rendering.
Root Cause: Cache keys used during iterdir() didn’t match the cache keys used during stat() calls.
Solution:
cache_key_override parameter to _cached_api_call() methodBenefits:
Problem: N+1 API call problem causing slow directory rendering (1 list_objects_v2 call + N head_object calls for N files).
Solution:
list_objects_v2 response for subsequent stat() callsPerformance Improvements:
| Scenario | Before | After | Improvement |
|---|---|---|---|
| Directory with 20 files | 21 calls | 1 call | 95% reduction |
| Directory with 100 files | 101 calls | 1 call | 99% reduction |
| Repeated directory access | N+1 calls | 0 calls | 100% reduction |
Enhancement: Made S3 cache TTL configurable through XeFM configuration system.
Configuration:
class Config:
S3_CACHE_TTL = 120 # Cache for 2 minutes (default: 60)
Recommended TTL Values:
Enhancement: Automatic cache invalidation after file and archive operations.
Invalidation Strategies:
Problem: Backspace key would not work correctly when browsing S3 buckets, particularly for paths ending with trailing slashes.
Root Cause: The parent property didn’t properly handle S3 keys ending with trailing slashes.
Solution:
@property
def parent(self) -> 'Path':
# Strip trailing slash to handle directory keys properly
key_without_trailing_slash = self._key.rstrip('/')
if '/' not in key_without_trailing_slash:
return Path(f's3://{self._bucket}/')
parent_key = '/'.join(key_without_trailing_slash.split('/')[:-1])
if parent_key:
return Path(f's3://{self._bucket}/{parent_key}/')
else:
return Path(f's3://{self._bucket}/')
Problem: Directories were appearing with empty names and showing as “0B” in size.
Root Cause: When S3 directory keys end with a forward slash (e.g., test1/), the name property would return an empty string.
Solution:
@property
def name(self) -> str:
# Strip trailing slash before splitting to handle directory keys properly
key_without_slash = self._key.rstrip('/')
return key_without_slash.split('/')[-1] if '/' in key_without_slash else key_without_slash
Problem: Copying files from local filesystem to S3 resulted in “Permission denied” errors.
Root Cause: XeFM was using shutil.copy2() for all copy operations, which only works with local filesystem paths.
Solution:
copy_to() method to Path classCross-Storage Copy Support:
shutil.copy2() for optimal performanceupload_from_stream()download_to_stream()copy_from_s3()Problem (issue #131): S3 copies showed no byte-level progress. The bar sat at 0% for the whole transfer and then jumped to 100%.
Root Cause: The copy path called read_bytes()/write_bytes(), which are a
single get_object(...)['Body'].read() and a single put_object(...). One
blocking call has nothing to report from, so progress_callback was never even
wired up for S3. It also meant the entire object was held in memory, and a read
additionally cached the whole body in the S3 cache.
Solution: Copies go through boto3’s managed transfer
(download_fileobj/upload_fileobj/copy), which takes a Callback= invoked
as each chunk moves. read_bytes()/write_bytes() are left alone — the viewer
still wants whole objects, and caching a small file’s body there is useful.
Implementation notes (xefm/s3.py):
_byte_progress_adapter() converts boto3’s per-chunk deltas into the
absolute (transferred, total) pairs ProgressManager.update_file_byte_progress()
expects, and emits an initial (0, total) so the bar appears immediately.stat().st_size, which reuses the head_object already
cached by the exists() check in copy_to() — no extra API call._copy_file_cross_storage() unlinks the partial destination and re-raises.Not covered: S3 ↔ SSH copies still buffer the whole object, because the SSH
implementation only deals in whole bytes. Each leg reports its own progress in
turn.
Problem (issue #131): a copy to or from S3 could not be cancelled. The whole
transfer was one blocking call, and task.checkpoint() only ran between files.
Solution: the byte-progress callback doubles as the cancel checkpoint —
mid-file it is the only thread of control that returns to the caller often
enough. FileOperationService._remote_progress() calls task.checkpoint()
before forwarding each update, so a cancel raises Cancelled inside boto3’s
transfer callback, which aborts the transfer.
Verified behaviour (against a real HTTP endpoint — a botocore Stubber
never reads the request body, so the callback never fires and the transfer looks
uncancellable):
AbortMultipartUpload, so no
orphaned parts are left accruing storage charges.PUT, so no object appears._copy_file_cross_storage().Keeping the signal intact: Path.copy_to() wraps the caller’s callback with
_guard_progress(), tagging anything it raises as _CallbackAbort so the
generic except Exception → OSError in the cross-storage helpers cannot relabel
a cancel as a copy failure. copy_to() unwraps it and re-raises the original.
Genuine transfer errors still become OSError as before.
Problem: Moving files between S3 directories resulted in “No such file or directory” errors.
Root Cause: Move operations were using shutil.move() which doesn’t understand S3 URIs.
Solution:
shutil.move() calls with Path.rename() callsProblem: Attempting to delete S3 directories resulted in “No files to delete” error.
Root Cause:
exists() method only checked for actual S3 objects, not virtual directoriesSolution:
exists() method to check for virtual directoriesrmtree() method for recursive S3 directory deletion_delete_objects_batch() for efficient batch deletionProblem: Virtual directories (directories without actual S3 objects) caused HeadObject failures and unnecessary API calls.
Solution: Store metadata as S3PathImpl instance properties to eliminate API calls.
Implementation:
def __init__(self, s3_uri: str, metadata: Optional[Dict[str, Any]] = None):
self._metadata = metadata or {}
self._is_dir_cached = self._metadata.get('is_dir')
self._is_file_cached = self._metadata.get('is_file')
self._size_cached = self._metadata.get('size')
self._mtime_cached = self._metadata.get('last_modified')
Performance Improvements:
| Operation | Before | After | Improvement |
|---|---|---|---|
is_dir() on virtual directory |
1 API call | 0 API calls | 100% reduction |
is_file() on cached file |
1 API call | 0 API calls | 100% reduction |
stat() on cached object |
1 API call | 0 API calls | 100% reduction |
| Directory with 20 items | 20+ API calls | 0 API calls | 100% reduction |
| Metric | Improvement | Impact |
|---|---|---|
| API Calls | 90-99% reduction | Faster operations, lower costs |
| Directory Rendering | 50-90% faster | Better user experience |
| Cache Hit Rate | 95%+ for repeated operations | Near-instant responses |
| Error Rate | 100% reduction for virtual directories | More reliable operations |