Skip to content

Fix NG subsystem mypy plugin#23334

Open
benjyw wants to merge 1 commit into
pantsbuild:mainfrom
benjyw:fix-subsystem-mypy-plugin
Open

Fix NG subsystem mypy plugin#23334
benjyw wants to merge 1 commit into
pantsbuild:mainfrom
benjyw:fix-subsystem-mypy-plugin

Conversation

@benjyw
Copy link
Copy Markdown
Contributor

@benjyw benjyw commented May 8, 2026

The previous implementation failed if the subsystem
extended an intermediate SubsystemNg subclass
instead of SubsystemNg directly.

This change switches to get_base_class_hook, so that
setting abstract_status = IS_ABSTRACT on the
underlying FuncDef sticks when the type checker runs.

…dditional_deps

The previous implementation piggybacked on get_additional_deps to modify
abstract_status on FuncDef nodes for @option-decorated methods. This hook
fires during import resolution, before mypy's semantic analysis passes run.
Because mypy's semantic analyzer processes FuncDef nodes after this hook,
any modifications made there do not persist into the type-checking phase,
so the [empty-body] errors were never suppressed.

Switch to get_base_class_hook, which fires during semantic analysis (after
the class's own semanal pass), so setting abstract_status = IS_ABSTRACT
on the underlying FuncDef sticks when the type checker runs.

Since get_base_class_hook matches only direct base classes, enumerate the
known levels of the SubsystemNg hierarchy explicitly: SubsystemNg itself,
UniversalSubsystem, ContextualSubsystem, and GoalSubsystemNg. Any new
intermediate base class added to the hierarchy will need to be listed here.
@benjyw benjyw added the release-notes:not-required [CI] PR doesn't require mention in release notes label May 8, 2026
@benjyw
Copy link
Copy Markdown
Contributor Author

benjyw commented May 8, 2026

AI Disclosure: Claude discovered the cause, suggested a fix (which I simplified a bit) and generated a test (which I simplified a lot).

@benjyw benjyw requested a review from tdyas May 8, 2026 17:58


# NB: If we create more intermediate subclasses, they must be added here.
_SUBSYSTEM_BASE_CLASSES = frozenset(
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.

Can these be discovered by the plugin from semantic analysis in a performant manner?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I don't think that can be done reliably, let alone performantly?

Copy link
Copy Markdown
Contributor

@tdyas tdyas May 9, 2026

Choose a reason for hiding this comment

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

I asked Codex and it suggests looking at ctx.cls.info.mro to access the MRO for the class:

def _is_subsystem_ng(ctx: ClassDefContext) -> bool:
      return any(info.fullname == _SUBSYSTEM_ROOT for info in ctx.cls.info.mro)

Open question whether that is performant enough.

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.

Yes, but not with the current get_base_class_hook(fullname) shape alone.

get_base_class_hook() only receives the direct base class fullname being analyzed, so for:

class FooSubsystem(GoalSubsystemNg):
...

mypy asks the plugin about pants.ng.goal.GoalSubsystemNg, not about its transitive base
SubsystemNg. That is why _SUBSYSTEM_BASE_CLASSES currently has to include intermediate
classes like GoalSubsystemNg, UniversalSubsystem, and ContextualSubsystem.

A dynamic version is possible by making the hook run more broadly, then filtering inside the
callback using the class MRO:

  _SUBSYSTEM_ROOT = "pants.ng.subsystem.SubsystemNg"

  def _is_subsystem_ng(ctx: ClassDefContext) -> bool:
      return any(info.fullname == _SUBSYSTEM_ROOT for info in ctx.cls.info.mro)

  def _process_subclass_hook(ctx: ClassDefContext) -> None:
      if _is_subsystem_ng(ctx):
          _process_defns(ctx.cls.defs.body)

  class SubsystemPlugin(Plugin):
      def get_base_class_hook(self, fullname: str):
          return _process_subclass_hook

That would infer intermediate subclasses dynamically from mypy’s semantic model, avoiding
the manually maintained _SUBSYSTEM_BASE_CLASSES.

Tradeoff: returning a hook for every base class means the callback runs for many non-
subsystem classes, and possibly more than once for classes with multiple bases. The callback
is cheap and idempotent enough here, but I’d add a guard such as metadata on ctx.cls.info if
duplicate processing becomes a concern.

Copy link
Copy Markdown
Contributor

@tdyas tdyas May 9, 2026

Choose a reason for hiding this comment

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

Maybe add a conformance check in the plugin for all direct subclasses of pants.ng.subsystem.SubsystemNg that they are listed in _SUBSYSTEM_BASE_CLASSES?

Basically, it would be better if the plugin either discovers the contents of _SUBSYSTEM_BASE_CLASSES or otherwise flags an error when items are missing from it.

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

Labels

release-notes:not-required [CI] PR doesn't require mention in release notes

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants