Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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 personal test_*.py files, tests/ trees you wrote for practice, .pytest_cache/, or __pycache__ unless your instructor explicitly tells you otherwise. See Testing with pytest.

Important

GitHub Classroom

  1. Open Accept the GitHub Classroom assignment.
  2. Sign in to GitHub, accept the assignment, and wait for your personal repository.
  3. Complete any email / invitation steps your course requires.
  4. 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 to main (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 on main and 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-python if it specifies one).
  • Git and a code editor.
  • A terminal to run python, pip, and later pytest and uvicorn.

Next: getting started with main and your first program.

Getting started: main and your first program

Start with a single entry functionmain—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 definition
  • main — the name you (and your autograder) will call
  • () — parameter list; empty for now
  • -> Nonereturn type annotation: this function does not return a useful value (it returns None implicitly)

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.

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

  1. Create main.py (or use your template’s entry file) with def main() -> None:, an indented body, and if __name__ == "__main__": main().
  2. Inside main, add variables (with annotations if required), print / f-strings, and optional nested if to practice indentation.
  3. Run: python main.py
  4. Optionally try pytest locally (see Testing with pytest)—do not push practice tests.
  5. 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 official pytest tests 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_*.py files or a tests/ folder to practice. Never git 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 .gitignore as 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

  1. pip install pytest (or use your template’s env).
  2. Write greet(name) that returns a string and a say_hi() that prints a fixed greeting; verify both locally with pytest and capsys.
  3. Delete or keep your practice tests only on disk—do not push them.
  4. 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

  1. Write clamp(value: int, low: int, high: int) -> int that returns value limited to [low, high].
  2. Write a loop that prints squares for i from 0 through 5 (e.g. i * i).
  3. Commit and push.

Modules, imports, and collections

Importing

import string
import random
from datetime import datetime
  • import module — use module.name
  • from module import name — use name directly

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

  1. Create a module tokens.py with random_token and timestamp (or reuse names your rubric gives).
  2. Import and call them from main.py or a small test script.
  3. Build a dict representing a fake server config (host, port, use_ssl) and print one field with an f-string.
  4. 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

  1. Define BaseModel classes for something simple (e.g. User with username, age with default Field constraints if your rubric asks).
  2. Instantiate from literals, then print a field.
  3. 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

  1. Create a model for a {"status": "ok", "code": 200}-shaped object.
  2. Serialize with model_dump_json(), parse back with model_validate_json.
  3. 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 --version and which python (or where 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 framework
  • uvicorn — ASGI server
  • pydantic — 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

  1. Create and activate .venv in your repo (if you have not already).
  2. Install FastAPI, Uvicorn, HTTPX, and ensure Pydantic is available.
  3. Confirm python -c "import fastapi, uvicorn, httpx, pydantic" succeeds.
  4. Commit lockfiles / dependency files your instructor wants—not .venv itself.

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

  1. Start your FastAPI app locally (later chapter) or use https://httpbin.org/get for a smoke test.
  2. Write a script probe.py that performs a GET, prints status code and JSON (if any).
  3. 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. mesage vs message) 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

  1. Complete Virtual environments and packages so FastAPI is installed in your venv.
  2. Create app.py (or the filename your template uses) with app = FastAPI().
  3. Add GET / returning any small JSON dict.
  4. Add GET /ping returning {"message": "pong"} (your capstone route—keep it working from here on).
  5. 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.pyapp).
  • Second app: variable holding the FastAPI() 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

  1. Start the server.
  2. Visit http://127.0.0.1:8000/docs for interactive OpenAPI docs (FastAPI feature).
  3. curl http://127.0.0.1:8000/ping should show {"message":"pong"}.

Implement

  1. Document in README.md the exact command to start your app (or python ... entry).
  2. Verify /ping from browser or curl / HTTPX.
  3. Commit and push.

Capstone: JSON ping web server

Requirements

Deliver a Python service using FastAPI and Uvicorn that:

ItemRequirement
FrameworkFastAPI
ServerUvicorn (CLI or Server in code)
RouteGET /ping
ResponseJSON body exactly: {"message": "pong"}
Status200 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 /ping returns Content-Type: application/json and 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.toml per template.
  • README explains how to create venv, install, and run.
  • Committed and pushed to main for final review (solution code only—not personal test_*.py files; see Testing with pytest).

Congratulations—you now have a minimal JSON API foundation without relying on terminal UI frameworks.