anti-slop-py
Opinionated lint rules that reject low-evidence, low-signal Python patterns. A port of anti-slop (Oxlint/TypeScript) to the Python ecosystem.
Use it three ways: scan a project without adding a single file to it, install the PyPI package for CI enforcement, or vendor the rules into your repository and change them until they say what your team actually believes. The bundled agent skill wires up whichever mode you pick.
What it catches
Two snippets straight out of an AI assistant's comfort zone. First, a "typed" loader that proves nothing:
from typing import Any, cast
def load_user(payload: dict[str, Any]) -> dict[str, Any]:
if isinstance(payload.get("id"), str):
return cast(dict[str, Any], payload)
raise ValueError("bad payload")
loader.py:4:24: ASP009 dictionary contract with 'Any'/'object' values; define a TypedDict, dataclass, or model
loader.py:4:43: ASP009 dictionary contract with 'Any'/'object' values; define a TypedDict, dataclass, or model
loader.py:5:8: ASP002 ad hoc runtime narrowing; parse input at the boundary or move the check into a TypeGuard/TypeIs function
loader.py:6:16: ASP001 type assertion requires a '# SAFETY:' comment on the same or preceding line documenting the checked invariant
loader.py:6:21: ASP009 dictionary contract with 'Any'/'object' values; define a TypedDict, dataclass, or model
The function checks one key, asserts the rest into existence, and every caller inherits a dict that could hold anything. The shape the rules push toward instead:
from pydantic import BaseModel
class User(BaseModel):
id: str
name: str
def load_user(payload: bytes) -> User:
return User.model_validate_json(payload)
One parse at the boundary, evidence everywhere after it. A typo in a field access is now a type error instead of a None in production, and all five findings disappear because the code stopped needing Any, isinstance, and cast at all.
Second, a FastAPI endpoint with its contract buried in the implementation (framework group enabled):
@app.post("/orders")
async def create_order(request: Request):
data = await request.json()
order = save_order(data)
return JSONResponse(content={"id": order.id, "status": "created"})
orders.py:2:1: ASF001 endpoint without a typed response contract; annotate the return with a model or set response_model
orders.py:3:18: ASF002 manual request parsing; declare a body model parameter so FastAPI parses and validates at the boundary
orders.py:5:12: ASF003 ad hoc dict response; return a response model instead of hand-built JSON
After:
@app.post("/orders")
async def create_order(order_in: OrderIn) -> OrderOut:
order = save_order(order_in)
return OrderOut(id=order.id, status="created")
FastAPI now validates the request, serializes the response, and publishes both shapes in the OpenAPI schema; none of that existed in the first version. Both outputs above are real runs of the published package (uvx --with anti-slop-py flake8), not mockups.
Architecture: three layers
Ruff still has no API for custom rules, so anti-slop-py can't be a single plugin. It's one opinionated configuration spread across three tools, each covering the part it already handles well:
- Ruff (
configs/ruff-anti-slop.toml), for the rules that already exist:ANN401(noAnyin signatures),PGH003(no blankettype: ignore),B009/B010(no constant-namegetattr/setattr), andTID251with a banned-API list that includesunittest.mock.patch. - mypy strict flags (
configs/mypy-anti-slop.ini).disallow_any_explicitand friends catchAnyin the places Ruff can't see: nested generics, aliases, plain variables. - A Flake8 plugin (
src/anti_slop/, vendored as a local plugin) carrying the ten customASPrules below. It runs next to Ruff, scoped withselect = ASP.
Install with an agent skill
Clone the repository and point your coding agent at the bundled skill:
git clone https://github.com/infoslack/anti-slop-py
Then, from the target repository, ask the agent to install anti-slop-py following <clone>/skills/install-anti-slop-py/SKILL.md. The skill copies the plugin to tools/flake8/anti_slop/, registers it as a Flake8 local plugin, merges the Ruff and mypy layers into whatever configuration already exists, and validates the result. When pydantic or fastapi shows up as a direct dependency, it also enables the matching framework group.
If you have Node available, the skills.sh CLI does the fetch in one step and registers the skill with your agent:
npx skills add infoslack/anti-slop-py --skill install-anti-slop-py
Claude Code users can install it as a plugin instead:
/plugin marketplace add infoslack/anti-slop-py
/plugin install anti-slop-py@anti-slop-py
Scan without installing
The rules also run straight from a clone, reporting findings without adding a single file to the target project. Flake8 resolves the plugin relative to the config file, so from the target repository:
flake8 --config <clone>/configs/flake8-scan.ini src tests
Pair it with CLI-only flags for the other layers (ruff check --extend-select ANN401,PGH003,B009,B010, mypy --disallow-any-explicit) and nothing in the repo changes. With the package published, the clone becomes optional too: uvx --with anti-slop-py flake8 --enable-extensions=ASD,ASF src tests. This is the audit mode; the two installation paths below are for repositories that want the rules enforced in CI for every developer.
Install as a package
The lighter enforcement path, from PyPI:
uv add --dev anti-slop-py
# or: pip install anti-slop-py
The entry points register the ASP rules with Flake8 automatically; a fresh flake8 src tests already reports them. The framework groups ship off by default and turn on per project:
[flake8]
select = ASP
# with pydantic/fastapi as direct dependencies:
# select = ASP,ASD,ASF
# enable-extensions = ASD,ASF
Then merge configs/ruff-anti-slop.toml into your Ruff configuration and configs/mypy-anti-slop.ini into your mypy configuration (both ship inside the sdist as reference).
Running in CI
With the package in the dev dependencies, the whole team and the CI run the same rules. A minimal GitHub Actions job:
name: lint
on: [push, pull_request]
jobs:
anti-slop:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: astral-sh/setup-uv@v5
- run: uv sync
- run: uv run flake8 src tests
The same job is the natural home for the other two layers (uv run ruff check, uv run mypy). A team that has not adopted the dependency yet can still audit every PR with one line and zero repo changes:
- run: uvx --with anti-slop-py flake8 --enable-extensions=ASD,ASF src tests
Vendored installation
For teams that want to own and edit the rules rather than track releases: copy src/anti_slop/ into the target repository (say, at tools/flake8/anti_slop/), install flake8 as a development dependency, and register the local plugin in .flake8, since Flake8 doesn't read pyproject.toml:
[flake8]
select = ASP
[flake8:local-plugins]
extension =
ASP = anti_slop.checker:AntiSlopChecker
paths =
./tools/flake8
The Ruff and mypy layers merge the same way as in the package path. Avoid running the vendored copy and the installed package at once; each finding gets reported twice.
Rules
Each rule documents which original anti-slop rule it ports and names the ready-made tool covering the complementary half.
| Code | Ports | Rejects |
|---|---|---|
ASP001 |
require-safety-comment-for-type-assertion |
typing.cast(...) or # type: ignore with no # SAFETY: comment documenting the checked invariant |
ASP002 |
no-runtime-typeof |
ad hoc isinstance/hasattr/type(x) is narrowing; parse at the boundary instead. --anti-slop-allow-typeguards permits checks inside TypeGuard/TypeIs functions |
ASP003 |
no-module-mocking |
mock.patch(...), mocker.patch(...), monkeypatch.setattr(...); inject dependencies through real seams |
ASP004 |
no-chained-type-assertions |
cast(User, cast(object, value)) |
ASP005 |
no-known-value-widening |
handlers: dict[str, Handler] = {"start": ...}, where the broad annotation throws away known keys; use inference, Final, or a TypedDict |
ASP006 |
no-reflect-get / no-reflect-apply |
getattr(owner, dynamic_name); Ruff B009/B010 cover the constant-name forms |
ASP007 |
no-shape-in-symbol-names |
shape in class, function, parameter, or variable names |
ASP008 |
no-object-parameters / no-unknown-* |
parameters or returns typed bare object, Python's safe top type; dunder methods and cause parameters are exempt |
ASP009 |
no-unsafe-dictionary-type |
dict[str, Any], Mapping[str, object], and friends; define a TypedDict, dataclass, or model |
ASP010 |
no-unknown-type-aliases |
type ExternalValue = Any plus the TypeAlias and bare-assignment spellings |
Not ported: no-conditional-empty-object-spread, because the JS idiom barely exists in Python, and no-widen-then-assert, which needs local flow analysis and stays on the roadmap. The Effect rule no-service-constructor-imports maps to import-linter contracts rather than a lint rule.
Framework groups (opt-in)
Framework policy lives in separate rule groups, the same split the original makes with anti-slop-effect. The groups are off_by_default Flake8 plugins: enable one only when its framework is a direct dependency, with enable-extensions = ASD,ASF next to select (package path) or together with the extension lines in [flake8:local-plugins] (vendored path). Each group repeats the three-layer split, with ready-made rules first and custom ASD/ASF rules only for the gaps.
Pydantic (ASD)
Ready-made layer: flake8-pydantic for PYD hygiene rules, plus the official Pydantic mypy plugin (configs/mypy-anti-slop-pydantic.ini).
| Code | Rejects |
|---|---|
ASD001 |
model_construct() or construct() without a # SAFETY: comment. Both skip validation; the docs allow them only for data you already trust |
ASD002 |
extra="allow" in ConfigDict or a legacy class Config, which stores unvalidated keys without a contract |
ASD003 |
model fields typed Any, bare or nested (list[Any]), opting the field out of validation |
ASD004 |
f(**model.model_dump()) and x: dict[...] = model.model_dump(): re-widening a validated model into an untyped dict, the Pydantic version of no-widen-then-assert |
ASD005 |
TypeAdapter(...) inside a function; official performance guidance says build it once at module scope |
FastAPI (ASF)
Ready-made layer: Ruff FAST (redundant response_model, non-Annotated Depends, unused path params) and ASYNC (blocking I/O inside async routes), via configs/ruff-anti-slop-fastapi.toml.
| Code | Rejects |
|---|---|
ASF001 |
endpoints with no typed response contract: no return annotation and no response_model, or a return annotated Any/dict |
ASF002 |
await request.json(), .body(), or .form() inside endpoints; declare a body model parameter and let the framework parse at the boundary |
ASF003 |
JSONResponse(content={...}) with a dict literal inside endpoints; return a model. Exception handlers and middleware stay unflagged |
Detection is syntactic. Models are found by base-class name (same-module subclasses included), endpoints by @<receiver>.<http-verb>(...) decorators; cross-module inheritance and aliased imports are known misses.
Development
uv run --group dev pytest
uv run flake8 src tests scripts # the project's own rules, on itself
uv run ruff check src tests scripts
uv run mypy
The repo dogfoods all three layers on its own source through the installed entry points; CI enforces it. One documented deviation lives in .flake8: ASP002 targets application boundaries, and an AST linter discriminating the closed ast.* sum type is that boundary, so the rule does not apply to this codebase.
src/ is canonical. After changing production source, run scripts/sync_skill_assets.py so the skill's bundled copy stays identical; --check verifies without writing. Releases: bump __version__ in src/anti_slop/__init__.py, tag v*, and the publish workflow ships to PyPI via trusted publishing.
License
MIT
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 anti_slop_py-0.1.1.tar.gz.
File metadata
- Download URL: anti_slop_py-0.1.1.tar.gz
- Upload date:
- Size: 22.7 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
73849f0d2c6ad47edc46e4d009e0f410da7c83add60c9e427724822deff9840f
|
|
| MD5 |
76b6c69f1d7127b9a7deb72c05149086
|
|
| BLAKE2b-256 |
e001bc12a485ff673a1d09eac46e6d02b553504e1e3fb0c1add448dc62e09033
|
Provenance
The following attestation bundles were made for anti_slop_py-0.1.1.tar.gz:
Publisher:
publish.yml on infoslack/anti-slop-py
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
anti_slop_py-0.1.1.tar.gz -
Subject digest:
73849f0d2c6ad47edc46e4d009e0f410da7c83add60c9e427724822deff9840f - Sigstore transparency entry: 2567912878
- Sigstore integration time:
-
Permalink:
infoslack/anti-slop-py@2a3e51b7bce694c7ea94e3207d8d655a7120d99f -
Branch / Tag:
refs/tags/v0.1.1 - Owner: https://github.com/infoslack
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@2a3e51b7bce694c7ea94e3207d8d655a7120d99f -
Trigger Event:
push
-
Statement type:
File details
Details for the file anti_slop_py-0.1.1-py3-none-any.whl.
File metadata
- Download URL: anti_slop_py-0.1.1-py3-none-any.whl
- Upload date:
- Size: 29.9 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
727bb26404abfa1273693c852c839cb94d4277e1fc41c943923169fe06b2342c
|
|
| MD5 |
0dffe494c4d0b44171e3adca61bf5747
|
|
| BLAKE2b-256 |
07f99c6b1be840aa368fb1defcc5db4b8fa12f00925dc9b14f9782ba682cad15
|
Provenance
The following attestation bundles were made for anti_slop_py-0.1.1-py3-none-any.whl:
Publisher:
publish.yml on infoslack/anti-slop-py
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
anti_slop_py-0.1.1-py3-none-any.whl -
Subject digest:
727bb26404abfa1273693c852c839cb94d4277e1fc41c943923169fe06b2342c - Sigstore transparency entry: 2567912888
- Sigstore integration time:
-
Permalink:
infoslack/anti-slop-py@2a3e51b7bce694c7ea94e3207d8d655a7120d99f -
Branch / Tag:
refs/tags/v0.1.1 - Owner: https://github.com/infoslack
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@2a3e51b7bce694c7ea94e3207d8d655a7120d99f -
Trigger Event:
push
-
Statement type: