Ruff-adjacent Python legibility rules for readable, reviewable code.
Project description
ruff-legibility
ruff-legibility is a Ruff-adjacent Python linter for readability and reviewability rules inspired by eslint-plugin-legibility.
Ruff does not currently load third-party rule implementations from Python packages. This package therefore runs beside Ruff and emits Ruff-style diagnostics with LEG### codes.
ruff check .
ruff-legibility check .
To keep # noqa: LEG001 comments valid when Ruff checks unused or unknown noqa codes, add LEG as an external prefix in Ruff:
[tool.ruff.lint]
external = ["LEG"]
Install
pip install ruff-legibility
For local development:
uv sync --all-groups
make check
Usage
ruff-legibility check .
ruff-legibility check src tests --output-format json
ruff-legibility check . --select LEG001,LEG002 --ignore LEG007
ruff-legibility check . --exit-zero
Default text output is intentionally close to Ruff:
example.py:4:8: LEG002 If condition has 2 boolean operators (max 0). Hoist it into a named boolean.
Agent Skill
The package includes a reusable Claude/Codex skill, but it is never installed
automatically. Install it explicitly when you want local agents to use the
ruff-legibility loop:
ruff-legibility install-skill
ruff-legibility install-skill --target auto
ruff-legibility install-skill --target codex
ruff-legibility install-skill --path ~/.agents/skills --force
Default installs copy the skill to ~/.agents/skills/ruff-legibility. Codex
target installs copy it to $CODEX_HOME/skills/ruff-legibility, or
~/.codex/skills/ruff-legibility when CODEX_HOME is not set.
Auto target detection uses the packaged static target registry, prefers a
configured target such as CODEX_HOME, then falls back to an existing known
skill root, then ~/.agents/skills.
Use --path for any other agent skill root instead of adding vendor-specific
folders to this repository.
After installing the skill, use it in an agent prompt:
Use $ruff-legibility to check Python readability and iterate on LEG diagnostics.
Configuration
Configuration can live in pyproject.toml under [tool.ruff-legibility], or in ruff-legibility.toml / .ruff-legibility.toml.
[tool.ruff-legibility]
select = ["LEG"]
extend-select = []
ignore = ["LEG007"]
extend-ignore = []
exclude = [".venv", "build", "dist"]
max-expression-operators = 4
max-if-operators = 0
max-ternary-operators = 2
max-computed-value-operators = 1
max-control-flow-depth = 3
max-array-chain-depth = 2
min-object-lookup-chain-length = 3
min-dirname-match-depth = 3
[tool.ruff-legibility.per-file-ignores]
"tests/*" = ["LEG003"]
Standalone config files omit the tool.ruff-legibility wrapper:
select = ["LEG"]
ignore = ["LEG007"]
This repository includes a ruff-legibility.toml for its own source. The default package thresholds stay stricter than the project-local development config.
Rules
Each rule has an inline dos / don'ts diff example in Examples.
| Code | Rule | Default |
|---|---|---|
LEG001 |
Limit readability operators inside a single expression. | on |
LEG002 |
Prefer a named boolean before operator-heavy if / while conditions. |
on |
LEG003 |
Limit nested control-flow depth. | on |
LEG004 |
Avoid complex ternary expressions. | on |
LEG005 |
Flag likely quadratic patterns such as nested loops and repeated membership checks in loops. | on |
LEG006 |
Avoid redundant boolean comparisons and boolean ternaries like flag == True or True if flag else False. |
on |
LEG007 |
Prefer positive condition names over names like is_not_ready. |
on |
LEG008 |
Avoid trivial wrapper functions that only forward parameters to another call. | on |
LEG009 |
Avoid else branches after a branch that already exits. |
on |
LEG010 |
Prefer guard clauses over wrapping the main path in one large if block. |
on |
LEG011 |
Limit consecutive collection-style method chains. | on |
LEG012 |
Prefer named values before returning computed expressions or building dict values. | on |
LEG013 |
Avoid mutations and assignment expressions hidden inside expressions. | on |
LEG014 |
Avoid standalone list mutation calls when an expression is clearer. | on |
LEG015 |
Prefer explicit collection composition over starred literal unpacking. | on |
LEG016 |
Require configured executable Python source files to start with a shebang. | on |
LEG017 |
Prefer smoke-testing installed Python package entry points. | on |
LEG018 |
Avoid repeated scans over the same collection in one scope. | on |
LEG019 |
Avoid aliases that only rename another value for one use. | on |
LEG020 |
Avoid lambdas that only forward their parameters to another callable. | on |
LEG021 |
Prefer a flat comprehension over map followed by flattening. | on |
LEG022 |
Avoid map/filter callbacks that keep every item unchanged. | on |
LEG023 |
Avoid fallback expressions that only return None unchanged. |
on |
LEG024 |
Prefer set or dict lookups over long equality-or chains. | on |
LEG025 |
Require files in named subdirectories to match the directory name. | on |
LEG026 |
Avoid filenames that mix casing conventions. | on |
LEG027 |
Avoid comprehensions that keep every item unchanged. | on |
LEG028 |
Prefer comprehensions over map/filter calls with lambdas. | on |
LEG029 |
Prefer comprehensions over simple list-building append loops. | on |
LEG030 |
Avoid filtering the same collection with comprehensions multiple times in one scope. | on |
LEG031 |
Avoid deep subscript chains without named intermediate values. | on |
LEG032 |
Prefer named context when wrapping or logging broad exceptions. | on |
LEG033 |
Avoid positive boolean names assigned from inverted expressions. | on |
Examples
Removed lines are don'ts. Added lines are dos.
LEG001 max-expression-operators
do / don't
- return user.is_active and user.score > 10 and (user.role == "admin" or user.role == "owner")
+ is_admin = user.role == "admin"
+ is_owner = user.role == "owner"
+ has_privileged_role = is_admin or is_owner
+ return user.is_active and user.score > 10 and has_privileged_role
LEG002 hoist-if-operators
do / don't
- if user and user.is_active and not user.is_locked:
- send_invite(user)
+ can_invite_user = user and user.is_active and not user.is_locked
+ if can_invite_user:
+ send_invite(user)
LEG003 max-control-flow-depth
do / don't
- if user:
- for invite in invites:
- if invite.pending:
- while invite.retries < 3:
- send_invite(invite)
+ if not user:
+ return
+ pending_invites = [invite for invite in invites if invite.pending]
+ for invite in pending_invites:
+ retry_invite(invite)
LEG004 no-complex-ternary
do / don't
- label = "owner" if user.is_owner else "admin" if user.is_admin else "member"
+ if user.is_owner:
+ label = "owner"
+ elif user.is_admin:
+ label = "admin"
+ else:
+ label = "member"
LEG005 no-quadratic-patterns
do / don't
- for user in users:
- for owner in owners:
- if user.id == owner.user_id:
- assign_owner(user, owner)
+ owners_by_user_id = {owner.user_id: owner for owner in owners}
+ for user in users:
+ owner = owners_by_user_id.get(user.id)
+ if owner is not None:
+ assign_owner(user, owner)
LEG006 no-redundant-boolean-logic
do / don't
- return True if flag == True else False
+ return flag
LEG007 prefer-positive-condition-names
do / don't
- is_not_ready = status != "ready"
- if is_not_ready:
+ is_ready = status == "ready"
+ if not is_ready:
return
LEG008 no-trivial-wrapper-functions
do / don't
- def normalize(value):
- return clean(value)
- result = normalize(value)
+ result = clean(value)
LEG009 prefer-early-return
do / don't
- if not user:
- return None
- else:
- return user.email
+ if not user:
+ return None
+ return user.email
LEG010 prefer-guard-clauses
do / don't
- if user:
- prepare(user)
- send_invite(user)
+ if not user:
+ return
+ prepare(user)
+ send_invite(user)
LEG011 max-array-chain-depth
do / don't
- users = query.filter(active=True).order_by("name").limit(10)
+ active_users = query.filter(active=True)
+ sorted_users = active_users.order_by("name")
+ users = sorted_users.limit(10)
LEG012 no-computed-values
do / don't
- return total + tax - discount
+ subtotal = total + tax
+ return subtotal - discount
LEG013 no-hidden-side-effects
do / don't
- return cache.setdefault(key, build_value())
+ if key not in cache:
+ cache[key] = build_value()
+ return cache[key]
LEG014 no-standalone-array-mutations
do / don't
- items.append(item)
- return items
+ return items + [item]
LEG015 prefer-concat-object-assign
do / don't
- payload = {**base_payload, "id": user_id}
+ payload = base_payload | {"id": user_id}
LEG016 require-executable-shebang
do / don't
- # scripts/report.py
- print("ok")
+ #!/usr/bin/env python3
+ print("ok")
LEG017 no-direct-python-bin-smoke
do / don't
- subprocess.run(["python", "src/example/cli.py", "--help"], check=True)
+ subprocess.run(["example", "--help"], check=True)
LEG018 no-repeated-collection-search
do / don't
- if user_id in ids and owner_id in ids:
- return True
+ id_lookup = set(ids)
+ required_ids = {user_id, owner_id}
+ return required_ids.issubset(id_lookup)
LEG019 no-single-use-renaming-alias
do / don't
- current_user = request.user
- return current_user.email
+ return request.user.email
LEG020 no-unnecessary-lambda
do / don't
- users = sorted(users, key=lambda user: normalize(user))
+ users = sorted(users, key=normalize)
LEG021 prefer-flat-comprehension
do / don't
- values = list(chain.from_iterable(map(expand, items)))
+ values = [value for item in items for value in expand(item)]
LEG022 no-identity-array-callback
do / don't
- names = list(map(lambda name: name, names))
+ names = list(names)
LEG023 no-redundant-none-fallback
do / don't
- return value if value is not None else None
+ return value
LEG024 prefer-object-lookup
do / don't
- if status == "new" or status == "open" or status == "pending":
- queue_item(item)
+ if status in {"new", "open", "pending"}:
+ queue_item(item)
LEG025 require-filename-matches-dirname
do / don't
- src/billing/customer/profile.py
+ src/billing/customer/customer.py
LEG026 no-mixed-filename-casing
do / don't
- user_Profile.py
+ user_profile.py
LEG027 no-identity-comprehension
do / don't
- copied = [item for item in items]
+ copied = list(items)
LEG028 prefer-comprehension-over-map-filter
do / don't
- names = list(map(lambda user: user.name, users))
+ names = [user.name for user in users]
LEG029 no-loop-append-comprehension
do / don't
- names = []
- for user in users:
- names.append(user.name)
+ names = [user.name for user in users]
LEG030 no-repeated-comprehension-filter
do / don't
- active_users = [user for user in users if user.active]
- admin_users = [user for user in users if user.is_admin]
+ filtered_users = [user for user in users if user.active or user.is_admin]
+ active_users = [user for user in filtered_users if user.active]
+ admin_users = [user for user in filtered_users if user.is_admin]
LEG031 no-deep-subscript-chain
do / don't
- return payload["user"]["profile"]["email"]
+ user = payload["user"]
+ profile = user["profile"]
+ return profile["email"]
LEG032 prefer-named-exception-context
do / don't
- except Exception as error:
- raise RuntimeError(error)
+ except Exception as error:
+ message = "Failed to load user profile"
+ raise RuntimeError(message) from error
LEG033 no-boolean-parameter-name-drift
do / don't
- is_ready = status != "ready"
+ is_ready = status == "ready"
Pre-commit
repos:
- repo: local
hooks:
- id: ruff-legibility
name: ruff-legibility
entry: ruff-legibility check
language: system
types: [python]
Development
Common commands:
uv sync --all-groups
uv run ruff check .
uv run ruff-legibility check src tests
uv run pytest
uv build
Local release artifact checks should use:
uv build --no-sources
Tagged releases are published by GitHub Actions. The workflow builds a Python
3.11+ abi3 manylinux wheel so supported CPython versions can install without
building from source.
Publishing is configured for PyPI Trusted Publishing:
uv publish
Project details
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file ruff_legibility-0.3.1.tar.gz.
File metadata
- Download URL: ruff_legibility-0.3.1.tar.gz
- Upload date:
- Size: 62.9 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: uv/0.7.19
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
30ec56d6f1e1e65bc1e8957cb9c5aa5c6569c125f8a3c40e8b8ebae3203940ba
|
|
| MD5 |
0ce9be9bfa2862f9a604c4d7632a649d
|
|
| BLAKE2b-256 |
04bd5f8d6f9411bfed689e9068662036090862144fc605d0935c6c98fc7ab531
|
File details
Details for the file ruff_legibility-0.3.1-cp311-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl.
File metadata
- Download URL: ruff_legibility-0.3.1-cp311-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl
- Upload date:
- Size: 1.2 MB
- Tags: CPython 3.11+, manylinux: glibc 2.17+ x86-64
- Uploaded using Trusted Publishing? Yes
- Uploaded via: uv/0.7.19
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
4c47ee7a92687a29f09c6d4b10ef71cec4e90fc706032264c4f0dd06df4f5b5e
|
|
| MD5 |
fd8172a9ab7a23b175f1abfdd1158250
|
|
| BLAKE2b-256 |
d942d25c74cf1ef52b15dc9d0d991f078aa27f85b40b504f4ba19adf130c7e61
|