Subscribe To Our Newsletter
You will receive our latest post and tutorial.
Thank you for subscribing!

required
required


Python – Testing

If you have ever pushed code to production and immediately felt a knot in your stomach, you already understand why testing matters. Automated tests are the safety net that lets you ship with confidence. They catch bugs before your users do, give you the freedom to refactor without fear, and serve as living documentation that shows exactly how your code is supposed to behave. In Python, the testing ecosystem is mature, well-designed, and a pleasure to work with. This guide walks you through everything you need to write effective tests, from the built-in unittest module to the more modern pytest framework, including mocking, parameterization, coverage, and the practices that separate professional-grade test suites from fragile afterthoughts.

unittest — Python’s Built-in Testing Framework

Python ships with the unittest module in the standard library. It follows the xUnit pattern familiar to developers coming from Java (JUnit) or C# (NUnit). You organize tests into classes that inherit from unittest.TestCase, and each test method must start with the prefix test_.

A Simple Example: Testing a Calculator

Let us start with a module we want to test. Create a file called calculator.py:

class Calculator:
    """A simple calculator class."""

    def add(self, a, b):
        return a + b

    def subtract(self, a, b):
        return a - b

    def multiply(self, a, b):
        return a * b

    def divide(self, a, b):
        if b == 0:
            raise ValueError("Cannot divide by zero")
        return a / b

Now create test_calculator.py:

import unittest
from calculator import Calculator


class TestCalculator(unittest.TestCase):
    """Tests for the Calculator class."""

    def setUp(self):
        """Create a fresh Calculator instance before each test."""
        self.calc = Calculator()

    def test_add_positive_numbers(self):
        result = self.calc.add(3, 5)
        self.assertEqual(result, 8)

    def test_add_negative_numbers(self):
        result = self.calc.add(-2, -7)
        self.assertEqual(result, -9)

    def test_subtract(self):
        result = self.calc.subtract(10, 4)
        self.assertEqual(result, 6)

    def test_multiply(self):
        result = self.calc.multiply(3, 7)
        self.assertEqual(result, 21)

    def test_divide(self):
        result = self.calc.divide(10, 2)
        self.assertEqual(result, 5.0)

    def test_divide_by_zero_raises_error(self):
        with self.assertRaises(ValueError) as context:
            self.calc.divide(10, 0)
        self.assertIn("Cannot divide by zero", str(context.exception))

    def tearDown(self):
        """Clean up after each test (if needed)."""
        self.calc = None


if __name__ == "__main__":
    unittest.main()

Run the tests from the terminal:

python -m unittest test_calculator -v

The -v flag gives you verbose output so you can see each test name and its result.

setUp and tearDown

The setUp() method runs before every single test method in the class. Use it to create objects, open connections, or prepare any state your tests need. The tearDown() method runs after every test method, whether the test passed or failed. Use it to close connections, delete temporary files, or reset state.

This is critical: each test must be fully independent. Never rely on one test setting up state for another test. The order tests run in is not guaranteed, and coupling tests together leads to mysterious failures that waste hours of debugging time.

import unittest
import os
import tempfile


class TestFileOperations(unittest.TestCase):

    def setUp(self):
        """Create a temporary file before each test."""
        self.temp_dir = tempfile.mkdtemp()
        self.file_path = os.path.join(self.temp_dir, "test_data.txt")
        with open(self.file_path, "w") as f:
            f.write("initial content")

    def test_file_exists(self):
        self.assertTrue(os.path.exists(self.file_path))

    def test_file_content(self):
        with open(self.file_path, "r") as f:
            content = f.read()
        self.assertEqual(content, "initial content")

    def tearDown(self):
        """Remove the temporary file after each test."""
        if os.path.exists(self.file_path):
            os.remove(self.file_path)
        os.rmdir(self.temp_dir)

There are also class-level equivalents: setUpClass() and tearDownClass(). These run once for the entire test class rather than before each individual test. They are useful for expensive setup operations like creating a database connection.

class TestWithDatabase(unittest.TestCase):

    @classmethod
    def setUpClass(cls):
        """Runs once before all tests in this class."""
        cls.db_connection = create_database_connection()

    @classmethod
    def tearDownClass(cls):
        """Runs once after all tests in this class."""
        cls.db_connection.close()

Common Assertion Methods

The unittest.TestCase class provides a rich set of assertion methods. Here are the ones you will use most often:

class TestAssertions(unittest.TestCase):

    def test_equality(self):
        self.assertEqual(1 + 1, 2)          # a == b
        self.assertNotEqual(1 + 1, 3)       # a != b

    def test_truthiness(self):
        self.assertTrue(10 > 5)             # bool(x) is True
        self.assertFalse(5 > 10)            # bool(x) is False

    def test_identity(self):
        a = [1, 2, 3]
        b = a
        self.assertIs(a, b)                 # a is b
        self.assertIsNot(a, [1, 2, 3])      # a is not b

    def test_none_checks(self):
        self.assertIsNone(None)             # x is None
        self.assertIsNotNone("hello")       # x is not None

    def test_membership(self):
        self.assertIn(3, [1, 2, 3])         # a in b
        self.assertNotIn(4, [1, 2, 3])      # a not in b

    def test_type_checks(self):
        self.assertIsInstance(42, int)       # isinstance(a, b)

    def test_approximate_equality(self):
        self.assertAlmostEqual(0.1 + 0.2, 0.3, places=7)  # for floats

    def test_exceptions(self):
        with self.assertRaises(ZeroDivisionError):
            _ = 1 / 0

    def test_exception_message(self):
        with self.assertRaisesRegex(ValueError, "invalid literal"):
            int("not_a_number")

Skipping Tests

Sometimes you need to skip a test conditionally, for example when a feature is only available on certain platforms or a dependency is missing. Use the @unittest.skip decorator or its conditional variants:

import sys
import unittest


class TestConditionalSkipping(unittest.TestCase):

    @unittest.skip("Temporarily disabled while refactoring")
    def test_feature_in_progress(self):
        pass

    @unittest.skipIf(sys.platform == "win32", "Does not run on Windows")
    def test_unix_only_feature(self):
        pass

    @unittest.skipUnless(sys.platform.startswith("linux"), "Linux only")
    def test_linux_feature(self):
        pass

Test Suites

You can group related tests into suites for selective execution:

import unittest
from test_calculator import TestCalculator


def suite():
    test_suite = unittest.TestSuite()
    test_suite.addTest(TestCalculator("test_add_positive_numbers"))
    test_suite.addTest(TestCalculator("test_divide_by_zero_raises_error"))
    return test_suite


if __name__ == "__main__":
    runner = unittest.TextTestRunner(verbosity=2)
    runner.run(suite())

In practice, most teams rely on test discovery rather than manually building suites. Running python -m unittest discover will find and run all test files matching the pattern test*.py.

pytest — The Modern Testing Framework

While unittest is solid, pytest is the tool most Python developers reach for today. It requires less boilerplate, produces better error messages, and has a powerful plugin ecosystem. You do not need to inherit from any class or remember assertion method names — you just use plain assert statements.

Installation

pip install pytest

Writing Tests with pytest

With pytest, tests are just functions. No class inheritance required:

from calculator import Calculator


def test_add():
    calc = Calculator()
    assert calc.add(2, 3) == 5


def test_subtract():
    calc = Calculator()
    assert calc.subtract(10, 4) == 6


def test_divide_by_zero():
    calc = Calculator()
    import pytest
    with pytest.raises(ValueError, match="Cannot divide by zero"):
        calc.divide(10, 0)

Run the tests:

pytest test_calculator.py -v

When a test fails, pytest gives you a detailed diff showing exactly what went wrong, which is far more helpful than unittest’s default output.

Fixtures — Managing Test Dependencies

Fixtures in pytest replace setUp and tearDown. They are more flexible because they are composable, can have different scopes, and are injected by name:

import pytest
from calculator import Calculator


@pytest.fixture
def calculator():
    """Provide a Calculator instance to each test that requests it."""
    return Calculator()


@pytest.fixture
def sample_data():
    """Provide test data."""
    return {"a": 10, "b": 5, "expected_sum": 15}


def test_add(calculator, sample_data):
    result = calculator.add(sample_data["a"], sample_data["b"])
    assert result == sample_data["expected_sum"]


def test_multiply(calculator):
    assert calculator.multiply(4, 5) == 20

Fixtures can also handle teardown using the yield keyword. Everything before yield is setup; everything after is teardown:

import pytest
import tempfile
import os


@pytest.fixture
def temp_file():
    """Create a temporary file, yield it, then clean up."""
    path = tempfile.mktemp(suffix=".txt")
    with open(path, "w") as f:
        f.write("test data")
    yield path
    # Teardown: runs after the test completes
    if os.path.exists(path):
        os.remove(path)


def test_temp_file_exists(temp_file):
    assert os.path.exists(temp_file)


def test_temp_file_content(temp_file):
    with open(temp_file) as f:
        assert f.read() == "test data"

Fixture scopes control how often a fixture is created: "function" (default, once per test), "class", "module", or "session" (once for the entire test run).

Parameterized Tests

When you need to test the same logic with multiple inputs, @pytest.mark.parametrize eliminates copy-paste duplication:

import pytest
from calculator import Calculator


@pytest.fixture
def calc():
    return Calculator()


@pytest.mark.parametrize("a, b, expected", [
    (1, 2, 3),
    (0, 0, 0),
    (-1, 1, 0),
    (-5, -3, -8),
    (100, 200, 300),
])
def test_add_various_inputs(calc, a, b, expected):
    assert calc.add(a, b) == expected


@pytest.mark.parametrize("a, b, expected", [
    (6, 3, 2.0),
    (10, 5, 2.0),
    (7, 2, 3.5),
    (0, 1, 0.0),
])
def test_divide_various_inputs(calc, a, b, expected):
    assert calc.divide(a, b) == expected

