feat(php): add a PHP skill, grounded in the synchra-php SDK and the bleichi site

PHP was the one language with real code in two shapes here and no skill: a
Composer library held at PHPStan level max, and a plain PHP-FPM application
behind nginx with no framework. The rules come from reading both rather than
from general PHP advice, so each one names the failure it prevents.

The two halves differ enough to be separate sections. A library requires PSR
interfaces and ships no implementation; an application's rules are about
escaping, keeping the document root unwritable, atomic writes and sessions.
Everything in §1-§8 applies to both.

Three findings are worth the space they take because they were each paid for
once already: `json_encode` with JSON_UNESCAPED_SLASHES into an inline
<script> is an XSS hole, a web user that cannot write its own cache fails
silently on every request, and inside the dev container the host's own `php`
bridge shadows the real interpreter so every vendor/bin tool dies naming
distrobox.

docs(python): cover MCP servers, Red cogs and HA components, and correct three
rules the owner's own code contradicts

The skill described a toolchain and was silent on the four things most of this
Python actually is. Three of its rules are also wrong in context, and the code
is right: getLogger(__name__) puts a cog outside the red.* tree where Red's log
controls apply; TaskGroup's cancel-on-first-error is the wrong default for
fan-out over N providers, where the real pattern is a semaphore plus
gather(return_exceptions=True); and the gate runs locally and through
pre-commit, not through CI, which never runs under this account.

Also: ruff ALL-minus-documented-ignores is the preferred shape rather than the
explicit select list, lockfiles split by artifact type, and bare `python` and
bare `pip` are different interpreters on this host.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
BluscreamandClaude Opus 5 committed 2026-10-06 17:40:23 +02:00
1 parent 1b688d128c
commit 5aef9707d2
5 files changed
+694 -24

No files matched your search

