Session 8: ConfigurationManager & File I/O¶
Week: 8 Element: ICTPRG430 Element 2.2 Duration: 4 hours Phase: Advanced OOP - Data Management
Session Introduction¶
In this session, you'll learn how to manage robot configurations through professional file I/O patterns. Building on your robot hierarchy from Session 6 (LineFollowerRobot, GripperRobot, ArmRobot), you'll design a ConfigurationManager class that separates configuration concerns from robot logic. This follows the separation of concerns principle—a professional design pattern where configuration management is its own responsibility, independent of robot behavior.
You'll support multiple configuration formats (JSON, XML, YAML), implement robust error handling, and integrate your ConfigurationManager with your S6 robot classes. This foundational work prepares you for Session 9, where you'll use ConfigurationManager with the Factory Pattern.
Learning Objectives¶
By the end of this session, you will be able to:
- Implement file I/O operations using Python context managers safely
- Parse and validate configuration data from multiple formats (JSON, XML, YAML)
- Design a ConfigurationManager class that decouples configuration from robot logic
- Handle file errors gracefully with appropriate exception handling
- Integrate ConfigurationManager with your S6 robot hierarchy
- Support dynamic robot configuration driven by external data files
Session Structure¶
- Theory - File I/O patterns and configuration formats
- Custom Exceptions & Logging - ConfigurationError hierarchy, Python logging module
- ConfigurationManager Design - Class design, format support, validation
- Hands-on Exercise - Build ConfigurationManager and integrate with robots
- Testing & Validation - Verify configuration loading with multiple formats
- Assessment Preparation - Ready for AT2-S8 submission
Session Slides
View the presentation slides for this session:
The slides contain: - Why configuration files matter for robotics - File I/O fundamentals and context managers - Configuration formats: JSON, XML, YAML - Custom exception hierarchy for ConfigurationManager - Python logging for professional diagnostics - Separation of concerns with ConfigurationManager - Integration with S6 robot hierarchy - Testing strategies
Pre-Session Preparation¶
Required
- Python 3.9+ installed
- VS Code with Python extension
- Completed Session 7 (Refactoring & OOP integration)
Review
- Class design and inheritance from S4-6
- Exception handling basics
- Dictionary and list data structures
1. File I/O Fundamentals for Robotics¶
Why Configuration Files?¶
Robots need flexibility. Rather than hardcoding robot parameters (sensor count, max speed, gripper strength), configuration files allow:
- Dynamic behavior - Change robot settings without recompiling code
- Testing multiple configurations - Test different sensor counts, speeds, settings
- Professional deployment - Non-programmers can adjust robot behavior
- Data persistence - Save sensor readings, mission logs, calibration data
Context Managers & Safe File Handling¶
Always use context managers (with statement) for file operations:
# Correct: Context manager ensures file closes automatically
with open('robot_config.json', 'r') as file:
config_data = json.load(file)
# Avoid: Manual file handling can leak resources if errors occur
file = open('robot_config.json', 'r')
config_data = json.load(file) # If error here, file never closes!
file.close()
File Operation Patterns¶
# Reading configuration files
with open('config.json', 'r') as file:
config = json.load(file)
# Writing configuration files (creates or overwrites)
with open('config.json', 'w') as file:
json.dump(config, file, indent=2)
# Appending sensor logs
with open('sensor_log.txt', 'a') as file:
file.write(f"[{timestamp}] {sensor_reading}\n")
Error Handling for File Operations¶
Build on Session 7
In Session 7 you stopped using print() for error handling and built custom exception hierarchies. Session 8 continues that — see sections 3 and 4 for the ConfigurationManager exception hierarchy and logging pattern. The snippet below shows the professional usage:
# ✅ Professional: use custom exceptions + logging (covered in sections 3 & 4)
try:
config_mgr = ConfigurationManager('robot_config.json')
config_mgr.validate()
except ConfigFileNotFoundError as e:
logger.warning("No config file — using defaults. (%s)", e.file_path)
config_mgr = ConfigurationManager.from_defaults()
except ConfigParseError as e:
logger.error("Corrupted config file: %s", e)
raise # Surface this — don't silently swallow it
except ConfigValidationError as e:
logger.error("Invalid config: %s", e)
raise
2. Configuration File Formats¶
Robots often use structured data formats. Understanding their trade-offs is professional practice.
JSON (JavaScript Object Notation)¶
Pros:
- Lightweight, human-readable
- Native Python support via json module
- Good for hierarchical data (nested objects)
- Wide tool support
Example:
{
"robot_id": "go2_explorer_01",
"sensors": {
"ir_count": 4,
"has_camera": true
},
"movement": {
"max_speed": 2.5,
"battery_drain_per_meter": 1.2
}
}
XML (eXtensible Markup Language)¶
Pros: - Self-documenting (tags describe data) - Widely used in robotics systems - Supports complex hierarchies - Schema validation possible
Example:
<?xml version="1.0" encoding="UTF-8"?>
<robot>
<id>go2_explorer_01</id>
<sensors>
<ir_count>4</ir_count>
<camera>true</camera>
</sensors>
<movement>
<max_speed>2.5</max_speed>
<battery_drain>1.2</battery_drain>
</movement>
</robot>
YAML (YAML Ain't Markup Language)¶
Pros:
- Very human-readable (minimal syntax)
- Whitespace-based (clean appearance)
- Good for configuration files
- Python support via pyyaml package
Example:
robot_id: go2_explorer_01
sensors:
ir_count: 4
has_camera: true
movement:
max_speed: 2.5
battery_drain_per_meter: 1.2
Format Comparison¶
| Feature | JSON | XML | YAML |
|---|---|---|---|
| Human-readable | Good | Good | Excellent |
| Parsing simplicity | Easy | Medium | Easy |
| Hierarchical support | Yes | Yes | Yes |
| Schema validation | No | Yes | Limited |
| Python native support | Yes | Yes | Via pyyaml |
| Robotics industry use | Common | Very common | Growing |
3. Custom Exception Hierarchy¶
In Session 7, you built a custom exception hierarchy for the coffee shop. Session 8 applies the same pattern to file-loading classes — this is how professionals signal what went wrong rather than printing a message and hoping.
Example: Sensor Calibration Reader¶
To see the pattern clearly, here is a teaching example using a different domain — a CalibrationReader that loads sensor calibration data from a file. Your exercise will apply the same structure to ConfigurationManager.
class CalibrationError(Exception):
"""Base exception for all calibration errors.
All CalibrationReader exceptions inherit from this,
allowing callers to catch broadly or specifically.
"""
pass
class CalibrationFileNotFoundError(CalibrationError):
"""Raised when the calibration file cannot be found.
Args:
file_path: The path that was searched.
"""
def __init__(self, file_path: str):
self.file_path = file_path
super().__init__(f"Calibration file not found: {file_path}")
class CalibrationParseError(CalibrationError):
"""Raised when a calibration file exists but cannot be parsed.
Args:
file_path: The file that failed to parse.
reason: Human-readable description of the parse failure.
"""
def __init__(self, file_path: str, reason: str):
self.file_path = file_path
self.reason = reason
super().__init__(f"Failed to parse '{file_path}': {reason}")
class CalibrationValidationError(CalibrationError):
"""Raised when calibration data fails validation checks.
Args:
errors: List of validation error messages.
"""
def __init__(self, errors: list):
self.errors = errors
error_list = "\n - ".join(errors)
super().__init__(f"Calibration validation failed:\n - {error_list}")
Why Use a Hierarchy?¶
Catch broadly or narrowly — the caller decides:
# Catch ALL calibration problems in one place
try:
cal = CalibrationReader('ir_sensor_cal.json')
cal.validate()
except CalibrationError as e:
logger.error("Calibration problem: %s", e)
# OR catch specific problems to handle differently
try:
cal = CalibrationReader('ir_sensor_cal.json')
cal.validate()
except CalibrationFileNotFoundError:
cal = CalibrationReader.from_defaults() # Fallback to defaults
except CalibrationParseError as e:
logger.error("Corrupted calibration file: %s", e.file_path)
raise # Re-raise — needs attention
except CalibrationValidationError as e:
for error in e.errors:
logger.warning("Calibration issue: %s", error)
The Pattern Across Domains¶
Notice how the structure is identical regardless of domain:
| Coffee Shop (S7) | Calibration Reader (this example) | Your ConfigurationManager |
|---|---|---|
CoffeeShopError |
CalibrationError |
ConfigurationError (you design) |
OrderError |
CalibrationFileNotFoundError |
ConfigFileNotFoundError (you design) |
PaymentError |
CalibrationParseError |
ConfigParseError (you design) |
InsufficientPointsError |
CalibrationValidationError |
ConfigValidationError (you design) |
Your task is to design the ConfigurationError hierarchy yourself — same pattern, applied to the robot configuration domain. Think about what can go wrong when loading a robot config file and what a caller would need to distinguish between.
4. Professional Logging¶
In Session 7 you stopped using print() for errors—you raised exceptions instead. Now meet Python's logging module: the professional tool for diagnostic messages (startup info, debug traces, status updates) that aren't errors.
Why Logging Instead of Print?¶
print() |
logging |
|
|---|---|---|
| Severity levels | ❌ None | ✅ DEBUG / INFO / WARNING / ERROR / CRITICAL |
| Timestamps | ❌ Manual | ✅ Automatic |
| Output control | ❌ Always shows | ✅ Toggle per level |
| Module filtering | ❌ All or nothing | ✅ Per-module control |
| Production-safe | ❌ Clutters output | ✅ Can silence without code change |
Configuring a Logger¶
import logging
# Get a logger named after this module (best practice)
logger = logging.getLogger(__name__)
# Basic configuration — done once at the application entry point
logging.basicConfig(
level=logging.DEBUG,
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
datefmt="%Y-%m-%d %H:%M:%S"
)
Log Levels in Practice¶
logger.debug("Loading config from: %s", self.config_file) # Dev detail
logger.info("Configuration loaded successfully (%d keys)", len(self.config)) # Normal ops
logger.warning("Optional field 'description' missing from config") # Unusual but ok
logger.error("Failed to open config file: %s", self.config_file) # Something broke
logger.critical("Config validation failed — robot cannot start") # Fatal
CalibrationReader with Logging¶
Here is the CalibrationReader example class — showing how logging and exceptions combine in a real file-loading class. Your exercise will apply this same integration to ConfigurationManager.
import logging
import json
import os
logger = logging.getLogger(__name__)
class CalibrationReader:
"""Loads sensor calibration data from a JSON file."""
def __init__(self, cal_file: str) -> None:
self.cal_file = cal_file
self.data = {}
logger.debug("CalibrationReader initialised: %s", cal_file)
self.load()
def load(self) -> None:
"""Load calibration data, raising typed exceptions on failure."""
if not os.path.exists(self.cal_file):
logger.error("Calibration file not found: %s", self.cal_file)
raise CalibrationFileNotFoundError(self.cal_file)
try:
with open(self.cal_file, 'r') as f:
self.data = json.load(f)
logger.info("Calibration loaded: %s (%d keys)", self.cal_file, len(self.data))
except json.JSONDecodeError as e:
logger.error("Parse error in %s: %s", self.cal_file, e)
raise CalibrationParseError(self.cal_file, str(e)) from e
def validate(self) -> None:
"""Validate calibration data, raising CalibrationValidationError if invalid."""
errors = []
if 'sensor_name' not in self.data:
errors.append("Missing required field: sensor_name")
if 'channels' in self.data:
if not isinstance(self.data['channels'], int):
errors.append("channels must be an integer")
elif self.data['channels'] <= 0:
errors.append("channels must be greater than zero")
if errors:
logger.warning("Validation failed with %d error(s)", len(errors))
raise CalibrationValidationError(errors)
logger.debug("Calibration validation passed")
Notice how every method follows the same structure:
logger.debug()/logger.info()for normal operationslogger.error()/logger.warning()before raising an exception- Typed exceptions propagate to the caller — never silent failure
Key Rule: Exceptions for Errors, Logging for Diagnostics¶
Rule of Thumb
- Raise exceptions when something is wrong and the caller needs to handle it
- Log messages to record what happened for diagnostics and debugging
- Never silently swallow errors — the robot will run with empty config and fail mysteriously
# ❌ Wrong: Silent failure
except FileNotFoundError:
print("File not found")
self.config = {} # Robot runs with empty config!
# ✅ Right: Raise + log for full context
if not os.path.exists(self.config_file):
logger.error("Config file not found: %s", self.config_file)
raise ConfigFileNotFoundError(self.config_file)
5. Separation of Concerns Principle¶
This is the key design concept for Session 8.
Problem: Coupled Design¶
class LineFollowerRobot:
def __init__(self, config_file):
self.name = None
self.ir_sensors = None
# Robot handles BOTH behavior AND configuration
self.load_config(config_file) # ← Mixing concerns
def load_config(self, config_file):
# Robot class tied to specific file format
with open(config_file) as f:
config = json.load(f)
self.name = config['name']
self.ir_sensors = config['ir_count']
Problems: - Robot class knows about JSON file format - Can't change config format without changing robot code - Robot responsible for both behavior AND data loading - Testing requires actual files
Solution: Separation of Concerns¶
class ConfigurationManager:
"""Handles all configuration file operations."""
def __init__(self, config_file):
self.config_file = config_file
self.config = self.load()
def load(self):
"""Load configuration from file."""
# ConfigurationManager handles file I/O, not robot
class LineFollowerRobot:
"""Focuses only on behavior."""
def __init__(self, name, ir_sensors):
self.name = name
self.ir_sensors = ir_sensors
def read_ir_sensors(self):
"""Behavior - independent of how config was loaded"""
return [0 if i % 2 == 0 else 1 for i in range(self.ir_sensors)]
# Usage: Separate concerns
config_manager = ConfigurationManager('robot_config.json')
robot = LineFollowerRobot(
name=config_manager.get('name'),
ir_sensors=config_manager.get('sensors.ir_count')
)
Benefits: - Robot class focuses on behavior only - ConfigurationManager handles all file operations - Can change file format without touching robot code - Each class has single responsibility - Easier to test (mock ConfigurationManager)
6. ConfigurationManager Class Design¶
Core Responsibilities¶
A ConfigurationManager should:
- Load configurations from different file formats
- Parse and validate the loaded data
- Provide safe access to configuration values
- Handle errors gracefully (missing files, bad format, invalid data)
- Support querying with dot notation (e.g.,
get('sensors.ir_count'))
Suggested Interface¶
class ConfigurationManager:
"""Manages robot configurations from files."""
def __init__(self, config_file):
"""Initialize with configuration file path."""
self.config_file = config_file
self.config = {}
self.load()
def load(self):
"""Load configuration from file (auto-detect format)."""
# Determine format from file extension
# Parse appropriate format
# Store in self.config
def get(self, key, default=None):
"""Safely retrieve configuration value.
Supports dot notation: get('sensors.ir_count')
Returns default if key not found.
"""
def save(self, output_file=None):
"""Save current configuration to file."""
# Save to file (auto-detect format from extension)
def validate(self) -> None:
"""Validate configuration data.
Raises:
ConfigValidationError: If any validation checks fail.
"""
# Check required fields exist
# Check data types are correct
# Check values are in valid ranges
# Raise ConfigValidationError with all errors if any fail
Validation Pattern¶
Here is the same validation pattern applied to CalibrationReader — you will implement this pattern inside your own ConfigurationManager.validate():
def validate(self) -> None:
"""Validate calibration data — same pattern you will apply to ConfigurationManager."""
errors = []
# Check required fields exist
if 'sensor_name' not in self.data:
errors.append("Missing required field: sensor_name")
# Check data types
if 'channels' in self.data:
if not isinstance(self.data['channels'], int):
errors.append("channels must be integer")
elif self.data['channels'] <= 0:
errors.append("channels must be greater than zero")
# Raise with all errors at once — don't stop at the first failure
if errors:
raise CalibrationValidationError(errors)
Key points to carry into your implementation:
- Collect all errors before raising — don't fail on the first one
- The exception message should list every problem
- Calling code can then report all issues to the user at once
7. Hands-On Exercise: Build ConfigurationManager¶
Competency Outcomes¶
You will demonstrate competency when you can:
- Design and implement a custom exception hierarchy for configuration errors (without copying the calibration example — your hierarchy, your names, your decisions)
- Load a JSON configuration file using a context manager and handle file-not-found and parse errors with typed exceptions
- Load at least one additional format (XML or YAML) with auto-detection from file extension
- Retrieve a value using a simple key:
config.get('robot_id') - Retrieve a nested value using dot notation:
config.get('sensors.ir_count') - Return a default value when a key is absent:
config.get('unknown', default=0) - Validate a loaded config and raise a typed exception listing all validation failures
- Distinguish clearly between a parse error (bad syntax) and a validation error (valid syntax, wrong data)
- Log at DEBUG, INFO, WARNING, and ERROR levels at appropriate points
- Save a config back to file in the detected format
- Load a robot config file and use it to instantiate a robot object from your S6 hierarchy
Deliverables¶
Submit these files:
configuration_manager.py— yourConfigurationManagerclass and your exception hierarchyexample_configs/— at least 3 config files: one JSON, one in your second format, one for a different robot typetest_configuration.py— your test suite (skeleton provided below)
Part A: Basic Implementation¶
Build a ConfigurationManager class that:
- Loads JSON and one other format (XML or YAML — your choice)
- Auto-detect format from file extension
- Raise your
ConfigFileNotFoundErrorwhen file is missing -
Raise your
ConfigParseErrorfor corrupted or invalid files -
Supports value access
- Simple keys:
get('robot_id') - Nested keys with dot notation:
get('sensors.ir_count') -
Default values:
get('unknown_key', default=0) -
Validates configuration
- Required fields check
- Type validation
- Range validation (e.g., sensor count > 0)
-
Raises your
ConfigValidationErrorlisting all failures -
Saves configuration
- Write back to file in detected format
- Pretty-printed for readability
Part B: Integration with S6 Robot Hierarchy¶
Create your own configuration files for at least two of your S6 robot types. Decide what fields each robot needs — there is no single correct answer.
Your goal is to load one of these files and use the data to instantiate the correct robot:
# This is the target — your code should achieve this result
config = ConfigurationManager('my_robot_config.json')
config.validate()
robot = LineFollowerRobot(
name=config.get('name'),
battery_level=config.get('battery_level'),
ir_sensors=config.get('sensors.ir_count')
)
What config fields you define, and what validation rules you apply, are your design decisions.
Part C: Testing & Validation¶
Test your ConfigurationManager with the scenarios below. Use the skeleton provided — fill in the test bodies.
# test_configuration.py — fill in the test bodies
import unittest
import os
import json
from configuration_manager import ConfigurationManager
class TestConfigurationManagerLoad(unittest.TestCase):
def setUp(self):
"""Create a minimal valid config file for use in tests."""
self.valid_file = 'test_valid_config.json'
with open(self.valid_file, 'w') as f:
json.dump({'robot_id': 'test_robot', 'battery_level': 100}, f)
def tearDown(self):
"""Remove test files created during tests."""
if os.path.exists(self.valid_file):
os.remove(self.valid_file)
def test_load_valid_json(self):
"""ConfigurationManager loads a valid JSON file without error."""
# YOUR CODE HERE
pass
def test_load_missing_file_raises_error(self):
"""ConfigFileNotFoundError raised when file does not exist."""
# YOUR CODE HERE — hint: use self.assertRaises()
pass
def test_load_corrupted_file_raises_error(self):
"""ConfigParseError raised when file contains invalid JSON/XML/YAML."""
# YOUR CODE HERE — create a temp file with invalid content, then load it
pass
class TestConfigurationManagerGet(unittest.TestCase):
def setUp(self):
self.valid_file = 'test_get_config.json'
with open(self.valid_file, 'w') as f:
json.dump({
'robot_id': 'test_robot',
'sensors': {'ir_count': 4, 'threshold': 0.5}
}, f)
self.config = ConfigurationManager(self.valid_file)
def tearDown(self):
if os.path.exists(self.valid_file):
os.remove(self.valid_file)
def test_get_simple_key(self):
"""get() returns correct value for a top-level key."""
# YOUR CODE HERE
pass
def test_get_nested_key_dot_notation(self):
"""get() returns correct value for a nested key using dot notation."""
# YOUR CODE HERE — e.g., config.get('sensors.ir_count') should return 4
pass
def test_get_missing_key_returns_default(self):
"""get() returns the provided default when key does not exist."""
# YOUR CODE HERE
pass
class TestConfigurationManagerValidate(unittest.TestCase):
def test_valid_config_passes_validation(self):
"""validate() does not raise when config data is valid."""
# YOUR CODE HERE
pass
def test_missing_required_field_raises_validation_error(self):
"""ConfigValidationError raised when a required field is absent."""
# YOUR CODE HERE — create a config missing a required field
pass
def test_invalid_field_type_raises_validation_error(self):
"""ConfigValidationError raised when a field has the wrong type."""
# YOUR CODE HERE
pass
if __name__ == '__main__':
unittest.main()
8. Key Design Patterns¶
Separation of Concerns¶
- ConfigurationManager: "How do we load and manage configuration data?"
- Robot classes: "What behavior does each robot type have?"
- Neither class needs to know about the other's implementation details
Type Hints for Clarity¶
from typing import Dict, Optional, Any
class ConfigurationManager:
def __init__(self, config_file: str) -> None:
"""Initialize with configuration file path."""
def get(self, key: str, default: Optional[Any] = None) -> Any:
"""Safely retrieve configuration value."""
def validate(self) -> list:
"""Validate configuration. Returns list of error strings."""
Error Handling Strategy¶
def load(self) -> None:
"""Load configuration, raising typed exceptions on failure.
Raises:
ConfigFileNotFoundError: If the config file does not exist.
ConfigParseError: If the file exists but cannot be parsed.
"""
if not os.path.exists(self.config_file):
logger.error("Config file not found: %s", self.config_file)
raise ConfigFileNotFoundError(self.config_file)
try:
with open(self.config_file, 'r') as f:
self.config = self._parse_file(f)
logger.info("Loaded config: %s", self.config_file)
except (json.JSONDecodeError, ValueError) as e:
logger.error("Parse error in '%s': %s", self.config_file, e)
raise ConfigParseError(self.config_file, str(e)) from e
9. Professional Standards¶
Configuration File Organization¶
Store configurations in a consistent directory:
my_robot_project/
├── src/
│ └── robot_config.py
├── configs/
│ ├── lineFollower_config.json
│ ├── gripper_config.xml
│ └── arm_config.yaml
├── tests/
│ └── test_configuration.py
└── README.md
Documentation¶
Document your ConfigurationManager:
class ConfigurationManager:
"""Manages robot configuration files.
Supports JSON, XML, and YAML formats. Auto-detects format
from file extension. Provides safe access to nested
configuration values with validation.
Example:
config_mgr = ConfigurationManager('robot_config.json')
robot_name = config_mgr.get('name')
ir_count = config_mgr.get('sensors.ir_count', default=4)
config_mgr.validate() # Raises ConfigValidationError if invalid
print("Configuration is valid")
"""
Check Your Understanding¶
Q1: Separation of Concerns
Why should ConfigurationManager be a separate class rather than part of the robot classes?
Answer
- Robot classes focus on behavior (what the robot does)
- ConfigurationManager focuses on data persistence (how configs are loaded)
- Separating concerns makes code easier to test, modify, and reuse
- Robot behavior doesn't change if we switch from JSON to YAML format
Q2: Nested Configuration Access
How would you support get('sensors.ir_count') for nested dictionaries?
Q3: Error Handling
What errors should ConfigurationManager handle gracefully?
Answer
Use custom exceptions (see Sections 3 & 4), not bare built-in exceptions:
ConfigFileNotFoundError: File doesn't exist — raise, let caller decide on fallbackConfigParseError: File is corrupted/malformed — raise, this needs attentionConfigValidationError: Data is invalid — raise with the full list of errors- Log context at each point with
logger.error()orlogger.warning() - Never silently swallow errors with
except ...: self.config = {}— the robot will run with empty config and fail mysteriously later
Q4: Testing ConfigurationManager
How would you test ConfigurationManager without using actual files?
Session Summary¶
You've learned:
- File I/O best practices - Context managers, safe resource cleanup
- Configuration file formats - JSON, XML, YAML comparison and trade-offs
- Custom exceptions -
ConfigurationErrorhierarchy extending S7 patterns - Professional logging -
loggingmodule, log levels, diagnostics vs errors - Separation of concerns - Keep configuration management separate from robot behavior
- ConfigurationManager design - Professional configuration system class
- Validation and error handling - Robust, defensive programming with typed exceptions
- Integration with S6 hierarchy - Load robot configurations from files
This foundation prepares you for Session 9: Factory Pattern, where you'll use ConfigurationManager to elegantly manage robot creation.
Assessment: AT2-S7¶
See the AT2-S7-ConfigurationManager assessment document for submission requirements.
Navigation: ← Week 7 | Learning Plan | Week 9 →