Python Functions, Decorators, and Reusable Modules
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.11withfunctools(standard library)- An existing script to refactor (or the sample used here)
pytestinstalled (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.pyExpected 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.
(retry, timing)"] F --> P["mypkg/ package"] P --> T["pytest: 5 passed"]
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.wrapsin decorators. - ImportError: ensure the package has
__init__.pyor is on the path. - Hidden state: avoid mutable module-level globals in reusable code.
Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
| ImportError: attempted relative import | a module run as a script cannot use relative imports | run with python -m mypkg.core from the project root instead |
| decorated function lost its name | the wrapper did not use functools.wraps | apply @functools.wraps(fn) to the inner wrapper function |
| circular import | module A imports B while B imports A at load time | move the shared piece to a third module or defer the import into the function |
| pytest collects zero tests | files or functions do not match the test_ naming convention | name files test_*.py and functions test_* |
| retry swallows the real error | the decorator retried on every exception including programming bugs | retry 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.