Each parameter set becomes its own test case in the output, making it easy to see exactly which inputs caused a failure.

Mocking — Testing Code with External Dependencies

Real-world code rarely exists in isolation. It calls APIs, queries databases, reads files, and sends emails. You do not want your unit tests making actual HTTP requests or writing to a production database. This is where mocking comes in.

The unittest.mock module (part of the standard library) provides Mock, MagicMock, and the patch decorator to replace real objects with controllable fakes during tests.

Example: Testing a Function That Calls an API

Suppose you have a service that fetches user data from an external API:

# user_service.py
import requests


class UserService:
    BASE_URL = "https://api.example.com"

    def get_user(self, user_id):
        """Fetch a user by ID from the external API."""
        response = requests.get(f"{self.BASE_URL}/users/{user_id}")
        response.raise_for_status()
        return response.json()

    def get_user_name(self, user_id):
        """Return just the user's full name."""
        user_data = self.get_user(user_id)
        return f"{user_data['first_name']} {user_data['last_name']}"

Here is how you test it without making real HTTP calls:

# test_user_service.py
import unittest
from unittest.mock import patch, MagicMock
from user_service import UserService


class TestUserService(unittest.TestCase):

    def setUp(self):
        self.service = UserService()

    @patch("user_service.requests.get")
    def test_get_user_returns_data(self, mock_get):
        # Arrange: configure the mock response
        mock_response = MagicMock()
        mock_response.json.return_value = {
            "id": 1,
            "first_name": "Jane",
            "last_name": "Doe",
            "email": "jane@example.com"
        }
        mock_response.raise_for_status.return_value = None
        mock_get.return_value = mock_response

        # Act: call the method under test
        result = self.service.get_user(1)

        # Assert: verify the result and that the mock was called correctly
        self.assertEqual(result["first_name"], "Jane")
        mock_get.assert_called_once_with("https://api.example.com/users/1")

    @patch("user_service.requests.get")
    def test_get_user_name(self, mock_get):
        mock_response = MagicMock()
        mock_response.json.return_value = {
            "first_name": "John",
            "last_name": "Smith"
        }
        mock_response.raise_for_status.return_value = None
        mock_get.return_value = mock_response

        name = self.service.get_user_name(42)
        self.assertEqual(name, "John Smith")

    @patch("user_service.requests.get")
    def test_get_user_handles_http_error(self, mock_get):
        mock_response = MagicMock()
        mock_response.raise_for_status.side_effect = Exception("404 Not Found")
        mock_get.return_value = mock_response

        with self.assertRaises(Exception) as context:
            self.service.get_user(999)
        self.assertIn("404", str(context.exception))

The key insight with @patch is that you patch where the object is used, not where it is defined. Since user_service.py does import requests, you patch "user_service.requests.get".

Using MagicMock Directly

MagicMock automatically creates attributes and methods as you access them, making it ideal for replacing complex objects:

from unittest.mock import MagicMock


def test_magic_mock_basics():
    # MagicMock creates attributes on the fly
    mock_db = MagicMock()
    mock_db.query.return_value = [{"id": 1, "name": "Alice"}]

    # Use it as if it were the real database
    results = mock_db.query("SELECT * FROM users")
    assert results == [{"id": 1, "name": "Alice"}]

    # Verify it was called
    mock_db.query.assert_called_once_with("SELECT * FROM users")

    # Check call count
    assert mock_db.query.call_count == 1

Test Organization

A well-organized test suite is easy to navigate and maintain. Here is the structure used in most professional Python projects:

my_project/
├── src/
│   ├── calculator.py
│   ├── user_service.py
│   └── utils.py
├── tests/
│   ├── __init__.py
│   ├── test_calculator.py
│   ├── test_user_service.py
│   └── test_utils.py
├── requirements.txt
└── pytest.ini

Follow these naming conventions:

  • Test files should start with test_ (e.g., test_calculator.py)
  • Test classes should start with Test (e.g., TestCalculator)
  • Test functions and methods should start with test_ (e.g., test_add_positive_numbers)
  • Use descriptive names that explain what the test verifies, not just what it calls

You can configure pytest using a pytest.ini or pyproject.toml file:

# pytest.ini
[pytest]
testpaths = tests
python_files = test_*.py
python_classes = Test*
python_functions = test_*
addopts = -v --tb=short

Test Coverage

Test coverage measures what percentage of your code is actually executed by your test suite. It is a useful metric, but treat it as a guide rather than a goal. One hundred percent coverage does not mean your code is bug-free — it just means every line ran at least once.

Install the coverage tool:

pip install pytest-cov

Run tests with coverage:

pytest --cov=src --cov-report=term-missing tests/

This shows you which lines are not covered by any test. The term-missing report format lists the exact line numbers that were never executed, so you know where to focus your next tests.

---------- coverage: ----------
Name                  Stmts   Miss  Cover   Missing
-----------------------------------------------------
src/calculator.py        12      0   100%
src/user_service.py      10      2    80%   15-16
-----------------------------------------------------
TOTAL                    22      2    91%

Testing Exceptions Thoroughly

Testing the happy path is the easy part. Senior developers know that testing error handling is just as important:

import pytest
from calculator import Calculator


class TestCalculatorExceptions:
    """Test that error cases are handled correctly."""

    def setup_method(self):
        self.calc = Calculator()

    def test_divide_by_zero_raises_value_error(self):
        with pytest.raises(ValueError):
            self.calc.divide(10, 0)

    def test_divide_by_zero_message(self):
        with pytest.raises(ValueError, match="Cannot divide by zero"):
            self.calc.divide(10, 0)

    def test_divide_by_zero_exception_details(self):
        with pytest.raises(ValueError) as exc_info:
            self.calc.divide(10, 0)
        assert "Cannot divide by zero" in str(exc_info.value)
        assert exc_info.type is ValueError

Common Pitfalls

These are the mistakes I see developers make repeatedly. Learn to recognize them early:

1. Testing implementation details instead of behavior. Your tests should verify what the code does, not how it does it. If you refactor the internals of a function without changing its output, your tests should still pass. When tests break because you renamed a private variable or changed an internal algorithm, those tests are too tightly coupled to the implementation.

# Bad: tests implementation details
def test_sort_uses_quicksort():
    sorter = Sorter()
    sorter.sort([3, 1, 2])
    assert sorter._algorithm_used == "quicksort"  # fragile

# Good: tests behavior
def test_sort_returns_sorted_list():
    sorter = Sorter()
    result = sorter.sort([3, 1, 2])
    assert result == [1, 2, 3]

2. Not testing edge cases. Always think about boundary conditions: empty inputs, None values, negative numbers, very large inputs, special characters, and concurrent access. These are exactly the scenarios where bugs hide.

@pytest.mark.parametrize("input_list, expected", [
    ([], []),                    # empty list
    ([1], [1]),                  # single element
    ([1, 1, 1], [1, 1, 1]),     # all same
    ([-3, -1, -2], [-3, -2, -1]),  # negative numbers
    ([1, 2, 3], [1, 2, 3]),     # already sorted
    ([3, 2, 1], [1, 2, 3]),     # reverse sorted
])
def test_sort_edge_cases(input_list, expected):
    sorter = Sorter()
    assert sorter.sort(input_list) == expected

3. Writing fragile tests that depend on external state. Tests that depend on the current time, a running database, network access, or file system state are unreliable. Use mocking and fixtures to control your test environment completely.

4. Tests that depend on each other. Each test must be independent. If test B fails when test A does not run first, you have a coupling problem. Use setUp/tearDown or fixtures to ensure each test starts from a clean state.

5. Ignoring test maintenance. Tests are code too. They need refactoring, clear naming, and review just like production code. A neglected test suite full of disabled or flaky tests provides a false sense of security.

Best Practices

Follow the AAA pattern: Arrange, Act, Assert. Structure every test in three clear phases. First, set up the preconditions (Arrange). Then, execute the action you are testing (Act). Finally, verify the result (Assert). This makes tests easy to read and reason about.

def test_add_returns_sum(calculator):
    # Arrange
    a, b = 5, 3

    # Act
    result = calculator.add(a, b)

    # Assert
    assert result == 8

Use descriptive test names. A test name should tell you what is being tested, under what conditions, and what the expected outcome is. When a test fails in CI, the name should give you enough context to start debugging without reading the test body.

# Bad
def test_divide():
    ...

# Good
def test_divide_by_zero_raises_value_error():
    ...

# Good
def test_get_user_name_returns_full_name_when_both_names_present():
    ...

Aim for one logical assertion per test. A test that checks five different things gives you less information when it fails. You only know the first assertion that broke. Separate tests pinpoint the exact problem immediately. That said, multiple assertions that verify the same logical concept (like checking several fields of a returned object) are perfectly fine in a single test.

Keep tests fast. A slow test suite does not get run. If your tests take minutes, developers will skip them, and bugs will reach production. Mock expensive operations, use in-memory databases for integration tests, and save end-to-end tests for a separate CI stage.

Test the public interface. Focus on the methods and functions your users (other developers or modules) actually call. Testing private methods creates tight coupling between tests and implementation, which makes refactoring painful.

Use fixtures to eliminate duplication. If you find the same setup code repeated across multiple tests, extract it into a fixture or setUp method. This makes your tests DRY without sacrificing clarity.

Key Takeaways

  • unittest is built into Python and follows the xUnit pattern with TestCase classes, setUp/tearDown, and rich assertion methods. It works out of the box with no additional dependencies.
  • pytest reduces boilerplate by letting you write tests as plain functions with plain assert statements. Its fixture system, parameterization support, and plugin ecosystem make it the preferred choice for most projects.
  • Use unittest.mock (specifically patch and MagicMock) to isolate the code under test from external dependencies like APIs, databases, and file systems.
  • Organize tests to mirror your source code structure. Follow naming conventions so test discovery works automatically.
  • Measure coverage to find untested code, but do not chase 100% as a goal. Focus on testing critical paths and edge cases.
  • Follow the AAA pattern (Arrange, Act, Assert) and give tests descriptive names. Each test should be independent and fast.
  • Test behavior, not implementation. If refactoring internals breaks your tests but not your functionality, the tests are too tightly coupled.
  • Treat your test code with the same care as production code. Refactor it, review it, and keep it clean.

