EasyTask works: v0.16 has classes with validation, an agenda that protects its data and persistence in JSON. But all of that lives in a single file that by now piles up three classes, a dozen input and output functions, the constants, the menu and the persistence. Every time something needs changing you have to scan the whole thing looking for where, and if tomorrow Marta wants a web version, there is no way to reuse the logic without dragging the console menu along with it. This lesson solves that problem with the last piece of organisation the course is missing: splitting the code across several files that import each other. You will see what a module is and what exactly happens when Python imports one —which finally explains the if __name__ == "__main__": we have been carrying since 04-04—, how several modules are grouped into a package, what treasures the standard library brings and how third-party libraries are installed with pip. By the end, EasyTask will stop being a file and become the easytask/ package.
Contents
- What a module is and why to split
- The ways of importing
- How Python finds modules
- What happens on import:
__name__andif __name__ == "__main__" - Packages: folders with
__init__.py - The standard library
- Third-party packages: PyPI and
pip - Circular imports
- EasyTask v0.17: the
easytask/package - Common mistakes and tips
- Exercises
- Conclusion
- What a module is and why to split
A module is, quite simply, a .py file. There is nothing to declare and no marking of any kind: the moment you save validations.py, you have a module called validations that any other file can import. You have been using them since the start of the course without knowing it: import json, import csv, from pathlib import Path all load modules written by other people.
Why split the code across several files? For four very concrete reasons:
- Finding things. In a project with
model.py,agenda.py,storage.pyandinterface.py, knowing where to make a change is immediate. In a thousand-line file, it is not. - Reuse. If the task logic lives in
model.pywith not a single call toprintorinput, that file serves the console application, a website or a reporting script equally well. - Working as a team. Two people in two different files do not tread on each other; in the same file, they do.
- Limiting the damage. A module with one clear responsibility can be read, understood and changed without holding the whole application in your head.
The criterion for deciding what goes in each file is the same one you already used with functions in 04-04 and with classes in this module: one responsibility per module. The data on one side, the persistence on another, the conversation with the user on another.
- The ways of importing
Suppose a file formatting.py with a constant and a function:
# formatting.py
WIDTH = 52
def title(text):
"""Return the text centred and in uppercase."""
return text.upper().center(WIDTH)From another file, in the same folder, it can be reached in four ways:
| Form | How it is used afterwards | When it suits |
|---|---|---|
import formatting |
formatting.title("hello") |
The safest: it is clear where each name comes from |
import formatting as fmt |
fmt.title("hello") |
When the module name is long (import numpy as np) |
from formatting import title |
title("hello") |
When you use one or two names very often |
from formatting import title as tit |
tit("hello") |
To avoid a name clash |
from formatting import * |
title("hello") |
Discouraged |
import formatting
print(formatting.title("today's agenda")) # the prefix says where it comes from
from formatting import title, WIDTH
print(title("today's agenda"), WIDTH) # no prefix, shorterThe last row of the table deserves an explanation. from module import * brings every public name of the module into the current file, and it is discouraged for two serious reasons: whoever reads your code cannot tell where title comes from —did you define it, does it come from formatting or from another import *?—, and if two modules export the same name, the second silently overwrites the first. A from os import * followed by from numpy import * can leave you with a different function from the one you think you are calling. It is a saving in typing paid for with hours of debugging. The practical rule almost every project follows: import module by default, from module import name when one particular name is used a lot, and never *.
- How Python finds modules
When you write import formatting, Python looks for that file in a list of folders, in order:
- The folder of the script you ran (or the current directory, in the interactive console).
- The folders listed in the
PYTHONPATHenvironment variable, if it exists. - The folders of the Python installation, where the standard library lives.
- The
site-packagesfolder, wherepipinstalls third-party packages.
That list is visible and can be inspected:
If Python does not find the module, the error is unmistakable: ModuleNotFoundError: No module named 'formatting'. There are three usual causes: the file is not in the folder you think it is, the name is misspelt (capitalisation included: on Linux Formatting and formatting are different), or you ran the script from a different folder.
And there is a trap you absolutely must know about: never name your files after a standard library module. If you create a random.py to practise and write import random inside it, Python will find your file before the official one —the script's folder comes first— and it will fail with an incomprehensible error of the sort AttributeError: module 'random' has no attribute 'randint'. The same goes for json.py, csv.py, math.py, time.py or email.py. If it happens to you, rename your file and delete the __pycache__ folder Python left beside it.
- What happens on import:
__name__ and if __name__ == "__main__"
__name__ and if __name__ == "__main__"Here comes the explanation we promised in 04-04. When Python imports a module, it runs its code from top to bottom, in full, exactly once. The def and class lines define functions and classes without running them, but every other line does run: assignments, print, calls.
And during that run, Python defines a special variable in the module called __name__, whose value depends on how the module was reached:
| Situation | Value of __name__ |
|---|---|
The file is run directly (python program.py) |
"__main__" |
The file is imported from another one (import program) |
"program" (its module name) |
Let us check it with two files:
# greeting.py
print(f"Running greeting.py, __name__ is: {__name__}")
def greet(name):
return f"Hello, {name}."
if __name__ == "__main__":
print(greet("Marta")) # only if we run THIS file# main.py
import greeting
print(f"Running main.py, __name__ is: {__name__}")
print(greeting.greet("Luis"))Running python greeting.py produces two lines: Running greeting.py, __name__ is: __main__ and Hello, Marta.. But running python main.py the output is this:
Read it slowly, because it contains three lessons:
- The
printingreeting.pyran even though we only imported it: importing is running. That is why a module must not do heavy work or ask the user for data at its top level. __name__was"greeting", not"__main__", so the lineprint(greet("Marta"))did not run. That is exactly the purpose of theif: the code that only makes sense when the file is run directly.__name__is"__main__"in the file that started everything, whatever its name.
From there comes the conclusion we have been dragging along: if __name__ == "__main__": main() means "start the application only if I am the one being run; if somebody imports me to reuse my functions, do nothing". Without that guard, importing easytask to use its Task class would launch the interactive menu by surprise.
One last detail: on the first import, Python stores a compiled version in __pycache__ to speed up later runs, and it does not run the module again if it is already imported, even if ten identical import lines appear.
- Packages: folders with
__init__.py
__init__.pyWhen modules multiply, they are grouped into folders. A folder that Python treats as a set of modules is a package, and to mark it as such you add a file called __init__.py.
graph TD
A["project/"] --> B["main.py"]
A --> C["easytask/"]
C --> D["__init__.py"]
C --> E["model.py"]
C --> F["agenda.py"]
C --> G["storage.py"]
C --> H["interface.py"]
With that structure, the modules are imported with dot notation: import easytask.model for the whole module, from easytask.model import Task for one particular name or from easytask import agenda for a module of the package. The __init__.py runs when anything from the package is imported, and it serves two purposes: leaving it empty (the most usual and perfectly correct option), or using it to expose a convenient interface, so that whoever uses the package does not have to know its internal structure:
# easytask/__init__.py
"""EasyTask package: task manager for Alba Studio."""
from .model import Task, RecurringTask
from .agenda import Agenda
__version__ = "0.17"Thanks to that, whoever uses the package can write from easytask import Task, Agenda without knowing which file each class is in. What an __init__.py must never do is heavy work, because it runs on every import. And notice the dot in from .model import Task: it is a relative import, and it means "the model module that is in my own package".
| Type | Example | When to use it |
|---|---|---|
| Absolute | from easytask.model import Task |
By default: it reads unambiguously from anywhere |
| Relative | from .model import Task |
Inside the package itself; .. goes up one level |
Both work and you will see them in real projects. The official style guide recommends absolute ones for clarity, with relative ones as an acceptable option inside a large package. What is not advisable is mixing the two styles without criterion in the same project.
- The standard library
Python comes "batteries included": once installed you have hundreds of modules already available, with nothing to install. These are the ones that will serve you most right now:
| Module | What it is for |
|---|---|
math |
Roots, powers, precise rounding, constants such as pi |
random |
Random numbers, choice, shuffle, sample |
datetime |
Dates and times: calculations, differences and formats |
os |
Operating system: environment variables, processes, old-style paths |
pathlib |
Modern object-oriented paths: Path, exists(), read_text() |
json |
Reading and writing JSON (05-05) |
csv |
Reading and writing CSV, with DictReader and DictWriter (05-05) |
sys |
The interpreter: sys.path, sys.argv, sys.exit() |
time |
Pauses with sleep and measurement with perf_counter (06-02) |
statistics |
mean, median, stdev with nothing to install |
collections |
Counter, defaultdict, namedtuple, deque |
functools |
lru_cache (06-03), partial, reduce |
An example with datetime applied to our tasks, which is the use EasyTask needs most: going from "estimated days" to real delivery dates.
from datetime import date, timedelta
today = date.today() # date(2026, 8, 5)
delivery = today + timedelta(days=3) # adding days is as simple as this
print(delivery.strftime("%d/%m/%Y")) # 08/08/2026
left = (delivery - today).days # subtracting dates gives a timedelta
print(f"{left} days left.") # 3 days left.
print(delivery < today) # False: dates can be comparedThree pieces: date.today() gives the current date, timedelta(days=n) represents a duration that is added to or subtracted from a date, and strftime formats it (%d day, %m month, %Y year). Subtracting two dates returns a timedelta, whose .days gives the difference. And since dates are compared with < and >, sorting tasks by delivery date with sorted(..., key=attrgetter("delivery")) just works.
- Third-party packages: PyPI and
pip
pipWhen the standard library is not enough, you turn to PyPI (Python Package Index), the public repository where the community publishes more than half a million packages: requests for talking to websites, pandas for analysing data, flask and django for web applications, pytest for testing.
They are installed with pip, the package manager that ships with Python:
pip install requests # install pip install requests==2.31.0 # a specific version pip list # see what is installed pip freeze > requirements.txt # save the exact list of versions pip install -r requirements.txt # reinstall it on another computer pip uninstall requests # uninstall
The requirements.txt file is the key piece of teamwork: it holds the dependencies with their exact versions, it is kept next to the code and it lets anybody reproduce your environment with a single command.
And here it is worth recalling the virtual environment from Development environments. Packages should be installed inside the project's venv, not in the system Python, for two reasons: two projects may need different versions of the same library, and this way the requirements.txt reflects exactly what that project uses and nothing else.
A security warning you should never forget again: installing a package means running a stranger's code on your computer. Before a pip install, check that the name is spelt correctly —there are malicious packages with names almost identical to the popular ones, hunting for typos—, look at whether the project has recent activity and users, and be suspicious of dependencies that appear without your knowing who asked for them. EasyTask, incidentally, needs none: everything it uses is in the standard library.
- Circular imports
This happens when two modules import each other: agenda.py does import storage and storage.py does import agenda. Python starts loading the first, sees the import of the second, starts loading the second, which comes back to the first... which is still halfway through running, and therefore missing names. The result is an ImportError: cannot import name 'X' from partially initialized module that baffles everybody.
The solution is not technical but a matter of design: dependencies must flow in one direction only. In EasyTask, storage knows about model, and interface knows about both, but model knows about nobody. If you run into a cycle, it almost always means that:
- The responsibilities are badly distributed and there is something that should live in a third module that both depend on.
- Or one of the two directions is unnecessary: usually the "lower" module does not need to know the upper one, but to receive what it needs as a parameter.
- EasyTask v0.17: the
easytask/ package
easytask/ packageWe split v0.16 across five modules, each with one responsibility:
graph TD
A["easytask/"] --> B["__init__.py"]
A --> C["model.py<br/>Task, RecurringTask"]
A --> D["agenda.py<br/>Agenda"]
A --> E["storage.py<br/>JSON and CSV"]
A --> F["interface.py<br/>menu and input/output"]
A --> G["__main__.py<br/>main()"]
And these are the headers of each one, which show who depends on whom:
# easytask/model.py --- imports nothing from the project: it is the base
PRIORITIES = ("high", "medium", "low")
TEAM = ("marta", "luis", "nuria")
PRIORITY_ORDER = {"high": 0, "medium": 1, "low": 2}
class Task: ...
class RecurringTask(Task): ...
# easytask/agenda.py
from .model import Task, PRIORITY_ORDER
class Agenda: ... # no save() or load(): that belongs to storage# easytask/storage.py
import csv, json
from pathlib import Path
from .model import Task
from .agenda import Agenda
JSON_PATH = Path("tasks.json")
CSV_PATH = Path("tasks.csv")
FIELDS = ("title", "assignee", "priority", "days", "done", "completed")
def save(agenda, path=JSON_PATH): ...
def load(path=JSON_PATH): ... # returns an Agenda
def export_csv(agenda, path=CSV_PATH): ...# easytask/interface.py
from .model import PRIORITIES, TEAM, Task
from .agenda import Agenda
WIDTH = 52
OPTIONS = ("1", "2", "3", "4", "5", "6", "7", "8", "9", "10")
def ask_text(message): ... # and ask_option, ask_integer, confirm
def show_menu(): ... # and show_list, show_card# easytask/__main__.py
from . import storage, interface
from .model import Task
def main():
"""Run the main loop of EasyTask."""
agenda = storage.load()
while True:
interface.show_menu()
option = interface.ask_option("Choose an option (1-10): ", interface.OPTIONS)
if option == "10" and interface.confirm("Are you sure you want to quit?"):
storage.save(agenda)
break
# ... the rest of the options
if __name__ == "__main__":
main()And this is how the program is run now, from the folder that contains easytask/:
The -m option tells Python "run this package as a program", and that is why the file is called __main__.py: it is the one Python looks for to start a package. The classic alternative is to leave a three-line main.py outside the folder —from easytask.__main__ import main, plus the guard if __name__ == "__main__": main()— and run python main.py. Look at the result, which is what the whole lesson is about:
| Module | Responsibility | Who does it depend on? |
|---|---|---|
model.py |
What a task is and what it knows how to do | Nobody |
agenda.py |
The collection and its operations | On model |
storage.py |
Reading and writing files | On model and agenda |
interface.py |
Talking to the user | On model and agenda |
__main__.py |
Coordinating the flow | On all of them |
The arrows go in one direction only, so there are no circular imports. And model.py, which depends on nothing and has not a single print, can be reused as it is in a web version or tested in isolation, which is exactly what we will do in 08-04.
Common Mistakes and Tips
- Naming a file after a standard module (
random.py,json.py,csv.py). Python will load yours and the error will be incomprehensible. Rename it and delete__pycache__. - Using
from module import *. It destroys the traceability of names and causes silent clashes. - Running the package from inside its own folder.
python __main__.pyfrom insideeasytask/breaks the relative imports (attempted relative import with no known parent package). Stand in the folder above and usepython -m easytask. - Putting executable code at the top level of a module. Remember that importing is running: a stray
input()orprintwill fire as soon as somebody imports the file. Everything executable goes inside functions and underif __name__ == "__main__":. - Creating cycles between modules. If
aimportsbandbimportsa, review the distribution of responsibilities: almost always one of the two directions is unnecessary. - Installing packages outside the virtual environment. You end up with a system Python full of libraries and a
requirements.txtthat does not reflect the project. - Tip: start with one file and split when it hurts. Dividing too early gives you ten twenty-line files and a mess of imports. The signal to split is concrete: when it becomes hard to find where to make a change, or when you want to reuse one part without the others.
Exercises
Exercise 1: Module, import and __name__
Create pricing.py with a constant VAT = 0.21, a function with_vat(base) returning the amount including VAT, and a print("loading pricing") at the top level. Then create invoice.py that imports it and calculates the total of a €1,200 quote for client Vidal. Predict what each will print when run directly and check it; then add to pricing.py a quick test protected with if __name__ == "__main__": and explain what changes.
Exercise 2: Distributing responsibilities
A colleague has written a single 600-line file manager.py containing: the Client class, the Invoice class, functions for reading and writing the invoice CSV, functions that ask for data from the keyboard, the menu and the main(). Propose a split into modules, state what each one imports and picture the dependency arrows to check that there are no cycles.
Exercise 3: Delivery dates with datetime
Write a function delivery_date(days) that returns the delivery date starting from today, formatted as dd/mm/yyyy, and another days_until(target) that receives a date and returns how many days are left (negative if it has already passed). Use them to print the plan for the three Alba Studio tasks: poster (3 days), logo (5) and quote (2).
Solutions
Solution 1.
# pricing.py
print("loading pricing")
VAT = 0.21
def with_vat(base):
"""Return the amount with VAT applied."""
return round(base * (1 + VAT), 2)
if __name__ == "__main__":
print(with_vat(100)) # 121.0: quick test, only when running this file
# invoice.py
import pricing
print(f"Vidal quote: {pricing.with_vat(1200)} EUR")Running python invoice.py the output is loading pricing and then Vidal quote: 1452.0 EUR. The first message appears even though we never asked for it, because importing is running; the 121.0 from the test does not appear, because on import __name__ is "pricing" and not "__main__". That guard is what lets a module have its own quick check without bothering anyone who reuses it. And the stray print("loading pricing") is exactly what a real module must not do: here it is only so you can see the effect.
Solution 2.
| Module | Contents | Imports |
|---|---|---|
model.py |
The Client and Invoice classes |
Nothing from the project |
storage.py |
Reading and writing the invoice CSV | model |
interface.py |
Asking for data from the keyboard and showing the menu | model |
main_app.py |
The main() and the option loop |
The three above |
The arrows go from main_app towards the others and from storage/interface towards model: none goes back, so there are no cycles. The key to the split is that model.py imports nothing from the project and contains not a single print or input; if Invoice needed to ask the user for something, that would be a sign that the logic is in the wrong place. If the four pieces grow, the next step is to group them into a manager/ package with its __init__.py.
Solution 3.
from datetime import date, timedelta
def delivery_date(days):
"""Return the delivery date in 'days' time, as dd/mm/yyyy."""
return (date.today() + timedelta(days=days)).strftime("%d/%m/%Y")
def days_until(target):
"""Days left until a date; negative if it has already passed."""
return (target - date.today()).days
for title, days in (("Book fair poster", 3), ("Sole Bakery logo", 5),
("Vidal quote", 2)):
print(f"{title:<28} delivery on {delivery_date(days)}")
print(days_until(date(2026, 1, 1))) # negative: that date has already passedTwo details worth fixing in your mind. date.today() + timedelta(days=days) returns a new date object: dates are immutable, like tuples and strings, so they are never modified in place. And strftime is applied only at the end, for display: while the program is calculating, always work with date objects and not with strings, because objects can be compared, subtracted and sorted, and "08/08/2026" cannot.
Conclusion
A module is any .py file, and splitting the code across several answers four very concrete needs: finding things, reusing the logic without dragging the interface along, working as a team and limiting the damage of each change. They are imported with import module —the clearest form, because the prefix says where each name comes from—, import module as alias, from module import name and its as variant, always avoiding from module import *, which erases traceability and causes silent clashes. Python looks for modules in the script's folder, in PYTHONPATH, in the installation and in site-packages, a list visible in sys.path; hence the ModuleNotFoundError and the trap of naming a file of your own random.py or json.py. On import, the module runs in full exactly once, and Python gives __name__ the value "__main__" only if it is the file you launched: that is the complete explanation of the if __name__ == "__main__": we had been using since 04-04, the guard that separates "what I do when I am run" from "what I offer when I am imported". Several modules in a folder with an __init__.py form a package, with absolute imports (from easytask.model import Task) or relative ones (from .model import Task), and with an __init__.py that can stay empty or expose a convenient interface. The standard library already solves nearly everything —math, random, datetime, os, pathlib, json, csv, sys, time, statistics, collections, functools—, and for the rest there is PyPI with pip install and requirements.txt, always inside the project's venv and always with an eye on what is being installed. Finally, circular imports are not fixed with tricks: they are avoided by making dependencies flow in one direction only.
That closes module 7 and the biggest leap of the course. You started with a fragile list of dictionaries, in which any task could be born without completed or with its priority written as "High", and with the functions that operated on them scattered around a file. Now there is a Task class that guarantees in its constructor that no invalid task ever comes into existence and that carries its own behaviour inside; an Agenda class that encapsulates the collection, validates what goes in, protects its internal list and knows how to save and rebuild itself from JSON; inheritance and polymorphism in just the right measure, with the warning to prefer composition; and a project split across the easytask/ package, with each module in its place and the dependencies pointing in one direction only. EasyTask is v0.17 and is run with python -m easytask.
And yet, look at it through a professional programmer's eyes: that code is not documented beyond a few scattered docstrings, and nobody from outside would know where to start; it does not handle errors, so a corrupt JSON file or a days value arriving as text still brings the whole program down with a traceback; it has no history, so an unfortunate change is lost with no way back and there is no telling who touched what; and it has no tests, so every modification forces you to go through the menu by hand praying you have not broken anything. Module 8 solves all four, starting with Documentation and comments: real docstrings, comments that explain the why and not the what, and a README that lets anybody —including you six months from now— understand the project in five minutes.
Fundamentals of Programming
Module 1: Introduction to Programming
- What is programming?
- History of programming
- Programming languages
- Development environments
- From problem to algorithm
Module 2: Core Concepts
- Variables and data types
- Operators and expressions
- Input and output
- Type conversion and data validation
Module 3: Control Structures
Module 4: Functions and Procedures
- Defining and using functions
- Parameters and return values
- Variable scope
- Breaking a program down into functions
- Functions as values: lambda and higher order
Module 5: Data Structures
- Lists and arrays
- Strings
- Dictionaries and sets
- Tuples and nested structures
- Saving data to files: text, CSV and JSON
Module 6: Basic Algorithms
Module 7: Objects and Code Organisation
- From data to objects: classes and instances
- Attributes, methods and the constructor
- Collections of objects
- Modules, packages and imports
Module 8: Good Practices and Tools
- Documentation and comments
- Debugging and error handling
- Version control
- Automated testing
- Style, readability and refactoring
