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.

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

Release builds should use:

uv build --no-sources

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.2.0.tar.gz (63.8 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.2.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl (1.2 MB view details)

Uploaded CPython 3.14manylinux: glibc 2.17+ x86-64

File details

Details for the file ruff_legibility-0.2.0.tar.gz.

File metadata

  • Download URL: ruff_legibility-0.2.0.tar.gz
  • Upload date:
  • Size: 63.8 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: uv/0.11.25 {"installer":{"name":"uv","version":"0.11.25","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}

File hashes

Hashes for ruff_legibility-0.2.0.tar.gz
Algorithm Hash digest
SHA256 b6d6deeffc7f423838e0f56704eab934476d60201583532082d4fe012ca2b9cf
MD5 54efd283fb88f9721bd0ed6145b25e65
BLAKE2b-256 019e533803727b1029129243d6948a7fc25a26e2726111aa3f772f5ebdd71bf1

See more details on using hashes here.

File details

Details for the file ruff_legibility-0.2.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl.

File metadata

  • Download URL: ruff_legibility-0.2.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl
  • Upload date:
  • Size: 1.2 MB
  • Tags: CPython 3.14, manylinux: glibc 2.17+ x86-64
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: uv/0.11.25 {"installer":{"name":"uv","version":"0.11.25","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}

File hashes

Hashes for ruff_legibility-0.2.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl
Algorithm Hash digest
SHA256 e69875fe678c9b80a338f7e012112783460d9761a57de58cf1f1ea24059e3f92
MD5 6f2909fd903f7362ecedf6b29a6399fe
BLAKE2b-256 128c04c2ce17c2235df35862d82bacedb9a88d3fe2a4d80640c2dcef0de33d47

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