Skip to content

chore(devex): ratchet against eager __init__.py re-exports via semgrep - #71445

Merged
webjunkie merged 2 commits into
masterfrom
chore/devex-ban-init-reexports
Jul 17, 2026
Merged

chore(devex): ratchet against eager __init__.py re-exports via semgrep#71445
webjunkie merged 2 commits into
masterfrom
chore/devex-ban-init-reexports

Conversation

@webjunkie

Copy link
Copy Markdown
Contributor

Problem

Eager top-level re-exports in __init__.py (from .submodule import Thing) pull a package's whole submodule subtree onto the import path. Every entrypoint (web, celery, temporal, CI, shell) pays that import cost, and cross-package re-exports are a frequent source of import cycles. House style is deep imports from the defining module, but nothing stops new eager re-exports from landing.

Changes

A new severity: ERROR semgrep rule in the semgrep-devex set, run as a pure ratchet (not a burn-down):

  • Blocks any new eager top-level re-export in __init__.py.
  • Grandfathers the 60 existing occurrences via paths.exclude. There is no cleanup obligation. The list just freezes today's set so the pattern stops spreading, and shrinks only if someone happens to convert one.
  • Pattern is anchored at column 0 ((?m)^from\s+\.), so already-lazy re-exports (TYPE_CHECKING, try/except ImportError, function-local) are indented and never flagged. No allowlist to maintain for them.
  • The "deemed alright" boundaries are exempt by path, not file lists, so they stay self-maintaining: Django model aggregators (**/models/), facades (**/facade/, the import-linter-enforced boundary), Celery autoimport (**/tasks/), Django admin (**/admin/), Temporal worker wiring (**/temporal/).

No CI wiring needed. The semgrep-devex job already scans .semgrep/rules/devex/.

Note

This is a file-level ratchet, matching the existing no-environments-api-urls-frontend rule. It stops the pattern reaching new packages, but a grandfathered file is not frozen at its current occurrence count. Occurrence-level guarding would need semgrep --baseline-commit in the shared devex job, which is out of scope here.

How did you test this code?

I (Claude) ran the rule locally with semgrep 1.161.0 over the exact CI scope (bin common ee frontend packages posthog products). No manual product testing applies, this is a lint rule.

  • ERROR pass: green, 0 findings.
  • Synthetic new top-level from .x import Y in a non-exempt path: flagged.
  • TYPE_CHECKING / function-local / excluded-path re-exports: not flagged.
  • Whole .semgrep/rules/devex/ set still parses (semgrep --validate).
  • Survived the lint-staged format:yaml pass with the regex and anchor intact.

🤖 Agent context

Autonomy: Human-driven (agent-assisted)

Julian directed this; I (Claude) built it. It started as an exploration of whether to lint against __init__.py re-exports. I measured the scope first (138 re-exporting __init__.py repo-wide, and confirmed the nesting is shallow, mostly single-level), then landed on a ratchet rather than a burn-down at Julian's direction: existing usage grandfathered, new usage blocked, curated boundaries exempt by path.

Two decisions worth flagging for review:

  • Exempt-by-path, not by listing. Model/facade/tasks/admin/temporal boundaries are globbed so new packages in those categories stay covered without editing the rule. Only the genuine backlog is an explicit list, and it was generated empirically from semgrep's own output rather than hand-authored.
  • Deliberately not ratcheting __all__. The harmful __all__ only exists to silence F401 on eager re-exports, so it is already capped indirectly by this rule. A direct __all__ ban would wrongly hit the lazy PEP 562 __getattr__ pattern we want to encourage.

No repo skills were invoked. /simplify was run on the diff (clean).

Eager top-level re-exports in __init__.py pull a package's whole submodule
subtree onto the import path, so every entrypoint (web, celery, temporal, CI,
shell) pays the cost, and cross-package re-exports are a common source of import
cycles. House style is deep imports from the defining module; convenience
aliases should be lazy (PEP 562 __getattr__) or TYPE_CHECKING-only.

New ERROR rule in the semgrep-devex set, run purely as a ratchet: it blocks new
packages from adopting the pattern while grandfathering every existing occurrence
under paths.exclude (60 files). No burn-down is implied or required — the list
just freezes today's set so master stays green and the pattern stops spreading.

The pattern is anchored at column 0 ((?m)^from\s+\.), so only eager top-level
re-exports match; indented ones (TYPE_CHECKING, try/except ImportError,
function-local) are already lazy and skipped with no allowlist to maintain.

The "deemed alright" boundaries are exempt by path rather than listed
individually, so they stay self-maintaining: Django model aggregators
(**/models/), facades (**/facade/ — the import-linter-enforced boundary), Celery
autoimport (**/tasks/), Django admin (**/admin/), and Temporal worker wiring
(**/temporal/).
@webjunkie webjunkie added the skip-agent-review Save $$$, skip auto agent reviews (Greptile) — use for trivial or chore PRs label Jul 16, 2026
@webjunkie webjunkie self-assigned this Jul 16, 2026
@webjunkie
webjunkie marked this pull request as ready for review July 16, 2026 09:19
@webjunkie
webjunkie requested review from a team and Copilot July 16, 2026 09:19

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds a new devex Semgrep rule to prevent introducing new eager, top-level relative imports in __init__.py (a common source of import-time cost and import cycles), while grandfathering existing occurrences via paths.exclude and exempting specific “boundary” directories by path.

Changes:

  • Introduce no-init-reexports (severity ERROR) scanning **/__init__.py for column-0 from .… imports.
  • Exempt known intentional boundaries (e.g. **/models/**, **/facade/**, **/tasks/__init__.py, **/admin/__init__.py, **/temporal/**) and freeze the current baseline via explicit excludes.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +26 to +28
# existing ruff exemptions (settings star-imports, generated stubs)
- 'posthog/settings/**'
- '**/generated/**'

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: dae3f09acc

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +102 to +104
# Column-0 anchor: only flag eager, top-level re-exports. Indented ones (TYPE_CHECKING,
# try/except ImportError, function-local) are already lazy and intentionally skipped.
pattern-regex: '(?m)^from\s+\.'

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Catch eager imports inside module-level blocks

When a new __init__.py wraps a re-export in a module-level block such as try: from .heavy import Heavy or if optional: from .heavy import Heavy, this regex misses it solely because the import is indented, even though Python still executes it during package import and pays the same eager-import/cycle cost the rule is trying to ratchet against. Either extend the rule to distinguish module-level blocks from truly lazy locations, or narrow this comment so try/except ImportError is not treated as an allowed lazy pattern.

Useful? React with 👍 / 👎.

@webjunkie
webjunkie enabled auto-merge (squash) July 17, 2026 15:48
@webjunkie
webjunkie merged commit 0ec503b into master Jul 17, 2026
175 checks passed
@webjunkie
webjunkie deleted the chore/devex-ban-init-reexports branch July 17, 2026 16:00
@deployment-status-posthog

deployment-status-posthog Bot commented Jul 17, 2026

Copy link
Copy Markdown

Deploy status

Environment Status Deployed At Workflow
dev ✅ Deployed 2026-07-17 16:30 UTC Run
prod-us ✅ Deployed 2026-07-17 16:50 UTC Run
prod-eu ✅ Deployed 2026-07-17 16:50 UTC Run

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

skip-agent-review Save $$$, skip auto agent reviews (Greptile) — use for trivial or chore PRs

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants