8. Complete application assembly

The four layers exist on a shared foundation; nothing yet wires them together. This section adds the two final files that turn a pile of modules into a runnable program: a config.py that loads API keys and timeouts from the environment, and a weather_dashboard.py coordinator that wraps the workflow you built in §6 inside a lifecycle: boot, loop, shutdown.

The diagram below is the shape of weather_dashboard.py's lifecycle. Don't try to memorise it; it's a map of the section, and each box becomes a code block in the pages that follow.

Vertical lifecycle flowchart with three tinted regions stacked top to bottom. Top region (light blue tint, labelled 'BOOT' with a power icon): three rounded rectangles in sequence connected by downward arrows: '.env file → load_config()' with caption 'Reads OPENWEATHER_API_KEY and optional overrides; raises ValueError if key missing', then 'WeatherDashboardApp(config)' with caption 'Stores config; registers SIGINT and SIGTERM handlers', then 'app.initialize()' with caption 'Constructs Orchestrator; wires retry and timeout from config; prints readiness banner'. Middle region (light amber tint, labelled 'RUN LOOP' with a circular-arrow icon): a flowchart loop. An arrow descends from boot into a rounded rectangle 'input(): user enters location or command', which leads down to a diamond decision node 'Command or location?'. The diamond branches left to '_handle_command()' (sub-caption 'quit / debug / stats / help') and right to '_process_weather_request()' (sub-caption 'orchestrator.get_weather_for_city() → display_weather_result()' with a small grey '(internals: §6)' reference). Both branches loop back up to the input box. To the right of the run loop, a small dashed-bordered note labelled 'Ctrl+C / SIGINT / SIGTERM interrupts at any point' with a keyboard icon. Three dashed arrows leave the run-loop region heading down into shutdown: 'quit' from _handle_command, 'EOF (Ctrl+D)' from below the input box, and the Ctrl+C/SIGINT path from the side note. Bottom region (light grey tint, labelled 'SHUTDOWN' with a checkmark icon): two rounded rectangles in sequence: '_shutdown()' with caption 'Prints session summary: duration, requests processed', then 'sys.exit(0)' with caption 'Process ends cleanly'. All three dashed shutdown arrows converge at _shutdown(). Tagline at the bottom: 'One coordinator, three lifecycle phases. Many paths into shutdown; one clean exit.'
Boot, loop, shutdown. §8 isn't introducing new layers; it's wrapping a lifecycle around the workflow §6 already built. The boot phase loads config and constructs the orchestrator; the run loop branches on whether the user typed a command or a location and loops back to input() after either path; shutdown converges from four sources (the quit command, Ctrl+C/SIGINT, Ctrl+D/EOF, and SIGTERM) into one clean exit. The _process_weather_request() box is where §6's hub-and-spoke runs.

config.py: every tunable knob in one place

Configuration that isn't separated from code becomes configuration that isn't changeable. Save the loader at the project root as config.py:

config.py
"""
Configuration management for weather dashboard.

Centralizes all configuration values and provides environment-based overrides.
"""
import os
from dataclasses import dataclass

try:
    from dotenv import load_dotenv
    load_dotenv()
except ImportError:
    # python-dotenv is optional; if missing, fall back to shell-exported env
    pass

@dataclass
class WeatherDashboardConfig:
    """Configuration for production weather dashboard."""
    
    # API Configuration
    api_key: str
    geocoding_url: str = "https://api.openweathermap.org/geo/1.0/direct"
    weather_url: str = "https://api.openweathermap.org/data/2.5/weather"
    
    # Retry Configuration (from Chapter 9)
    max_retries: int = 3
    base_timeout: float = 10.0
    base_delay: float = 1.0
    max_delay: float = 60.0
    
    # Validation Configuration
    min_input_length: int = 2
    max_input_length: int = 100
    
    # Display Configuration
    show_debug_info: bool = False
    max_alternative_locations: int = 3
    
    @classmethod
    def from_environment(cls):
        """
        Create configuration from environment variables.
        
        Returns:
            WeatherDashboardConfig with values from environment
            
        Raises:
            ValueError: If required environment variables are missing
        """
        api_key = os.getenv('OPENWEATHER_API_KEY')
        if not api_key:
            raise ValueError(
                "Missing required environment variable: OPENWEATHER_API_KEY\n"
                "Set it with: export OPENWEATHER_API_KEY='your_key_here'"
            )
        
        # Optional overrides from environment
        max_retries = int(os.getenv('WEATHER_MAX_RETRIES', '3'))
        base_timeout = float(os.getenv('WEATHER_TIMEOUT', '10.0'))
        show_debug = os.getenv('WEATHER_DEBUG', 'false').lower() == 'true'
        
        return cls(
            api_key=api_key,
            max_retries=max_retries,
            base_timeout=base_timeout,
            show_debug_info=show_debug
        )

def load_config():
    """
    Load configuration with error handling.
    
    Returns:
        WeatherDashboardConfig instance
    """
    try:
        return WeatherDashboardConfig.from_environment()
    except ValueError as e:
        print(f"\n[FAIL] Configuration Error: {e}\n")
        raise

The try-import block at the top applies the load_dotenv() pattern centrally, so it's applied once, in config.py, instead of per-script. It reads the project's .env file and copies every NAME=value pair into the process environment, so os.getenv() finds them as if they'd been shell-exported. Every script that imports config picks up .env automatically, no shell exports required. The try/except keeps the file importable if python-dotenv isn't installed; in that case, shell-exported variables still work.

Now one file holds every tunable knob: API key, base URLs, timeouts, retry counts, debug flag. Environment-specific deployment, secret management, and feature flags all reduce to "edit .env." When the deployment chapters in Part V (27-29) push this code to AWS, the only piece that changes is what's in .env; the configuration loader stays.

weather_dashboard.py: lifecycle, not logic

weather_dashboard.py is the entry point, the file you'll run with python weather_dashboard.py. It loads the config, builds the orchestrator and the display module, runs the CLI loop, and handles a clean Ctrl+C exit. Save it at the project root, alongside config.py:

weather_dashboard.py
"""
Production Weather Dashboard - Main Application

Wires config, the orchestrator, and the display module into an
interactive CLI session.
"""
import sys
import signal
from datetime import datetime

# Import all layers
from config import load_config
from orchestrator import WeatherOrchestrator
from display import display_weather_result

class WeatherDashboardApp:
    """
    Main application coordinator for weather dashboard.
    
    Manages application lifecycle: initialization, interactive loop, shutdown.
    """
    
    def __init__(self, config):
        """
        Initialize application with configuration.
        
        Args:
            config: WeatherDashboardConfig instance
        """
        self.config = config
        self.orchestrator = None
        self.running = False
        self.request_count = 0
        self.start_time = None
        
        # Setup signal handlers for graceful shutdown
        signal.signal(signal.SIGINT, self._signal_handler)
        signal.signal(signal.SIGTERM, self._signal_handler)
    
    def initialize(self):
        """
        Initialize all application components.
        
        Returns:
            bool: True if initialization successful, False otherwise
        """
        print("\n Production Weather Dashboard")
        print("=" * 70)
        print("Initializing components...")
        
        try:
            # Initialize orchestrator with all layers
            self.orchestrator = WeatherOrchestrator(self.config.api_key)
            
            # Configure orchestrator with config values
            self.orchestrator.api_client.max_retries = self.config.max_retries
            self.orchestrator.api_client.base_timeout = self.config.base_timeout
            
            print("[OK] API client initialized")
            print("[OK] Geocoding processor initialized")
            print("[OK] Weather processor initialized")
            print("[OK] Business logic coordinator initialized")
            print("[OK] Presentation layer ready")
            
            if self.config.show_debug_info:
                print("\n[WARN] Debug mode enabled")
            
            print("\nReady to process weather requests!")
            return True
            
        except Exception as e:
            print(f"\n[FAIL] Initialization failed: {e}")
            return False
    
    def run(self):
        """
        Run interactive weather dashboard session.
        
        Manages the main application loop and user interaction.
        """
        if not self.initialize():
            return
        
        self.running = True
        self.start_time = datetime.now()
        
        print("\n" + "=" * 70)
        print("INSTRUCTIONS")
        print("=" * 70)
        print("Enter city names, coordinates, or locations:")
        print(" • City: 'London' or 'San Francisco, CA'")
        print(" • With country: 'Paris, France' or 'Tokyo, Japan'")
        print(" • Coordinates: '51.5074,-0.1278'")
        print("\nCommands:")
        print(" • 'quit' or 'exit' - Exit the application")
        print(" • 'debug' - Toggle debug information")
        print(" • 'stats' - Show session statistics")
        print(" • 'help' - Show this help message")
        print("=" * 70 + "\n")
        
        while self.running:
            try:
                # Get user input
                user_input = input(" Enter location (or command): ").strip()
                
                # Handle empty input
                if not user_input:
                    print("Please enter a location or command.\n")
                    continue
                
                # Handle commands
                if self._handle_command(user_input):
                    continue
                
                # Process weather request
                self._process_weather_request(user_input)
                
            except EOFError:
                # Handle Ctrl+D (EOF)
                print("\n")
                self._shutdown()
                break
                
            except Exception as e:
                print(f"\n[FAIL] Unexpected error: {e}")
                print("Please try again.\n")
    
    def _process_weather_request(self, user_input):
        """
        Process a weather request through the complete workflow.
        
        Args:
            user_input: User's location input
        """
        # Track request
        self.request_count += 1
        request_start = datetime.now()
        
        print() # Blank line for spacing
        
        # Show processing indicator for longer requests
        if self.config.show_debug_info:
            print(f" Processing request #{self.request_count}...")
        
        # Execute workflow through orchestrator
        result = self.orchestrator.get_weather_for_city(user_input)
        
        # Display result through presentation layer
        display_weather_result(result)
        
        # Show debug information if enabled
        if self.config.show_debug_info:
            self._display_debug_info(result, request_start)
        
        print() # Blank line for spacing
    
    def _handle_command(self, user_input):
        """
        Handle special commands.
        
        Args:
            user_input: User's input string
            
        Returns:
            bool: True if input was a command, False otherwise
        """
        command = user_input.lower()
        
        # Quit commands
        if command in ['quit', 'exit', 'q']:
            self._shutdown()
            return True
        
        # Toggle debug mode
        if command == 'debug':
            self.config.show_debug_info = not self.config.show_debug_info
            status = "enabled" if self.config.show_debug_info else "disabled"
            print(f"\n Debug mode {status}\n")
            return True
        
        # Show statistics
        if command == 'stats':
            self._display_statistics()
            return True
        
        # Help command
        if command == 'help':
            self._display_help()
            return True
        
        return False
    
    def _display_debug_info(self, result, request_start):
        """Display detailed debug information about the request."""
        request_duration = (datetime.now() - request_start).total_seconds()
        
        print("\n DEBUG INFORMATION")
        print("-" * 70)
        print(f"Request duration: {request_duration:.3f}s")
        print(f"Workflow status: {result.status.value}")
        
        metadata = result.processing_metadata
        print(f"Processing steps: {len(metadata.get('steps', []))}")
        
        for step in metadata.get('steps', []):
            step_name = step.get('step', 'unknown')
            step_duration = step.get('duration', 0)
            step_status = step.get('status', 'unknown')
            print(f" • {step_name}: {step_duration:.3f}s ({step_status})")
        
        if result.warnings:
            print(f"Warnings: {len(result.warnings)}")
        
        print("-" * 70)
    
    def _display_statistics(self):
        """Display session statistics."""
        if not self.start_time:
            print("\nNo statistics available yet.\n")
            return
        
        session_duration = (datetime.now() - self.start_time).total_seconds()
        
        print("\n" + "=" * 70)
        print(" SESSION STATISTICS")
        print("=" * 70)
        print(f"Session started: {self.start_time.strftime('%Y-%m-%d %H:%M:%S')}")
        print(f"Session duration: {session_duration:.1f}s")
        print(f"Requests processed: {self.request_count}")
        
        if self.request_count > 0:
            time_per_request = session_duration / self.request_count
            print(f"Session time per request: {time_per_request:.2f}s")
            print(" (wall clock; includes idle time between prompts)")
        
        print("=" * 70 + "\n")
    
    def _display_help(self):
        """Display help information."""
        print("\n" + "=" * 70)
        print(" HELP")
        print("=" * 70)
        print("\nLocation Formats:")
        print(" • City name: 'London', 'Paris', 'Tokyo'")
        print(" • City with state: 'San Francisco, CA'")
        print(" • City with country: 'London, UK', 'Paris, France'")
        print(" • Coordinates: 'latitude,longitude' (e.g., '51.5074,-0.1278')")
        print("\nCommands:")
        print(" • quit/exit - Exit the application")
        print(" • debug - Toggle detailed debug information")
        print(" • stats - Show session statistics")
        print(" • help - Show this help message")
        print("=" * 70 + "\n")
    
    def _shutdown(self):
        """Perform graceful shutdown."""
        self.running = False
        
        print("\n" + "=" * 70)
        print(" SHUTTING DOWN")
        print("=" * 70)
        
        if self.start_time:
            session_duration = (datetime.now() - self.start_time).total_seconds()
            print(f"Session duration: {session_duration:.1f}s")
            print(f"Requests processed: {self.request_count}")
        
        print("\nThank you for using Weather Dashboard!")
        print("=" * 70 + "\n")
    
    def _signal_handler(self, signum, frame):
        """Handle interrupt signals gracefully."""
        print("\n") # New line after ^C
        self._shutdown()
        sys.exit(0)

def main():
    """
    Main entry point for weather dashboard application.
    
    Handles configuration loading and application lifecycle.
    """
    try:
        # Load configuration
        config = load_config()
        
        # Create and run application
        app = WeatherDashboardApp(config)
        app.run()
        
    except ValueError as e:
        # Configuration error
        print(f"Cannot start application: {e}")
        sys.exit(1)
        
    except KeyboardInterrupt:
        # User interrupted during startup
        print("\n\nStartup interrupted. Exiting.")
        sys.exit(0)
        
    except Exception as e:
        # Unexpected error during startup
        print(f"\n[FAIL] Fatal error: {e}")
        sys.exit(1)

if __name__ == "__main__":
    main()

The coordinator is intentionally thin. Notice that _process_weather_request is one line of real work, self.orchestrator.get_weather_for_city(user_input), wrapped in tracking and display. Every method on this class either manages lifecycle (initialize, run, _shutdown, _signal_handler) or routes input (_handle_command, _display_help, _display_statistics). Nothing here knows what weather data looks like, how to call OpenWeatherMap, or how to format a forecast. That's the test: if any of those concerns leaked in here, the layering broke.

Running the complete application

Set the API key, run the app, and walk through a typical session:

Terminal session
$ cp .env.example .env
$ # edit .env to add your real OPENWEATHER_API_KEY
$ python weather_dashboard.py

  Production Weather Dashboard
======================================================================
Initializing components...
[OK] API client initialized
[OK] Geocoding processor initialized
[OK] Weather processor initialized
[OK] Business logic coordinator initialized
[OK] Presentation layer ready

Ready to process weather requests!

======================================================================
INSTRUCTIONS
======================================================================
Enter city names, coordinates, or locations:
  • City: 'London' or 'San Francisco, CA'
  • With country: 'Paris, France' or 'Tokyo, Japan'
  • Coordinates: '51.5074,-0.1278'

Commands:
  • 'quit' or 'exit' - Exit the application
  • 'debug' - Toggle debug information
  • 'stats' - Show session statistics
  • 'help' - Show this help message
======================================================================

 Enter location (or command): London

======================================================================
  WEATHER FOR LONDON, ENGLAND, GB
======================================================================

  CURRENT CONDITIONS
   Temperature: 15.3°C
   Feels like: 12.5°C
   Description: Light Rain
   Humidity: 72%
   Pressure: 1013 hPa
   Wind: 12.5 km/h from SW
   Visibility: 8.5 km

 DATA QUALITY
   Updated: 2026-05-01 14:23:45
   UTC offset: UTC+01:00
   Quality: 92% (Excellent)
======================================================================

 Enter location (or command): debug

 Debug mode enabled

 Enter location (or command): Tokyo

 Processing request #2...

======================================================================
  WEATHER FOR TOKYO, TOKYO, JAPAN
======================================================================

  CURRENT CONDITIONS
   Temperature: 18.2°C
   Description: Clear Sky
   Humidity: 45%
   Pressure: 1015 hPa
   Wind: 8.3 km/h from E

 DATA QUALITY
   Updated: 2026-05-01 23:24:15
   UTC offset: UTC+09:00
   Quality: 95% (Excellent)
======================================================================

 DEBUG INFORMATION
----------------------------------------------------------------------
Request duration: 1.234s
Workflow status: success
Processing steps: 5
  • input_validation: 0.001s (success)
  • geocoding_api: 0.456s (success)
  • location_processing: 0.012s (success)
  • weather_api: 0.623s (success)
  • weather_processing: 0.008s (success)