Source code on Github

March 8, 2020

Python – User Input

Every meaningful program needs a way to communicate with its users. User input is the bridge between your program and the person running it — it transforms a static script into an interactive experience. Whether you are building a command-line tool, a data processing pipeline, or a quick utility script, understanding how to collect, validate, and safely handle user input is a foundational skill that separates production-ready code from toy examples.

In this tutorial, we will explore every major technique Python offers for gathering user input: from the built-in input() function all the way to command-line argument parsing, stdin piping, and secure password prompts. Along the way, we will build real programs and discuss the security pitfalls that trip up even experienced developers.

The input() Function — Basics

The input() function is Python’s primary tool for reading interactive input from the console. When called, it displays an optional prompt string, pauses execution, and waits for the user to type something and press Enter. The value is always returned as a string, regardless of what the user types.

Syntax

variable = input(prompt_message)

The prompt_message is optional but strongly recommended. A clear prompt tells users exactly what you expect from them, which reduces errors and frustration.

Example

name = input("What is your name: ")
print(f"Hello, {name}! Welcome to the tutorial.")

Output

What is your name: Folau
Hello, Folau! Welcome to the tutorial.

Notice we use an f-string for formatting. This is the modern, preferred approach in Python 3.6+ because it is more readable and less error-prone than string concatenation or .format().

Type Conversion of Input

Since input() always returns a string, you must explicitly convert the value when you need a number, a float, or another type. This is one of the most common sources of bugs for beginners.

Converting to int

age = int(input("Enter your age: "))
print(f"In 10 years, you will be {age + 10} years old.")

Converting to float

price = float(input("Enter the item price: $"))
tax = price * 0.08
total = price + tax
print(f"Price: ${price:.2f}, Tax: ${tax:.2f}, Total: ${total:.2f}")

Converting to bool

There is no direct bool(input()) pattern that works intuitively, because bool("False") returns True (any non-empty string is truthy). Instead, compare against expected values explicitly:

response = input("Continue? (yes/no): ").strip().lower()
should_continue = response in ("yes", "y")
print(f"Continuing: {should_continue}")

Input Validation

Never trust user input. Users will type unexpected things — empty strings, letters when you expect numbers, negative values when you need positive ones. Validating input before using it is a hallmark of professional code.

Checking for empty input

username = input("Enter your username: ").strip()

if not username:
    print("Error: Username cannot be empty.")
else:
    print(f"Welcome, {username}!")

Checking numeric ranges

age = int(input("Enter your age: "))

if age < 0 or age > 150:
    print("Error: Please enter a realistic age.")
else:
    print(f"Your age is {age}.")

Checking string patterns

email = input("Enter your email: ").strip()

if "@" not in email or "." not in email:
    print("Error: That does not look like a valid email address.")
else:
    print(f"Email saved: {email}")

Handling Invalid Input with try/except

When converting user input to a number, the program will crash with a ValueError if the user types something that cannot be converted. The try/except block lets you handle this gracefully instead of letting your program blow up.

try:
    number = int(input("Enter a whole number: "))
    print(f"You entered: {number}")
except ValueError:
    print("Error: That is not a valid integer.")

Input validation loop — keep asking until valid

In practice, you almost always want to combine try/except with a loop so the user gets another chance:

def get_positive_integer(prompt):
    """Keep asking until the user provides a valid positive integer."""
    while True:
        try:
            value = int(input(prompt))
            if value <= 0:
                print("Error: Please enter a positive number.")
                continue
            return value
        except ValueError:
            print("Error: Invalid input. Please enter a whole number.")

# Usage
quantity = get_positive_integer("How many items? ")
print(f"You ordered {quantity} items.")

This pattern is extremely common in production code. Extract it into a reusable function so you do not repeat yourself.

Getting Multiple Inputs with split()

Sometimes you need multiple values in a single line. The split() method breaks a string into a list of substrings, which you can then unpack or process.

Space-separated values

first, last = input("Enter your first and last name: ").split()
print(f"First: {first}, Last: {last}")

Comma-separated values

values = input("Enter numbers separated by commas: ").split(",")
numbers = [int(v.strip()) for v in values]
print(f"Sum: {sum(numbers)}, Average: {sum(numbers) / len(numbers):.2f}")

Using map() for cleaner conversion

x, y, z = map(int, input("Enter three integers (space-separated): ").split())
print(f"x={x}, y={y}, z={z}, sum={x + y + z}")

Command-Line Arguments

For scripts that run in automated pipelines or are invoked from the terminal, command-line arguments are often a better choice than interactive prompts. Python provides two main approaches.

sys.argv — The Simple Approach

sys.argv is a list of strings passed to your script from the command line. The first element (sys.argv[0]) is the script name itself.

import sys

if len(sys.argv) != 3:
    print("Usage: python greet.py <first_name> <last_name>")
    sys.exit(1)

first_name = sys.argv[1]
last_name = sys.argv[2]
print(f"Hello, {first_name} {last_name}!")

Run it from the terminal:

# Terminal
$ python greet.py Folau Kaveinga
Hello, Folau Kaveinga!

argparse — The Professional Approach

For anything beyond trivial scripts, argparse is the standard library module for building proper command-line interfaces. It handles argument parsing, type conversion, help text, and error messages automatically.

import argparse

parser = argparse.ArgumentParser(description="A simple greeting tool.")
parser.add_argument("name", help="The name to greet")
parser.add_argument("-c", "--count", type=int, default=1,
                    help="Number of times to greet (default: 1)")
parser.add_argument("-u", "--uppercase", action="store_true",
                    help="Print greeting in uppercase")

args = parser.parse_args()

greeting = f"Hello, {args.name}!"
if args.uppercase:
    greeting = greeting.upper()

for _ in range(args.count):
    print(greeting)

Run it from the terminal:

# Terminal
$ python greet.py Folau --count 3 --uppercase
HELLO, FOLAU!
HELLO, FOLAU!
HELLO, FOLAU!

$ python greet.py --help
usage: greet.py [-h] [-c COUNT] [-u] name
...

argparse automatically generates --help output and provides clear error messages when users pass invalid arguments. Use it whenever you are building a tool that others (or your future self) will use.

Reading from stdin for Piping

Unix-style piping lets you chain programs together. Python can read from sys.stdin to accept piped input, making your scripts composable with other tools.

import sys

line_count = 0
word_count = 0

for line in sys.stdin:
    line_count += 1
    word_count += len(line.split())

print(f"Lines: {line_count}, Words: {word_count}")

Usage from the terminal:

# Terminal
$ cat myfile.txt | python word_counter.py
Lines: 42, Words: 318

$ echo "hello world" | python word_counter.py
Lines: 1, Words: 2

A useful pattern is to support both piped input and interactive input:

import sys

if not sys.stdin.isatty():
    # Input is being piped
    data = sys.stdin.read()
else:
    # Interactive mode
    data = input("Enter your text: ")

print(f"You provided {len(data)} characters.")

Password Input with getpass

When asking for passwords or other sensitive data, you should never echo the characters back to the screen. The getpass module handles this for you.

import getpass

username = input("Username: ")
password = getpass.getpass("Password: ")

# The password is NOT displayed as the user types
if username == "admin" and password == "secret123":
    print("Access granted.")
else:
    print("Access denied.")

Note: getpass may not work correctly in some IDEs or notebook environments. It is designed for terminal usage.

Practical Examples

Example 1: Simple Calculator

def calculator():
    """A simple calculator that takes two numbers and an operator."""
    print("=== Simple Calculator ===")

    try:
        num1 = float(input("Enter first number: "))
        operator = input("Enter operator (+, -, *, /): ").strip()
        num2 = float(input("Enter second number: "))
    except ValueError:
        print("Error: Please enter valid numbers.")
        return

    if operator == "+":
        result = num1 + num2
    elif operator == "-":
        result = num1 - num2
    elif operator == "*":
        result = num1 * num2
    elif operator == "/":
        if num2 == 0:
            print("Error: Division by zero is not allowed.")
            return
        result = num1 / num2
    else:
        print(f"Error: Unknown operator.")
        return

    print(f"{num1} {operator} {num2} = {result}")

calculator()

Example 2: Menu-Driven Program

def todo_app():
    """A menu-driven to-do list that loops until the user quits."""
    tasks = []

    while True:
        print("\n=== To-Do List ===")
        print("1. View tasks")
        print("2. Add task")
        print("3. Remove task")
        print("4. Quit")

        choice = input("Choose an option (1-4): ").strip()

        if choice == "1":
            if not tasks:
                print("No tasks yet.")
            else:
                for i, task in enumerate(tasks, 1):
                    print(f"  {i}. {task}")

        elif choice == "2":
            task = input("Enter task description: ").strip()
            if task:
                tasks.append(task)
                print(f"Added: {task}")
            else:
                print("Error: Task cannot be empty.")

        elif choice == "3":
            if not tasks:
                print("No tasks to remove.")
                continue
            try:
                index = int(input(f"Enter task number to remove (1-{len(tasks)}): "))
                if 1 <= index <= len(tasks):
                    removed = tasks.pop(index - 1)
                    print(f"Removed: {removed}")
                else:
                    print("Error: Invalid task number.")
            except ValueError:
                print("Error: Please enter a valid number.")

        elif choice == "4":
            print("Goodbye!")
            break

        else:
            print("Error: Please choose 1, 2, 3, or 4.")

todo_app()

Example 3: A Simple Quiz Game

