Skip to main content

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.

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
LEG034 Reject unmatched comments and unowned comment stacking within functions. on

Examples

Removed lines are don'ts. Added lines are dos.


LEG001 max-expression-operators

LEG001 example diff

- 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

LEG002 example diff

- 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

LEG003 example diff

- 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

LEG004 example diff

- 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

LEG005 example diff

- 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

LEG006 example diff

- return True if flag == True else False
+ return flag

LEG007 prefer-positive-condition-names

LEG007 example diff

- is_not_ready = status != "ready"
- if is_not_ready:
+ is_ready = status == "ready"
+ if not is_ready:
      return

LEG008 no-trivial-wrapper-functions

LEG008 example diff

- def normalize(value):
-     return clean(value)
- result = normalize(value)
+ result = clean(value)

LEG009 prefer-early-return

LEG009 example diff

- if not user:
-     return None
- else:
-     return user.email
+ if not user:
+     return None
+ return user.email

LEG010 prefer-guard-clauses

LEG010 example diff

- if user:
-     prepare(user)
-     send_invite(user)
+ if not user:
+     return
+ prepare(user)
+ send_invite(user)

LEG011 max-array-chain-depth

LEG011 example diff

- 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

LEG012 example diff

- return total + tax - discount
+ subtotal = total + tax
+ return subtotal - discount

LEG013 no-hidden-side-effects

LEG013 example diff

- return cache.setdefault(key, build_value())
+ if key not in cache:
+     cache[key] = build_value()
+ return cache[key]

LEG014 no-standalone-array-mutations

LEG014 example diff

- items.append(item)
- return items
+ return items + [item]

LEG015 prefer-concat-object-assign

LEG015 example diff

- payload = {**base_payload, "id": user_id}
+ payload = base_payload | {"id": user_id}

LEG016 require-executable-shebang

LEG016 example diff

- # scripts/report.py
- print("ok")
+ #!/usr/bin/env python3
+ print("ok")

LEG017 no-direct-python-bin-smoke

LEG017 example diff

- subprocess.run(["python", "src/example/cli.py", "--help"], check=True)
+ subprocess.run(["example", "--help"], check=True)

LEG018 no-repeated-collection-search

LEG018 example diff

- 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

LEG019 example diff

- current_user = request.user
- return current_user.email
+ return request.user.email

LEG020 no-unnecessary-lambda

LEG020 example diff

- users = sorted(users, key=lambda user: normalize(user))
+ users = sorted(users, key=normalize)

LEG021 prefer-flat-comprehension

LEG021 example diff

- values = list(chain.from_iterable(map(expand, items)))
+ values = [value for item in items for value in expand(item)]

LEG022 no-identity-array-callback

LEG022 example diff

- names = list(map(lambda name: name, names))
+ names = list(names)

LEG023 no-redundant-none-fallback

LEG023 example diff

- return value if value is not None else None
+ return value

LEG024 prefer-object-lookup

LEG024 example diff

- 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

LEG025 example diff

- src/billing/customer/profile.py
+ src/billing/customer/customer.py

LEG026 no-mixed-filename-casing

LEG026 example diff

- user_Profile.py
+ user_profile.py

LEG027 no-identity-comprehension

LEG027 example diff

- copied = [item for item in items]
+ copied = list(items)

LEG028 prefer-comprehension-over-map-filter

LEG028 example diff

- names = list(map(lambda user: user.name, users))
+ names = [user.name for user in users]

LEG029 no-loop-append-comprehension

LEG029 example diff

- names = []
- for user in users:
-     names.append(user.name)
+ names = [user.name for user in users]

LEG030 no-repeated-comprehension-filter

LEG030 example diff

- 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

LEG031 example diff

- return payload["user"]["profile"]["email"]
+ user = payload["user"]
+ profile = user["profile"]
+ return profile["email"]

LEG032 prefer-named-exception-context

LEG032 example diff

- 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

LEG033 example diff

- is_ready = status != "ready"
+ is_ready = status == "ready"

LEG034 no-unmatched-comments

