Goal: get Python 3.11+, a virtual environment and the three tools running, and know what each command actually checks before you trust its "all clear".
Every file in lessons/, practice/, projects/ and tests/ is held to the
same four commands. They are the whole contract:
python -m ruff check . # lint
python -m ruff format --check . # formatting
python -m mypy . # types, in strict mode
python -m pytest # testsCI runs those four and one thing more: every program under lessons/,
practice/ and projects/ is executed, and a single non-zero exit fails the
build. make check is all five in one command. Nothing else is required of
you, and nothing less is accepted.
| You need | Minimum | Why |
|---|---|---|
| Python | 3.11 | tomllib, except*, typing.Self, better tracebacks - and pyproject.toml says requires-python = ">=3.11" |
| git | any | to clone, and .gitattributes normalises line endings for you |
| a terminal | any | the whole repository is run from one |
| make | optional | convenience only; every target has a plain command underneath |
Python 3.12 and 3.13 work too - CI runs all three. Write 3.11 syntax anyway:
no PEP 695 type X = ... aliases, no class Box[T]. Use typing.TypeVar and
typing.Generic, so what you write runs on the oldest version the repository
supports.
| Distribution | Command |
|---|---|
| Ubuntu 24.04+, Debian 13+ | sudo apt update && sudo apt install python3 python3-venv python3-pip |
| Ubuntu 22.04 (ships 3.10) | sudo add-apt-repository ppa:deadsnakes/ppa, then sudo apt install python3.11 python3.11-venv |
| Fedora | sudo dnf install python3.11 |
| Arch | sudo pacman -S python |
| Alpine | apk add python3 py3-pip |
Debian and Ubuntu split the standard library across packages. Without
python3-venv you get ensurepip is not available the first time you build a
virtual environment. Install the package; do not work around it.
| Route | Command | Notes |
|---|---|---|
| Homebrew (recommended) | brew install python@3.11 |
puts python3.11 on PATH |
| python.org installer | download the macOS universal2 .pkg |
installs python3.11 and IDLE |
| Xcode command line tools | xcode-select --install |
gives you a python3, usually an old one |
/usr/bin/python3 belongs to macOS. Leave it alone, and never sudo pip install
into it. A virtual environment built from Homebrew's Python keeps your work and
the operating system's own tooling apart.
| Route | Command |
|---|---|
| winget (recommended) | winget install Python.Python.3.11 |
| python.org installer | download, and tick "Add python.exe to PATH" on the first screen |
| Microsoft Store | search "Python 3.11" - fine for learning, awkward for paths |
Windows also installs a launcher, py, which is more precise than python:
| Command | Does |
|---|---|
py -0p |
list every installed Python with its full path |
py -3.11 --version |
run 3.11 specifically, whatever else is installed |
py -3.11 -m venv .venv |
build the virtual environment with 3.11 |
pyenv (pyenv install 3.11.9 && pyenv local 3.11.9) and uv
(uv python install 3.11) both give you an exact version per project without
touching the system interpreter. Neither is required here.
python --version # python3 --version on Linux and macOS
python -c "import sys; print(sys.executable)"| Symptom | Meaning | Fix |
|---|---|---|
python: command not found |
Linux and macOS name the binary python3 |
use python3 - or activate a venv, where python always exists |
| Windows opens the Store | the Store alias is shadowing a real install | Settings > Apps > Advanced > App execution aliases, turn off python.exe |
| it reports 3.9 | an older interpreter is first on PATH |
name the version explicitly when you create the venv: python3.11 or py -3.11 |
pip installs into whichever interpreter runs it. A virtual environment gives
this project its own site-packages, so the repository's ruff and mypy
cannot be disturbed by another project - and python unambiguously means this
project's Python.
git clone https://github.com/limonuser/Python_educator.git
cd Python_educator
python -m venv .venv # python3.11 -m venv .venv / py -3.11 -m venv .venvThat writes .venv/ beside pyproject.toml. It is already in .gitignore:
never commit it, never edit inside it, and delete-and-recreate rather than repair
it when it breaks.
| OS and shell | Command |
|---|---|
| Linux / macOS, bash or zsh | source .venv/bin/activate |
| Linux / macOS, fish | source .venv/bin/activate.fish |
| Windows, PowerShell | .venv\Scripts\Activate.ps1 |
| Windows, cmd.exe | .venv\Scripts\activate.bat |
| Windows, Git Bash | source .venv/Scripts/activate |
| any of them | deactivate to leave |
Your prompt gains a (.venv) prefix. Confirm it is real and not cosmetic:
python -c "import sys; print(sys.prefix)" # ...Python_educator/.venvActivation lasts for one shell session. A new terminal, a new tab or a restarted editor starts deactivated - which is what "ruff: command not found" means nine times out of ten.
If PowerShell refuses with "running scripts is disabled on this system", that is Windows policy, not a broken install:
Set-ExecutionPolicy -Scope CurrentUser RemoteSignedOr skip activation entirely and call the interpreter by path:
.venv\Scripts\python.exe -m ruff check . needs no policy change at all.
python -m pip install --upgrade pip
python -m pip install -e ".[dev]"| Piece | Why it is there |
|---|---|
python -m pip |
runs the pip belonging to this interpreter; a bare pip may belong to another |
-e |
editable install: the project is registered by path, so your edits need no reinstall |
. |
this directory, described by pyproject.toml |
[dev] |
the dev extra: pytest>=8.0, ruff>=0.6, mypy>=1.11 |
"..." |
the quotes are for zsh, which treats a bare .[dev] as a glob and refuses to run |
Verify:
python -m ruff --version
python -m mypy --version
python -m pytest --versionSetuptools looks at the project root for something to package. lessons/,
practice/, projects/ and coursework/ all look like candidates, so it
refuses to guess:
error: Multiple top-level packages discovered in a flat-layout:
['lessons', 'practice', 'projects', 'coursework'].
(docs/ is missing from that list because setuptools excludes a handful of
conventional names - docs, tests, examples, scripts - on its own. The
other four it has never heard of.)
That is a packaging question, not a Python one: this repository is a curriculum, not a library, and has nothing to install. Two ways out:
| Fix | What to do |
|---|---|
| Tell setuptools there is nothing to package (permanent) | add the two tables below to pyproject.toml |
| Skip the editable install (works right now) | python -m pip install "pytest>=8.0" "ruff>=0.6" "mypy>=1.11" |
[build-system]
requires = ["setuptools>=68"]
build-backend = "setuptools.build_meta"
[tool.setuptools]
packages = [] # a curriculum, not a library - there is no package to installThe second route puts the same three tools in the same virtual environment.
Nothing under lessons/, practice/ or tests/ imports this project as an
installed package, so all four checks behave identically either way.
| Command | Answers | Catches |
|---|---|---|
python -m ruff check . |
is the code legal, consistent and free of known bug shapes? | unused imports, mutable default arguments, os.path where pathlib belongs, unsorted imports |
python -m ruff format . |
is it laid out the one agreed way? | rewrites the file: 88 columns, double quotes, spaces |
python -m mypy . |
do the annotations describe what the code really does? | None reaching an int, a missing -> None, an unreachable branch |
python -m pytest |
does it still do what it promised? | behaviour regressions |
They are not interchangeable. ruff check will never notice that a function
returns str | None while its caller subscripts the result; mypy will never
notice that your quotes are inconsistent; neither notices that the answer is
wrong.
Configured under [tool.ruff.lint]. The selected families:
| Prefix | Family | Why it is on |
|---|---|---|
E, W |
pycodestyle | the baseline everyone already expects |
F |
pyflakes | unused imports and names, undefined names |
I |
isort | import order decided once, not per file |
UP |
pyupgrade | stops 3.11 code being written like 2010 code |
B |
bugbear | the mutable-default-argument trap lives here |
C4 |
comprehensions | list(x for x in y) becomes [x for x in y] |
SIM |
simplify | collapses if x: return True / else: return False |
RET |
return | inconsistent returns out of one function |
PTH |
pathlib | open(os.path.join(...)) becomes Path(...).open() |
RUF |
ruff's own | mutable class attributes, useless noqa |
| Flag | Use |
|---|---|
--fix |
apply the safe fixes - then read the diff before committing it |
--diff |
show what --fix would do, and change nothing |
--statistics |
violations counted by rule: the fastest way to see what a file does wrong |
--watch |
re-run on every save |
--select B,SIM |
check those families only, ignoring the configured set |
--output-format concise |
one line per violation, easy to grep |
Two rules are off repository-wide. E501 (line too long), because the formatter
owns line length and a lint error for something a formatter silently fixes is
noise. B007 (unused loop variable), because looping for effect is ordinary in a
teaching example. On top of that lessons/** ignores B006, SIM105 and
RET504, and practice/** ignores RET504 - those files show the wrong version
of a construct before correcting it, and the linter cannot tell the difference.
A justified escape hatch names its rule - # noqa: B008 - never a bare # noqa,
which also silences the rules you have not read yet.
| Command | Effect |
|---|---|
python -m ruff format . |
rewrite every file in place |
python -m ruff format --check . |
exit 1 if anything would change, touch nothing - this is what CI runs |
python -m ruff format --diff . |
print the diff it would apply |
Settings: line length 88, double quotes, space indentation. This is not a matter of taste here - it is what makes a sixty-file diff readable.
ruff check passing does not mean the file is formatted. They are separate
commands, and CI runs both.
[tool.mypy] already sets python_version = "3.11", strict = true,
warn_unreachable = true and excludes coursework/. So a plain
python -m mypy . is strict mode; writing --strict on the command line just
says so out loud, which is why every instruction in this repository includes it.
What --strict turns on, and what each part catches:
| Sub-flag | Rejects |
|---|---|
--disallow-untyped-defs |
a function with no annotations at all |
--disallow-incomplete-defs |
a half-annotated function - the kind that fools readers |
--disallow-untyped-calls |
typed code calling into untyped code |
--disallow-any-generics |
bare list, dict, tuple with the element type unstated |
--check-untyped-defs |
skipping the body of an unannotated function |
--warn-return-any |
returning Any from a function that promised a real type |
--warn-unused-ignores |
a # type: ignore that no longer suppresses anything |
--warn-redundant-casts |
cast(int, x) where x is already int |
--no-implicit-reexport |
importing a name a module imported but never exported |
--strict-equality |
"1" == 1, which is always False and always a mistake |
warn_unreachable is layered on top: it flags branches that can never run, which
is usually a type narrowing you did not expect.
| Flag | Use |
|---|---|
python -m mypy . |
the whole repository, as configured |
python -m mypy --strict path/to/file.py |
one file, explicit about the mode |
--no-incremental |
ignore .mypy_cache/ when the errors look impossible |
--show-error-codes |
print [arg-type], the code you would put in # type: ignore[...] |
--pretty |
on already in config: prints the offending source line under each error |
To debug a type rather than a value, drop reveal_type(x) into the code, run
mypy, read the note, delete the line. It is not a real function: leave it in and
Python raises NameError.
addopts = "-q --strict-markers" is configured, and testpaths = ["tests"] means
a bare pytest collects tests/ and nothing else.
| Flag | Use |
|---|---|
-q |
one character per test instead of one line (on already) |
-x |
stop at the first failure - the only sane setting while fixing something |
-k "median and not empty" |
run the tests whose names match the expression |
--lf |
re-run only what failed last time |
-vv |
full test names and full assertion diffs |
--tb=short |
shorter tracebacks when the failure is already obvious |
-s |
do not capture stdout - for when you are debugging with print |
--co |
list what would run, run nothing |
--strict-markers |
an unregistered @pytest.mark.foo is an error, not a silent no-op (on already) |
Every program here is standalone: no arguments, no input, exit 0.
| You want | Command |
|---|---|
| one lesson | cd lessons/09-dicts-and-sets && python mappings.py |
| one exercise | cd practice/beginner && python 01_fizzbuzz.py |
| one project | cd projects/01-calculator && python calc.py |
| a file, from the repository root | python lessons/09-dicts-and-sets/mappings.py |
| the exit code | echo $? in bash, echo $LASTEXITCODE in PowerShell |
| to lint one file | python -m ruff check lessons/09-dicts-and-sets/mappings.py |
| to type-check one file | python -m mypy --strict lessons/09-dicts-and-sets/mappings.py |
cd first is what the Makefile and CI both do, and it is the habit worth
keeping. It is not load-bearing here: every program in this repository that
writes a scratch file anchors it on Path(__file__).resolve().parent, so the
file lands beside the program however you launched it. That is deliberate, and
it is the pattern to copy. A bare Path("out.txt") is relative to the current
directory instead, which is how a lesson ends up littering the repository root.
Two files deserve a note of their own:
| File | How to run it | Why |
|---|---|---|
lessons/14-modules-and-packages/main.py |
from either directory | it imports the neighbouring textkit/ package, and the lesson explains what sys.path did to allow that |
lessons/15-files-and-context-managers/fileio.py |
from anywhere | it creates _scratch/ beside itself and removes it in a finally |
| Target | Equivalent command |
|---|---|
make install |
python -m pip install -e ".[dev]" |
make lint |
python -m ruff check . |
make format |
python -m ruff format . |
make format-check |
python -m ruff format --check . |
make types |
python -m mypy . |
make test |
python -m pytest |
make run |
run every file under lessons/, practice/ and projects/, requiring exit 0 |
make lesson-09 |
cd lessons/09-dicts-and-sets && python mappings.py |
make check |
lint, format-check, types, test, run - exactly what CI does |
make clean |
remove __pycache__/, the three tool caches and the generated demo files |
Windows has no make by default. Use Git Bash or WSL, install one
(winget install GnuWin32.Make, or choco install make), or just type the
right-hand column: nothing in this repository depends on make existing.
.github/workflows/ci.yml runs on every push to main, every pull request
against it, and on demand from the Actions tab (workflow_dispatch). The matrix
is Python 3.11, 3.12 and 3.13, in parallel, with fail-fast: false so one
version breaking still tells you about the other two:
| Step | Command |
|---|---|
| 1 | python -m pip install -e ".[dev]" |
| 2 | python -m ruff check . |
| 3 | python -m ruff format --check . |
| 4 | python -m mypy . |
| 5 | python -m pytest |
| 6 | run every lesson, exercise and project; a single non-zero exit fails the build |
A second job runs every file in coursework/ on 3.13 and asks only that each
one exits 0. Those are the author's original class exercises, kept verbatim:
extend-exclude in [tool.ruff] and exclude in [tool.mypy] hold the
linters off them, but nothing excuses them from running.
make check is the first list, locally. Run it before you push and CI holds no
surprises.
- A new terminal is a deactivated terminal. "ruff: command not found" and
"but I installed it yesterday" are the same sentence. Re-activate, or call
.venv/bin/python -m ruffby path. pip installwithoutpython -mcan target a different interpreter. The package lands somewhere real - just not where yourpythonwill look for it.pip install -e .[dev]unquoted fails in zsh withno matches found: .[dev], because zsh expands the brackets before pip ever sees them.ruff checkclean does not mean formatted. CI runsruff format --checkas a separate step and it fails on its own.ruff check --fixedits your files. Run--difffirst when you are unsure what a rule wants, and read the change before you commit it.mypy one_file.pyis notmypy .. A single file can miss errors that only appear across module boundaries, and can report ones the full run resolves.- A stale
.mypy_cache/produces impossible errors after you move or rename files.make clean, or--no-incremental, before you go hunting. Path("out.txt")is relative to the current directory, not to the source file. Run such a program from the repository root and the file lands in the root. Every program here sidesteps it by anchoring onPath(__file__).resolve().parent; do the same in yours.- Never
sudo pip install. It writes into the interpreter your operating system depends on, and a virtual environment exists so that you never have to. - Do not commit
.venv/. Machine-specific, hundreds of megabytes, already ignored. If it shows up ingit status, something overrode.gitignore. - Line endings are settled by
.gitattributes- LF in the repository, native in your working copy. Do not fight it with a localcore.autocrlfoverride; that is how a one-line change becomes a four-hundred-line diff.
From nothing to a green checkout:
git clone https://github.com/limonuser/Python_educator.git
cd Python_educator
python -m venv .venv
source .venv/bin/activate # Windows: .venv\Scripts\Activate.ps1
python -m pip install --upgrade pip
python -m pip install -e ".[dev]" # see section 3 if this errors
python -m ruff check .
python -m ruff format --check .
python -m mypy .
python -m pytest
cd lessons/01-hello-world && python hello.pyNext: Cheat sheet - Back to: the curriculum