Expert-level coding style conventions for Python GUI libraries built on tkinter or CustomTkinter. These rules apply to any project that couples a stateful parent widget with managed child objects.
src/
your_package/
__init__.py # Public API only — re-exports, version, __all__
parent_widget.py # Canvas / container class
child_object.py # Managed interactive objects
utils.py # Pure helpers with no widget dependencies
__init__.pymust never contain logic. It exports symbols and declares__version__,__all__.- One class per file unless the classes are tightly coupled and always imported together.
- Keep platform-specific concerns (DPI, OS bindings) isolated in clearly named sections.
Follow PEP 8 with explicit grouping and one blank line between groups:
# 1. Standard library
import sys
from pathlib import Path
from typing import Dict, List, Optional
# 2. Third-party
import customtkinter as ctk
from PIL import Image
# 3. Local / intra-package (always relative inside src/)
from .child_object import ChildObjectInside src/, always use relative imports (from . import ..., from .module import Class). This guarantees the development source is used regardless of what is installed in the environment.
In standalone scripts (examples, tools), insert src/ at sys.path[0] before any project import:
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent.parent / "src"))- Use full type annotations on every public method signature. Return type is mandatory.
- Use
Optional[X]/X | None(Python 3.10+) consistently; never leave ambiguity. - Prefer concrete types (
Dict[int, Rect]) over vague generics (dict). - Use
TYPE_CHECKINGguard to avoid circular imports from type-only dependencies:
from __future__ import annotations
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from .parent_widget import ParentWidget- Avoid
Anyexcept at true system boundaries (tkinter event data, external JSON blobs). Document every# type: ignorewith a one-line reason.
| Thing | Convention | Example |
|---|---|---|
| Public method | snake_case |
get_topleft_pos() |
| Private method | _snake_case |
_builtin_on_drag() |
| Protected (override-intended) | _snake_case with docstring note |
_on_press() |
| Instance attribute | snake_case |
self.canvas |
| Private attribute | _snake_case |
self._attached_items |
| Class constant | UPPER_SNAKE |
MAX_HISTORY = 50 |
| Boolean attributes | is_, has_, enable_, _has_ prefix |
self._has_dispatch |
| Callback attributes | on_ prefix |
on_drag_callback |
Avoid abbreviations unless they are universally understood in the domain (dpi, px, mm, rect, idx).
A widget class should own exactly one responsibility. If a class both renders and manages business logic, split them.
- Parent widget: layout, event routing, lifetime management of children, history.
- Child object: own state, own canvas items, own event handlers. Delegates upward only through well-defined hooks.
- List every instance attribute at the top of
__init__, even if set toNone. Readers and type-checkers depend on this. - Unconditionally initialize attributes that guard optional features. Use sensible defaults instead of setting them only inside
if feature_enabled:branches — this eliminates defensivegetattrcalls at runtime. - Initialize expensive structures (dicts, sets) once; never inside event handlers.
# Good
self._reverse_map: Dict[int, ChildObject] = {}
self._registered: set[int] = set()
# Bad — creates attribute only when feature is enabled, forces getattr elsewhere
if self.enable_feature:
self._reverse_map = {}Use @property only when:
- A value must be computed or validated on access.
- You want to protect writes with a setter.
Do not use @property purely for style. Plain attributes are faster and clearer for simple stored state.
Implement magic methods to express mathematical and containment semantics naturally — but only when the semantics are unambiguous and self-documenting. Document the return type and side-effect behavior clearly.
__hash__and__eq__must be consistent and immutable. If an object's hash must reflect coordinates, either freeze the object after creation, or — better — do not use the object as a dict key when its coordinates are mutable. Useid(obj)as a dict key instead.- If
__eq__is defined,__hash__must also be defined (or explicitly set toNoneto make the object unhashable).
Prefer explicit registration over monkey-patching or subclassing for user-supplied callbacks:
canvas.on_select_callback = my_handler # stored referenceGuard all user callbacks against exceptions — a buggy callback must not crash the widget:
if self.on_drag_callback:
try:
self.on_drag_callback(self)
except Exception:
pass # or log; never propagate into tkinter's event loop- Use
canvas.tag_bind(item_id, "<Event>", handler)rather thancanvas.bind(...)for item-level events. - Store the result of
canvas.bind(...)if you need to unbind later. Do not rely on implicit rebinding to replace old bindings. - Never bind the same event multiple times on the same widget/item without first unbinding.
Use narrow-scoped boolean flags for transient state (_dragging, _resizing, _restoring_state). Set them to False immediately after the operation completes — never leave flags set between event cycles.
- Validate inputs at public API boundaries only. Trust internal methods.
- Raise
ValueErrorfor invalid argument values,TypeErrorfor wrong types. Never raiseExceptiondirectly. - Use guard clauses (early
returnorraise) to eliminate nesting:
# Good
def set_size(self, size: List[float]) -> None:
if len(size) != 2:
raise ValueError("size must be [width, height]")
...
# Bad
def set_size(self, size):
if len(size) == 2:
...
if size[0] > 0:
...- Never catch broad exceptions silently in library code. If you suppress an exception (e.g. for tkinter widget-already-destroyed scenarios), add a comment explaining why.
Use Google-style docstrings for all public classes and methods:
def align(cls, rectangles: List["Rect"], mode: str = "top") -> None:
"""Align a list of rectangles along a common axis.
Args:
rectangles: The objects to align. Must be on the same canvas.
mode: Alignment mode. One of ``"top"``, ``"bottom"``, ``"middle"``,
``"start"``, ``"end"``, ``"center"``.
Raises:
ValueError: If fewer than 2 rectangles are provided or mode is unknown.
"""- First line is a one-sentence imperative summary.
Args:andReturns:are required for non-trivial signatures.Raises:documents every exception the caller might need to handle.- Private methods need only a brief inline comment if the logic is non-obvious.
- Line length: 100 characters (matches
black+ruffconfig). - Use
blackfor formatting andrufffor linting. Do not argue with the formatter. - Trailing commas in multi-line function signatures and collections — they make diffs cleaner.
- Use f-strings for all string interpolation. Avoid
%and.format(). - Never use mutable default arguments (
def f(lst=[])). UseNoneand assign inside the body.
- Tests live in
tests/. Theconftest.pymust insertsrc/atsys.path[0]so tests always run against the local source, never against an installed package. - Name tests
test_<behavior>, nottest_<implementation_detail>. - Fixtures should be function-scoped for GUI widget tests — shared state between tests causes false positives.
- Test public behavior, not private implementation. Refactoring internals must not break tests.
- For GUI tests that require a display, use
pytest.skipgracefully when Tkinter is not available.
- Version follows
MAJOR.MINOR.PATCH(SemVer). Breaking API changes bumpMAJOR. - Every released version gets a
CHANGELOG.mdentry grouped as: Added, Changed, Fixed, Performance, Breaking. - Version string lives in
__init__.pyandpyproject.toml. Keep them in sync.