Create your own
Lesson illustration

Classes, Modules, and Exception Handling

Hello again. In the previous lesson, you worked with Python’s everyday data structures: lists, tuples, dictionaries, slicing, unpacking, and comprehensions. You can now represent a collection of text records and transform it concisely.

The next step is to turn a growing script into code that can be reused, imported, tested, and safely operated. In this lesson, you will define a focused class, split responsibilities into modules and a package, and use exceptions to distinguish invalid input from unexpected defects. These patterns will carry directly into later dataset-processing, PyTorch, and Hugging Face projects.


1. From a script to reusable design

A Python file can begin as a small script, but a single file becomes difficult to maintain when it contains:

  • record definitions and validation;
  • text-cleaning functions;
  • command-line or application code;
  • configuration;
  • error handling;
  • eventually, tests.

The architectural response is familiar: give each unit a clear responsibility and keep dependencies explicit.

In Python, the basic units are:

  • A function groups a reusable operation.
  • A class groups related state and behavior.
  • A module is a Python file.
  • A package is a directory that groups related modules.

Do not introduce a class merely because a language supports object-oriented programming. A function is often the right choice for a stateless transformation:

def normalize_whitespace(text: str) -> str:
    return " ".join(text.split())

A class is useful when several values form one meaningful entity and operations naturally belong with that entity. A validated text sample, tokenizer configuration, model checkpoint, or training run are all plausible class candidates.

For this lesson, we will model a text sample that has a stable identity, a dataset split, validated text, and a method for displaying a preview.

Corey Schafer’s Python OOP Tutorial 1: Classes and Instances provides a concise visual introduction to the class-instance relationship, initialization, and instance methods.

Python OOP Tutorial 1: Classes and Instances

Watch Corey Schafer’s explanation to establish the Python vocabulary: a class is a definition, while each instance holds its own data.

Watch classes and instances for the distinction between a blueprint and separate objects. Then watch initialization to see how __init__ establishes instance state. Finish with instance methods, paying particular attention to why self appears in a method definition but is not supplied when calling the method through an instance.


2. Define a class around a meaningful invariant

Here is a first version of a TextSample class:

class TextSample:
    """A validated piece of text belonging to a dataset split."""

    def __init__(
        self,
        sample_id: str,
        text: str,
        split: str = "train",
    ) -> None:
        normalized_id = sample_id.strip()
        normalized_text = " ".join(text.split())
        normalized_split = split.strip()

        if not normalized_id:
            raise ValueError("sample_id must not be empty.")

        if not normalized_text:
            raise ValueError("text must contain non-whitespace characters.")

        if not normalized_split:
            raise ValueError("split must not be empty.")

        self.sample_id = normalized_id
        self.text = normalized_text
        self.split = normalized_split

    def preview(self, limit: int = 40) -> str:
        """Return at most `limit` characters of the cleaned text."""
        return self.text[:limit]

Create and use an instance:

sample = TextSample(
    sample_id="ex-001",
    text="  Attention compares positions in a sequence.  ",
    split="train",
)

print(sample.sample_id)   # ex-001
print(sample.text)        # Attention compares positions in a sequence.
print(sample.preview(18)) # Attention compares

What happens during construction?

TextSample(...) creates an instance. Python then calls its __init__ method automatically.

Inside __init__:

  • sample_id, text, and split are ordinary local parameters.
  • self refers to the newly created instance.
  • self.sample_id, self.text, and self.split become attributes belonging to that specific instance.

The conceptual correspondence to C# is close:

PythonApproximate C# concept
class TextSample:class declaration
__init__constructor-style initialization method
selfexplicit instance reference, comparable to this
self.textinstance field or property-like attribute
sample.preview(40)instance method call

One important distinction: self is explicit in a Python method definition.

def preview(self, limit: int = 40) -> str:
    return self.text[:limit]

But Python supplies it automatically when you call the method through an object:

sample.preview()

Conceptually, that call invokes the function stored on TextSample with sample as its first argument.

Constructors should establish valid state

The checks in __init__ enforce an invariant:

Every successfully created TextSample has a non-empty ID, text, and split.

This gives downstream code a simpler contract. If a variable is a TextSample, other functions do not need to repeatedly ask whether its text is blank.

Notice also that normalization happens once at construction:

normalized_text = " ".join(text.split())

This removes leading and trailing whitespace and collapses repeated whitespace between words. The stored self.text is therefore already clean enough for the small example.

Instance variables versus class variables

The attributes assigned through self are per-instance values:

first = TextSample("ex-001", "First record")
second = TextSample("ex-002", "Second record")

print(first.sample_id)   # ex-001
print(second.sample_id)  # ex-002

Each object has independent attributes.

By contrast, an attribute defined directly in the class body is shared unless an instance overrides it:

class TextSample:
    default_split = "train"

A shared class attribute is suitable for an immutable constant. It is not a safe default location for a mutable list or dictionary:

class BadDataset:
    samples: list[TextSample] = []  # Do not do this.