LEG034 checks standalone and trailing inline # comments.

LEG034 example diffs

Fails with two diagnostics:

# Explain the assignment.
value = 1  # Keep this value stable.

Fix:

- # Convert cents to dollars.
- total_dollars = cents / 100  # Store the converted total.
+ total_dollars = cents / 100

LEG034 setup

[tool.ruff-legibility]
comment-matchers = [
  '\b(ENG|OPS)-\d+\b',
  '^\s*(noqa\b|type:\s*|ruff:\s*noqa\b)',
  '^\s*(fmt|isort):\s*(on|off|skip)\b',
  '^\s*pragma:\s*no cover\b',
]
comment-prefix-identifiers = ["HUMAN", "LEGAL", "SECURITY"]
comment-suffix-identifiers = ["@owned"]
Setting Allows the comment Exempt from one-comment-per-function limit
comment-matchers Regex matches no
comment-prefix-identifiers Block starts with identifier yes
comment-suffix-identifiers Block ends with identifier yes

Matching is case-insensitive and ignores the leading #.

Regex-matched comments

Passes with the setup above:

# ENG-481: Provider retries must remain ordered.
for retry in retries:
    send(retry)

timeout = 30  # OPS-92: Keep worker and provider timeouts aligned.

Only one regex-matched physical comment is allowed per function. This reports the second comment:

def load_value():
    value = load()  # ENG-481: Load the configured value once.
    # ENG-482: Preserve the configured fallback.
    return value or fallback()

Fix by keeping one useful comment:

 def load_value():
-    value = load()  # ENG-481: Load the configured value once.
-    # ENG-482: Preserve the configured fallback.
+    # ENG-481: Load once and use the configured fallback when empty.
+    value = load()
     return value or fallback()

The same limit applies immediately above and below a function. The second line in each block reports:

# ENG-481: First leading comment.
# ENG-482: Second leading comment.
def first():
    return 1


def second():
    return 2
# ENG-483: First trailing comment.
# ENG-484: Second trailing comment.

An indented comment after the final statement still belongs to the function. This reports the second comment:

def load_value():
    # ENG-481: Load the configured value.
    value = load()
    return value
    # ENG-482: Preserve the provider contract.

Nested functions have separate counts:

def outer():
    # ENG-481: Preserve the outer contract.
    value = load()

    def inner():
        # ENG-482: Preserve the inner contract.
        return value

    return inner()

A comment immediately below a nested function belongs to that nested function. This reports the second comment:

def outer():
    def inner():
        # ENG-481: Preserve the inner contract.
        return 1
    # ENG-482: Keep this adjacent to the inner function.
    return inner()

Human-owned comment blocks

Prefix- and suffix-owned blocks pass and do not count toward the function limit:

def load_value():
    # HUMAN: The provider requires ordered retries.
    # Reordering these calls breaks failover.
    retry()

    # The operator selects this timeout.
    # The worker must use the same value. @owned
    return timeout

Identifiers require a boundary:

- # HUMANIZED: This is not owned.
+ # HUMAN: This block is owned.

- value = 1  # Preserve this not@owned
+ value = 1  # Preserve this. @owned

Ignored Python metadata