def quiz_game():
    """A trivia quiz that scores the answers."""
    questions = [
        {
            "question": "What keyword defines a function in Python?",
            "options": ["a) func", "b) def", "c) function", "d) define"],
            "answer": "b"
        },
        {
            "question": "Which data type is immutable?",
            "options": ["a) list", "b) dict", "c) tuple", "d) set"],
            "answer": "c"
        },
        {
            "question": "What does len() return for an empty list?",
            "options": ["a) None", "b) -1", "c) 0", "d) Error"],
            "answer": "c"
        },
        {
            "question": "Which module is used for regular expressions?",
            "options": ["a) regex", "b) re", "c) regexp", "d) match"],
            "answer": "b"
        }
    ]

    score = 0
    total = len(questions)

    print("=== Python Trivia Quiz ===\n")

    for i, q in enumerate(questions, 1):
        print(f"Question {i}: {q['question']}")
        for option in q["options"]:
            print(f"  {option}")

        answer = input("Your answer (a/b/c/d): ").strip().lower()

        if answer == q["answer"]:
            print("Correct!\n")
            score += 1
        else:
            print(f"Wrong. The correct answer was '{q['answer']}'.\n")

    percentage = (score / total) * 100
    print(f"Final Score: {score}/{total} ({percentage:.0f}%)")

    if percentage == 100:
        print("Perfect score! Excellent work.")
    elif percentage >= 75:
        print("Great job! You know your Python well.")
    elif percentage >= 50:
        print("Not bad. Keep studying!")
    else:
        print("Keep practicing. You will get there.")

quiz_game()

Example 4: Command-Line Tool with argparse

# file_stats.py - A command-line tool that reports statistics about a text file.
# Usage: python file_stats.py myfile.txt --verbose

import argparse
import os

def get_file_stats(filepath, verbose=False):
    """Read a file and return its statistics."""
    if not os.path.exists(filepath):
        print(f"Error: File not found.")
        return

    with open(filepath, "r") as f:
        content = f.read()

    lines = content.split("\n")
    words = content.split()
    characters = len(content)

    print(f"File: {filepath}")
    print(f"  Lines:      {len(lines)}")
    print(f"  Words:      {len(words)}")
    print(f"  Characters: {characters}")

    if verbose:
        unique_words = set(word.lower().strip(".,!?;:") for word in words)
        print(f"  Unique words: {len(unique_words)}")
        avg_line_len = characters / len(lines) if lines else 0
        print(f"  Avg line length: {avg_line_len:.1f} characters")

if __name__ == "__main__":
    parser = argparse.ArgumentParser(description="Display statistics about a text file.")
    parser.add_argument("file", help="Path to the text file")
    parser.add_argument("-v", "--verbose", action="store_true",
                        help="Show additional statistics")

    args = parser.parse_args()
    get_file_stats(args.file, args.verbose)

Security Considerations

User input is one of the most common attack vectors in software. Treating all input as potentially malicious is not paranoia — it is good engineering.

Never use eval() on user input

This is arguably the most dangerous mistake in Python. eval() executes arbitrary Python expressions, which means a malicious user can run any code on your system:

# DANGEROUS - Never do this
user_input = input("Enter a math expression: ")
result = eval(user_input)  # User could type: __import__('os').system('rm -rf /')

# SAFE alternative - use the ast module for math expressions
import ast

def safe_eval_math(expression):
    """Safely evaluate a simple math expression."""
    try:
        tree = ast.parse(expression, mode='eval')
        # Only allow numbers and basic math operators
        for node in ast.walk(tree):
            if not isinstance(node, (ast.Expression, ast.BinOp, ast.UnaryOp,
                                     ast.Constant, ast.Add, ast.Sub,
                                     ast.Mult, ast.Div, ast.Pow,
                                     ast.USub)):
                raise ValueError("Unsupported operation")
        return eval(compile(tree, "<string>", "eval"))
    except (ValueError, SyntaxError):
        return None

result = safe_eval_math(input("Enter a math expression: "))
if result is not None:
    print(f"Result: {result}")
else:
    print("Error: Invalid expression.")

Sanitize input before using it in file paths, SQL queries, or shell commands

# DANGEROUS - path traversal attack
filename = input("Enter filename: ")
# User types: ../../etc/passwd

# SAFE - validate and sanitize
import os

filename = input("Enter filename: ").strip()
# Remove any path separators
safe_name = os.path.basename(filename)
# Verify it is within the expected directory
filepath = os.path.join("/data", safe_name)
filepath = os.path.realpath(filepath)

if not filepath.startswith("/data/"):
    print("Error: Access denied.")
else:
    with open(filepath, "r") as f:
        print(f.read())

Never pass user input directly to os.system() or subprocess with shell=True

import subprocess
import os

# DANGEROUS
user_dir = input("Directory to list: ")
os.system(f"ls {user_dir}")  # User types: /tmp; rm -rf /

# SAFE - use subprocess with a list of arguments (no shell interpretation)
user_dir = input("Directory to list: ")
subprocess.run(["ls", user_dir], check=True)

Common Pitfalls

These are mistakes that developers encounter repeatedly when dealing with user input.

1. Forgetting type conversion

# Bug: string concatenation instead of addition
age = input("Enter your age: ")  # Returns "25" (a string)
print(age + 10)  # TypeError: can only concatenate str to str

# Fix: convert to int
age = int(input("Enter your age: "))
print(age + 10)  # 35

2. Not handling empty input

# Bug: crashes on empty input
number = int(input("Enter a number: "))  # User just presses Enter
# ValueError: invalid literal for int() with base 10: ''

# Fix: check before converting
raw = input("Enter a number: ").strip()
if raw:
    number = int(raw)
else:
    print("No input provided.")

3. EOFError when input is piped or exhausted

# Bug: crashes when stdin is closed (e.g., in automated testing)
name = input("Name: ")  # EOFError if no stdin available

# Fix: handle EOFError
try:
    name = input("Name: ")
except EOFError:
    name = "default"
    print("No input stream available, using default.")

4. Not stripping whitespace

# Bug: trailing spaces cause comparison to fail
choice = input("Type yes to continue: ")
if choice == "yes":  # Fails if user typed "yes " with a trailing space
    print("Continuing...")

# Fix: always strip
choice = input("Type yes to continue: ").strip().lower()
if choice == "yes":
    print("Continuing...")

5. Assuming input encoding

# In Python 3, input() returns a Unicode string by default.
# However, if reading from a file or pipe, encoding issues can arise.
import sys

# Set explicit encoding when needed
sys.stdin.reconfigure(encoding='utf-8')

Best Practices

After years of writing Python professionally, these are the habits that pay off consistently:

  1. Always validate input. Never assume users will provide correct data. Validate type, range, format, and length.
  2. Provide clear, specific prompts. Instead of input(": "), write input("Enter your age (1-120): "). Tell users exactly what you expect.
  3. Handle edge cases. Empty strings, extremely large numbers, special characters, and Unicode input all need to be considered.
  4. Use functions for reusable input patterns. If you ask for validated integers in multiple places, write a get_valid_int() function once and reuse it.
  5. Prefer argparse over sys.argv for command-line tools. It gives you help text, type checking, and error handling for free.
  6. Use getpass for sensitive data. Never display passwords on the screen.
  7. Strip whitespace. Call .strip() on virtually every input() call to avoid invisible whitespace bugs.
  8. Normalize case for comparisons. Use .lower() or .upper() before comparing text input so “Yes”, “YES”, and “yes” all work.
  9. Set timeouts for critical applications. For network services or long-running tools, consider using signal or threading to set input timeouts so your program does not hang indefinitely.
  10. Log input for debugging, but never log passwords. In production systems, log what users entered (sanitized) to help diagnose issues, but scrub any sensitive fields.

Key Takeaways

  • input() always returns a string. Convert explicitly with int(), float(), or other type constructors when you need a different type.
  • Wrap type conversions in try/except blocks and use while loops for a retry pattern that does not crash on bad input.
  • Use split() and map() to collect multiple values from a single line of input.
  • For command-line tools, argparse is the professional choice — it handles parsing, validation, and help generation automatically.
  • Read from sys.stdin when you want your script to work with Unix pipes and redirects.
  • Use getpass to securely collect passwords without echoing them to the screen.
  • Never use eval() on user input. Never pass raw input to shell commands. Always sanitize input before using it in file paths or database queries.
  • Professional code validates everything: type, range, format, length, and encoding. If validation fails, provide a clear error message and give the user another chance.

Source code on Github

March 8, 2020

Python – OOP

Introduction to Object-Oriented Programming

Object-Oriented Programming (OOP) is a programming paradigm that organizes software design around objects rather than functions and logic. An object bundles together data (attributes) and the operations that act on that data (methods) into a single, self-contained unit. This is not merely an academic concept — it is the dominant paradigm behind most large-scale, production-grade software systems in the world today.

Why does OOP matter? When a codebase grows beyond a few hundred lines, procedural code tends to become tangled and fragile. OOP addresses this by providing mechanisms to:

  • Model real-world entities — classes map naturally to business concepts (users, orders, payments).
  • Manage complexity — encapsulation hides internal details so teams can work on separate modules without stepping on each other.
  • Promote reuse — inheritance and composition let you build new functionality on top of existing, tested code.
  • Enable flexibility — polymorphism lets you swap implementations without rewriting the code that depends on them.

Python is a multi-paradigm language — it supports procedural, functional, and object-oriented styles. However, at its core, everything in Python is an object: integers, strings, functions, even classes themselves. Understanding OOP in Python is therefore not optional; it is foundational.

An object has two fundamental characteristics:

  • State (attributes) — the data the object holds.
  • Behavior (methods) — the actions the object can perform.

A class is a blueprint that defines what attributes and methods its objects will have. No memory is allocated until you instantiate the class to create an object.

class User:
    """Blueprint for a user in the system."""

    def __init__(self, name: str, age: int, role: str = "USER"):
        self.name = name
        self.age = age
        self.role = role

    def greet(self) -> str:
        return f"Hello, my name is {self.name} and I am {self.age} years old."

    def __repr__(self) -> str:
        return f"User(name='{self.name}', age={self.age}, role='{self.role}')"


