feat(no-private-imports): приватным считается любой сегмент пути импорта #25

Merged
claude-secretary merged 1 commit from fix/private-module-in-path into main 2026-08-27 14:03:50 +07:00

Закрывает #19.

Асимметрия, которую видно невооружённым глазом

Проверялось импортируемое имя, но не путь:

from . import _private          # FAIL
from ._private import public    # проходил молча ← строго сильнее первого
import pkg._private.sub         # проходил молча ← приватен пакет в середине

Теперь приватен любой сегмент: сегменты node.module у ImportFrom (включая относительные) и все сегменты пути у Import.

Звёздочка проверяется наполовину: from mypkg.public import * чист, а from mypkg._internal import * — нарушение. Путь смотрится как обычно, а имена, приезжающие из звёздочки, — нет: что там придёт, знает только импортируемый модуль через свой __all__.

Замер дельты и что он на самом деле значит

Дерево Файлов old new Δ
personal/baton backend 1850 54 54 +0
/usr/lib/python3/dist-packages 3259 486 2224 +1738

Первая редакция этого PR утверждала, что +1738 — это «чужой вендоринг, ровно тот класс, который правило и называет пробоем инкапсуляции». Это было неверно, и ревью справедливо на этом настояло. Я классифицировал каждое новое срабатывание по node.level и по тому, совпадает ли верхний сегмент пути с пакетом самого файла:

Класс Шт. Доля Пример
Свой пакет, абсолютный импорт 1294 72% blinker/base.py: from blinker._utilities import …
Свой пакет, относительный импорт 438 24% PIL/PcxImagePlugin.py: from ._binary import i16le
Чужие внутренности 74 4% attrs/__init__.py: from attr._next_gen import asdict

То есть 96% новых срабатываний — пакет, читающий собственные внутренности, а не пробой чужой инкапсуляции. Для C-расширений эта форма вообще безальтернативна: скомпилированный модуль по конвенции зовётся _rust/_sodium/_speedups, и pure-python обёртка обязана его импортировать.

Почему относительные импорты всё же не получают поблажки

Не потому, что это «пробой» — относительный импорт по определению не выходит за пределы своего пакета, и владелец приватного модуля тут же и нарушитель. Причина другая, и она структурная: в наших репозиториях такого класса не возникает, потому что соседний хук no-underscore-filenames запрещает _*.py вовсе. На baton не-dunder _-модулей ноль штук из 1850 файлов — отсюда и +0, это не везение.

Поблажка для level > 0 вернула бы ровно ту асимметрию, ради которой заводился #19: from . import _private падает, а строго более сильное from ._private import x — нет.

Консьюмеру, который включает no-private-imports без no-underscore-filenames, правило обойдётся дорого, и точечно это не гасится: exclude: в pre-commit фильтрует по пути файла, а не по строке — исключать придётся файл целиком. Записано в README прямым текстом, а не спрятано.

Allowlist

_thread (документированный модуль stdlib) и _typeshed (from _typeshed import Incomplete — рекомендованная mypy идиома). Оба до этой правки краснели, и это был ложняк на корректном коде. Приватные модули библиотек (pip._internal, nacl._sodium) в allowlist не идут: там подчёркивание значит ровно то, что значит.

Проверка

Фикстуры: module_in_path.py, middle_segment.py, relative_module.py, star_private_path.py, public_path.py (все сегменты публичны либо в allowlist — включая from __future__ import annotations, _typeshed, _thread и from . import sibling, где module пуст).

Мутации, каждая роняет smoke: вернуть проверку последнего сегмента у Import, перестать смотреть node.module, игнорировать allowlist, снять dunder-исключение. Последняя красит from __future__ import annotations — фикстура добавлена по ревью: dunder-предикат теперь несёт новую нагрузку (проверяет сегменты пути), и его регрессия покрасила бы буквально каждый файл консьюмера.

Версия

0.18.00.20.0. Мержить после #24 (0.19.0) — иначе он понизит версию при мерже; конфликты по трём версионным строкам разрешу при ребейзе.

Закрывает [#19](https://git.homedevlab.ru/senokosov/pre-commit-hooks/issues/19). ## Асимметрия, которую видно невооружённым глазом Проверялось импортируемое имя, но не путь: ```python from . import _private # FAIL from ._private import public # проходил молча ← строго сильнее первого import pkg._private.sub # проходил молча ← приватен пакет в середине ``` Теперь приватен **любой** сегмент: сегменты `node.module` у `ImportFrom` (включая относительные) и все сегменты пути у `Import`. Звёздочка проверяется наполовину: `from mypkg.public import *` чист, а `from mypkg._internal import *` — нарушение. Путь смотрится как обычно, а имена, приезжающие из звёздочки, — нет: что там придёт, знает только импортируемый модуль через свой `__all__`. ## Замер дельты и что он на самом деле значит | Дерево | Файлов | old | new | Δ | |---|---|---|---|---| | `personal/baton` backend | 1850 | 54 | 54 | **+0** | | `/usr/lib/python3/dist-packages` | 3259 | 486 | 2224 | +1738 | **Первая редакция этого PR утверждала, что +1738 — это «чужой вендоринг, ровно тот класс, который правило и называет пробоем инкапсуляции». Это было неверно**, и ревью справедливо на этом настояло. Я классифицировал каждое новое срабатывание по `node.level` и по тому, совпадает ли верхний сегмент пути с пакетом самого файла: | Класс | Шт. | Доля | Пример | |---|---|---|---| | Свой пакет, абсолютный импорт | 1294 | 72% | `blinker/base.py: from blinker._utilities import …` | | Свой пакет, относительный импорт | 438 | 24% | `PIL/PcxImagePlugin.py: from ._binary import i16le` | | Чужие внутренности | 74 | **4%** | `attrs/__init__.py: from attr._next_gen import asdict` | То есть 96% новых срабатываний — **пакет, читающий собственные внутренности**, а не пробой чужой инкапсуляции. Для C-расширений эта форма вообще безальтернативна: скомпилированный модуль по конвенции зовётся `_rust`/`_sodium`/`_speedups`, и pure-python обёртка обязана его импортировать. ## Почему относительные импорты всё же не получают поблажки Не потому, что это «пробой» — относительный импорт по определению не выходит за пределы своего пакета, и владелец приватного модуля тут же и нарушитель. Причина другая, и она структурная: **в наших репозиториях такого класса не возникает**, потому что соседний хук `no-underscore-filenames` запрещает `_*.py` вовсе. На baton не-dunder `_`-модулей ноль штук из 1850 файлов — отсюда и +0, это не везение. Поблажка для `level > 0` вернула бы ровно ту асимметрию, ради которой заводился #19: `from . import _private` падает, а строго более сильное `from ._private import x` — нет. Консьюмеру, который включает `no-private-imports` **без** `no-underscore-filenames`, правило обойдётся дорого, и точечно это не гасится: `exclude:` в pre-commit фильтрует по пути файла, а не по строке — исключать придётся файл целиком. Записано в README прямым текстом, а не спрятано. ## Allowlist `_thread` (документированный модуль stdlib) и `_typeshed` (`from _typeshed import Incomplete` — рекомендованная mypy идиома). Оба до этой правки краснели, и это был ложняк на корректном коде. Приватные модули библиотек (`pip._internal`, `nacl._sodium`) в allowlist не идут: там подчёркивание значит ровно то, что значит. ## Проверка Фикстуры: `module_in_path.py`, `middle_segment.py`, `relative_module.py`, `star_private_path.py`, `public_path.py` (все сегменты публичны либо в allowlist — включая `from __future__ import annotations`, `_typeshed`, `_thread` и `from . import sibling`, где `module` пуст). Мутации, каждая роняет smoke: вернуть проверку последнего сегмента у `Import`, перестать смотреть `node.module`, игнорировать allowlist, снять dunder-исключение. Последняя красит `from __future__ import annotations` — фикстура добавлена по ревью: dunder-предикат теперь несёт новую нагрузку (проверяет сегменты пути), и его регрессия покрасила бы буквально каждый файл консьюмера. ## Версия `0.18.0` → **`0.20.0`**. Мержить после [#24](https://git.homedevlab.ru/senokosov/pre-commit-hooks/pulls/24) (`0.19.0`) — иначе он понизит версию при мерже; конфликты по трём версионным строкам разрешу при ребейзе.
feat(no-private-imports): приватным считается любой сегмент пути импорта
All checks were successful
ci / smoke (push) Successful in 16s
ci / smoke (pull_request) Successful in 16s
6d8a6418f9
Закрывает hooks#19.

Проверялось импортируемое имя, но не путь, и правило выходило
асимметричным: `from . import _private` падало, а строго более сильное
`from ._private import public` проходило молча. Так же молча проезжал
`import pkg._private.sub` — смотрелся только последний сегмент, а
приватен пакет в середине.

Теперь приватен любой сегмент: сегменты node.module у ImportFrom
(включая относительные — там module и есть внутренность пакета) и все
сегменты пути у Import. `from x import *` по-прежнему мимо: что приедет
из звёздочки, знает только импортируемый модуль.

Поблажки внутрипакетным импортам нет намеренно: `from ._helpers import
build_payload` — FAIL. Если модуль скрыт подчёркиванием, его содержимое
не интерфейс даже для соседей по пакету; где стиль принят осознанно,
гасится exclude у консьюмера.

Замер дельты (old → new, файлов / срабатываний):
  personal/baton backend   1850 файлов   54 → 54   (+0)
  /usr/lib/python3/…       3259 файлов  486 → 2224 (+1738)

Ноль на своём дереве и лавина на чужих пакетах — ожидаемая картина:
+1738 это pip._vendor, pip._internal, nacl._sodium и относительные
импорты внутри сторонних библиотек, то есть ровно тот класс, который
правило и называет пробоем инкапсуляции. Консьюмерам volody изменение
обходится бесплатно.

Мутации, каждая роняет smoke: вернуть проверку последнего сегмента у
Import, перестать смотреть node.module.

Версия 0.18.0 → 0.20.0 (0.19.0 занята PR #24).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
claude-secretary force-pushed fix/private-module-in-path from 6d8a6418f9
All checks were successful
ci / smoke (push) Successful in 16s
ci / smoke (pull_request) Successful in 16s
to d6a8d271d2
All checks were successful
ci / smoke (pull_request) Successful in 16s
ci / smoke (push) Successful in 16s
2026-08-27 13:40:30 +07:00
Compare
Author
Owner

Ревью (субагент, свежий контекст): код APPROVE, блокеры — по тексту и порядку мержа. Классификацию дельты воспроизвёл независимо, ревью право: 1294 своих абсолютных + 438 своих относительных + 74 чужих. Диспозиция:

Блокеры — исправлены:

  • B1 (неверная интерпретация +1738). Тело PR и commit message переписаны: 96% новых срабатываний — пакет, читающий собственные внутренности, а не пробой чужой инкапсуляции. Таблица классов приведена в теле. Пример uaclient…_is_attached из описания убран — он и старой версией ловился, в дельту не входит.
  • B2 (exclude: продавался дешевле, чем есть). README теперь говорит прямо: exclude: фильтрует по пути файла, а не по строке, точечно такой импорт не гасится. Настоящая причина +0 на baton названа: соседний no-underscore-filenames запрещает _*.py, поэтому класса внутрипакетных импортов там нет структурно, а не по везению. Консьюмеру без этого хука цена высокая — так и написано.
  • B3 (порядок мержа). Мержу после #24, с ребейзом и разрешением трёх версионных конфликтов. Тег v0.20.0 проставлю после мержа — v0.10.0v0.18.0 уже на remote.

Ниты — исправлены:

  • README.md:107 («приватным делает последний сегмент») — снято, противоречило соседнему абзацу.
  • Формулировка про import * уточнена в README и докстринге: путь до звёздочки проверяется, имена из неё — нет. Добавлен кейс private_imports_star_private_path_fail.
  • .pre-commit-hooks.yaml — description хука описывает новую семантику: консьюмер видит именно его в выводе pre-commit.
  • Устаревший docstring fixtures/private_imports/relative.py.
  • Фикстура на from __future__ import annotations — принято как есть: dunder-предикат теперь проверяет и сегменты пути, и его регрессия покрасила бы каждый файл консьюмера. Мутация «снять dunder-исключение» теперь роняет и этот кейс.
  • Пины строк-улик у middle_segment и relative_module.

_typeshed / _thread — сделано сразу, а не follow-up'ом. Это единственный класс, где краснел корректный код не своего пакета, причём from _typeshed import Incomplete — идиома, рекомендованная самим mypy. Заведён точечный ALLOWED_MODULES — ровно по образцу ALLOWED_MARKS в no-session-marks-in-code: только документированные значения, правило не сужается. Мутация «игнорировать allowlist» роняет private_imports_public_path.

Ревью (субагент, свежий контекст): код APPROVE, блокеры — по тексту и порядку мержа. Классификацию дельты воспроизвёл независимо, ревью право: 1294 своих абсолютных + 438 своих относительных + 74 чужих. Диспозиция: **Блокеры — исправлены:** - **B1** (неверная интерпретация +1738). Тело PR и commit message переписаны: 96% новых срабатываний — пакет, читающий собственные внутренности, а не пробой чужой инкапсуляции. Таблица классов приведена в теле. Пример `uaclient…_is_attached` из описания убран — он и старой версией ловился, в дельту не входит. - **B2** (`exclude:` продавался дешевле, чем есть). README теперь говорит прямо: `exclude:` фильтрует по пути файла, а не по строке, точечно такой импорт не гасится. Настоящая причина `+0` на baton названа: соседний `no-underscore-filenames` запрещает `_*.py`, поэтому класса внутрипакетных импортов там нет структурно, а не по везению. Консьюмеру без этого хука цена высокая — так и написано. - **B3** (порядок мержа). Мержу после [#24](https://git.homedevlab.ru/senokosov/pre-commit-hooks/pulls/24), с ребейзом и разрешением трёх версионных конфликтов. Тег `v0.20.0` проставлю после мержа — `v0.10.0`–`v0.18.0` уже на remote. **Ниты — исправлены:** - `README.md:107` («приватным делает последний сегмент») — снято, противоречило соседнему абзацу. - Формулировка про `import *` уточнена в README и докстринге: путь до звёздочки проверяется, имена из неё — нет. Добавлен кейс `private_imports_star_private_path_fail`. - `.pre-commit-hooks.yaml` — description хука описывает новую семантику: консьюмер видит именно его в выводе pre-commit. - Устаревший docstring `fixtures/private_imports/relative.py`. - Фикстура на `from __future__ import annotations` — принято как есть: dunder-предикат теперь проверяет и сегменты пути, и его регрессия покрасила бы каждый файл консьюмера. Мутация «снять dunder-исключение» теперь роняет и этот кейс. - Пины строк-улик у `middle_segment` и `relative_module`. **`_typeshed` / `_thread` — сделано сразу, а не follow-up'ом.** Это единственный класс, где краснел корректный код *не своего* пакета, причём `from _typeshed import Incomplete` — идиома, рекомендованная самим mypy. Заведён точечный `ALLOWED_MODULES` — ровно по образцу `ALLOWED_MARKS` в `no-session-marks-in-code`: только документированные значения, правило не сужается. Мутация «игнорировать allowlist» роняет `private_imports_public_path`.
claude-secretary force-pushed fix/private-module-in-path from d6a8d271d2
All checks were successful
ci / smoke (pull_request) Successful in 16s
ci / smoke (push) Successful in 16s
to 9323ec8799
All checks were successful
ci / smoke (push) Successful in 17s
ci / smoke (pull_request) Successful in 17s
2026-08-27 14:03:23 +07:00
Compare
claude-secretary deleted branch fix/private-module-in-path 2026-08-27 14:03:50 +07:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
senokosov/pre-commit-hooks!25
No description provided.