@@ -112,9 +112,15 @@ def old_function(x): ...
112112catches uses of ` @deprecated ` at type-check time.
113113
114114### Changelog
115- * Recommended.* A ` CHANGELOG.md ` in [ keepachangelog] ( https://keepachangelog.com/ ) format.
116- Release notes written from a git log are not release notes — the reader wants to know what
117- broke, what's new, and what's deprecated.
115+ * Nice.* A ` CHANGELOG.md ` in [ keepachangelog] ( https://keepachangelog.com/ ) format. Release
116+ notes written from a git log are not release notes — the reader wants to know what broke,
117+ what's new, and what's deprecated.
118+
119+ Curating one by hand is real work, and a stale changelog is worse than none. Think twice
120+ before starting: if nobody reads it, skip it. If you do want one, let a tool assemble it from
121+ a fragment per pull request — [ towncrier] ( https://towncrier.readthedocs.io/ ) or
122+ [ git-cliff] ( https://git-cliff.org/ ) — so the cost lands on the author of each change rather
123+ than on you at release time.
118124
119125### License
120126* Blocker.* Without a license the package is "all rights reserved" and legally unusable by
@@ -209,18 +215,26 @@ Return a new object instead.
209215``` python
210216def clip (values ):
211217 for i in range (len (values)): # caller's list silently changed
212- values[i] = min (0 , values[i])
218+ values[i] = max (0 , values[i])
213219
214220def clip (values ):
215- return [min (0 , v) for v in values] # ✓
221+ return [max (0 , v) for v in values] # ✓
216222```
217223
218224### One return type
219225* Blocker.* A function that returns a ` bool ` on success and a ` str ` on failure will read as
220226success — a non-empty string is truthy.
221227
228+ A function with a ` return ` on one path and nothing on another is the same bug: the missing
229+ path returns ` None ` .
230+
222231``` python
223- if is_operable(height = 12.0 , period = 5.0 ): # returns "No way!" — and this runs
232+ def is_operable (height , period ):
233+ if height > 10.0 :
234+ return " No way!" # str here, None on every other path
235+ return True # ...and bool here
236+
237+ if is_operable(height = 12.0 , period = 5.0 ): # "No way!" is truthy — this runs
224238 print (" Go ahead!" )
225239```
226240
@@ -251,15 +265,24 @@ class Toolbox:
251265
252266### Type hints
253267* Recommended.* On public functions at minimum. They are hints, not enforcement — they exist
254- for the reader and the editor.
268+ for the reader and the editor, until you add a [ type checker ] ( #type-checking-in-ci ) .
255269
256270``` python
257271def clip (values : list[int ], * , threshold : int = 0 ) -> list[int ]: ...
258272```
259273
260274### Keyword-only arguments
261- * Nice.* ` def f(*, x, y) ` forces callers to be explicit and lets you reorder parameters later
262- without breaking anyone.
275+ * Recommended.* One or two positional parameters is fine — that is the data the function
276+ operates on. Everything after them is configuration, and belongs after a ` * ` so callers have
277+ to name it. You can then reorder or add options without breaking anyone.
278+
279+ Three or more positional parameters is a strong smell: ` resample(df, 3, 0, True) ` can't be
280+ read at the call site, and nobody can safely change the order again.
281+
282+ ``` python
283+ def resample (data , freq , * , offset = 0 , dropna = True ): ...
284+ resample(df, " 1h" , dropna = False ) # ✓ the data and its frequency; the rest is named
285+ ```
263286
264287### Dataclasses for data
265288* Recommended.* Fields with type hints, a constructor, a useful ` repr ` , and equality by value
@@ -297,7 +320,8 @@ happening, rename something instead.
297320
298321### When a long signature is a smell
299322* Nice.* Many optional keyword arguments with sane defaults are perfectly Pythonic — see
300- ` read_csv ` , ` plot ` , or any sklearn estimator. The smell is not the count; it's when the
323+ ` read_csv ` , ` plot ` , or any sklearn estimator. The smell is not the total count (that's
324+ [ positional arguments] ( #keyword-only-arguments ) , which are a separate rule); it's when the
301325arguments are switches for ** separate jobs** the function has absorbed. If half the signature
302326only applies when another argument is set, that's several functions wearing one signature.
303327
@@ -398,17 +422,22 @@ def remove_outlier(data: pd.DataFrame, column: str, threshold: float = 3) -> pd.
398422```
399423
400424### Examples that are tested
401- * Nice.* ` doctest ` runs the examples in your docstrings. Documentation that is wrong is worse
402- than documentation that is missing.
425+ * Nice.* Documentation that is wrong is worse than documentation that is missing. ` doctest `
426+ runs the examples in your docstrings. For prose pages, [ Quarto] ( https://quarto.org/ ) executes
427+ every snippet as part of the build, so the docs cannot ship broken — the build fails first.
403428
404429``` bash
405430python -m doctest -v add.py
406431```
407432
408433### Published API documentation
409434* Recommended.* ` mkdocs ` + ` mkdocstrings ` + GitHub Pages, at
410- ` https://dhi.github.io/<repository>/ ` . Note that a private repository can still have a public
411- website — ` robots.txt ` hides it from search engines but is not security.
435+ ` https://dhi.github.io/<repository>/ ` . [ Quarto] ( https://quarto.org/ ) ,
436+ [ Great Docs] ( https://github.com/machow/great-docs ) (which wraps Quarto) and
437+ [ zensical] ( https://zensical.org/ ) are viable alternatives.
438+
439+ A private repository can have access-controlled Pages on GitHub Enterprise — use that when the
440+ site should stay internal, rather than relying on the URL not being found.
412441
413442## Automation
414443
@@ -425,36 +454,63 @@ jobs:
425454 test :
426455 runs-on : ubuntu-latest
427456 steps :
428- - uses : actions/checkout@v3
457+ - uses : actions/checkout@v4
429458 - uses : astral-sh/setup-uv@v6
430459 with : { python-version: "3.13" }
431460 - run : uv sync
432461 - run : uv run pytest
433462` ` `
434463
435464### Lint and format with ruff
436- *Recommended.* ` ruff check` finds unused imports, undefined names and dead variables — usually
437- typos, sometimes bugs. `ruff format` ends style arguments. Run both in CI.
465+ *Recommended.* There is no reason not to. One binary, no configuration required, and it
466+ replaces flake8, black and isort at once. ` ruff check` finds unused imports, undefined names
467+ and dead variables — usually typos, sometimes bugs. `ruff format` ends style arguments. Run
468+ both in CI.
438469
439470` ` ` bash
440471ruff check . ruff format --check .
441472` ` `
442473
443- # ## Makefile
474+ # ## Type checking in CI
475+ *Nice.* [Type hints](#type-hints) are not enforcement — a type checker is. Run `mypy` (or
476+ ` ty` ) in CI on the package, not the tests, and turn it on for new code before old. It also
477+ catches uses of anything you have marked
478+ [`@deprecated`](#deprecate-before-removing).
479+
480+ ` ` ` bash
481+ uv run mypy src --enable-error-code=deprecated
482+ ` ` `
483+
484+ # ## A task runner
444485*Nice.* One source of truth for how to run the project's tools, and the fastest onboarding
445- document there is.
486+ document there is. A `Makefile` if everyone is on Linux or macOS; `just` if anyone is on
487+ Windows, where `make` is not installed by default and `just` is a single binary
488+ (`uv tool install rust-just`).
446489
447490` ` ` makefile
448- check: lint test
491+ check: lint test # Makefile
492+ lint:
493+ uv run ruff check src
494+ test:
495+ uv run pytest
496+ ` ` `
497+
498+ ` ` ` just
499+ check: lint test # justfile
449500lint:
450- ruff check src
501+ uv run ruff check src
451502test:
452- pytest
503+ uv run pytest
453504` ` `
454505
455506# ## Test the matrix
456507*Nice.* If you claim to support Windows and Python 3.10, test on Windows and Python 3.10.
457508
509+ Test what you claim and no more. CI is not free — every cell costs minutes on every push. An
510+ [application](#libraries-loose-applications-pinned) has one deployment target, so one cell is
511+ the honest matrix; a library that others install needs the range it advertises in
512+ ` requires-python` .
513+
458514` ` ` yaml
459515strategy:
460516 matrix:
@@ -464,14 +520,28 @@ strategy:
464520
465521# # Release
466522
467- # ## Publish from a GitHub release
468- *Recommended.* Tag a release, let a workflow build and publish. Use Trusted Publishers so
469- there are no secrets to manage.
523+ # ## Tag every release
524+ *Recommended.* An annotated `vX.Y.Z` tag, pushed. It is what makes "which commit is 1.2.0?"
525+ answerable a year later, and what lets you diff two releases. Just do it — it costs one
526+ command.
527+
528+ ` ` ` bash
529+ git tag -a v1.2.0 -m "v1.2.0"
530+ git push --tags
531+ ` ` `
532+
533+ # ## Publish from a tag or a release
534+ *Recommended.* Let a workflow build and publish; never upload from your laptop. Use
535+ [Trusted Publishers](https://docs.pypi.org/trusted-publishers/) so there are no secrets to
536+ manage.
537+
538+ Trigger on the tag, or on a published GitHub release — either works with Trusted Publishers.
539+ The release gives you somewhere to put release notes; the tag is one step fewer.
470540
471541` ` ` yaml
472542on:
473- release :
474- types: [published]
543+ push :
544+ tags: ["v*"] # or: release: { types: [published] }
475545` ` `
476546
477547# ## Somewhere to install from
0 commit comments