# Creating instances (objects)
alice = User("Alice", 30, "ADMIN")
bob = User("Bob", 25)

print(alice.greet())   # Hello, my name is Alice and I am 30 years old.
print(repr(bob))       # User(name='Bob', age=25, role='USER')

The four pillars of OOP are Encapsulation, Inheritance, Polymorphism, and Abstraction. Let us explore each one in depth.


Pillar 1 — Encapsulation

Encapsulation is the practice of bundling data with the methods that operate on it and restricting direct access to some of that data. The goal is to protect an object’s internal state from invalid or unintended modifications. External code interacts with the object through a well-defined public interface, while internal implementation details remain hidden.

Python uses a naming convention for access control rather than strict access modifiers like Java or C++:

  • public_attr — accessible from anywhere (the default).
  • _protected_attr — a single leading underscore signals “internal use”; it is a convention, not enforced by the interpreter.
  • __private_attr — a double leading underscore triggers name mangling, making accidental access from outside the class harder (the attribute becomes _ClassName__private_attr internally).

The Pythonic way to expose controlled access to internal data is through the @property decorator, which lets you define getters and setters that look like simple attribute access to the caller.

Practical Example — BankAccount with Validation

class BankAccount:
    """A bank account with encapsulated balance and validation."""

    def __init__(self, owner: str, initial_balance: float = 0.0):
        self._owner = owner
        self.__balance = initial_balance  # name-mangled: _BankAccount__balance

    @property
    def owner(self) -> str:
        """Read-only access to the account owner."""
        return self._owner

    @property
    def balance(self) -> float:
        """Controlled read access to balance."""
        return self.__balance

    def deposit(self, amount: float) -> None:
        if amount <= 0:
            raise ValueError("Deposit amount must be positive.")
        self.__balance += amount
        print(f"Deposited ${amount:.2f}. New balance: ${self.__balance:.2f}")

    def withdraw(self, amount: float) -> None:
        if amount <= 0:
            raise ValueError("Withdrawal amount must be positive.")
        if amount > self.__balance:
            raise ValueError("Insufficient funds.")
        self.__balance -= amount
        print(f"Withdrew ${amount:.2f}. New balance: ${self.__balance:.2f}")

    def __repr__(self) -> str:
        return f"BankAccount(owner='{self._owner}', balance={self.__balance:.2f})"


account = BankAccount("Alice", 1000.0)
account.deposit(500)       # Deposited $500.00. New balance: $1500.00
account.withdraw(200)      # Withdrew $200.00. New balance: $1300.00
print(account.balance)     # 1300.0  (via @property getter)

# Direct modification is prevented:
# account.__balance = 999999  # This creates a NEW attribute, does NOT touch the real balance
# account.balance = 999999    # AttributeError: can't set attribute (no setter defined)

Notice how balance is exposed as a read-only property. There is no setter, so the only way to change the balance is through deposit() and withdraw(), both of which enforce business rules. This is encapsulation in practice.


Pillar 2 — Inheritance

Inheritance allows a new class (the child or derived class) to acquire the attributes and methods of an existing class (the parent or base class). This promotes code reuse — shared behavior is defined once in the parent, and children only implement what is specific to them.

Key concepts:

  • super() — calls the parent class’s method, ensuring proper initialization up the chain.
  • Method overriding — a child class can redefine a parent’s method to provide specialized behavior.
  • Multiple inheritance — Python supports inheriting from more than one class. The Method Resolution Order (MRO) determines which method is called when there are conflicts (C3 linearization).

Practical Example — Animal Hierarchy

class Animal:
    """Base class for all animals."""

    def __init__(self, name: str, species: str):
        self.name = name
        self.species = species

    def sound(self) -> str:
        raise NotImplementedError("Subclasses must implement sound()")

    def describe(self) -> str:
        return f"{self.name} is a {self.species} that says '{self.sound()}'."


class Dog(Animal):
    """A dog is an Animal with specific behavior."""

    def __init__(self, name: str, breed: str):
        super().__init__(name, species="Dog")
        self.breed = breed

    def sound(self) -> str:
        return "Woof!"

    def fetch(self, item: str) -> str:
        return f"{self.name} fetches the {item}."


class Cat(Animal):
    """A cat is an Animal with specific behavior."""

    def __init__(self, name: str, indoor: bool = True):
        super().__init__(name, species="Cat")
        self.indoor = indoor

    def sound(self) -> str:
        return "Meow!"

    def purr(self) -> str:
        return f"{self.name} purrs contentedly."


# Usage
rex = Dog("Rex", breed="German Shepherd")
whiskers = Cat("Whiskers", indoor=False)

print(rex.describe())       # Rex is a Dog that says 'Woof!'.
print(rex.fetch("ball"))    # Rex fetches the ball.
print(whiskers.describe())  # Whiskers is a Cat that says 'Meow!'.
print(whiskers.purr())      # Whiskers purrs contentedly.

Multiple Inheritance and MRO

Python supports multiple inheritance, which means a class can inherit from more than one parent. The Method Resolution Order (MRO) determines the order in which base classes are searched when looking for a method.

class Swimmer:
    def move(self) -> str:
        return "Swimming"

class Flyer:
    def move(self) -> str:
        return "Flying"

class Duck(Swimmer, Flyer):
    """A duck can both swim and fly."""
    pass

donald = Duck()
print(donald.move())  # "Swimming" - Swimmer comes first in MRO

# Inspect the MRO
print(Duck.__mro__)
# (<class 'Duck'>, <class 'Swimmer'>, <class 'Flyer'>, <class 'object'>)

The MRO follows C3 linearization. In the example above, Swimmer appears before Flyer in the class definition, so Swimmer.move() wins. Understanding MRO is critical when working with multiple inheritance — use ClassName.__mro__ or ClassName.mro() to inspect it.


Pillar 3 — Polymorphism

Polymorphism means “many forms.” It allows objects of different types to be treated through a uniform interface. In Python, polymorphism is pervasive and manifests in several ways:

  • Method overriding — child classes provide their own implementation of a parent’s method.
  • Duck typing — “If it walks like a duck and quacks like a duck, it is a duck.” Python does not require formal interfaces; any object that implements the expected methods works.
  • Operator overloading — classes can define how built-in operators (+, ==, len(), etc.) behave via dunder methods.

Practical Example — Shape Classes with area()

import math

class Shape:
    """Base shape class."""

    def area(self) -> float:
        raise NotImplementedError("Subclasses must implement area()")

    def __str__(self) -> str:
        return f"{self.__class__.__name__}(area={self.area():.2f})"


class Rectangle(Shape):
    def __init__(self, width: float, height: float):
        self.width = width
        self.height = height

    def area(self) -> float:
        return self.width * self.height


class Circle(Shape):
    def __init__(self, radius: float):
        self.radius = radius

    def area(self) -> float:
        return math.pi * self.radius ** 2


class Triangle(Shape):
    def __init__(self, base: float, height: float):
        self.base = base
        self.height = height

    def area(self) -> float:
        return 0.5 * self.base * self.height


# Polymorphism in action: same interface, different behavior
shapes = [Rectangle(10, 5), Circle(7), Triangle(8, 6)]

for shape in shapes:
    print(shape)  # Calls __str__ which calls area() - each shape computes its own

# Output:
# Rectangle(area=50.00)
# Circle(area=153.94)
# Triangle(area=24.00)

Duck Typing Example

class CSVExporter:
    def export(self, data: list) -> str:
        return "\n".join(",".join(str(v) for v in row) for row in data)

class JSONExporter:
    def export(self, data: list) -> str:
        import json
        return json.dumps(data, indent=2)

def run_export(exporter, data):
    """Works with ANY object that has an export() method.
    No inheritance required - this is duck typing."""
    print(exporter.export(data))

data = [["name", "age"], ["Alice", 30], ["Bob", 25]]

run_export(CSVExporter(), data)
run_export(JSONExporter(), data)

Notice that CSVExporter and JSONExporter share no common parent class. The function run_export() does not care about the object’s type — it only cares that the object has an export() method. This is duck typing, and it is one of Python’s most powerful features.

Operator Overloading

class Vector:
    """A simple two-dimensional vector with operator overloading."""

    def __init__(self, x: float, y: float):
        self.x = x
        self.y = y

    def __add__(self, other: "Vector") -> "Vector":
        return Vector(self.x + other.x, self.y + other.y)

    def __eq__(self, other: "Vector") -> bool:
        return self.x == other.x and self.y == other.y

    def __abs__(self) -> float:
        return (self.x ** 2 + self.y ** 2) ** 0.5

    def __repr__(self) -> str:
        return f"Vector({self.x}, {self.y})"


v1 = Vector(3, 4)
v2 = Vector(1, 2)

print(v1 + v2)    # Vector(4, 6)
print(abs(v1))    # 5.0
print(v1 == v2)   # False

Pillar 4 — Abstraction

Abstraction means exposing only the essential features of an object while hiding the complex implementation details. In Python, we achieve formal abstraction using the abc module (Abstract Base Classes). An abstract class cannot be instantiated directly — it exists only to define a contract that subclasses must fulfill.

Practical Example — Payment Processing System

from abc import ABC, abstractmethod


class AbstractPayment(ABC):
    """Abstract base class defining the payment contract."""

    def __init__(self, amount: float):
        if amount <= 0:
            raise ValueError("Payment amount must be positive.")
        self._amount = amount

    @abstractmethod
    def authorize(self) -> bool:
        """Verify the payment method is valid. Must be implemented by subclasses."""
        pass

    @abstractmethod
    def capture(self) -> str:
        """Process the actual payment. Must be implemented by subclasses."""
        pass

    def process(self) -> str:
        """Template method: authorize then capture."""
        if self.authorize():
            return self.capture()
        return "Payment authorization failed."


