These are the rules I write code by. They are based on Robert Martin’s “Clean Code” and PEP 8 for Python. It’s a living document, I extend it as I learn things the hard way.
The main idea is simple: code is read far more often than it is written. So I write it for the person who opens the file six months from now. Often that person is me.
0. No spec, no result#
Any work starts with requirements, not with code.
- First I pin down what problem we’re solving and how we’ll know it’s solved.
- Then the architecture: components, data, the boundaries between them.
- Only after that, code. If requirements change along the way, I update the spec first, then the code.
1. Code style: PEP 8 + my rules#
PEP 8 is the baseline standard. My rules work on top of it, like the cascade in CSS: everything I haven’t overridden is inherited from PEP 8 as is. There’s one override.
Tabs instead of spaces. One level of nesting is one character. Everyone sees indentation at the width they set in their editor, and nothing changes in the file. I never mix spaces and tabs in one file, Python 3 won’t allow it anyway.
Everything else follows PEP 8: line length, blank lines between functions and classes, import order, spaces around operators.
So I don’t have to keep this in my head, a tool checks and fixes the style. I use ruff:
# pyproject.toml
[tool.ruff]
line-length = 100
[tool.ruff.format]
indent-style = "tab"
[tool.ruff.lint]
select = ["E", "F", "I", "N", "B", "UP"]
ignore = ["W191"] # W191 complains about tabs, for us it's a deliberate choiceIDE: JetBrains (PyCharm). The project settings have tabs enabled and ruff runs on save.
2. Names#
A name should answer three questions: why it exists, what it does and how it’s used. If a name needs a comment, it’s a bad name.
- Variables and functions in
snake_case, classes inPascalCase, constants inUPPER_CASE. - Functions are named with a verb:
fetch_page,parse_price,send_report. Classes and variables with a noun:Browser,proxy_pool. - No abbreviations or single-letter names. Exceptions:
iin a short loop andefor an exception. - One concept, one word. If the project has
fetch, thenget,loadandretrievedon’t show up next to it for the same thing. - No type encoding in names:
users, notusers_listorlst_users. - Boolean variables read like a question:
is_ready,has_proxy,can_retry.
3. Functions#
- Small. A function should fit on the screen without scrolling. If it doesn’t, it can be split.
- Do one thing. If a function can’t be described in one sentence without the word “and”, it’s two functions.
- One level of abstraction. A function either orchestrates a process and calls other functions, or works with details. Not both at once.
- Few arguments. Ideally zero to two. Three or more is a reason to group them into a dataclass.
- No flag arguments.
render(page, True)is hard to read. Better two functions:render_fullandrender_preview. - No hidden side effects. If a function is called
check_session, it shouldn’t create a new session along the way. - Command or query. A function either changes state or returns data. Not both.
4. Comments#
The best comment is the one that wasn’t needed because the code is clear as it is.
I write a comment when I need to explain why, not what:
# Cloudflare returns 403 on the first request without a cookie, so we always make two
response = session.get(url)I don’t write:
- comments that retell the code
- commented-out code. That’s what git history is for
- a change log and authorship in the file header. That’s git’s job too
A docstring is required for a module’s public functions and classes. For internal ones, it depends.
5. Error handling#
- Exceptions instead of return codes. A function doesn’t return
Noneor-1when something goes wrong, it raises an exception. - I catch specific exceptions, never a bare
except:orexcept Exception: pass. - My own exceptions for my domain:
ProxyBannedError,CaptchaError. They tell you what happened without reading the stack trace. - I don’t return
Nonewhere a collection is expected. An empty list is better than aNonecheck at every call site. - I handle an error where I know what to do with it. If I don’t know, I let it go up.
6. Classes and modules#
- Small classes with a single responsibility. A class should have one reason to change. If a class talks to the network, parses HTML and writes to the database, that’s three classes.
- High cohesion. A class’s methods work with its fields. If a method doesn’t touch
self, it most likely doesn’t belong in this class. - Dependencies from outside. A class receives the browser, session or database client in its constructor instead of creating them itself. That makes it easy to test and swap.
- Law of Demeter. I talk only to immediate neighbors.
order.customer.address.cityis a sign that a method is needed. - Boundaries with third-party code. I wrap third-party libraries in my own thin layer. If the library has to be replaced, the change happens in one place.
7. Tests#
- Tests are code too, and the same cleanliness requirements apply.
- A test checks one thing, and its name shows it:
test_returns_empty_list_when_page_has_no_items. - Every test has the same structure: arrange, act, assert.
- Tests are fast, independent of each other and give the same result on every run. No trips to the real internet in unit tests.
- I reproduce a bug with a test first, then fix it.
8. The Boy Scout rule#
I leave code cleaner than I found it. There’s no need to rewrite everything at once. It’s enough to rename one unclear variable or extract one piece into a function every time I touch a file.
9. Git#
- One commit, one logical change.
- Commit messages in the conventional commits format:
feat:,fix:,refactor:,docs:,chore:. - Code that doesn’t pass the linter and tests doesn’t get into the main branch.
- Secrets, keys and
.envnever get into the repository.
