Software complexity grows until it exceeds the capacity of the developer who created it. The single-file Python application is a deliberate constraint against this tendency—a forcing function that keeps systems comprehensible, deployable, and maintainable. This guide explores when single-file architecture makes sense, when it breaks down, and how to build robust systems within the constraint.
The Case for Constraints
Most software projects start simple and grow complex. A single script becomes a package, then a module, then a framework, then a microservices architecture. Each step seems justified, but the cumulative result is often a system that no single developer fully understands.
Single-file applications invert this trajectory. By constraining the entire system to one file, you force:
- Explicit dependencies: Every import is visible; no hidden couplings
- Linear reasoning: You can read the file top-to-bottom and understand the whole system
- Zero deployment friction: Copy one file; no packaging, no virtual environments
- Auditability: A single diff shows exactly what changed
When Single-File Works (and When It Doesn't)
| Good Fit | Bad Fit |
|---|---|
| Automation scripts (under 500 lines) | Web applications with multiple routes |
| Utility tools with focused scope | Systems with complex database schemas |
| Data processing pipelines | Multi-team collaborative projects |
| Prototypes and proofs-of-concept | Long-lived enterprise systems |
| Configuration generators | Plugin-extensible architectures |
The Anatomy of a Robust Single-File App
A well-structured single-file application has clear internal organization despite the lack of file boundaries:
#!/usr/bin/env python3
"""
robust_single_file.py
Template for production-ready single-file Python applications.
Features: dependency auto-install, structured logging, error handling,
configuration, CLI parsing, and graceful shutdown.
"""
# ═══════════════════════════════════════════════════════════════
# SECTION 1: Metadata & Configuration
# ═══════════════════════════════════════════════════════════════
__version__ = "1.0.0"
__author__ = "Your Name"
__description__ = "Description of what this tool does"
import sys
import os
import argparse
import logging
import json
from pathlib import Path
from dataclasses import dataclass, asdict
from typing import Optional, List
from enum import Enum
# ═══════════════════════════════════════════════════════════════
# SECTION 2: Dependency Management
# ═══════════════════════════════════════════════════════════════
REQUIRED_PACKAGES = {
'requests': 'requests>=2.28.0',
'rich': 'rich>=13.0.0'
}
def ensure_dependencies():
"""Auto-install missing dependencies to user site-packages."""
import importlib
missing = []
for module, package in REQUIRED_PACKAGES.items():
try:
importlib.import_module(module)
except ImportError:
missing.append(package)
if missing:
print(f"Installing dependencies: {', '.join(missing)}")
import subprocess
subprocess.check_call([
sys.executable, '-m', 'pip', 'install', '--user', *missing
])
# Restart to pick up newly installed packages
print("Dependencies installed. Restarting...")
os.execv(sys.executable, [sys.executable] + sys.argv)
ensure_dependencies()
# Now safe to import optional dependencies
import requests
from rich.console import Console
from rich.logging import RichHandler
from rich.progress import Progress, SpinnerColumn, TextColumn
# ═══════════════════════════════════════════════════════════════
# SECTION 3: Configuration
# ═══════════════════════════════════════════════════════════════
@dataclass
class AppConfig:
"""Application configuration with defaults and validation."""
verbose: bool = False
output_dir: str = "./output"
max_retries: int = 3
timeout: int = 30
parallel: bool = True
@classmethod
def from_args(cls, args: argparse.Namespace) -> 'AppConfig':
"""Create config from parsed CLI arguments."""
return cls(
verbose=args.verbose,
output_dir=args.output,
max_retries=args.retries,
timeout=args.timeout,
parallel=not args.sequential
)
@classmethod
def from_file(cls, path: str) -> 'AppConfig':
"""Load config from JSON file."""
with open(path) as f:
data = json.load(f)
return cls(**data)
def save(self, path: str):
"""Persist config to JSON file."""
with open(path, 'w') as f:
json.dump(asdict(self), f, indent=2)
def validate(self) -> List[str]:
"""Return list of validation errors. Empty list means valid."""
errors = []
if self.max_retries < 0:
errors.append("max_retries must be non-negative")
if self.timeout < 1:
errors.append("timeout must be at least 1 second")
out = Path(self.output_dir)
if out.exists() and not out.is_dir():
errors.append(f"output_dir exists but is not a directory: {out}")
return errors
# ═══════════════════════════════════════════════════════════════
# SECTION 4: Logging Setup
# ═══════════════════════════════════════════════════════════════
def setup_logging(verbose: bool = False) -> logging.Logger:
"""Configure structured logging with Rich formatting."""
level = logging.DEBUG if verbose else logging.INFO
logging.basicConfig(
level=level,
format="%(message)s",
datefmt="[%X]",
handlers=[RichHandler(rich_tracebacks=True, show_path=False)]
)
return logging.getLogger("app")
# ═══════════════════════════════════════════════════════════════
# SECTION 5: Core Business Logic
# ═══════════════════════════════════════════════════════════════
class ProcessingError(Exception):
"""Domain-specific error with context."""
def __init__(self, message: str, context: dict = None):
super().__init__(message)
self.context = context or {}
class ResultStatus(Enum):
SUCCESS = "success"
PARTIAL = "partial"
FAILED = "failed"
SKIPPED = "skipped"
@dataclass
class ProcessingResult:
status: ResultStatus
data: Optional[dict] = None
error: Optional[str] = None
duration_ms: int = 0
class Processor:
"""Core processing logic with error handling and progress tracking."""
def __init__(self, config: AppConfig, logger: logging.Logger):
self.config = config
self.logger = logger
self.console = Console()
self.session = requests.Session()
# Configure session
adapter = requests.adapters.HTTPAdapter(
max_retries=self.config.max_retries,
pool_connections=10 if self.config.parallel else 1,
pool_maxsize=10 if self.config.parallel else 1
)
self.session.mount('http://', adapter)
self.session.mount('https://', adapter)
def process(self, inputs: List[str]) -> List[ProcessingResult]:
"""Process all inputs with progress tracking."""
results = []
with Progress(
SpinnerColumn(),
TextColumn("[progress.description]{task.description}"),
console=self.console
) as progress:
task = progress.add_task("Processing...", total=len(inputs))
for item in inputs:
start = __import__('time').time()
try:
result = self._process_single(item)
except ProcessingError as e:
self.logger.warning(f"Processing failed for {item}: {e}")
result = ProcessingResult(
status=ResultStatus.FAILED,
error=str(e),
duration_ms=int((__import__('time').time() - start) * 1000)
)
except Exception as e:
self.logger.error(f"Unexpected error processing {item}: {e}")
result = ProcessingResult(
status=ResultStatus.FAILED,
error=f"Unexpected: {str(e)}",
duration_ms=int((__import__('time').time() - start) * 1000)
)
results.append(result)
progress.advance(task)
return results
def _process_single(self, item: str) -> ProcessingResult:
"""Process a single input item. Override for specific logic."""
self.logger.debug(f"Processing: {item}")
# Example: fetch and process
response = self.session.get(
item,
timeout=self.config.timeout
)
response.raise_for_status()
return ProcessingResult(
status=ResultStatus.SUCCESS,
data={"url": item, "status": response.status_code},
duration_ms=0
)
def shutdown(self):
"""Clean up resources."""
self.session.close()
# ═══════════════════════════════════════════════════════════════
# SECTION 6: CLI & Entry Point
# ═══════════════════════════════════════════════════════════════
def create_parser() -> argparse.ArgumentParser:
"""Build CLI argument parser with comprehensive help."""
parser = argparse.ArgumentParser(
description=__description__,
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Examples:
%(prog)s input.txt
%(prog)s --config config.json --verbose
%(prog)s --output ./results file1.txt file2.txt
"""
)
parser.add_argument('inputs', nargs='+', help='Input files or URLs to process')
parser.add_argument('-c', '--config', help='Path to JSON config file')
parser.add_argument('-o', '--output', default='./output', help='Output directory')
parser.add_argument('-v', '--verbose', action='store_true', help='Enable debug logging')
parser.add_argument('--retries', type=int, default=3, help='Max retry attempts')
parser.add_argument('--timeout', type=int, default=30, help='Request timeout (seconds)')
parser.add_argument('--sequential', action='store_true', help='Disable parallel processing')
parser.add_argument('--version', action='version', version=f'%(prog)s {__version__}')
return parser
def main():
"""Application entry point with full lifecycle management."""
parser = create_parser()
args = parser.parse_args()
# Load configuration
if args.config:
config = AppConfig.from_file(args.config)
else:
config = AppConfig.from_args(args)
# Validate
errors = config.validate()
if errors:
print("Configuration errors:", file=sys.stderr)
for error in errors:
print(f" • {error}", file=sys.stderr)
sys.exit(1)
# Setup
logger = setup_logging(config.verbose)
logger.info(f"Starting {__description__} v{__version__}")
# Ensure output directory
out = Path(config.output_dir)
out.mkdir(parents=True, exist_ok=True)
# Process
processor = Processor(config, logger)
try:
results = processor.process(args.inputs)
# Summary
status_counts = {}
for r in results:
status_counts[r.status.value] = status_counts.get(r.status.value, 0) + 1
logger.info("Processing complete:")
for status, count in status_counts.items():
logger.info(f" {status}: {count}")
# Save results
output_file = out / f"results_{__import__('datetime').datetime.now().strftime('%Y%m%d_%H%M%S')}.json"
with open(output_file, 'w') as f:
json.dump([
{
'status': r.status.value,
'data': r.data,
'error': r.error,
'duration_ms': r.duration_ms
}
for r in results
], f, indent=2)
logger.info(f"Results saved to {output_file}")
# Exit code based on results
if all(r.status == ResultStatus.SUCCESS for r in results):
sys.exit(0)
elif any(r.status == ResultStatus.FAILED for r in results):
sys.exit(2)
else:
sys.exit(1)
except KeyboardInterrupt:
logger.info("Interrupted by user")
sys.exit(130)
finally:
processor.shutdown()
logger.info("Shutdown complete")
if __name__ == '__main__':
main()
Key Techniques for Single-File Robustness
1. Auto-Install Dependencies
Single files shouldn't require manual pip install. The ensure_dependencies() pattern handles this gracefully.
2. Structured Sections
Use clear section headers (even ASCII art dividers) so readers can navigate the file mentally. Each section should have a single responsibility.
3. Dataclass Configuration
@dataclass configurations with from_args(), from_file(), and validate() methods provide type-safe, testable configuration without external libraries.
4. Resource Lifecycle
Always implement shutdown() and call it in a finally block. Even in a single file, resource leaks accumulate.
5. Exit Codes
Return meaningful exit codes: 0 (success), 1 (partial success), 2 (failure), 130 (interrupted). This enables shell scripting integration.
Testing Single-File Applications
Single files are actually easier to test than sprawling packages. Embed tests using the if __name__ == '__main__' pattern:
# Add at end of file, after main()
def self_test():
"""Run embedded unit tests."""
import tempfile
# Test configuration validation
config = AppConfig(max_retries=-1)
assert len(config.validate()) > 0, "Should detect negative retries"
config = AppConfig(timeout=0)
assert len(config.validate()) > 0, "Should detect zero timeout"
# Test processing
with tempfile.TemporaryDirectory() as tmpdir:
config = AppConfig(output_dir=tmpdir, verbose=True)
logger = setup_logging(True)
processor = Processor(config, logger)
# Mock input (would use actual test fixtures)
results = processor.process(["https://httpbin.org/get"])
assert len(results) == 1
processor.shutdown()
print("✓ All self-tests passed")
# Uncomment to run tests:
# self_test()
The Bottom Line
Single-file Python applications aren't primitive—they're disciplined. The constraint forces clarity in architecture, explicitness in dependencies, and intentionality in every line. When you need to distribute a tool to non-developers, deploy to constrained environments, or simply maintain your own sanity, the single-file approach is often the most robust choice.
Start with one file. Add structure within that file. Only split when the file exceeds your ability to hold the entire system in working memory. That threshold is usually higher than you think.