These pass without comment configuration:

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Module documentation."""


def load():
    """Load the value."""

Encoding cookies must occupy a comment-only line:

- value = 1  # coding: utf-8
+ # coding: utf-8
+ value = 1

Recipes

Use the same committed comment ownership configuration in every context. Change only the scope and when a diagnostic blocks work.

Context Why How
Agent Agents can produce redundant comments at scale. Block every edited Python file.
Human A hard failure on every save interrupts editing. Warn while editing; block before commit.
CI Local checks can be skipped or narrowly scoped. Block the whole repository.

Run the tested Docker examples with make test-e2e.

Agent: block edited files

Why: an agent should remove narration it introduced, not disguise it with an ownership marker. Existing human-owned comments remain untouched.

How: install the shared skill once, give the agent the policy, and make its edited-file check blocking.

ruff-legibility install-skill --target auto
Use $ruff-legibility on every Python file you edit. Remove unmatched comments
you add. Do not add ownership markers, matchers, or noqa suppressions.
ruff check src/package/changed.py
ruff-legibility check src/package/changed.py

Tested agent example

Human: warn while editing, block before commit

Why: advisory output keeps the policy visible without breaking the edit loop. The blocking check keeps unmatched comments out of commits.

How: use --exit-zero while editing, then remove it for the changed-file gate.

ruff-legibility check src/package --select LEG034 --exit-zero
ruff-legibility check src/package/changed.py

pre-commit can install the pinned package and pass staged Python filenames to the blocking command:

repos:
  - repo: local
    hooks:
      - id: ruff-legibility
        name: ruff-legibility
        entry: ruff-legibility check
        language: python
        additional_dependencies:
          - ruff-legibility==0.4.0
        types: [python]

Tested human example

CI: block the repository

Why: CI is the backstop for skipped hooks, partial local checks, and files changed outside the normal edit loop.

How: install from the lockfile, run both linters across the repository, and use GitHub annotations for review visibility.

- run: uv sync --locked --all-groups
- run: uv run --locked ruff check .
- run: uv run --locked ruff-legibility check . --output-format github

Tested CI example

Existing codebase: warn, fix, then block

Why: a new gate should not stop unrelated work because of an existing comment baseline.

How: inventory LEG034 without blocking, tune the ownership configuration, fix the baseline, then remove --exit-zero.

ruff-legibility check . --select LEG034 --exit-zero
ruff-legibility check . --select LEG034

Install

pip install ruff-legibility

For local development:

uv sync --all-groups
make check

Usage

Run it beside Ruff:

ruff check .
ruff-legibility check .
ruff-legibility check src tests --output-format json
ruff-legibility check . --select LEG001,LEG002 --ignore LEG007
ruff-legibility check . --exit-zero

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.

Generate tracked package and shared-skill files:

make build-agent

Generate ignored local rule pointers:

uv run python -m scripts.agent.build --target codex
uv run python -m scripts.agent.build --target claude

Check generated files without writing:

make check-agent

Configuration

Keep # noqa: LEG001 valid when Ruff checks unknown noqa codes:

[tool.ruff.lint]
external = ["LEG"]

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
comment-matchers = []
comment-prefix-identifiers = []
comment-suffix-identifiers = []

[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.

Development

Common commands:

uv sync --all-groups
uv run ruff check .
uv run ruff-legibility check src tests scripts
uv run pytest
uv build

Repository scripts:

./scripts/setup.sh
./scripts/test_setup.sh
uv run python -m scripts.agent.build --target package,agents

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

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.4.0.tar.gz (80.0 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.4.0-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.4.0.tar.gz.

File metadata

  • Download URL: ruff_legibility-0.4.0.tar.gz
  • Upload date:
  • Size: 80.0 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: uv/0.7.19

File hashes

Hashes for ruff_legibility-0.4.0.tar.gz
Algorithm Hash digest
SHA256 ff307f1754b05034d0b72da7b95ac9351001aac8ec3620de9c7e6a94b88ff118
MD5 6358623fd83b93d49af9a4fc19da5f23
BLAKE2b-256 a2e25191f13a7949eb53028e0a0193e680e9607a4afb2091ceddf7cf3de92d09

See more details on using hashes here.

File details

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

File metadata

File hashes

Hashes for ruff_legibility-0.4.0-cp311-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl
Algorithm Hash digest
SHA256 eaa40b7053750fa2ad1c015b1f90e7fd6b091f514732f39fdf7cd1da4c1bf60b
MD5 61cc0d378b3b30a952d6e0abb48b0f0a
BLAKE2b-256 99359dfa545e9c74e9e96c59e380375d339c1af4146933a0c3f5fbe27b3427d4

See more details on using hashes here.

Release history Release notifications | RSS feed

This release

0.4.0 This release

2 files

0.3.1

2 files

0.3.0

2 files

0.2.0

2 files

0.1.0

2 files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page