class CreditCardPayment(AbstractPayment):
    def __init__(self, amount: float, card_number: str, cvv: str):
        super().__init__(amount)
        self._card_number = card_number
        self._cvv = cvv

    def authorize(self) -> bool:
        # In production, this would call a payment gateway API
        print(f"Authorizing credit card ending in {self._card_number[-4:]}...")
        return len(self._card_number) == 16 and len(self._cvv) == 3

    def capture(self) -> str:
        return f"Charged ${self._amount:.2f} to card ending in {self._card_number[-4:]}."


class PayPalPayment(AbstractPayment):
    def __init__(self, amount: float, email: str):
        super().__init__(amount)
        self._email = email

    def authorize(self) -> bool:
        print(f"Authorizing PayPal account {self._email}...")
        return "@" in self._email

    def capture(self) -> str:
        return f"Charged ${self._amount:.2f} to PayPal account {self._email}."


# Usage
payments = [
    CreditCardPayment(99.99, "4111111111111234", "123"),
    PayPalPayment(49.50, "alice@example.com"),
]

for payment in payments:
    result = payment.process()
    print(result)

# Trying to instantiate the abstract class directly:
# payment = AbstractPayment(100)  # TypeError: Can't instantiate abstract class

The AbstractPayment class defines the contract (authorize() and capture()) and provides a template method (process()) that orchestrates the workflow. Subclasses fill in the details. If a subclass forgets to implement an abstract method, Python raises a TypeError at instantiation time — catching the bug early.


SOLID Principles in Python

The SOLID principles are five design guidelines that help you write maintainable, flexible, and scalable object-oriented code. Here is a brief overview of the two most impactful ones.

Single Responsibility Principle (SRP)

A class should have one reason to change. If a class handles both business logic and file I/O, changes to the file format force you to modify a class that also contains business rules — risking unintended breakage.

# BAD: One class doing too many things
class Report:
    def __init__(self, data):
        self.data = data

    def calculate_stats(self):
        """Business logic"""
        return {"mean": sum(self.data) / len(self.data)}

    def save_to_file(self, path):
        """File I/O - separate concern"""
        with open(path, "w") as f:
            f.write(str(self.calculate_stats()))

    def send_email(self, recipient):
        """Email logic - yet another concern"""
        pass


# GOOD: Each class has one responsibility
class StatsCalculator:
    def calculate(self, data: list) -> dict:
        return {"mean": sum(data) / len(data)}

class ReportSaver:
    def save(self, content: dict, path: str) -> None:
        with open(path, "w") as f:
            f.write(str(content))

class EmailService:
    def send(self, recipient: str, body: str) -> None:
        pass  # email logic here

Open/Closed Principle (OCP)

Classes should be open for extension but closed for modification. You should be able to add new behavior without changing existing, tested code.

from abc import ABC, abstractmethod

class Discount(ABC):
    @abstractmethod
    def apply(self, price: float) -> float:
        pass

class NoDiscount(Discount):
    def apply(self, price: float) -> float:
        return price

class PercentageDiscount(Discount):
    def __init__(self, percent: float):
        self.percent = percent

    def apply(self, price: float) -> float:
        return price * (1 - self.percent / 100)

class FixedDiscount(Discount):
    def __init__(self, amount: float):
        self.amount = amount

    def apply(self, price: float) -> float:
        return max(0, price - self.amount)

# Adding a new discount type does NOT require modifying existing classes.
# Just create a new subclass:
class BuyOneGetOneFree(Discount):
    def apply(self, price: float) -> float:
        return price / 2

Composition vs Inheritance

A common saying among experienced developers is “Favor composition over inheritance.” Inheritance creates a tight coupling between parent and child — changes to the parent ripple down to all descendants. Composition, on the other hand, builds functionality by containing objects rather than extending them, leading to more flexible designs.

When to use inheritance: There is a clear “is-a” relationship (a Dog is an Animal).

When to use composition: There is a “has-a” relationship (a Car has an Engine).

# INHERITANCE approach - tightly coupled
class Engine:
    def start(self):
        return "Engine started."

class CarBad(Engine):  # A Car IS an Engine? That does not make sense.
    pass


# COMPOSITION approach - flexible and logical
class Engine:
    def start(self) -> str:
        return "Engine started."

class ElectricMotor:
    def start(self) -> str:
        return "Electric motor humming."

class Car:
    """A Car HAS an engine. This is composition."""

    def __init__(self, engine):
        self._engine = engine  # Inject the dependency

    def start(self) -> str:
        return f"Car: {self._engine.start()}"


# Easily swap implementations without changing Car
gas_car = Car(Engine())
electric_car = Car(ElectricMotor())

print(gas_car.start())       # Car: Engine started.
print(electric_car.start())  # Car: Electric motor humming.

With composition, swapping an Engine for an ElectricMotor requires zero changes to the Car class. With inheritance, you would need a completely separate class hierarchy.


Common Pitfalls

Even experienced developers fall into these traps. Knowing them upfront saves hours of debugging.

1. The Diamond Problem

When a class inherits from two classes that share a common ancestor, method resolution can become confusing. Python handles this via MRO (C3 linearization), but it can still lead to subtle bugs if you are not careful with super() calls.

class A:
    def greet(self):
        return "Hello from A"

class B(A):
    def greet(self):
        return "Hello from B"

class C(A):
    def greet(self):
        return "Hello from C"

class D(B, C):
    pass

d = D()
print(d.greet())  # "Hello from B" - follows MRO: D -> B -> C -> A
print(D.__mro__)  # Inspect to understand the resolution order

2. Deep Inheritance Hierarchies

If your inheritance chain goes more than two or three levels deep, the code becomes brittle and hard to reason about. A change in a grandparent class can have unexpected effects three levels down. Prefer composition for complex relationships.

3. God Classes

A “God class” is a class that knows too much or does too much. It violates the Single Responsibility Principle and becomes a maintenance nightmare. If your class has more than ten to fifteen methods or its docstring needs a table of contents, it is time to break it apart.

4. Mutable Default Arguments

# BAD: Mutable default argument is shared across all instances
class Team:
    def __init__(self, members=[]):
        self.members = members

t1 = Team()
t1.members.append("Alice")

t2 = Team()
print(t2.members)  # ['Alice'] - Bug! t2 shares t1's list

# GOOD: Use None and create a new list in __init__
class Team:
    def __init__(self, members=None):
        self.members = members if members is not None else []

Best Practices

  • Follow naming conventions: UpperCamelCase for class names, snake_case for methods and attributes, _single_underscore for protected, __double_underscore for name-mangled private.
  • Use __init__ to set all instance attributes: Avoid adding attributes outside of __init__. This makes the class’s state explicit and discoverable.
  • Implement __repr__: Always define __repr__ for your classes. It is invaluable for debugging. A good __repr__ returns a string that could recreate the object.
  • Use type hints: Type annotations make your code self-documenting and enable static analysis tools like mypy to catch bugs before runtime.
  • Prefer composition over inheritance: Use inheritance for genuine “is-a” relationships. Use composition for “has-a” relationships.
  • Keep classes small and focused: Each class should have a single, clear purpose. If you struggle to name a class or describe it in one sentence, it is doing too much.
  • Use @property instead of getters/setters: Python is not Java. Replace get_name() / set_name() with @property for a cleaner, more Pythonic API.
  • Leverage ABCs for interfaces: When you need to enforce that subclasses implement specific methods, use abc.ABC and @abstractmethod rather than raising NotImplementedError.
  • Write docstrings: Every class and public method should have a docstring explaining what it does, not how it does it.

Key Takeaways

  • Encapsulation protects internal state. Use _ and __ naming conventions and @property to control access with validation.
  • Inheritance promotes code reuse through “is-a” relationships. Use super() for proper initialization and keep hierarchies shallow.
  • Polymorphism lets different objects respond to the same interface. Python’s duck typing makes this especially flexible — no formal interface declarations needed.
  • Abstraction defines contracts using abc.ABC and @abstractmethod, catching missing implementations at instantiation time.
  • Favor composition over inheritance for flexible, loosely coupled designs.
  • Follow SOLID principles — especially Single Responsibility and Open/Closed — to write code that is easy to extend and hard to break.
  • Watch for pitfalls: the diamond problem, deep hierarchies, God classes, and mutable default arguments.
  • OOP is not about using classes everywhere — it is about choosing the right tool for the problem. A well-designed system uses OOP where it reduces complexity, not where it adds it.

Source code on Github

March 8, 2020

Python – File(read/write)

Every meaningful application eventually needs to interact with files. Whether you are reading configuration, writing logs, processing CSV reports, or persisting user data as JSON, file I/O is one of the fundamental skills that separates hobby scripts from production-grade software. Python makes file operations straightforward, but doing them correctly—handling encodings, closing resources, and dealing with platform differences—requires a disciplined approach.

In this guide we will work through Python’s file I/O capabilities from the ground up: opening and closing files, reading and writing text, working with structured formats like CSV and JSON, managing file paths properly, and building practical utilities you can use in real projects.


How Files Work

At the operating-system level, a file is a contiguous sequence of bytes stored on disk. Those bytes are organized according to a format specification—plain text, CSV, JSON, PNG, and so on. Every file has three conceptual parts:

  • Header – metadata such as file name, size, type, and permissions
  • Data – the actual content
  • End of File (EOF) – a marker that signals where the content ends

What the bytes represent depends on the format. A .txt file stores human-readable characters; a .png stores pixel data in a binary layout. Python gives us a unified API to handle both text and binary files.


Opening Files: The open() Function

All file operations begin with open(). Its signature is:

file_object = open(file_path, mode="r", encoding=None)

The mode string tells Python what you intend to do with the file:

Mode Description
"r" Read (default). File must exist.
"w" Write. Creates the file if it does not exist; truncates (empties) the file if it does.
"a" Append. Creates the file if it does not exist; writes are added to the end.
"r+" Read and write. File must exist.
"x" Exclusive creation. Fails if the file already exists.
"b" Binary mode (combine with others, e.g., "rb", "wb").
"t" Text mode (default; combine with others, e.g., "rt").

