Skip to main content

Ruff-adjacent Python legibility rules for readable, reviewable code.

Project description

ruff-legibility

PyPI version CI OpenSSF Scorecard

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

ruff_legibility-0.3.1.tar.gz (62.9 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

ruff_legibility-0.3.1-cp311-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl (1.2 MB view details)

Uploaded CPython 3.11+manylinux: glibc 2.17+ x86-64

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

Hashes for ruff_legibility-0.3.1.tar.gz
Algorithm Hash digest
SHA256 30ec56d6f1e1e65bc1e8957cb9c5aa5c6569c125f8a3c40e8b8ebae3203940ba
MD5 0ce9be9bfa2862f9a604c4d7632a649d
BLAKE2b-256 04bd5f8d6f9411bfed689e9068662036090862144fc605d0935c6c98fc7ab531

See more details on using hashes here.

File details

Details for the file ruff_legibility-0.3.1-cp311-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl.

File metadata

File hashes

Hashes for ruff_legibility-0.3.1-cp311-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl
Algorithm Hash digest
SHA256 4c47ee7a92687a29f09c6d4b10ef71cec4e90fc706032264c4f0dd06df4f5b5e
MD5 fd8172a9ab7a23b175f1abfdd1158250
BLAKE2b-256 d942d25c74cf1ef52b15dc9d0d991f078aa27f85b40b504f4ba19adf130c7e61

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page