Introduction
Welcome to Intro to Python. This book teaches Python without building terminal text-mode UI apps: we do not use Textual, Rich, or similar console UI libraries.
The first part of the course is core Python: syntax, control flow, modules, automated testing with pytest, and Pydantic / JSON. HTTP, FastAPI, Uvicorn, and HTTPX are saved for the final chapters, after virtual environments and dependency setup—so the web stack comes last.
Your work lives in a GitHub Classroom repository created from a base template. The book gives theory, directions, and code samples; you implement in that template and push for review.
Grading: Assignments are checked in part by an autograder on GitHub Classroom that runs pytest. You will learn to run pytest locally early on; the official tests live on the grader side.
Warning
Do not push your own tests
Practice tests stay local. The autograder supplies hidden tests. Never commit or push personaltest_*.pyfiles,tests/trees you wrote for practice,.pytest_cache/, or__pycache__unless your instructor explicitly tells you otherwise. See Testing with pytest.
Important
GitHub Classroom
- Open Accept the GitHub Classroom assignment.
- Sign in to GitHub, accept the assignment, and wait for your personal repository.
- Complete any email / invitation steps your course requires.
- Clone your repo and open the project in your editor. (Virtual environments are covered near the end, right before the web chapters.)
Note
One template, whole series
Add files and features to the same Classroom project as you go. Do not assume a separate “chapter repo” per week unless your instructor says otherwise.
Tip
Submit after each milestone
When you finish a chapter’s exercises, commit with a clear message and push tomain(or the branch your instructor uses).
Warning
Feedback pull request
If your repo has a Feedback PR for instructor comments, do not merge or close it. Keep working onmainand pushing; treat that PR as a discussion thread only.
Capstone preview
The final project is a Python web server (FastAPI + Uvicorn) that exposes a custom route:
- Path:
GET /ping - Response body (JSON):
{"message": "pong"}
The path through the book is: basics → pytest mindset → more Python → Pydantic/JSON → venv & packages → HTTP client → FastAPI → Uvicorn → capstone.
What you need
- Python 3.12+ (match your template’s
requires-pythonif it specifies one). - Git and a code editor.
- A terminal to run
python,pip, and laterpytestanduvicorn.
Next: getting started with main and your first program.
Getting started: main and your first program
Start with a single entry function—main—and the if __name__ == "__main__": guard. Everything else in this chapter (variables, indentation, type annotations, print) lives inside the body of main so you have one clear place where your program runs.
The main function
A function is a named block of code. main is the conventional name for the starting point of a small script or assignment.
Function signature — what you write on the def line:
def— keyword that starts a function definitionmain— the name you (and your autograder) will call()— parameter list; empty for now-> None— return type annotation: this function does not return a useful value (it returnsNoneimplicitly)
Function body — indented lines under the def. That is where your program’s work happens.
def main() -> None:
pass # placeholder: replace with real code
pass is a no-op: it keeps the body valid while the block is empty. Remove it once you add real statements.
Later in the course you will use the same “define main + guard” pattern so a file can also expose a FastAPI app; Uvicorn appears only in the final chapters.
Running the program: if __name__ == "__main__":
When Python runs a file as the main script (e.g. python main.py), the special module name __main__ is used. When the same file is imported elsewhere, its name is usually the module name (e.g. main).
The guard calls main() only when the file is executed directly:
def main() -> None:
pass
if __name__ == "__main__":
main()
So: define main at the top level (no indentation), then invoke it inside the guard.
Inside main: indentation
Python uses indentation (usually 4 spaces) to show what belongs to a block. Everything inside main must be indented one level more than def main.
def main() -> None:
print("this line is inside main")
print("this line is NOT inside main — runs at import time")
There are no curly braces; the colon : after def main() -> None: starts a block, and only indentation defines where it ends.
You can nest blocks (e.g. if inside main) by indenting another level:
def main() -> None:
if True:
print("inside if, inside main")
Variables and type annotations inside main
Variables are names that refer to values. Declare them inside main when they are only needed for that run.
Type annotations tell readers and tools what you intend (: int, : str, …). They are optional at runtime (Python does not enforce them unless you use extra tools) but your template or rubric may require them.
def main() -> None:
count: int = 670
label: str = "SEC"
ok: bool = True
nothing: None = None
Add more statements below those lines in the same indented block.
print and f-strings inside main
Use print to send text to standard output (what you see in the terminal).
def main() -> None:
name = "Python"
print("Hello,", name)
print(f"Hello, {name}! course={670}")
An f-string (f"...") lets you embed expressions in { ... } inside the string.
Putting it together
def main() -> None:
course: int = 670
topic: str = "Python"
print(f"Welcome to {topic} — course {course}")
if __name__ == "__main__":
main()
Run with:
python main.py
Implement
- Create
main.py(or use your template’s entry file) withdef main() -> None:, an indented body, andif __name__ == "__main__": main(). - Inside
main, add variables (with annotations if required),print/ f-strings, and optional nestedifto practice indentation. - Run:
python main.py - Optionally try
pytestlocally (see Testing with pytest)—do not push practice tests. - Commit and push your solution source only.
Testing with pytest
Your GitHub Classroom assignment is graded in part by an autograder that runs pytest against your repository. You should learn to run tests locally so you catch failures before you push.
Install pytest locally
On your machine (using the same Python you use for the assignment—see the virtual environments chapter at the end of the book if you use a venv):
pip install pytest
Then run from your project root (where your code and the autograder’s layout expect to run):
pytest
Your template may already list pytest as a dependency; use whatever pip install -r / uv sync instructions you were given.
A function you can test
Prefer testing functions that return values—easy to assert:
# greeting.py
def greeting(name: str) -> str:
return f"Hello, {name}!"
# test_greeting.py (LOCAL ONLY — see warning below)
from greeting import greeting
def test_greeting_includes_name():
assert greeting("World") == "Hello, World!"
Run: pytest test_greeting.py -v
Testing print output with capsys
To check print("Hello, World!"), capture stdout with pytest’s capsys fixture:
# hello.py
def say_hello() -> None:
print("Hello, World!")
# test_hello.py (LOCAL ONLY)
from hello import say_hello
def test_say_hello_prints(capsys):
say_hello()
captured = capsys.readouterr()
assert captured.out.strip() == "Hello, World!"
captured.out is everything printed to standard output during the test.
Parametrize (optional)
Run one test logic with many inputs:
import pytest
@pytest.mark.parametrize("n,expected", [(1, 1), (2, 4), (3, 9)])
def test_square(n, expected):
assert n * n == expected
Warning
Do not push your own tests
The course autograder supplies the officialpytesttests on GitHub Classroom. They run in CI when you push; you do not see those files as something to submit.Locally, you may create
test_*.pyfiles or atests/folder to practice. Nevergit add/ commit / push your personal test files unless your instructor explicitly tells you to. Pushing extra tests can interfere with grading, collide with hidden tests, or violate the assignment rules.Do not push
__pycache__,.pytest_cache/, or local virtualenv folders either—use.gitignoreas your template provides.
Final Example
Here is one final example complete with test output. For this one, the main program is quite simple, just like what you have seen already.
def main() -> None:
# intentionally missing the comma "," after Hello
print("Hello World!")
if __name__ == "__main__":
main()
When you run it, you should hope to see this:
➜ hello git:(main) ✗ python3 hello.py
Hello World!
To test, you could make this:
import pytest
import hello # ← imports the hello.py module
def test_main_prints_hello_world(capsys):
"""Check that main() prints exactly 'Hello, World!'"""
# Run the student's main function
hello.main()
# Capture everything that was printed
captured = capsys.readouterr()
# Clean up the output (remove extra spaces/newlines)
output = captured.out.strip()
expected = "Hello, World!"
# Use pytest.fail() instead of assert → no ugly diff at the bottom!
if output != expected:
pytest.fail(f"Expected exactly this: {expected} But your main() printed: \
{output}")
The output of this failed program looks like this:
➜ hello git:(main) ✗ pytest
================================================================================= test session starts =================================================================================
platform darwin -- Python 3.14.3, pytest-9.0.2, pluggy-1.6.0
rootdir: /Users/user/Documents/IntroToPythonTemplate/IntroToPythonTemplate/hello
collected 1 item
test_hello.py F [100%]
====================================================================================== FAILURES =======================================================================================
____________________________________________________________________________ test_main_prints_hello_world _____________________________________________________________________________
capsys = <_pytest.capture.CaptureFixture object at 0x10be817f0>
def test_main_prints_hello_world(capsys):
"""Check that main() prints exactly 'Hello, World!'"""
# Run the student's main function
hello.main()
# Capture everything that was printed
captured = capsys.readouterr()
# Clean up the output (remove extra spaces/newlines)
output = captured.out.strip()
expected = "Hello, World!"
# Use pytest.fail() instead of assert → no ugly diff at the bottom!
if output != expected:
> pytest.fail(f"Expected exactly this: {expected} But your main() printed: \
{output}")
E Failed: Expected exactly this: Hello, World! But your main() printed: Hello World!
test_hello.py:20: Failed
=============================================================================== short test summary info ===============================================================================
FAILED test_hello.py::test_main_prints_hello_world - Failed: Expected exactly this: Hello, World! But your main() printed: Hello World!
================================================================================== 1 failed in 0.05s ==================================================================================
Your turn!
Implement
pip install pytest(or use your template’s env).- Write
greet(name)that returns a string and asay_hi()thatprints a fixed greeting; verify both locally withpytestandcapsys. - Delete or keep your practice tests only on disk—do not push them.
- Commit and push only your solution source (e.g.
hello.py,main.py) per the rubric.
Control flow and functions
if / elif / else
def grade(score: int) -> str:
if score >= 90:
return "A"
elif score >= 80:
return "B"
else:
return "C"
Loops
for iterates over any iterable (list, string, range, …):
for i in range(3):
print(i)
items = ["a", "b", "c"]
for item in items:
print(item)
while repeats while a condition is true.
Functions
Define with def, document with a docstring, optionally annotate parameters and return type:
def add(a: int, b: int) -> int:
"""Return the sum of a and b."""
return a + b
Multiple return values use a tuple (often unpacked):
def divmod_custom(a: int, b: int) -> tuple[int, int]:
return a // b, a % b
q, r = divmod_custom(10, 3)
Implement
- Write
clamp(value: int, low: int, high: int) -> intthat returnsvaluelimited to[low, high]. - Write a loop that prints squares for
ifrom 0 through 5 (e.g.i * i). - Commit and push.
Modules, imports, and collections
Importing
import string
import random
from datetime import datetime
import module— usemodule.namefrom module import name— usenamedirectly
Lists and dicts
List — ordered, mutable:
nums = [1, 2, 3]
nums.append(4)
first = nums[0]
Dict — key → value:
config = {"host": "127.0.0.1", "port": 8080}
print(config["port"])
config["timeout"] = 30
JSON-like APIs often map naturally to dicts.
Useful standard library snippets
Small utility modules often wrap the standard library like this:
import string
import random
def random_token(length: int = 8) -> str:
letters = string.ascii_uppercase
return "".join(random.choice(letters) for _ in range(length))
from datetime import datetime
def timestamp() -> str:
return datetime.now().strftime("%Y-%m-%d %H:%M:%S")
Packages
A package is a directory with __init__.py (can be empty). Your template might use app/ or src/ layout—follow it for import paths.
Implement
- Create a module
tokens.pywithrandom_tokenandtimestamp(or reuse names your rubric gives). - Import and call them from
main.pyor a small test script. - Build a
dictrepresenting a fake server config (host,port,use_ssl) and print one field with an f-string. - Commit and push.
Pydantic models
Pydantic validates and parses data into Python objects—ideal for JSON bodies once you reach the web chapters. The examples below stay small so you can focus on syntax and validation first.
Install locally if needed (your template may already include it):
pip install pydantic
BaseModel and fields
from pydantic import BaseModel, Field
class ListenerHeader(BaseModel):
name: str
port: int
enabled: bool = Field(default=True)
class ListenerOptions(BaseModel):
use_ssl: bool = Field(default=False)
jitter: int = Field(default=1)
port: int = Field(default=4444)
- Required fields have no default.
Field(default=...)sets defaults and can carry validation rules (min, max, regex, …).
Creating and reading instances
h = ListenerHeader(name="alpha", port=8080)
print(h.name, h.port, h.enabled) # enabled defaults True
Enum and StrEnum (optional)
Enumerations restrict values to a fixed set:
from enum import Enum
class Status(Enum):
pending = "pending"
done = "done"
StrEnum (Python 3.11+) makes members strings as well—handy for JSON.
Implement
- Define
BaseModelclasses for something simple (e.g.Userwithusername,agewith defaultFieldconstraints if your rubric asks). - Instantiate from literals, then print a field.
- Commit and push.
JSON serialization with Pydantic
Web APIs speak JSON. Pydantic models convert cleanly to and from JSON text and bytes.
Model → JSON
from pydantic import BaseModel
class PingBody(BaseModel):
message: str
body = PingBody(message="pong")
json_text: str = body.model_dump_json()
json_bytes: bytes = body.model_dump_json().encode("utf-8")
In FastAPI, returning a dict or a model often becomes JSON automatically; under the hood similar serialization runs.
JSON → model
raw = '{"message": "pong"}'
obj = PingBody.model_validate_json(raw)
assert obj.message == "pong"
model_validate accepts a dict; model_validate_json accepts a string.
Implement
- Create a model for a
{"status": "ok", "code": 200}-shaped object. - Serialize with
model_dump_json(), parse back withmodel_validate_json. - Commit and push.
Virtual environments and packages (before the web modules)
By this point in the course you have been writing Python with whatever interpreter your template and editor use. For the final web chapters, you want an isolated environment and the right libraries installed cleanly.
Virtual environments
A virtual environment is a directory of packages for one project.
python3 -m venv .venv
Activate:
- macOS / Linux:
source .venv/bin/activate - Windows (cmd):
.venv\Scripts\activate.bat - Windows (PowerShell):
.venv\Scripts\Activate.ps1
Deactivate: deactivate
Tip
After activating, run
python --versionandwhich python(orwhere python) to confirm you are inside the venv.
Installing web and HTTP dependencies
Your Classroom template may ship pyproject.toml or requirements.txt. Typical packages for the closing chapters:
fastapi— web frameworkuvicorn— ASGI serverpydantic— data validation (you already used it earlier; ensure it is installed here if needed)httpx— HTTP client
Example:
pip install fastapi uvicorn pydantic httpx
Or use pip install -r requirements.txt, uv sync, or Poetry, per your template.
Running the web server (preview)
Once app exists in a module (next chapters):
uvicorn app:app --host 0.0.0.0 --port 8000 --reload
First app = module name; second app = FastAPI() instance.
Implement
- Create and activate
.venvin your repo (if you have not already). - Install FastAPI, Uvicorn, HTTPX, and ensure Pydantic is available.
- Confirm
python -c "import fastapi, uvicorn, httpx, pydantic"succeeds. - Commit lockfiles / dependency files your instructor wants—not
.venvitself.
HTTP client with HTTPX
HTTPX is a modern HTTP client (sync and async). Install it after you set up your virtual environment and dependencies (Virtual environments and packages).
GET request
import httpx
def fetch_status(url: str) -> int:
response = httpx.get(url, timeout=10.0)
response.raise_for_status()
return response.status_code
Reading JSON
data = response.json()
# data is usually a dict or list
Error handling
try:
r = httpx.get("http://127.0.0.1:8000/ping", timeout=5.0)
r.raise_for_status()
print(r.json())
except httpx.HTTPError as exc:
print("HTTP error:", exc)
Implement
- Start your FastAPI app locally (later chapter) or use
https://httpbin.org/getfor a smoke test. - Write a script
probe.pythat performs a GET, prints status code and JSON (if any). - Commit and push.
FastAPI: apps and routes
FastAPI maps URL paths and HTTP methods to Python functions. JSON dict return values become JSON responses.
Minimal app
A typical minimal FastAPI module looks like this:
from fastapi import FastAPI
app = FastAPI()
@app.get("/")
def root():
return {"hello": "world"}
@app.get("/ping")
def ping():
return {"message": "pong"}
@app.post("/alive")
def alive():
return {"message": "you're alive!"}
@app.get("/path")registers a GET route.@app.post(...)registers POST.- Return a
dict→ JSON object; return a list → JSON array.
Note
Typos in keys (e.g.
mesagevsmessage) become typos in JSON—clients and tests should match the exact spelling you intend.
Path vs query parameters (preview)
@app.get("/items/{item_id}")
def read_item(item_id: int, q: str | None = None):
return {"item_id": item_id, "q": q}
FastAPI parses and validates types for you.
Implement
- Complete Virtual environments and packages so FastAPI is installed in your venv.
- Create
app.py(or the filename your template uses) withapp = FastAPI(). - Add
GET /returning any small JSON dict. - Add
GET /pingreturning{"message": "pong"}(your capstone route—keep it working from here on). - Commit and push (application code only—not personal test files).
Running the server with Uvicorn
Uvicorn is an ASGI server. It runs your FastAPI app object and listens on a host and port.
Command line
From the directory that can import your module:
uvicorn app:app --host 0.0.0.0 --port 8000 --reload
- First
app: Python module name (e.g.app.py→app). - Second
app: variable holding theFastAPI()instance.
--reload restarts on file changes (development only).
In Python (programmatic startup)
You can start Uvicorn from code instead of the CLI:
import uvicorn
from fastapi import FastAPI
app = FastAPI()
def run(port: int = 5050) -> None:
config = uvicorn.Config(app=app, host="0.0.0.0", port=port, log_level="info")
server = uvicorn.Server(config=config)
server.run()
if __name__ == "__main__":
run()
Use this when your rubric asks for python app.py instead of the uvicorn CLI.
Threading note (advanced)
Some applications run Uvicorn in a background thread next to other code. For this course, run only Uvicorn in the foreground for labs unless your instructor assigns threading.
Check it
- Start the server.
- Visit
http://127.0.0.1:8000/docsfor interactive OpenAPI docs (FastAPI feature). curl http://127.0.0.1:8000/pingshould show{"message":"pong"}.
Implement
- Document in
README.mdthe exact command to start your app (orpython ...entry). - Verify
/pingfrom browser orcurl/ HTTPX. - Commit and push.
Capstone: JSON ping web server
Requirements
Deliver a Python service using FastAPI and Uvicorn that:
| Item | Requirement |
|---|---|
| Framework | FastAPI |
| Server | Uvicorn (CLI or Server in code) |
| Route | GET /ping |
| Response | JSON body exactly: {"message": "pong"} |
| Status | 200 OK for that route |
Optional extras (only if assigned): GET / with a welcome JSON, POST /alive with a JSON body, health checks, or HTTPX integration tests.
Example core
Your implementation can look like this:
from fastapi import FastAPI
app = FastAPI()
@app.get("/ping")
def ping():
return {"message": "pong"}
Start with:
uvicorn your_module:app --host 0.0.0.0 --port 8000
Replace your_module with your actual module path.
Checklist
-
GET /pingreturnsContent-Type: application/jsonand body{"message":"pong"}(spacing may vary; keys/values must match). - Virtual environment created and FastAPI / Uvicorn / dependencies installed per Virtual environments and packages.
- Dependencies listed in
requirements.txt/pyproject.tomlper template. - README explains how to create venv, install, and run.
- Committed and pushed to
mainfor final review (solution code only—not personaltest_*.pyfiles; see Testing with pytest).
Congratulations—you now have a minimal JSON API foundation without relying on terminal UI frameworks.