----------------------------------------------------------------------

 Enter location (or command): stats

======================================================================
 SESSION STATISTICS
======================================================================
Session started: 2026-05-01 14:23:30
Session duration: 125.3s
Requests processed: 2
Session time per request: 62.65s
 (wall clock; includes idle time between prompts)
======================================================================

 Enter location (or command): quit

======================================================================
 SHUTTING DOWN
======================================================================
Session duration: 145.8s
Requests processed: 2

Thank you for using Weather Dashboard!
======================================================================

The loop has three command modes plus location entry. debug toggles the verbose timing block; stats prints session counters mid-run; quit ends cleanly. Anything else is treated as a location and fires the workflow. The session statistics block at shutdown is what you'd want in production for capacity planning, and it costs almost nothing: just a counter and two timestamps the coordinator already had in scope.

If your run diverges from this session, the most likely culprit is configuration. If Configuration Error: Missing required environment variable: OPENWEATHER_API_KEY fires before the boot banner, the key isn't reaching config.py: check that you copied .env.example to .env (rather than editing the example in place), or, if you're using shell exports, that echo $OPENWEATHER_API_KEY shows the key in this shell. If the boot succeeds but every weather request returns [FAIL] UNABLE TO GET WEATHER DATA with category unknown, the key value is invalid; visit OpenWeatherMap and verify it's active.

Project file structure

The layout below mirrors the layered architecture one-for-one: each layer is a file, supporting modules live alongside, tests sit in their own folder. Build this on disk so the imports in every code block above resolve:

Project layout
weather_dashboard/
│
├── weather_dashboard.py # Application entry point and CLI loop
├── config.py # Configuration loaded from environment
│
├── errors.py # From Chapter 9: categorise + retry + message helpers
├── json_helpers.py # From Chapter 10: safe_get + try_fields + normalize
├── validators.py # From Chapter 12: structure + content + range checks
│
├── input_validator.py # User-input validation (city names, coordinates)
├── weather_api_client.py # API client layer: resilient HTTP
├── geocoding_processor.py # Processing layer: validate geocoding response
├── weather_processor.py # Processing layer: validate weather response
├── orchestrator.py # Business logic layer: sequence and partial-success
├── display.py # Presentation layer: render the WorkflowResult
├── models.py # Shared dataclasses (Location, WeatherData, WorkflowResult)
│
├── tests/ # Test suite
│ ├── test_components.py # Unit tests for individual components
│ ├── test_integration.py # Integration tests for layer interactions
│ └── test_end_to_end.py # End-to-end tests against the live API
│
├── requirements.txt # Python dependencies
├── .gitignore # Keeps .env out of source control
└── .env.example # Environment variable template (committed)

Three flat files round out the project. requirements.txt declares the runtime dependencies, .gitignore keeps .env out of source control, and .env.example documents the environment variables a fresh checkout needs to set:

requirements.txt
requests>=2.31.0
python-dotenv>=1.0.0 # Loads .env at startup (see config.py)
.gitignore
.env
.env.example
# Weather Dashboard Configuration
# Copy this file to .env and fill in your values

# Required: OpenWeatherMap API Key
OPENWEATHER_API_KEY=your_api_key_here

# Optional: Override default settings
# WEATHER_MAX_RETRIES=3
# WEATHER_TIMEOUT=10.0
# WEATHER_DEBUG=false

These three files form the standard environment-variable pattern for local projects. The split serves a specific purpose: .env.example is committed as a template documenting what variables your project needs (so a teammate cloning the repo knows exactly what to set), .env is your local copy with real values that .gitignore keeps out of source control, and .gitignore carries the rule that enforces the split. Because config.py calls load_dotenv() on import, every script that imports config picks up the values automatically. Shell exports still work too: load_dotenv() doesn't override variables that are already in the environment.

The four layers are now wired together by two new files in this section; the coordinator at the top is the thinnest of the lot. That's not because it does the least work; it's because every layer below it did the work for it. §4's APIResult made try/except unnecessary at the call site. §5's processors made field validation unnecessary at the call site. §6's WorkflowResult made status branching unnecessary at the call site. By the time the coordinator gets a result, there's nothing left to do but hand it to the display. The layering paid for itself.

The four layers are now wired together end-to-end. Section 9 adds the safety net that lets you keep changing them: a unit-then-integration-then-end-to-end test pyramid you can run before any commit.