I refactored a monolithic script into well-scoped functions, a reusable importable module, and decorators for cross-cutting concerns like timing and retry. Modular structure made the code unit-testable and reusable across multiple automation tasks.

Objective & Context

Functions and modules are how Python code stays DRY and testable. This lab covers signatures, *args/**kwargs, decorators with functools.wraps, and the import system, turning one-off scripts into a maintainable package.

Learning Objectives

  • Split a monolithic script into small, single-purpose functions.
  • Organize functions into an importable package with __init__.py.
  • Write decorators that preserve metadata with functools.wraps.
  • Implement a retry decorator for flaky operations.
  • Verify refactored behaviour with pytest.

Environment & Prerequisites

Prerequisites

  • Python 3.11 with functools (standard library)
  • An existing script to refactor (or the sample used here)
  • pytest installed (pip install pytest)
  • Assumed skill: defining and calling functions

Step-by-Step Execution

1. A retry decorator with wraps

import functools, time
def retry(n=3):
    def deco(fn):
        @functools.wraps(fn)
        def wrap(*a, **k):
            for i in range(n):
                try: return fn(*a, **k)
                except Exception:  # teaching shortcut: narrow to expected exceptions in real code
                    if i == n-1: raise
                    time.sleep(2**i)
        return wrap
    return deco

Expected output:

(no output; retry decorator defined)

What this teaches: functools.wraps copies the wrapped function's name and docstring onto the wrapper, so introspection, help(), and test reports keep showing the real function. Note the except Exception is a teaching shortcut; in production, retry only the transient exceptions you expect (timeouts, 5xx), then re-raise anything else immediately.

2. Create the package, then import the module

mkdir -p mypkg && touch mypkg/__init__.py && printf 'def process(item):\n return item.strip().upper()\n' > mypkg/core.py

Expected output:

(no output; mypkg/ package created with core.py)

What this teaches: A package is just a directory with __init__.py; the module file defines the reusable function. Run the next command from this same project root so mypkg is on the import path.

python -c "from mypkg.core import process; print(process(' x '))"

Expected output:

X

What this teaches: Importing from mypkg.core proves the package layout works: the directory is a package, the module resolves, and the function is reusable from any script.

3. Write a test and run the suite

mkdir -p tests && printf 'from mypkg.core import process\n\ndef test_process_strips_and_uppercases():\n assert process(" x ") == "X"\n' > tests/test_core.py && pytest -q tests/

Expected output:

.                                                                        [100%]
1 passed in 0.03s

What this teaches: pytest collects any test_*.py file with test_* functions; the dot means the assertion passed. A green run after the refactor proves the behaviour survived the restructuring unchanged. (Timing varies; the point is the "1 passed" summary.)

How It Works

How It Works

Functions are the unit of testability. A 200-line script can only be tested by running the whole thing; ten small functions can each be tested in isolation with known inputs and expected outputs, which is why refactoring precedes testing rather than following it.

A decorator is a function that takes a function and returns a new function. The retry example wraps the call in a loop with sleeps, adding cross-cutting behaviour without touching the original logic; functools.wraps keeps the wrapper transparent to introspection.

Modules and packages turn code into imports. A directory with __init__.py becomes a package, and absolute imports like from mypkg.core import process make dependencies explicit and the codebase navigable, which is what lets the same logic serve both CLI tools and automation scripts.

Validation & Testing

Unit-test each function in isolation and confirm the retry decorator backs off and re-raises after the limit. Pass criteria: green pytest run, importable package, and decorators preserve function metadata via wraps.

Advanced: Troubleshooting
  • Lost docstring/name: always apply functools.wraps in decorators.
  • ImportError: ensure the package has __init__.py or is on the path.
  • Hidden state: avoid mutable module-level globals in reusable code.

Troubleshooting

SymptomLikely causeFix
ImportError: attempted relative importa module run as a script cannot use relative importsrun with python -m mypkg.core from the project root instead
decorated function lost its namethe wrapper did not use functools.wrapsapply @functools.wraps(fn) to the inner wrapper function
circular importmodule A imports B while B imports A at load timemove the shared piece to a third module or defer the import into the function
pytest collects zero testsfiles or functions do not match the test_ naming conventionname files test_*.py and functions test_*
retry swallows the real errorthe decorator retried on every exception including programming bugsretry only on the expected transient exceptions, then re-raise

Key Results

  • Refactored a monolith into a tested, importable package.
  • Added retry/timing as reusable decorators with preserved metadata.
  • Reached a green unit-test suite covering core functions.
  • Enabled reuse of the module across multiple automation scripts.

Key Takeaways

  • Small functions are testable functions: refactor before testing, not after.
  • Decorators add cross-cutting behaviour (retry, timing) without touching logic.
  • functools.wraps keeps decorated functions transparent to introspection.
  • Packages with absolute imports make code reusable across scripts.
  • pytest after refactoring proves behaviour survived the restructuring.

Practice Challenges

Challenge 1: Timing decorator

Goal: Write a @timed decorator that logs how long a function takes, preserving its name and docstring.

Hint: Use time.perf_counter() around the call and functools.wraps on the wrapper.

Challenge 2: Package split

Goal: Split a 150-line script of yours into a package with at least three modules and get its tests green.

Hint: Group by responsibility: parsing, logic, and output each get a module.