Python Foundations
Sound familiar? You spent 1 hour writing Python code, then you spent 4 hours fighting PATH errors, virtual environments, and SSH keys that just would not let you push to GitHub. 🤬
If you're learning Python, you've probably noticed that the syntax is rarely the hard part. It's everything around it: the terminal, Git (and GitHub), virtual environments, dependencies, packaging, or "what Python interpreter is VS Code even using?"
And that layer doesn't seem to be taught enough. That's why I built this free, self-paced course. To not only write some Python code, but to also learn the workflow around it to ship a real app.
You'll build a command-line developer journal with a proper test suite. A small app that grows week by week and you can add to your GitHub profile as a portfolio project at the end.
Get the starter code on GitHub →
How it works: each week is a set of short lessons and one build exercise. You get the tests and you write the code that makes them pass. Finish the quick check at the end of a week to test your knowledge and unlock the next week. Progress is saved in this browser, so you can come back whenever you have time.
One tool, not five. This course uses uv (related blog article) for everything: installing Python, virtual environments, packages, and running code. No pip, no manual venv, no pyenv. uv makes this all a lot simpler, and I am sure that, like me, you don't want to look back once you're used to it.
What you'll learn this week
- Get comfortable in the terminal
- Set up Git and open your first pull request on GitHub (including SSH keys)
- Start a Python project with
uv - Set up VS Code: pick the right interpreter, run and debug your code (you can also use Vim, which is my favorite editor, in that case I recommend you watch my Supercharge Your Vim Workflow: Essential Tips and Plugins for Efficiency to get started)
- Model data with a dataclass and save it to disk as JSON
By the end you will have a project on GitHub with a validated data model, a persistence layer, and a passing test suite. Not bad for starters :)
Getting comfortable with the terminal
A terminal is a text-based way to talk to your computer. Instead of clicking, you type a command and press Enter.
- macOS: open Terminal.app (search "Terminal" in Spotlight)
- Windows: open PowerShell or Windows Terminal
- Linux: open your terminal emulator (often Ctrl+Alt+T)
You only need a handful of commands to start:
pwd # where am I? (print working directory)
ls # list files here
cd dev-journal # move into a folder
cd .. # go up one level
mkdir my-folder # make a new folder
Start typing a filename and press Tab to auto-complete it. If you ever feel lost, pwd tells you where you are and ls tells you what's in a directory. You will pick the rest up as you go (or send me a question, I really love Unix!)
Git, GitHub and your first pull request
Git is version control software, it tracks changes to your files on your computer. GitHub hosts those files online so you can share them and build a portfolio. Think of Git as tracking changes and GitHub as the shared drive.
Set up SSH keys
SSH keys let you communicate securely with a remote server (GitHub) without typing a password every time. You generate a key pair: a private key you keep on your computer, and a public key you give to GitHub. When you push code, your computer proves to GitHub that it has the private key without ever sending it.
# macOS / Linux
ssh-keygen -t ed25519 -C "[email protected]" # press Enter for the default path
eval "$(ssh-agent -s)"
ssh-add ~/.ssh/id_ed25519
cat ~/.ssh/id_ed25519.pub # copy this output
On GitHub go to Settings > SSH and GPG keys > New SSH key and paste the public key. Test it:
ssh -T [email protected]The weekly loop
Create a repository on GitHub (name it dev-journal, initialize it with a README and a Python .gitignore), then:
git clone [email protected]:YOUR_USERNAME/dev-journal.git
cd dev-journal
git checkout -b week1 # work on a branch, never straight on main
# ... make changes ...
git add main.py
git commit -m "feat: add JournalEntry dataclass"
git push origin week1
Then on GitHub, open a pull request from week1 into main. A pull request is how you say "this work is ready, review (and approve) it before merging."
Even working solo, open the PR: it is a checkpoint in this repo's history. And it's how professional dev teams work. You can review your own code diff under "Files changed". Then use the same page to merge it (big green button) into main and delete the branch. Next week, we'll start a new branch from main and repeat this process.
git status is your most-used command. Run it whenever you are unsure what is going on; it tells you what changed and what to do next (bonus: I have a shell alias gs for it because I use it many times each day).
Start the project with uv
Install uv:
# macOS / Linux
curl -LsSf https://astral.sh/uv/install.sh | sh
uv --version
From inside your cloned repo:
uv init
This creates pyproject.toml (project metadata and dependencies), uv.lock (exact pinned versions, commit this), a .python-version pin, and a starter main.py.
Note that we do the plain uv init here, not uv init --package. The latter is for a library, usually intended to be uploaded to PyPI and installed by others. We'll look at this in a later week. For now, we are building a single-file CLI app, so uv init keeps it simple.
The commands you will use all week:
uv run main.py # run code in the right environment, no activation needed
uv add typer # add a runtime dependency
uv add --dev pytest # add a dev-only dependency
uv sync # install everything from pyproject.toml (after cloning)
You never create or activate a virtual environment by hand. That's the beauty of uv, its commands (run and sync) cascade all needed operations: it creates a virtual environment if needed, installs dependencies if not already done so, and it runs your code automatically inside the virtual environment. It takes a lot of thinking out, or it's ergonomic as we developers like to say.
When you run uv add, uv updates the lock file (uv.lock) with the exact version of the dependency you added. When you run uv sync, it reads the lock file and installs. This lock file needs to be committed to your repository so that anyone else who clones your repo can run uv sync and get the exact same environment. This is crucial for reproducibility, especially in team settings or when deploying applications.
Write and run code in VS Code
You can use any editor, but if you are starting out, VS Code is the one to pick: it is free, and its Python support handles the parts that trip people up.
- Install VS Code, then install the Python extension from Microsoft (Extensions panel, search "Python").
- Open your project folder: File > Open Folder, pick
dev-journal. - Select the interpreter. This is the step that confuses everyone. Open the command palette (Cmd/Ctrl+Shift+P), run Python: Select Interpreter, and choose the one inside your project's
.venvfolder (the one uv created). Now VS Code uses the same environment asuv run, so imports resolve and there are no "module not found" surprises. - Run and debug. Open a Python file and press F5 to run it under the debugger, or click in the gutter to the left of a line number to set a breakpoint, then F5. Execution pauses there so you can inspect variables. This beats scattering
printstatements once you get used to it.
You will still run tests and git from the integrated terminal (Terminal > New Terminal), which opens right inside your project.
Model the data with a dataclass
A dataclass is Python's built-in way to write a class that mostly holds data. The @dataclass decorator generates __init__, __repr__, and __eq__ for you (in a regular class you would have to add those manually).
Let's define a dataclass to hold our journal entries. Each entry has a title, content, tags, and a date.
from dataclasses import dataclass, field
from datetime import datetime
MAX_TITLE_LENGTH = 50
MAX_CONTENT_LENGTH = 1000
@dataclass
class JournalEntry:
title: str
content: str
tags: list[str] = field(default_factory=list)
date: str = field(default_factory=lambda: datetime.now().isoformat())
def __post_init__(self):
if not self.title or len(self.title) > MAX_TITLE_LENGTH:
raise ValueError(f"Title must be non-empty and <= {MAX_TITLE_LENGTH} characters")
if not self.content or len(self.content) > MAX_CONTENT_LENGTH:
raise ValueError(f"Content must be non-empty and <= {MAX_CONTENT_LENGTH} characters")
Two things worth understanding, not just copying:
-
field(default_factory=list), nottags: list[str] = []. A plain[]default is shared by every instance, so a tag added to one entry would appear on all of them.default_factorygives each instance its own fresh list. This is a common mutability bug in Python (google or ask an LLM about "mutable default arguments"). -
__post_init__runs right after the object is built. Validating here means the model enforces its own rules: the title and content should not be empty, and they should not exceed certain lengths. This is a nice object oriented design principle: the object is responsible for its own integrity. -
Given we use the length variables twice, we define them as constants at the top of the file. This is the DRY principle: Don't Repeat Yourself. If we ever want to change the maximum lengths, we only need to change them in one place.
Save and load with JSON
JSON is a plain-text format for structured data. json.dump can write dicts and lists but not your dataclass directly, so we can use asdict to convert it to a dict first. When loading, we read the JSON into a list of dicts and then unpack each dict into a JournalEntry with JournalEntry(**entry).
import json
from dataclasses import asdict
from pathlib import Path
def save_entries(entries: list[JournalEntry], db_path: Path) -> None:
data = [asdict(entry) for entry in entries]
with open(db_path, "w") as f:
json.dump(data, f, indent=2)
def load_entries(db_path: Path) -> list[JournalEntry]:
if not db_path.exists() or db_path.stat().st_size == 0:
return []
with open(db_path) as f:
data = json.load(f)
return [JournalEntry(**entry) for entry in data]
JournalEntry(**entry) might look magical to you, it unpacks a dictionary into keyword arguments and it's the reverse of asdict.
We also check if the file exists and is non-empty, because json.load raises an error on an empty file. You can also raise an exception if the file is missing, but for this app we want to start with an empty journal, so an empty list is a valid state.
Keep the code clean with ruff
ruff (related blog article) is a fast formatter and linter from the same Astral team as uv. Add it and run it before every commit:
uv add --dev ruff
uv run ruff format . # auto-format
uv run ruff check --fix . # lint and auto-fix
Bonus: to not do this manually each time, I recommend you set up a pre-commit hook for this. Check out pre-commit.com or Prek, which is a reimagined version of pre-commit, built in Rust.
It will run ruff automatically before every commit, so you never have to think about it. It does add a bit of overhead of fixing the code before committing, but it is worth it to keep the codebase clean, consistent, and prevents unnecessary code review comments about formatting, style issues and easy-to-prevent bugs.
The build
Add the add_entry function that ties it together, then make the test suite pass.
def add_entry(title: str, content: str, tags: list[str], db_path: Path) -> None:
entries = load_entries(db_path) # load existing first
entry = JournalEntry(title=title, content=content, tags=tags)
entries.append(entry) # append, don't overwrite
save_entries(entries, db_path)
Put this test suite in test_main.py and run uv run pytest -v. All tests should pass (you'll see green dots in your terminal). If not, check the error messages and fix the code until they do.
import json
import tempfile
from pathlib import Path
import pytest
from main import (
MAX_CONTENT_LENGTH,
MAX_TITLE_LENGTH,
JournalEntry,
add_entry,
load_entries,
save_entries,
)
@pytest.fixture
def db_file():
with tempfile.NamedTemporaryFile(suffix=".json", delete=False) as f:
yield Path(f.name)
def test_create_valid_entry():
entry = JournalEntry(title="Test Title", content="Test content")
assert entry.title == "Test Title"
assert entry.tags == []
assert entry.date is not None
def test_each_entry_gets_own_tag_list():
entry1 = JournalEntry(title="First", content="Content")
entry2 = JournalEntry(title="Second", content="Content")
entry1.tags.append("python")
assert entry2.tags == []
def test_empty_title():
with pytest.raises(ValueError):
JournalEntry(title="", content="Valid")
def test_title_too_long():
with pytest.raises(ValueError):
JournalEntry(title="T" * (MAX_TITLE_LENGTH + 1), content="Valid")
def test_save_and_load_entries(db_file):
entries = [
JournalEntry(title="First", content="Content 1", tags=["tag1"]),
JournalEntry(title="Second", content="Content 2", tags=["tag2"]),
]
save_entries(entries, db_file)
loaded = load_entries(db_file)
assert len(loaded) == 2
assert loaded[0].tags == ["tag1"]
def test_load_entries_nonexistent_file():
assert load_entries(Path("nonexistent.json")) == []
def test_add_multiple_entries(db_file):
add_entry("First", "Content 1", ["tag1"], db_file)
add_entry("Second", "Content 2", ["tag2"], db_file)
entries = load_entries(db_file)
assert len(entries) == 2
assert entries[1].title == "Second"Stuck? Reveal a hint
You have everything you need above: the JournalEntry dataclass with __post_init__ validation, save_entries, load_entries with the empty-file guard, and add_entry that loads before appending. Build them one at a time and re-run pytest after each. If test_add_multiple_entries fails, you are probably overwriting instead of appending.
When the tests pass, run the full loop, then commit and open your PR:
# testing
uv run pytest -v
# linting
# manual run, if pre-commit is set up this gets run in the `git commit` step
uv run ruff format .
uv run ruff check --fix .
# commit your work and push it to GitHub
git add main.py test_main.py
git commit -m "feat: add JournalEntry dataclass with JSON persistence"
git push origin week1
# git should show a direct link here to open a pull request on GitHub
# if not, go to your repo on GitHub and open a PR from week1 into mainWeek 1 check
Answer all three to unlock Week 2.
Why use field(default_factory=list) instead of tags: list[str] = []?
What does uv run handle for you?
Why does load_entries check for an empty or missing file first?
Unlock the rest of the course, free
Week 1 is on the house. Add your email to open Weeks 2 to 6: the CLI, search, packaging, testing, and shipping. You will also get my emails on Python, Rust, and AI. Unsubscribe anytime.
Already subscribed? Enter the same email again, it unlocks right away.
You finished Python Foundations 🎉
You set up a project, built a tested CLI, and shipped it. That is real engineering, not a tutorial you followed.
Ready to go from a CLI to a full-stack, deployed app? In the full-stack Python program you build one with me, one PR at a time: databases, an API, a web UI, and cloud deployment. Pick the Snipster project, or a Django SaaS.
See the full-stack program →