+165 -19
View File
@@ -2,8 +2,9 @@
name: python
description: >-
Use this skill when writing, linting, type-checking, testing, or packaging Python —
including async code, CLI tools, bot cogs, and library packages. Covers strict typing with
mypy, ruff configuration, virtualenv discipline, async patterns, and packaging.
including async code, CLI tools, MCP servers, Red-DiscordBot cogs, Home Assistant custom
components, and library packages. Covers strict typing with mypy, ruff configuration,
virtualenv discipline, async patterns, framework conventions, and packaging.
---
# Python — Rules & Preferences
@@ -11,6 +12,9 @@ description: >-
Extends [`coding-dev.md`](skills-public/coding-dev.md). Everything there applies; this adds Python specifics.
For the surrounding build and deploy scripts, see [`bash.md`](skills-public/code-languages/bash.md).
§1–§9 are the general rules. §10–§12 are the three frameworks most of this Python is written
against, each of which overrides a general rule somewhere — and says so.
---
## 1. The Gate
@@ -23,7 +27,16 @@ pytest
```
All four clean before anything is called done. Configure them in `pyproject.toml` so the
editor, the CLI, and CI all read the same settings.
editor, the CLI, and the hooks all read the same settings.
**The gate runs locally and through `pre-commit`, not through CI** — workflows do not run under
this account (see the GitHub Actions section of the agent profile), so a green repository with no
local run means nothing was checked. A `.pre-commit-config.yaml` with `ruff-pre-commit`
(check + format) and `mirrors-mypy` scoped `files: src` is where enforcement actually lives.
Scale the gate to what the code is: **a new owner-authored project gets all four**. A vendored
fork gets lint only — match the surrounding style, keep the patch minimal and reviewable, and
do not open a mass reformat of someone else's package (`coding-dev.md` §8 scope discipline).
---
@@ -47,6 +60,11 @@ disallow_untyped_defs = true
- `@dataclass(frozen=True, slots=True)` for value objects.
- Third-party library missing stubs: install `types-*` if it exists, otherwise a narrowly
scoped `# type: ignore[import-untyped]` with a comment — never a blanket module-level ignore.
For a **framework with no stubs at all**, the per-import ignore does not scale; use a named
`[[tool.mypy.overrides]]` block listing those modules with `ignore_missing_imports = true`
and a comment saying why, so the exemption is one reviewable list rather than scattered.
- Worth adding beyond `strict`: `no_implicit_optional`, `warn_redundant_casts`,
`warn_unused_ignores`, `warn_unused_configs`, `show_error_codes`, `allow_redefinition = false`.
### Value types over primitives
@@ -70,15 +88,32 @@ choices are:
## 3. Linting
`ruff` is the linter and formatter (it replaces flake8, isort, black, pyupgrade, and most
plugins). Enable a broad rule set rather than the default minimum:
plugins). The preferred shape is **everything on, minus a documented ignore list**:
```toml
[tool.ruff.lint]
select = ["ALL"]
ignore = [
"COM812", # conflicts with the formatter
"D203", "D213", # mutually exclusive with the docstring style chosen below
"E501", # the formatter owns line length
]
```
The explicit set below is the floor, for a codebase where `ALL` would bury real findings:
```toml
select = ["E", "F", "W", "I", "N", "UP", "B", "A", "C4", "SIM", "RUF", "ASYNC", "S", "PTH"]
```
`S` (bandit security rules) and `ASYNC` are worth the noise. As always: fix, don't suppress —
and when you must suppress, use a specific code (`# noqa: S603`) with a reason.
- `S` (bandit security rules) and `ASYNC` are worth the noise. `PTH` keeps filesystem access on
`pathlib`. Add `PLR0915` and `C901` as the function-size proxies (§8).
- **Comment the ignore list too.** Every entry gets the reason beside it, exactly as a `# noqa`
does — an uncommented ignore list is a set of rules nobody can tell were disabled on purpose.
- As always: fix, don't suppress — and when you must suppress, use a specific code
(`# noqa: S603`) with a reason.
- Do not add `black` alongside ruff. Where both appear it is an inherited upstream toolchain,
not a choice; ruff's formatter is the one to keep in new work.
---
@@ -88,8 +123,26 @@ and when you must suppress, use a specific code (`# noqa: S603`) with a reason.
Never install into the system interpreter, and never `pip install --user` as a workaround
for an immutable OS — use a container or a venv.
- `uv` is the preferred installer/resolver where free choice exists; it is dramatically faster
and lockfile-native.
- Commit the lockfile. Pin the Python version in `pyproject.toml` (`requires-python`).
and lockfile-native. **Check it exists before relying on it** (`command -v uv`) — a committed
`uv.lock` is not evidence that the tool is installed on this machine.
> [!CAUTION]
> **Bare `python` and bare `pip` can be different interpreters.** On this host `python` is the
> system 3.14 while `pip`/`pip3` on PATH are bridge wrappers into the development container's
> 3.13 — so `pip install x` followed by `python x.py` produces a `ModuleNotFoundError` that
> looks like a bad install. Always go through a project venv, or `python -m pip` with the
> interpreter named explicitly. See [`distrobox-buildbox-dev.md`](skills-private/distrobox-buildbox-dev.md).
- **Lockfile by artifact type**: an application commits its lock, a library does not. A
throwaway script directory legitimately stays on a `requirements.txt` with floor pins and no
lock at all.
- Dev dependencies go in PEP 735 `[dependency-groups] dev = [...]`. `[tool.uv]
dev-dependencies` is the older uv-specific form — fine to leave in a fork, not what new work
uses.
- Pin the Python version in `pyproject.toml` (`requires-python`). Where there is no `[project]`
table at all — a plugin or custom component rather than a package — the pin lives in ruff's
`target-version` and mypy's `python_version` instead, and **those two will drift apart unless
something checks them**; set them in the same edit.
- `pathlib.Path` over `os.path` everywhere (ruff's `PTH` rules enforce this).
---
@@ -103,8 +156,13 @@ and when you must suppress, use a specific code (`# noqa: S603`) with a reason.
- Custom exception types deriving from a single package-level base, so callers can catch the
whole family.
- **`logging` module, never `print`,** in anything that isn't a CLI writing to stdout as its
actual output. One `logger = logging.getLogger(__name__)` per module, at module scope.
actual output. One `logger = logging.getLogger(__name__)` per module, at module scope — unless
the framework owns the logger tree, in which case its prefix wins (§11).
- **Never log secrets** — tokens, auth headers, message content, personal data.
- A blanket `contextlib.suppress(Exception)` is permitted in exactly one situation: a
development-ergonomics escape hatch whose every suppression carries a written paragraph saying
what staleness or platform quirk it absorbs. The rule is not "never suppress", it is
**suppression is allowed when the comment carries the argument**.
---
@@ -114,11 +172,19 @@ and when you must suppress, use a specific code (`# noqa: S603`) with a reason.
synchronous `requests`, no blocking file I/O in a coroutine. Use `asyncio.to_thread` for
genuinely blocking calls.
- `httpx` (or the framework's own client) over `requests` in async code.
- **`asyncio.TaskGroup`** (3.11+) over bare `create_task` — it propagates exceptions and
cancels siblings instead of losing them to an unawaited task.
- **`asyncio.TaskGroup`** (3.11+) over bare `create_task` **where one failure should cancel the
rest** — it propagates exceptions instead of losing them to an unawaited task.
- **For fan-out over N external calls, TaskGroup is the wrong default**: cancel-on-first-error
means one dead provider takes down the other nine. Use a bounded helper instead — an
`asyncio.Semaphore(limit)` around each worker plus `asyncio.gather(..., return_exceptions=True)`
— and keep it in one shared function rather than re-deriving it per caller.
- **Every `create_task` result must be held in a reference**, or the garbage collector can
cancel it mid-flight.
- Cancellation is cooperative: clean up in `finally`, and never swallow `CancelledError`.
cancel it mid-flight. Clear it in `finally`.
- Cancellation is cooperative: clean up in `finally`, and never swallow `CancelledError` —
but **distinguish a cancellation you asked for from a framework shutdown**. Re-raise unless a
stop was requested; converting a real shutdown into an application error strands it.
- Where a blocking library runs **its own** background thread, don't wrap it in `to_thread` —
bridge back onto the loop with `loop.call_soon_threadsafe(future.set_result, …)`.
- **Events over polling.** Subscribe to the library's event/callback API; do not loop on a
timer. Where polling is genuinely unavoidable (no event source exists), use a **staggered
backoff** rather than a fixed interval, and say why polling was necessary.
@@ -141,7 +207,13 @@ When talking to a rate-limited API (a chat platform, a store, a tracker):
## 8. Structure
- Package layout: `src/<package>/`, tests in `tests/`.
- Package layout: `src/<package>/`, tests in `tests/`. A single-binary stdlib-only CLI may stay
flat; a plugin or component follows its framework's layout instead.
- A tool that has to run on an immutable host is worth keeping **dependency-free** where the
stdlib covers it (`argparse`, `subprocess`, `shlex`, `json`, `pathlib`) — nothing to provision
is the strongest form of "it works on that machine".
- Entry points are `[project.scripts]`. `typer` for a CLI with subcommands, stdlib `argparse`
for everything else; do not add a CLI framework to a two-flag script.
- **Shared behavior goes in a real shared module**, imported by each consumer — not
copy-pasted between sibling packages. When three components grow the same queue/retry/
formatting logic, that is the signal to extract it.
@@ -153,17 +225,91 @@ When talking to a rate-limited API (a chat platform, a store, a tracker):
function-length rule — `PLR0915` (`too-many-statements`, default 50) and `C901`
(cyclomatic complexity) are the closest proxies and are worth enabling, but neither
measures lines. Put **both** the function-length and file-length limits in a pytest
case (see [`coding-dev.md` §4](skills-public/coding-dev.md)).
case (see [`coding-dev.md` §4](skills-public/coding-dev.md)): file hard 1000 / soft 600 (soft
prints a note rather than failing), function hard 100 measured by walking the `ast` rather than
counting lines of text. **Guard the guard** with a test asserting the glob matched anything at
all — a pattern that matches nothing makes every limit vacuous and still passes.
---
## 9. Testing
- `pytest`, with `pytest-asyncio` for coroutines.
- `pytest`, with `pytest-asyncio`. Set `asyncio_mode = "auto"` in
`[tool.pytest.ini_options]` — it removes the per-test marker entirely — plus
`asyncio_default_fixture_loop_scope = "function"`.
- Fixtures over setup/teardown methods; `tmp_path` for anything touching the filesystem.
- **Regression test every fixed bug.**
- Mock at the network boundary (`respx`, `responses`), not by monkeypatching your own
internals — a test that patches the code under test proves nothing.
- **Regression test every fixed bug**, and give them a home: a `test_regressions.py` is where
they stop being scattered and start being a list.
- **Mock at a boundary, not inside your own code.** A test that patches the code under test
proves nothing. The boundary can be a network mock (`respx`, `responses`), a **real local fake
service** the test drives end to end, or **recorded payloads fed to the provider seam** — the
last two are usually better evidence than a mocked HTTP layer, because they exercise the
parsing too.
- **Never exercise destructive operations against real accounts or real data.** Temporary
fixtures only.
- `pytest -p no:randomly` only when order genuinely matters, and note why.
---
## 10. MCP Servers
> [!CAUTION]
> **On the stdio transport, stdout is the protocol.** A stray `print`, a `logging`
> `StreamHandler` pointed at stdout, or a library's progress bar corrupts the frame and the
> client sees a parse error rather than your message. Log to **stderr** only, and keep §5's
> no-`print` rule absolute here.
- Make the transport an explicit option (`--transport stdio|http`) rather than hardcoding one.
- **Tool schemas are derived from the signature and the docstring**, so §2's "every function
annotated" rule has a second payoff: an unannotated parameter produces a useless client-facing
schema. Never hand-write JSON schema.
- Set the behavioural annotations on every tool — `readOnlyHint`, `openWorldHint`, a human
`title`.
- **Errors are raised, not returned.** Raise (FastMCP serialises it) or return
`isError=True` with a `TextContent` on the low-level SDK. A tool that returns the string
`"ERROR: …"` as its normal value is indistinguishable from data to the client and defeats
its retry logic.
- `pydantic-settings` for layered config is good, but note what using a settings value as a
**tool parameter default** means: it is frozen at import time *and* published in the advertised
schema. Fine for a hostname, a secret leak for anything else.
---
## 11. Red-DiscordBot Cogs
- `self.config = Config.get_conf(self, identifier=<int>, force_registration=True)`, then
`register_global`/`register_guild` for every key. **Never a side-car JSON file** beside the cog.
- Read-modify-write goes through the context manager — `async with
self.config.guild(ctx.guild).channels() as channels:` — because a plain get/set around a
mutable list is a lost-update bug.
- Lifecycle: `async def setup(bot)` in `__init__.py`; `async def cog_load(self)` for I/O-bearing
startup; `@commands.Cog.listener()` for gateway events.
- **`cog_unload` is synchronous.** It cannot await, so it cancels held tasks and fires teardown
off into `self.bot.loop.create_task(self.api.close())`. Start long-lived tasks the same way,
on the bot's loop.
- `async def red_delete_data_for_user(self, *, _requester: str, _user_id: int) -> None` is a
mandatory contract, not optional boilerplate.
- **The logger name overrides §5**: `logging.getLogger("red.<owner>.<cog>")`, not `__name__`.
Under `__name__` the cog sits outside the `red.*` tree where Red's own log-level controls
apply, so the generic rule produces a logger nobody can turn up.
- Red loads each cog directory independently, so a cross-cog shared module is awkward by design.
The answer is a per-cog `utils/` package, not a copy-pasted `lib.py` — §8's shared-module rule
still applies *within* a cog.
---
## 12. Home Assistant Custom Components
- `from __future__ import annotations` at the top of every module.
- The `async_setup_entry` / `async_unload_entry` / `_async_update_listener` triple, with state on
**`entry.runtime_data`**, not `hass.data`.
- **Never create your own `aiohttp.ClientSession`** — take `async_get_clientsession(hass)`, or
you leak a session per reload and HA logs it.
- A `DataUpdateCoordinator[T]` subclass whose `_async_update_data` raises `UpdateFailed`. **No
provider exception may escape it.**
- Declare dependencies in `manifest.json` `requirements`, not in `pyproject.toml` — the component
is not a package.
- Fire events in size-bounded chunks with a run id in the envelope; a single huge event is not
a thing the bus handles gracefully.
- PEP 695 syntax is current here: `type MyConfigEntry = ConfigEntry[MyCoordinator]`, and
`def f[T](…)` for generics.