no-private-imports: приватный модуль в пути импорта не ловится (from ._private import public) #19

Closed
opened 2026-08-27 12:01:30 +07:00 by claude-secretary · 1 comment

Найдено ревью #13. В самом PR не чинилось: там правился разбор, а это расширение охвата, и его цена — новая волна срабатываний у консьюмеров, которую надо сначала замерить.

Что не ловится

scripts/check_no_private_imports.py смотрит импортируемое имя (node.names), но не путь (node.module):

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

Пробой инкапсуляции здесь ровно тот же — приватным помечен модуль, а не символ, и импортирующий одинаково лезет во внутренности пакета. Асимметрия при этом видна невооружённым глазом: from . import _private падает, а from ._private import x — нет, хотя второе строго сильнее.

Регрессии нет: построчная регулярка тоже мимо. Но пока это не покрыто, волны долга у консьюмеров (backend#471) считаются меньше реального, и «зелёный» после волны не означает, что приватных зависимостей не осталось.

Что нужно

  • Проверять сегменты node.module у ImportFrom и все сегменты пути у Import, а не только последний.
  • Замерить дельту на живых деревьях (backend, dispatcher) до включения — это поведенческое изменение, ему нужен minor bump и предупреждение потребителям, как было с многострочными импортами.
  • Решить судьбу однобуквенных внутренних пакетов вида pkg._vendor.…: скорее всего попадают под то же правило, но проверить на реальных путях стоит.
  • Снять из README абзац «Чего хук не видит» в части модуля (no-private-imports), когда починится.

Приёмка

  • from ._private import public, from pkg._private.sub import x, import pkg._private.sub — FAIL
  • from . import public, import pkg.public.sub — чисто
  • Фикстуры + smoke-кейсы; мутация «смотреть только последний сегмент» роняет smoke
  • Дельта по консьюмерам приведена в PR, README обновлён
Найдено ревью [#13](https://git.homedevlab.ru/senokosov/pre-commit-hooks/pulls/13). В самом PR не чинилось: там правился разбор, а это расширение охвата, и его цена — новая волна срабатываний у консьюмеров, которую надо сначала замерить. ## Что не ловится `scripts/check_no_private_imports.py` смотрит импортируемое **имя** (`node.names`), но не путь (`node.module`): ```python from . import _private # FAIL — ловится from ._private import public # проходит молча import pkg._private.sub # проходит молча (ловится только последний сегмент) ``` Пробой инкапсуляции здесь ровно тот же — приватным помечен модуль, а не символ, и импортирующий одинаково лезет во внутренности пакета. Асимметрия при этом видна невооружённым глазом: `from . import _private` падает, а `from ._private import x` — нет, хотя второе строго сильнее. Регрессии нет: построчная регулярка тоже мимо. Но пока это не покрыто, волны долга у консьюмеров (`backend#471`) считаются меньше реального, и «зелёный» после волны не означает, что приватных зависимостей не осталось. ## Что нужно - Проверять сегменты `node.module` у `ImportFrom` и все сегменты пути у `Import`, а не только последний. - Замерить дельту на живых деревьях (backend, dispatcher) до включения — это поведенческое изменение, ему нужен minor bump и предупреждение потребителям, как было с многострочными импортами. - Решить судьбу однобуквенных внутренних пакетов вида `pkg._vendor.…`: скорее всего попадают под то же правило, но проверить на реальных путях стоит. - Снять из README абзац «Чего хук не видит» в части модуля (`no-private-imports`), когда починится. ## Приёмка - [ ] `from ._private import public`, `from pkg._private.sub import x`, `import pkg._private.sub` — FAIL - [ ] `from . import public`, `import pkg.public.sub` — чисто - [ ] Фикстуры + smoke-кейсы; мутация «смотреть только последний сегмент» роняет smoke - [ ] Дельта по консьюмерам приведена в PR, README обновлён
Author
Owner

Закрываю: сделано в #25, выпущено тегом v0.20.0.

from ._private import public, from pkg._private.sub import x и import pkg._private.sub теперь FAIL; from . import public и import pkg.public.sub чисты. Мутация «смотреть только последний сегмент» роняет smoke.

Дельта замерена и приведена в PR — но с поправкой, которую внесло ревью. Первая редакция утверждала, что +1738 срабатываний на /usr/lib/python3/dist-packages это «чужой вендоринг, ровно тот класс, ради которого правило заведено». Проверка по node.level и принадлежности пакету показала обратное: 96% — пакет, читающий собственные внутренности (PIL._binary, blinkerblinker._utilities, naclnacl._sodium), и лишь 4% — чужие внутренности. Для C-расширений эта форма вообще безальтернативна.

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

Заодно закрыт ложняк, найденный ревью: from _typeshed import Incomplete (идиома, рекомендованная mypy) и import _thread краснели на корректном коде. Заведён точечный ALLOWED_MODULES по образцу ALLOWED_MARKS — только документированные модули, правило не сужается.

Закрываю: сделано в [#25](https://git.homedevlab.ru/senokosov/pre-commit-hooks/pulls/25), выпущено тегом `v0.20.0`. `from ._private import public`, `from pkg._private.sub import x` и `import pkg._private.sub` теперь FAIL; `from . import public` и `import pkg.public.sub` чисты. Мутация «смотреть только последний сегмент» роняет smoke. Дельта замерена и приведена в PR — но с поправкой, которую внесло ревью. Первая редакция утверждала, что +1738 срабатываний на `/usr/lib/python3/dist-packages` это «чужой вендоринг, ровно тот класс, ради которого правило заведено». Проверка по `node.level` и принадлежности пакету показала обратное: **96% — пакет, читающий собственные внутренности** (`PIL` → `._binary`, `blinker` → `blinker._utilities`, `nacl` → `nacl._sodium`), и лишь 4% — чужие внутренности. Для C-расширений эта форма вообще безальтернативна. Поэтому обоснование в README переписано на настоящее: относительные импорты не получают поблажки не потому, что это «пробой инкапсуляции» (относительный импорт по определению не выходит за свой пакет), а потому что в наших репозиториях такого класса не возникает — соседний `no-underscore-filenames` запрещает `_*.py`, и на baton не-dunder приватных модулей ноль. Отсюда и `+0` — структурно, а не по везению. Консьюмеру без этого хука цена высокая, и там же сказано, что точечно это не гасится: `exclude:` фильтрует по пути файла, а не по строке. Заодно закрыт ложняк, найденный ревью: `from _typeshed import Incomplete` (идиома, рекомендованная mypy) и `import _thread` краснели на корректном коде. Заведён точечный `ALLOWED_MODULES` по образцу `ALLOWED_MARKS` — только документированные модули, правило не сужается.
Sign in to join this conversation.
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#19
No description provided.