You can combine modes: "rb" reads a binary file, "w+" opens for both writing and reading (truncating first), and so on.

# Open for reading (default mode)
f = open("data.txt")

# Open for writing with explicit UTF-8 encoding
f = open("output.txt", "w", encoding="utf-8")

# Open a binary file for reading
f = open("image.png", "rb")

The with Statement (Context Managers)

This is the correct way to work with files in Python. The with statement guarantees that the file is closed when the block exits, even if an exception is raised. Forgetting to close a file can cause data loss (buffered writes never flushed), resource leaks, and locking issues on Windows.

# Always prefer this pattern
with open("data.txt", "r", encoding="utf-8") as f:
    contents = f.read()
    # f is automatically closed when we leave this block

# The manual alternative (avoid this)
f = open("data.txt", "r", encoding="utf-8")
try:
    contents = f.read()
finally:
    f.close()

Every file example in the rest of this tutorial uses the with statement. If you take away only one thing from this post, let it be this: always use with when opening files.


Reading Files

Python provides several methods for reading file content. Which one you choose depends on the file size and how you want to process the data.

read() – Read Entire File

with open("notes.txt", "r", encoding="utf-8") as f:
    content = f.read()       # entire file as a single string
    print(content)

Use this for small files where loading everything into memory is acceptable.

read(size) – Read a Fixed Number of Characters

with open("large_file.txt", "r", encoding="utf-8") as f:
    chunk = f.read(1024)     # read first 1024 characters
    print(chunk)

readline() – Read One Line at a Time

with open("notes.txt", "r", encoding="utf-8") as f:
    first_line = f.readline()    # includes the trailing newline
    second_line = f.readline()
    print(first_line.strip())
    print(second_line.strip())

readlines() – Read All Lines into a List

with open("notes.txt", "r", encoding="utf-8") as f:
    lines = f.readlines()    # list of strings, each ending with \n
    for line in lines:
        print(line.strip())

Iterating Line by Line (Memory-Efficient)

For large files, iterating directly over the file object is the best approach. Python reads one line at a time, keeping memory usage low.

with open("server.log", "r", encoding="utf-8") as f:
    for line_number, line in enumerate(f, start=1):
        print(f"{line_number}: {line.strip()}")

Writing Files

write() – Write a String

with open("output.txt", "w", encoding="utf-8") as f:
    f.write("Line one\n")
    f.write("Line two\n")
    f.write("Line three\n")

Remember: "w" mode truncates the file first. Every time you open with "w", you are starting fresh.

writelines() – Write a List of Strings

lines = ["First\n", "Second\n", "Third\n"]

with open("output.txt", "w", encoding="utf-8") as f:
    f.writelines(lines)     # does NOT add newlines automatically

Note that writelines() does not insert line separators for you. Each string must already include \n if you want separate lines.


Appending to Files

Appending adds content to the end of an existing file without erasing what is already there.

with open("log.txt", "a", encoding="utf-8") as f:
    f.write("2024-01-15 10:30:00 INFO Application started\n")
    f.write("2024-01-15 10:30:05 INFO Connected to database\n")

If the file does not exist, "a" mode creates it.


Working with File Paths

Hard-coding file paths as strings is fragile. Paths differ between operating systems (forward slashes on Linux/macOS, backslashes on Windows). Python offers two approaches, but pathlib is the modern, recommended choice.

pathlib.Path (Recommended)

from pathlib import Path

# Build paths in a cross-platform way
base = Path.home() / "projects" / "myapp"
config_path = base / "config" / "settings.json"

print(config_path)              # /home/user/projects/myapp/config/settings.json
print(config_path.exists())     # True or False
print(config_path.suffix)       # .json
print(config_path.stem)         # settings
print(config_path.parent)       # /home/user/projects/myapp/config

# Read and write shortcuts
text = config_path.read_text(encoding="utf-8")
config_path.write_text("new content", encoding="utf-8")

# Iterate over files in a directory
for py_file in base.glob("**/*.py"):
    print(py_file)

os.path (Legacy Approach)

import os

# Join paths
config_path = os.path.join(os.path.expanduser("~"), "projects", "myapp", "settings.json")

# Common checks
print(os.path.exists(config_path))
print(os.path.isfile(config_path))
print(os.path.isdir(os.path.dirname(config_path)))
print(os.path.splitext(config_path))   # ('/path/to/settings', '.json')

Prefer pathlib for new code. It provides an object-oriented API that is easier to read, compose, and debug.


Reading and Writing CSV Files

CSV (Comma-Separated Values) is one of the most common data exchange formats. Python’s built-in csv module handles quoting, escaping, and different delimiters correctly.

Writing CSV

import csv

employees = [
    ["Name", "Department", "Salary"],
    ["Alice", "Engineering", 95000],
    ["Bob", "Marketing", 72000],
    ["Carol", "Engineering", 110000],
]

with open("employees.csv", "w", newline="", encoding="utf-8") as f:
    writer = csv.writer(f)
    writer.writerows(employees)

The newline="" parameter prevents the csv module from adding extra blank lines on Windows.

Reading CSV

import csv

with open("employees.csv", "r", encoding="utf-8") as f:
    reader = csv.reader(f)
    header = next(reader)          # skip header row
    print(f"Columns: {header}")

    for row in reader:
        name, department, salary = row
        print(f"{name} works in {department}, earns ${salary}")

Using DictReader and DictWriter

For more readable code, use dictionary-based readers and writers:

import csv

# Writing with DictWriter
employees = [
    {"name": "Alice", "department": "Engineering", "salary": 95000},
    {"name": "Bob", "department": "Marketing", "salary": 72000},
]

with open("employees.csv", "w", newline="", encoding="utf-8") as f:
    fieldnames = ["name", "department", "salary"]
    writer = csv.DictWriter(f, fieldnames=fieldnames)
    writer.writeheader()
    writer.writerows(employees)

# Reading with DictReader
with open("employees.csv", "r", encoding="utf-8") as f:
    reader = csv.DictReader(f)
    for row in reader:
        print(f"{row['name']}: ${row['salary']}")

Reading and Writing JSON Files

JSON is the standard format for configuration files, API responses, and structured data storage. Python’s json module handles serialization and deserialization.

Writing JSON

import json

config = {
    "app_name": "DataProcessor",
    "version": "2.1.0",
    "database": {
        "host": "localhost",
        "port": 5432,
        "name": "analytics"
    },
    "features": ["logging", "caching", "retry"],
    "debug": False
}

with open("config.json", "w", encoding="utf-8") as f:
    json.dump(config, f, indent=4, ensure_ascii=False)
    # indent=4 makes the output human-readable

Reading JSON

import json

with open("config.json", "r", encoding="utf-8") as f:
    config = json.load(f)

print(config["app_name"])            # DataProcessor
print(config["database"]["host"])    # localhost
print(config["features"])            # ['logging', 'caching', 'retry']

Tip: Use json.dumps() (with an “s”) to serialize to a string instead of writing directly to a file. Use json.loads() to parse a JSON string.


Binary Files

When working with images, audio, PDFs, or any non-text file, open in binary mode by adding "b" to the mode string. In binary mode, you read and write bytes objects instead of strings.

# Copy a binary file
with open("photo.jpg", "rb") as source:
    data = source.read()

with open("photo_backup.jpg", "wb") as dest:
    dest.write(data)

print(f"Copied {len(data)} bytes")

For large binary files, read and write in chunks to avoid loading the entire file into memory:

def copy_file_chunked(source_path, dest_path, chunk_size=8192):
    """Copy a file in chunks to handle large files efficiently."""
    with open(source_path, "rb") as src, open(dest_path, "wb") as dst:
        while True:
            chunk = src.read(chunk_size)
            if not chunk:
                break
            dst.write(chunk)

copy_file_chunked("large_video.mp4", "large_video_backup.mp4")

File and Directory Operations

The os and pathlib modules provide functions for managing files and directories beyond reading and writing.

import os
from pathlib import Path

# --- Check existence ---
print(os.path.exists("data.txt"))         # True/False
print(Path("data.txt").exists())          # True/False

# --- Create directories ---
os.makedirs("output/reports/2024", exist_ok=True)    # creates parent dirs too
Path("output/logs").mkdir(parents=True, exist_ok=True)

# --- List directory contents ---
for item in os.listdir("output"):
    print(item)

# pathlib approach (more powerful)
for item in Path("output").iterdir():
    print(f"{item.name}  (is_file={item.is_file()}, is_dir={item.is_dir()})")

# --- Delete files and directories ---
os.remove("temp_file.txt")               # delete a file
os.rmdir("empty_directory")              # delete an empty directory

Path("temp_file.txt").unlink(missing_ok=True)   # delete, ignore if missing

# --- Rename / move ---
os.rename("old_name.txt", "new_name.txt")
Path("draft.txt").rename("final.txt")

# --- Get file information ---
stats = os.stat("data.txt")
print(f"Size: {stats.st_size} bytes")
print(f"Last modified: {stats.st_mtime}")

Character Encoding

Text files are stored as bytes, and an encoding defines how those bytes map to characters. The two most common encodings are ASCII (128 characters) and Unicode / UTF-8 (over 1.1 million characters). ASCII is a subset of UTF-8, so any ASCII file is also valid UTF-8.

Always specify the encoding explicitly. If you omit it, Python uses the platform default, which varies across systems and can cause subtle bugs.

# Explicit encoding prevents cross-platform surprises
with open("data.txt", "r", encoding="utf-8") as f:
    content = f.read()

# Handle files with unknown encoding gracefully
with open("legacy_data.txt", "r", encoding="utf-8", errors="replace") as f:
    content = f.read()    # replaces undecodable bytes with the ? character

Practical Examples

