GitHub Copilot CLI for Python Developers
Python is the language where an agentic CLI most clearly beats an editor
assistant, and the reason is narrow: the feedback loop is already a terminal loop.
pytest, ruff, mypy — the tools that tell you whether code is correct all run
as commands, and an agent that can run them can close the loop itself instead of
handing you a suggestion and waiting.
Cluster 3’s Copilot for Python covers what Copilot knows about the language. This lesson is about driving a project from the terminal: environments, failing tests, linting, and the review that happens before anything is committed.
Key takeaways
- The agent should run
pytestandruffitself and iterate against real output. Pre-approve both; that is what turns a suggestion loop into a fix loop. - Virtual environments are the most common source of confusing failures. Tell the agent which interpreter to use rather than letting it guess.
- “Make the test pass” and “fix the bug” are different instructions. An agent given the first will sometimes edit the test.
- Type checking catches a category tests miss, and it is a static check — safe to pre-approve.
- Never let it install packages without asking. A new dependency is a supply-chain decision, not an implementation detail.
The loop
The value is in steps four through six happening without you. An agent that runs the suite, reads the failure, and adjusts is doing the same iteration you would, faster. Your job moves to the ends of the loop: framing the problem correctly and reviewing what came out.
Permissions for Python work
| Pattern | Decision | Why |
|---|---|---|
shell(pytest:*) | Allowed | The loop depends on running the suite. |
shell(ruff:*) | Allowed | Static analysis with no side effects. |
shell(git:*) | Allowed | Local inspection and staging. |
write | Asks first | Source changes surface before they land. |
shell(pip:*) | Denied | Adding a dependency is a decision, not a step. |
shell(git push:*) | Denied | Publishing stays human. |
Written for this lesson to illustrate least privilege. Not a GitHub default and not a security guarantee — read every rule before using it.
Denying pip is the row worth defending. An agent that hits a problem solvable by
a library will reach for the library, and that is often the wrong trade: a new
dependency is new supply-chain surface, a new licence, and a new thing to update
forever, in exchange for saving twenty lines.
Denying it does not prevent the agent proposing a dependency; it prevents the dependency arriving without a conversation.
Virtual environments, and telling it what you mean
The most common source of baffling behaviour in Python agent sessions is the
interpreter. The agent runs pytest, gets ModuleNotFoundError, and starts
solving a problem that does not exist — because it invoked the system Python
rather than your environment.
Be explicit:
This project uses a virtual environment at .venv.
Always run Python tools through it: .venv/bin/python -m pytest and .venv/bin/python -m ruff, not bare pytest or ruff.
Do not create a new environment. Do not install anything.
python -m pytest rather than pytest is worth insisting on generally. It uses
the interpreter you named rather than whatever the PATH resolves, which removes
a whole class of confusion — including the one where a globally-installed pytest
imports a different copy of your package than the one you are editing.
Working from a failing test
The strongest entry point for agentic Python work is a test that already fails, because the definition of done is objective.
tests/test_parser.py::test_handles_empty_input is failing.
Read the test and the code under test, explain why it fails, then fix the cause.
Do not modify the test. If you believe the test is wrong, stop and explain why rather than changing it.
Those last two sentences are the entire technique. An agent asked to make a test pass has two routes to success, and editing the assertion is the cheaper one. It does not do this maliciously — it is a legitimate reading of the instruction — but the result is a green suite that verifies nothing.
Running the suite properly
Practical example
A fix-verification loop
Confirm a change fixes the target test without breaking anything else, using output rather than the agent's summary.
- Status
- Example implementation
- Not executed here
- This code was written against the documentation cited at the end of the lesson but was not run while writing it. Treat the commands as the ones to run, not as output that has been observed.
.venv/bin/python -m pytest tests/test_parser.py -q # the target
.venv/bin/python -m pytest -q # the whole suite
.venv/bin/python -m ruff check . # lintThe two pytest invocations are both necessary. The first confirms the fix; the second confirms it did not break something else, which is the failure mode of a narrowly-focused agent that has not been asked to check.
Ask for the actual output rather than a summary. “All tests pass” and a pasted
142 passed in 3.21s are different kinds of claim.
Linting and formatting
Ruff is fast enough that there is no reason not to run it after every change, and it is a static check, so pre-approving it is safe.
Two distinct operations worth keeping separate in your head:
.venv/bin/python -m ruff check . # report problems
.venv/bin/python -m ruff format . # rewrite filescheck reports. format rewrites, and check --fix also rewrites. That
distinction matters in an agent session because a formatting pass across a
repository produces an enormous diff that buries the actual change.
Type checking
Type checking catches a category tests routinely miss: an argument that is never passed the wrong type in the tests but could be in production. It is static, so it is safe to run freely.
Run the type checker and fix the errors in src/parser.py.
Do not add # type: ignore comments. If an error cannot be fixed
without restructuring, explain what the restructuring would be and
stop.
Banning # type: ignore is the important constraint. It is the fastest way to
make a type error disappear and it removes the information the checker was
providing. The same applies to widening a type to Any — technically a fix,
practically a deletion of the check.
Refactoring, and keeping it bounded
Refactoring is where agents most reliably exceed their brief. The instruction that works is a boundary, not an adjective.
Refactor the validation logic in src/parser.py into a separate module, src/validation.py.
Constraints:
- Public behaviour must not change. The existing tests must pass unmodified.
- Only src/parser.py, src/validation.py and the imports that reference them may change.
- Do not rename any public function.
- Run the full suite before and after, and show me both results.
If you think a behaviour change is warranted, describe it and stop.
“Before and after” is a small addition with real value: it establishes that the suite was green to begin with. A refactor that ends with two failures is a different conversation if those two were already failing.
Understanding an unfamiliar codebase
Before changing anything in a project you inherited, spend a session that cannot change anything. This is the highest-value use of a read-only agent, and it is cheap.
Explain how this project is organised.
Do not modify anything.
Cover: the entry points, how configuration is loaded, where the tests live and how they are run, what the external dependencies are used for, and anything that looks like it was left half-finished.
Started with --deny-tool='write', that session cannot go wrong. What you learn
is where things are and, more usefully, whether the agent’s reading of the project
matches yours — because if it misreads the architecture now, it will misread it
later when it is editing.
The explore built-in agent is designed for this and is read-only by
construction:
copilot --agent explore -i "How does authentication work in this project?"Dependencies, and why pip stays denied
When an agent needs functionality the standard library does not provide, its instinct is to add a package. Sometimes that is right. Often the calculation is worse than it looks.
A dependency brings transitive dependencies, a licence, a maintenance status, a release cadence you now track, and — for anything running in production — supply chain surface. Trading that for code you could write in twenty lines is usually a bad deal, and it is a deal the agent is not positioned to evaluate because it cannot see your constraints.
Denying pip forces the conversation:
Before adding any dependency, tell me:
- What it does that the standard library cannot
- How many transitive dependencies it brings
- Roughly how much code it would replace
Then wait for me to decide.
The honest answer is sometimes “this replaces four hundred lines of date parsing and you should absolutely use it”. The point is not to refuse dependencies; it is to make the choice visible rather than discovering it in a diff.
Reading the diff
The habit that matters most, and the one most easily skipped when the tests are green.
git diff --stat # what changed, and how much
git diff # what actually changedOr /diff inside the session. Things worth looking for in Python specifically:
Did it touch tests you did not ask it to touch? --stat answers this
instantly.
Did it change a function signature? Adding a parameter with a default is source-compatible and can still break a caller passing positionally.
Did it swallow an exception? A bare except: or except Exception: pass
added to make a test pass is a bug with a green light on it.
Did it add an import? Standard library is fine. A third-party import is the
dependency conversation you denied pip to force.
Did it change behaviour for input the tests do not cover? This is the one static tools cannot help with, and the reason reading the diff is not optional.
Reviewing before you commit
/review
/security-review/review runs the code review agent over your changes; /security-review
analyses staged and unstaged changes specifically for security problems. Both are
read-only.
For Python, the security review is worth running whenever a change touches input handling, subprocess calls, deserialisation, or anything constructing a query or a path from user data. The code review lesson covers what these agents catch and — more importantly — what they do not.
Where the agent is weakest
Async correctness. Generated async code frequently awaits the wrong thing,
mixes sync and async in a way that blocks the loop, or forgets that a context
manager needs async with. Tests often pass anyway, because the bug is a
performance failure rather than a correctness one.
Anything depending on runtime state. Database contents, environment differences, timing. The agent reasons from code, and code does not contain the state.
Dependency versions. It will suggest APIs from a version you may not be on. Pinning matters, and so does telling it what you actually have installed.
Test quality. Generated tests trend towards asserting that the implementation is the implementation. A test that mirrors the code’s structure passes whenever the code is unchanged and tells you nothing about whether it is right.
A project layout that helps
None of this requires restructuring a project, but a few conventions make agent sessions noticeably smoother.
A src/ layout helps because it makes the import path unambiguous — the agent
cannot accidentally import the source directory instead of the installed package,
which is a real source of confusing test failures.
Configuration centralised in pyproject.toml helps because the agent reads it and
follows the settings. If Ruff’s line length and rule selection are declared
there, generated code matches them without being told.
And path-scoped instructions under .github/instructions/ let you say things
about tests that should not apply to source. A tests.instructions.md with
applyTo: "tests/**/*.py" can specify pytest conventions, fixture usage and
determinism requirements without those rules leaking into application code. The
custom instructions lesson covers the
frontmatter syntax.
Async, and why it needs extra scrutiny
Async Python is where generated code is least reliable, and the reason is worth understanding rather than just noting.
Async bugs frequently do not fail. A blocking call inside a coroutine — a
synchronous HTTP request, a file read, a time.sleep — works correctly. It
returns the right answer. It simply blocks the event loop while it does so,
serialising everything the loop was supposed to be overlapping. Tests pass,
behaviour is correct, and throughput quietly collapses under load.
Because there is no error, none of the checks in this lesson catch it. Not tests, not Ruff, not the type checker.
The things worth checking by eye in any async change:
- Every
awaitis on something awaitable, and everything awaitable is awaited. A missingawaitreturns a coroutine object, which is truthy and often flows on harmlessly until something inspects it. - No synchronous IO inside a coroutine —
requests,open,time.sleep. - Async context managers use
async with, async iteration usesasync for. - Anything CPU-bound is not sitting on the event loop.
Review the async code in this module.
Identify: any blocking call inside a coroutine, any awaitable that is not awaited, and any place where async with or async for should be used and is not.
For each, explain the runtime consequence rather than just naming the pattern.
Asking for the consequence is what separates a real finding from pattern matching — and in async code, the consequence is usually the only way to tell whether something matters.
What to carry forward
Name the interpreter. Most confusing Python agent sessions are environment problems wearing a costume.
Pre-approve the checks, prompt on writes, deny installs.
Say “fix the cause, do not modify the test” every time, or once in your instructions file.
Read git diff --stat before git diff. It catches scope expansion in one
line.
Verify new tests fail against broken code before trusting them. A test that passes regardless of whether the implementation is correct is worse than no test, because it carries the authority of a green suite.
One closing observation about where this workflow fits. The terminal loop suits work with an objective finish line — a failing test, a type error, a lint violation — because the agent can tell whether it is done. Work without one, such as “make this module nicer”, suits an editor better, where you are reading each change as it lands rather than reviewing a batch at the end.
Choosing the surface by whether the task has a checkable definition of done is a more useful heuristic than choosing by habit.
A note on notebooks
Jupyter notebooks are a common Python workflow and a poor fit for a terminal agent, which is worth saying plainly rather than discovering.
Notebooks are JSON containing code, output and execution counts. An agent editing one is editing JSON, diffs are close to unreadable, and cell execution order — which frequently determines whether the code works at all — is invisible in the file.
If your work is notebook-based, the CLI is still useful for everything around the notebook: the package it imports, the tests, the data pipeline, the environment. For the notebook itself, an editor with notebook support is the better surface, and Cluster 2 covers that ground.
Where a notebook holds logic worth testing, the durable answer is to move that logic into a module the notebook imports. That is good practice independently, and it happens to put the important code somewhere an agent can work on it properly.
Next
DevOps applies the same validate-before-execute discipline where the validation commands touch infrastructure rather than code. Custom instructions is how the environment and toolchain rules above stop needing to be repeated.
For Copilot’s Python knowledge outside the CLI, Cluster 3’s
Copilot for Python is the companion, and
code review goes deeper on /review.
Sources
Every version-sensitive claim on this page was checked against first-party documentation. Only sources actually used are listed.
Your progress
Saved in this browser only. No account, no server, and nothing leaves your device. Clearing site data resets it.Sync across devices if you want it everywhere.
Saved in this browser and synced to your account, so it follows you between devices. Manage or delete it.
Was this lesson helpful?
We record which lesson you rated and whether it helped. Nothing identifies you — no account, no cookie, no session.