Every BadDataset instance would refer to the same list. Initialize mutable per-instance state inside __init__ instead.

Python does not enforce private fields in the way C# does. A leading underscore is the conventional signal that an attribute or function is internal to a module or class:

def _require_string(value: object, field_name: str) -> str:
    ...

Treat an underscore-prefixed name as an implementation detail, not a public API.


3. Modules and packages: organize by responsibility

A module is simply a .py file that contains Python definitions or executable statements. Importing a module allows another file to use its public definitions without copying them.

The official Python tutorial is worth reading here because it clarifies module execution, imports, and package structure precisely.

6. Modules — Python 3.14.0 documentation

Read the official Python documentation to connect file layout with Python’s import behavior. Focus on modules as reusable files, the difference between running and importing, and package imports.

In Section 6, read the opening explanation from why scripts matter, then continue through the fibo import example. In Section 6.1, read module initialization and then Section 6.1.1, “Executing modules as scripts,” for the __name__ == "__main__" guard. Finally, read Section 6.4, “Packages,” starting at the sound-package example; use the package example to see why dotted module names prevent naming conflicts.

For our small text-ingestion package, use this layout:

text_ingest/
├── __init__.py
├── app.py
├── cleaning.py
├── errors.py
└── records.py

The responsibilities are deliberately narrow:

ModuleResponsibility
errors.pyDefines exceptions that describe domain-specific invalid input
records.pyDefines the TextSample class
cleaning.pyConverts raw mappings into validated TextSample objects
app.pyCoordinates the program and decides what to do with invalid records
__init__.pyMarks and initializes the package; leave it empty for now

Use snake_case for module names such as text_cleaning.py, and PascalCase for class names such as TextSample.

Imports should make dependencies visible

Within a package, use a relative import to refer to a sibling module:

from .records import TextSample

The dot means “from the current package.” By contrast, code outside the package would use an absolute import:

from text_ingest.records import TextSample

Avoid wildcard imports:

# Avoid in production code.
from text_ingest.records import *

They obscure where names originate and make a module’s dependencies harder to audit.

Also avoid naming your own files after standard-library or third-party packages. A local json.py, typing.py, torch.py, or transformers.py can hide the intended package during import resolution and produce confusing failures.


4. Exceptions: make invalid conditions explicit

An exception represents an abnormal condition that prevents a normal operation from completing. Python raises built-in exceptions such as:

  • KeyError when a required dictionary key is absent;
  • ValueError when a value has an invalid meaning;
  • TypeError when an operation receives an inappropriate type;
  • FileNotFoundError when an expected file does not exist.

You should usually not allow raw low-level errors to become the whole public contract of a reusable data-processing component. Instead, define an exception meaningful to your domain.

Create text_ingest/errors.py:

class InvalidTextSampleError(ValueError):
    """Raised when raw data cannot form a valid TextSample."""

This class adds no behavior yet, but it gives your application a specific, catchable error category. It inherits from ValueError because the problem is still fundamentally an invalid value.

The key idea is to separate responsibilities:

  1. A reusable module detects invalid input and raises a meaningful exception.
  2. The application boundary decides whether to skip, report, retry, or terminate.

This is preferable to burying a print() statement inside library code, where a caller cannot choose a different policy.

The diagram shows the roles of Python’s exception-handling clauses: `try` contains code that may fail, `except` handles a matching exception, `else` runs only when no exception occurred, and `finally` is reserved for cleanup that must run on either path.

Use narrow try blocks and catch specific errors

A try block should surround only the operation expected to fail:

try:
    sample_id = raw["id"]
except KeyError as exc:
    raise InvalidTextSampleError(
        "Record is missing required field 'id'."
    ) from exc

The from exc portion creates an exception chain. The new, domain-level error is shown to the caller, while the original KeyError remains available as the underlying cause for debugging.

Avoid this pattern:

try:
    # Many unrelated lines of code
    ...
except Exception:
    print("Something went wrong")

It can hide programming defects such as a misspelled attribute, an incorrect function call, or a logic error. Catch an exception only when you can take a justified recovery action.

else and finally

Use else for code that should execute only after the try block succeeds:

try:
    sample = parse_raw_record(raw)
except InvalidTextSampleError as exc:
    print(f"Skipping invalid record: {exc}")
else:
    print(sample.preview())

Keeping the success path in else prevents an exception raised by sample.preview() from being incorrectly treated as a parsing failure.

Use finally primarily for cleanup of resources acquired before or during the operation, such as closing a connection or releasing a lock. In later lessons, you will usually prefer Python’s with statement for files and similar resources; it provides reliable cleanup without manually writing finally.


5. Build a small importable text-ingestion package

Now assemble the package. First, create an empty text_ingest/__init__.py.

Then add text_ingest/errors.py:

class InvalidTextSampleError(ValueError):
    """Raised when raw data cannot form a valid TextSample."""

Add text_ingest/records.py:

from .errors import InvalidTextSampleError


class TextSample:
    """A validated and whitespace-normalized text sample."""

    def __init__(
        self,
        sample_id: str,
        text: str,
        split: str = "train",
    ) -> None:
        normalized_id = sample_id.strip()
        normalized_text = " ".join(text.split())
        normalized_split = split.strip()

        if not normalized_id:
            raise InvalidTextSampleError("sample_id must not be empty.")

        if not normalized_text:
            raise InvalidTextSampleError(
                f"Sample {normalized_id!r} has empty text."
            )

        if not normalized_split:
            raise InvalidTextSampleError("split must not be empty.")

        self.sample_id = normalized_id
        self.text = normalized_text
        self.split = normalized_split

    def preview(self, limit: int = 40) -> str:
        """Return a short text preview."""
        return self.text[:limit]

The class owns semantic validation: fields may be present, but an empty ID or whitespace-only text is still invalid.

Next, add text_ingest/cleaning.py:

from .errors import InvalidTextSampleError
from .records import TextSample


def _require_string(value: object, field_name: str) -> str:
    """Return a string value or raise a domain-specific error."""
    if not isinstance(value, str):
        actual_type = type(value).__name__
        raise InvalidTextSampleError(
            f"Field {field_name!r} must be a string, not {actual_type}."
        )

    return value


def parse_raw_record(raw: dict[str, object]) -> TextSample:
    """Convert one raw mapping into a validated TextSample."""
    try:
        raw_id = raw["id"]
        raw_text = raw["text"]
    except KeyError as exc:
        raise InvalidTextSampleError(
            f"Record is missing required field {exc.args[0]!r}."
        ) from exc

    raw_split = raw.get("split", "train")

    return TextSample(
        sample_id=_require_string(raw_id, "id"),
        text=_require_string(raw_text, "text"),
        split=_require_string(raw_split, "split"),
    )

There are several design decisions worth noticing:

  • parse_raw_record() receives raw external data as dict[str, object], because incoming values are not yet trusted.
  • _require_string() validates the structural requirement that a field must be a string.
  • TextSample validates the higher-level meaning of those strings.
  • Missing keys are translated from KeyError into InvalidTextSampleError.
  • The private helper begins with _, indicating that callers should use parse_raw_record() rather than depend on the helper.

Finally, add text_ingest/app.py:

from .cleaning import parse_raw_record
from .errors import InvalidTextSampleError


def main() -> None:
    raw_records: list[dict[str, object]] = [
        {
            "id": "ex-001",
            "split": "train",
            "text": "  Attention compares positions in a sequence.  ",
        },
        {
            "id": "ex-002",
            "split": "validation",
            "text": "Embeddings map tokens to vectors.",
        },
        {
            "id": "ex-003",
            "split": "train",
            "text": "   ",
        },
        {
            "text": "This record has no ID.",
        },
        {
            "id": "ex-005",
            "text": 42,
        },
    ]

    for raw in raw_records:
        try:
            sample = parse_raw_record(raw)
        except InvalidTextSampleError as exc:
            print(f"Skipping record: {exc}")
        else:
            print(
                f"{sample.sample_id} [{sample.split}]: "
                f"{sample.preview(35)!r}"
            )


if __name__ == "__main__":
    main()

From the directory containing text_ingest, run:

python -m text_ingest.app

The -m option runs text_ingest.app as a module. This is important because the relative imports in app.py, such as from .cleaning import ..., need Python to recognize text_ingest as a package.

The if __name__ == "__main__": guard means:

  • main() runs when this module is launched with python -m text_ingest.app.
  • main() does not run merely because another module imports text_ingest.app.

That distinction keeps reusable definitions import-safe and makes later testing straightforward.

A useful implementation check is to alter the raw data in three ways:

  • Add a valid record with no "split" field and confirm that it defaults to "train".
  • Add a record whose "id" is an integer and confirm that it is skipped with a clear message.
  • Import parse_raw_record in a Python REPL and call it directly, confirming that importing the module does not execute main().

6. Design rules to retain

For the code you will write throughout this pathway, these rules form a dependable baseline:

  1. Use a class when data and behavior form one coherent concept. Prefer functions for stateless transformations.
  2. Put related definitions in modules with one clear responsibility.
  3. Use packages to group modules that change together.
  4. Keep imports explicit. Prefer named imports and avoid wildcard imports.
  5. Validate untrusted input near the boundary where it enters the system.
  6. Raise specific exceptions from reusable code; decide recovery policy at the application boundary.
  7. Catch only exceptions you expect and can handle appropriately.
  8. Use else for a clean success path and finally for unavoidable cleanup.
  9. Use if __name__ == "__main__": to keep executable entry-point code separate from importable definitions.

You can now move beyond single-file scripts: define classes that preserve useful invariants, organize them into importable modules and packages, and handle invalid data without concealing real defects.

Next, you will learn iterators and generators, which let Python process large collections and datasets incrementally rather than loading everything into memory at once.

Can't find a good explanation? Sign up and we'll make it for you

Sign up