1. Log File Processor

This script reads a log file, parses each line, and produces a summary of log levels.

from collections import Counter
from pathlib import Path


def process_log_file(log_path):
    """Read a log file and summarize entries by log level."""
    level_counts = Counter()
    error_messages = []

    with open(log_path, "r", encoding="utf-8") as f:
        for line_num, line in enumerate(f, start=1):
            line = line.strip()
            if not line:
                continue

            # Expected format: "2024-01-15 10:30:00 INFO Some message"
            parts = line.split(maxsplit=3)
            if len(parts) < 4:
                continue

            date, time, level, message = parts
            level_counts[level] += 1

            if level == "ERROR":
                error_messages.append(f"  Line {line_num}: {message}")

    # Print summary
    print("=== Log Summary ===")
    total = sum(level_counts.values())
    for level, count in level_counts.most_common():
        percentage = (count / total) * 100
        print(f"  {level:8s}: {count:5d} ({percentage:.1f}%)")

    if error_messages:
        print(f"\n=== Errors ({len(error_messages)}) ===")
        for msg in error_messages[:10]:    # show first 10
            print(msg)

    return level_counts


# Usage
# process_log_file("application.log")

2. Config File Reader/Writer (JSON-Based)

A reusable configuration manager that loads defaults, merges user overrides, and persists changes.

import json
from pathlib import Path


class ConfigManager:
    """Manage application configuration stored as JSON."""

    DEFAULT_CONFIG = {
        "app_name": "MyApp",
        "log_level": "INFO",
        "max_retries": 3,
        "database": {
            "host": "localhost",
            "port": 5432,
        },
    }

    def __init__(self, config_path):
        self.path = Path(config_path)
        self.config = {}
        self.load()

    def load(self):
        """Load config from file, falling back to defaults."""
        if self.path.exists():
            with open(self.path, "r", encoding="utf-8") as f:
                user_config = json.load(f)
            # Merge: defaults first, then user overrides
            self.config = {**self.DEFAULT_CONFIG, **user_config}
        else:
            self.config = dict(self.DEFAULT_CONFIG)

    def save(self):
        """Persist current configuration to disk."""
        self.path.parent.mkdir(parents=True, exist_ok=True)
        with open(self.path, "w", encoding="utf-8") as f:
            json.dump(self.config, f, indent=4)

    def get(self, key, default=None):
        return self.config.get(key, default)

    def set(self, key, value):
        self.config[key] = value
        self.save()


# Usage
# cfg = ConfigManager("config/app_settings.json")
# print(cfg.get("log_level"))
# cfg.set("log_level", "DEBUG")

3. CSV Data Analyzer

Reads a CSV file, computes basic statistics, and writes a summary report.

import csv
from pathlib import Path


def analyze_csv(input_path, output_path):
    """Analyze a CSV file with a 'salary' column and write a report."""
    departments = {}

    with open(input_path, "r", encoding="utf-8") as f:
        reader = csv.DictReader(f)
        for row in reader:
            dept = row["department"]
            salary = float(row["salary"])
            departments.setdefault(dept, []).append(salary)

    # Build report lines
    report_lines = ["Department Analysis Report", "=" * 40, ""]

    for dept, salaries in sorted(departments.items()):
        avg = sum(salaries) / len(salaries)
        report_lines.append(f"Department: {dept}")
        report_lines.append(f"  Employees : {len(salaries)}")
        report_lines.append(f"  Avg Salary: ${avg:,.2f}")
        report_lines.append(f"  Min Salary: ${min(salaries):,.2f}")
        report_lines.append(f"  Max Salary: ${max(salaries):,.2f}")
        report_lines.append("")

    report_text = "\n".join(report_lines)
    print(report_text)

    # Write report to file
    Path(output_path).write_text(report_text, encoding="utf-8")
    print(f"\nReport saved to {output_path}")


# Usage
# analyze_csv("employees.csv", "salary_report.txt")

4. Simple File Backup Script

Copies files from a source directory to a timestamped backup folder.

import shutil
from datetime import datetime
from pathlib import Path


def backup_directory(source_dir, backup_root="backups"):
    """Create a timestamped backup of a directory."""
    source = Path(source_dir)
    if not source.is_dir():
        print(f"Error: {source_dir} is not a valid directory")
        return

    # Create timestamped backup folder
    timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
    backup_dir = Path(backup_root) / f"{source.name}_{timestamp}"
    backup_dir.mkdir(parents=True, exist_ok=True)

    file_count = 0
    total_bytes = 0

    for file_path in source.rglob("*"):
        if file_path.is_file():
            # Preserve directory structure
            relative = file_path.relative_to(source)
            dest = backup_dir / relative
            dest.parent.mkdir(parents=True, exist_ok=True)

            shutil.copy2(file_path, dest)      # copy2 preserves metadata
            file_count += 1
            total_bytes += file_path.stat().st_size

    size_mb = total_bytes / (1024 * 1024)
    print(f"Backup complete: {file_count} files ({size_mb:.2f} MB)")
    print(f"Location: {backup_dir}")
    return backup_dir


# Usage
# backup_directory("my_project")

Common Pitfalls

  1. Forgetting to close files. Without with, an unclosed file can leak resources and leave buffered data unwritten. On Windows, open file handles can also prevent other processes from accessing the file. Always use the with statement.
  2. Encoding mismatches. Opening a UTF-8 file without specifying encoding="utf-8" works on some systems but fails on others where the default encoding differs. Always pass the encoding parameter explicitly.
  3. FileNotFoundError. Attempting to read a file that does not exist crashes your program. Validate paths with Path.exists() or wrap operations in try/except.
  4. Using "w" when you meant "a". Write mode truncates the file. If you want to add content to an existing file, use append mode.
  5. Platform-specific path separators. Hard-coding / or \ in paths breaks on other operating systems. Use pathlib.Path or os.path.join() instead.
  6. Not using newline="" with the csv module. On Windows, omitting this causes extra blank lines in the output file.

Best Practices

  • Always use the with statement for file operations. It is safer, cleaner, and eliminates an entire class of resource-leak bugs.
  • Specify encoding explicitly. Use encoding="utf-8" unless you have a specific reason to use something else.
  • Use pathlib for file paths. It produces clearer code than string manipulation and handles cross-platform differences automatically.
  • Handle exceptions. Wrap file operations in try/except blocks to handle FileNotFoundError, PermissionError, and IOError gracefully.
  • Process large files in chunks or line by line. Avoid read() on files that could be gigabytes in size.
  • Use the right mode. Understand the difference between "w" (truncate), "a" (append), and "r+" (read/write) to avoid accidental data loss.
  • Validate before writing. Check that the target directory exists (or create it) before writing files.

Key Takeaways

  1. File I/O is essential for data persistence—logs, configuration, reports, and data exchange all depend on it.
  2. The with statement is non-negotiable. It guarantees files are properly closed and is the standard pattern in professional Python code.
  3. pathlib.Path is the modern way to work with file paths. It is expressive, cross-platform, and part of the standard library.
  4. Python's csv and json modules handle the two most common structured file formats. Use them instead of parsing these formats manually.
  5. Always specify encoding="utf-8" when opening text files to avoid cross-platform surprises.
  6. For binary files, use "rb" / "wb" modes and process large files in chunks.
  7. Combine file I/O with exception handling and path validation to build robust, production-quality scripts.

 

Source code on Github

March 6, 2020

CSS Shadows

Text Shadow

text-shadow property applies shadow to text.

<div class="row">
    <div class="col-4">
        <h4>Text Shadow</h4>
    </div>
    <div class="col-8" id="textShadow">
        Hi my name is Folau
    </div>
</div>
<style>
            #textShadow{
                /* 
                text-shadow: h-shadow v-shadow blur-radius color 

                h-shadow - The position of the horizontal shadow
                v-shadow - The position of the vertical shadow.
                blur-radius - The blur radius. Default value is 0
                color - color
                */
                text-shadow: 2px 2px 8px #FF0000;
            }
</style>

Text Multiple Shadows

<div class="row">
    <div class="col-4">
        <h4>Text Multiple Shadow</h4>
    </div>
    <div class="col-8" id="textMultipleShadow">
        Hi my name is Folau
    </div>
</div>
<style>
            #textMultipleShadow{
                color: white;
                text-shadow: 1px 1px 2px black, 0 0 25px blue, 0 0 5px darkblue;
            }
</style>

 

Box Shadow

box-shadow property applies shadow to elements.

<div class="row">
    <div class="col-4">
        <h4>Box Shadow</h4>
    </div>
    <div class="col-8" id="boxShadow">
        Hi my name is Folau
    </div>
</div>
<style>
            #boxShadow{
                /*
                box-shadow: none(no shadow)
                box-shadow: h-offset v-offset blur spread color

                h-offset - The horizontal offset of the shadow. A positive value puts the shadow on the right side of the box, a negative value puts the shadow on the left side of the box
                v-offset - The vertical offset of the shadow. A positive value puts the shadow below the box, a negative value puts the shadow above the box
                blur - The blur radius. The higher the number, the more blurred the shadow will be
                spread - The spread radius. A positive value increases the size of the shadow, a negative value decreases the size of the shadow
                color - color
                */
                box-shadow: 10px 10px 8px 10px #888888;
            }
</style>

Box Multiple Shadows

<div class="row">
    <div class="col-4">
        <h4>Box Multiple Shadow</h4>
    </div>
    <div class="col-8" id="boxMultipleShadow">
        <img src="superman.jpeg" alt=""  style="width:100%;padding: 10px 30px;"/>
        <div class="profile">
            Hi my name is Folau
        </div>
    </div>
</div>
<style>
    #boxMultipleShadow{
                box-shadow: 0 4px 8px 0 rgba(0, 0, 0, 0.2), 0 6px 20px 0 rgba(0, 0, 0, 0.19);
                text-align: center;
            }
        </style>

 

Source code on Github

March 6, 2020