Skip to content

feat(project): add ProjectSpec hub for markers and PluginManager - #10

Open
RonnyPfannschmidt wants to merge 1 commit into
refactor/hookcaller-and-executionfrom
refactor/project-spec
Open

feat(project): add ProjectSpec hub for markers and PluginManager#10
RonnyPfannschmidt wants to merge 1 commit into
refactor/hookcaller-and-executionfrom
refactor/project-spec

Conversation

@RonnyPfannschmidt

@RonnyPfannschmidt RonnyPfannschmidt commented Jul 24, 2026

Copy link
Copy Markdown
Owner

Review PR — step 6 of 7.

This PR targets the previous step's branch, so its diff is only this step's change. Review happens here. The corresponding upstream PR, which is the one that actually merges, is pytest-dev#709.

Merges happen upstream one step at a time, bottom-up. When step 6 lands upstream, this PR is closed and the rest of the stack is rebased onto the new main.

Step Branch Review (downstream) Merge (upstream)
1 refactor/split-hook-modules #6 pytest-dev#703
2 refactor/configuration-objects #5 pytest-dev#704
3 refactor/markers-attach-config #7 pytest-dev#706
4 refactor/hookimpl-wrapper-types #8 pytest-dev#707
5 refactor/hookcaller-and-execution #9 pytest-dev#708
6 refactor/project-spec #10 pytest-dev#709
7 refactor/async-submitter #11 pytest-dev#710

Chain step 06 of the internal-refactoring series (design/06-project-spec.md).

ProjectSpec bundles hookspec/hookimpl markers and plugin manager creation under one project name; markers and PluginManager accept str | ProjectSpec (strings keep working).

Stacked on the step-05 chain PR.

🤖 Generated with Claude Code

Summary by Sourcery

Add a ProjectSpec abstraction that unifies project-level hook markers and plugin manager creation while keeping existing string-based APIs compatible.

New Features:

  • Introduce a ProjectSpec hub that centralizes hook markers and plugin manager creation for a named pluggy project
  • Allow HookspecMarker, HookimplMarker, and PluginManager to be constructed with either a project name string or a ProjectSpec instance

Enhancements:

  • Expose ProjectSpec from the top-level pluggy package for easier access by consumers

Documentation:

  • Add API reference and changelog entry documenting the new ProjectSpec abstraction and its version introduction

Tests:

  • Add comprehensive tests covering ProjectSpec behavior, integration with markers and PluginManager, and string vs ProjectSpec construction paths

@sourcery-ai

sourcery-ai Bot commented Jul 24, 2026

Copy link
Copy Markdown

Reviewer's Guide

Introduce a ProjectSpec configuration hub that centralizes project name, hook markers, and plugin manager creation, and update existing markers and PluginManager to accept either a string project name or a ProjectSpec instance while keeping string-based usage fully backward compatible.

Sequence diagram for using ProjectSpec to configure markers and PluginManager

sequenceDiagram
    actor User
    participant ProjectSpec
    participant HookspecMarker
    participant PluginManager

    User->>ProjectSpec: __init__(project_name)
    ProjectSpec->>ProjectSpec: create hookspec and hookimpl

    User->>ProjectSpec: hookspec
    ProjectSpec->>HookspecMarker: __init__(ProjectSpec)
    HookspecMarker-->>User: decorator

    User->>ProjectSpec: create_plugin_manager()
    ProjectSpec->>PluginManager: __init__(ProjectSpec)
    PluginManager-->>User: PluginManager instance

    User->>PluginManager: add_hookspecs(decorated_function)
Loading

File-Level Changes

Change Details Files
Add ProjectSpec class as a central hub for project configuration, hook markers, and plugin manager creation.
  • Define ProjectSpec with a canonical project_name and configurable plugin_manager_cls
  • Instantiate shared HookspecMarker and HookimplMarker bound to the ProjectSpec instance
  • Provide create_plugin_manager factory method that returns new PluginManager instances wired to the ProjectSpec
  • Expose helper methods to retrieve hookspec and hookimpl configurations from decorated functions
  • Implement a stable repr for ProjectSpec instances
src/pluggy/_project.py
testing/test_project_spec.py
Update HookspecMarker and HookimplMarker to work with ProjectSpec while preserving the project_name-based interface.
  • Change marker slots to store a _project_spec reference instead of raw project_name
  • Allow marker constructors to accept either str or ProjectSpec and normalize str to a new ProjectSpec instance
  • Expose project_name as a read-only property derived from the associated ProjectSpec
  • Verify behavior via tests ensuring markers accept both strings and ProjectSpec and expose consistent project_name
src/pluggy/_decorators.py
testing/test_project_spec.py
Update PluginManager to accept ProjectSpec and integrate with the new configuration hub without breaking existing string usage.
  • Change PluginManager.init to accept project_name as str
ProjectSpec and normalize str to a ProjectSpec instance
  • Store the canonical ProjectSpec in a new _project_spec attribute
  • Expose project_name as a read-only property sourced from the ProjectSpec
  • Add tests verifying PluginManager works with both ProjectSpec and raw strings and that multiple managers can be created independently from the same ProjectSpec
  • Public API and documentation surface updated to expose ProjectSpec and announce the new capability.
    • Export ProjectSpec from the top-level pluggy package
    • Add ProjectSpec-related tests demonstrating end-to-end usage (markers, plugin manager, hook configs)
    • Stub changelog entry for the new feature and versioning note
    • Ensure API reference and docstrings mention that ProjectSpec is accepted where project_name was previously required
    src/pluggy/__init__.py
    docs/api_reference.rst
    changelog/708.feature.rst
    testing/test_project_spec.py

    Tips and commands

    Interacting with Sourcery

    • Trigger a new review: Comment @sourcery-ai review on the pull request.
    • Continue discussions: Reply directly to Sourcery's review comments.
    • Generate a GitHub issue from a review comment: Ask Sourcery to create an
      issue from a review comment by replying to it. You can also reply to a
      review comment with @sourcery-ai issue to create an issue from it.
    • Generate a pull request title: Write @sourcery-ai anywhere in the pull
      request title to generate a title at any time. You can also comment
      @sourcery-ai title on the pull request to (re-)generate the title at any time.
    • Generate a pull request summary: Write @sourcery-ai summary anywhere in
      the pull request body to generate a PR summary at any time exactly where you
      want it. You can also comment @sourcery-ai summary on the pull request to
      (re-)generate the summary at any time.
    • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
      request to (re-)generate the reviewer's guide at any time.
    • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
      pull request to resolve all Sourcery comments. Useful if you've already
      addressed all the comments and don't want to see them anymore.
    • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
      request to dismiss all existing Sourcery reviews. Especially useful if you
      want to start fresh with a new review - don't forget to comment
      @sourcery-ai review to trigger a new review!

    Customizing Your Experience

    Access your dashboard to:

    • Enable or disable review features such as the Sourcery-generated pull request
      summary, the reviewer's guide, and others.
    • Change the review language.
    • Add, remove or edit custom review instructions.
    • Adjust other review settings.

    Getting Help

    @sourcery-ai sourcery-ai 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.

    Hey - I've found 3 issues, and left some high level feedback:

    • The tests and some internal logic rely on accessing _project_spec directly on markers and the plugin manager; consider exposing a public project_spec attribute/property instead so callers don’t depend on private implementation details.
    • The conversion from str | ProjectSpec to a ProjectSpec instance is duplicated in HookspecMarker, HookimplMarker, and PluginManager; extracting a small helper (e.g. ensure_project_spec(...)) would reduce repetition and keep future changes to that behavior centralized.
    Prompt for AI Agents
    Please address the comments from this code review:
    
    ## Overall Comments
    - The tests and some internal logic rely on accessing `_project_spec` directly on markers and the plugin manager; consider exposing a public `project_spec` attribute/property instead so callers don’t depend on private implementation details.
    - The conversion from `str | ProjectSpec` to a `ProjectSpec` instance is duplicated in `HookspecMarker`, `HookimplMarker`, and `PluginManager`; extracting a small helper (e.g. `ensure_project_spec(...)`) would reduce repetition and keep future changes to that behavior centralized.
    
    ## Individual Comments
    
    ### Comment 1
    <location path="testing/test_project_spec.py" line_range="139-150" />
    <code_context>
    +    assert project.get_hookimpl_config(undecorated) is None
    +
    +
    +def test_marker_classes_accept_project_spec() -> None:
    +    project = ProjectSpec("testproject")
    +
    +    hookspec_from_project = HookspecMarker(project)
    +    hookimpl_from_project = HookimplMarker(project)
    +
    +    assert hookspec_from_project.project_name == "testproject"
    +    assert hookimpl_from_project.project_name == "testproject"
    +    assert hookspec_from_project._project_spec is project
    +    assert hookimpl_from_project._project_spec is project
    +
    +
    </code_context>
    <issue_to_address>
    **suggestion (testing):** Add tests for interoperability between string-based markers and ProjectSpec-based PluginManager (and vice versa)
    
    The current tests only cover all-ProjectSpec and all-string flows. Please also add mixed-type cases, e.g.:
    
    - `HookspecMarker("testproject")` / `HookimplMarker("testproject")` with `PluginManager(ProjectSpec("testproject"))`.
    - `HookspecMarker(ProjectSpec("testproject"))` / `HookimplMarker(ProjectSpec("testproject"))` with `PluginManager("testproject")`.
    
    These will exercise the `ProjectSpec` wrapping logic and help catch regressions when mixing marker and manager input types.
    
    ```suggestion
    def test_marker_classes_accept_project_spec() -> None:
        project = ProjectSpec("testproject")
    
        hookspec_from_project = HookspecMarker(project)
        hookimpl_from_project = HookimplMarker(project)
    
        assert hookspec_from_project.project_name == "testproject"
        assert hookimpl_from_project.project_name == "testproject"
        assert hookspec_from_project._project_spec is project
        assert hookimpl_from_project._project_spec is project
    
    
    def test_string_markers_with_project_spec_plugin_manager() -> None:
        pm = PluginManager(ProjectSpec("testproject"))
        hookspec = HookspecMarker("testproject")
        hookimpl = HookimplMarker("testproject")
    
        class Spec:
            @hookspec
            def hello(self) -> str:
                """A simple hook spec."""
    
        class Impl:
            @hookimpl
            def hello(self) -> str:
                return "world"
    
        pm.add_hookspecs(Spec)
        pm.register(Impl())
    
        assert pm.project_name == "testproject"
        assert pm._project_spec is not None
        assert pm._project_spec.project_name == "testproject"
        assert pm.hook.hello() == ["world"]
    
    
    def test_project_spec_markers_with_string_plugin_manager() -> None:
        pm = PluginManager("testproject")
        hookspec = HookspecMarker(ProjectSpec("testproject"))
        hookimpl = HookimplMarker(ProjectSpec("testproject"))
    
        class Spec:
            @hookspec
            def hello(self) -> str:
                """A simple hook spec."""
    
        class Impl:
            @hookimpl
            def hello(self) -> str:
                return "world"
    
        pm.add_hookspecs(Spec)
        pm.register(Impl())
    
        assert pm.project_name == "testproject"
        assert pm._project_spec is not None
        assert pm._project_spec.project_name == "testproject"
        assert pm.hook.hello() == ["world"]
    ```
    </issue_to_address>
    
    ### Comment 2
    <location path="testing/test_project_spec.py" line_range="37-48" />
    <code_context>
    +    assert isinstance(pm2, PluginManager)
    +
    +
    +def test_project_spec_custom_plugin_manager_class() -> None:
    +    class CustomPluginManager(PluginManager):
    +        def __init__(self, project_name: str | ProjectSpec) -> None:
    +            super().__init__(project_name)
    +            self.custom_attr = "custom_value"
    +
    +    project = ProjectSpec("testproject", plugin_manager_cls=CustomPluginManager)
    +    pm = project.create_plugin_manager()
    +
    +    assert isinstance(pm, CustomPluginManager)
    +    assert pm.project_name == "testproject"
    +    assert pm.custom_attr == "custom_value"
    +
    +
    </code_context>
    <issue_to_address>
    **suggestion (testing):** Strengthen tests to assert that custom PluginManager instances are independent across multiple calls
    
    The `ProjectSpec` docstring guarantees that `create_plugin_manager()` returns fresh, independent instances. For the custom `PluginManager` case, this test currently only validates type and attributes for a single instance. Please also verify independence by, for example:
    
    - Calling `create_plugin_manager()` twice with `CustomPluginManager` and asserting `pm1 is not pm2`.
    - Optionally registering different plugins on each and asserting they remain isolated.
    
    This would mirror `test_project_spec_multiple_plugin_managers_independent()` and ensure the independence contract holds when `plugin_manager_cls` is customized.
    
    ```suggestion
    def test_project_spec_custom_plugin_manager_class() -> None:
        class CustomPluginManager(PluginManager):
            def __init__(self, project_name: str | ProjectSpec) -> None:
                super().__init__(project_name)
                self.custom_attr = "custom_value"
    
        project = ProjectSpec("testproject", plugin_manager_cls=CustomPluginManager)
    
        pm1 = project.create_plugin_manager()
        pm2 = project.create_plugin_manager()
    
        # type and attribute guarantees
        assert isinstance(pm1, CustomPluginManager)
        assert isinstance(pm2, CustomPluginManager)
        assert pm1.project_name == "testproject"
        assert pm2.project_name == "testproject"
        assert pm1.custom_attr == "custom_value"
        assert pm2.custom_attr == "custom_value"
    
        # independence guarantees: fresh instances on each call
        assert pm1 is not pm2
    
        # isolation guarantees: plugins registered on each manager remain independent
        plugin1 = object()
        plugin2 = object()
    
        pm1.register(plugin1)
        pm2.register(plugin2)
    
        plugins1 = set(pm1.get_plugins())
        plugins2 = set(pm2.get_plugins())
    
        assert plugin1 in plugins1
        assert plugin1 not in plugins2
        assert plugin2 in plugins2
        assert plugin2 not in plugins1
    ```
    </issue_to_address>
    
    ### Comment 3
    <location path="changelog/708.feature.rst" line_range="1" />
    <code_context>
    +New :class:`pluggy.ProjectSpec` hub bundles a project's
    +:class:`~pluggy.HookspecMarker`, :class:`~pluggy.HookimplMarker` and
    +:meth:`~pluggy.ProjectSpec.create_plugin_manager` under one project name.
    </code_context>
    <issue_to_address>
    **suggestion (typo):** Consider adding an article before "New" for smoother grammar (e.g., "A new" or "The new").
    
    The current phrasing (“New :class:`pluggy.ProjectSpec` hub bundles…”) is grammatically abrupt. Starting with “A new …” or “The new …” would read more naturally while preserving the meaning.
    
    ```suggestion
    A new :class:`pluggy.ProjectSpec` hub bundles a project's
    ```
    </issue_to_address>

    Sourcery is free for open source - if you like our reviews please consider sharing them ✨
    Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

    Comment on lines +139 to +150
    def test_marker_classes_accept_project_spec() -> None:
    project = ProjectSpec("testproject")

    hookspec_from_project = HookspecMarker(project)
    hookimpl_from_project = HookimplMarker(project)

    assert hookspec_from_project.project_name == "testproject"
    assert hookimpl_from_project.project_name == "testproject"
    assert hookspec_from_project._project_spec is project
    assert hookimpl_from_project._project_spec is project


    Copy link
    Copy Markdown

    Choose a reason for hiding this comment

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

    suggestion (testing): Add tests for interoperability between string-based markers and ProjectSpec-based PluginManager (and vice versa)

    The current tests only cover all-ProjectSpec and all-string flows. Please also add mixed-type cases, e.g.:

    • HookspecMarker("testproject") / HookimplMarker("testproject") with PluginManager(ProjectSpec("testproject")).
    • HookspecMarker(ProjectSpec("testproject")) / HookimplMarker(ProjectSpec("testproject")) with PluginManager("testproject").

    These will exercise the ProjectSpec wrapping logic and help catch regressions when mixing marker and manager input types.

    Suggested change
    def test_marker_classes_accept_project_spec() -> None:
    project = ProjectSpec("testproject")
    hookspec_from_project = HookspecMarker(project)
    hookimpl_from_project = HookimplMarker(project)
    assert hookspec_from_project.project_name == "testproject"
    assert hookimpl_from_project.project_name == "testproject"
    assert hookspec_from_project._project_spec is project
    assert hookimpl_from_project._project_spec is project
    def test_marker_classes_accept_project_spec() -> None:
    project = ProjectSpec("testproject")
    hookspec_from_project = HookspecMarker(project)
    hookimpl_from_project = HookimplMarker(project)
    assert hookspec_from_project.project_name == "testproject"
    assert hookimpl_from_project.project_name == "testproject"
    assert hookspec_from_project._project_spec is project
    assert hookimpl_from_project._project_spec is project
    def test_string_markers_with_project_spec_plugin_manager() -> None:
    pm = PluginManager(ProjectSpec("testproject"))
    hookspec = HookspecMarker("testproject")
    hookimpl = HookimplMarker("testproject")
    class Spec:
    @hookspec
    def hello(self) -> str:
    """A simple hook spec."""
    class Impl:
    @hookimpl
    def hello(self) -> str:
    return "world"
    pm.add_hookspecs(Spec)
    pm.register(Impl())
    assert pm.project_name == "testproject"
    assert pm._project_spec is not None
    assert pm._project_spec.project_name == "testproject"
    assert pm.hook.hello() == ["world"]
    def test_project_spec_markers_with_string_plugin_manager() -> None:
    pm = PluginManager("testproject")
    hookspec = HookspecMarker(ProjectSpec("testproject"))
    hookimpl = HookimplMarker(ProjectSpec("testproject"))
    class Spec:
    @hookspec
    def hello(self) -> str:
    """A simple hook spec."""
    class Impl:
    @hookimpl
    def hello(self) -> str:
    return "world"
    pm.add_hookspecs(Spec)
    pm.register(Impl())
    assert pm.project_name == "testproject"
    assert pm._project_spec is not None
    assert pm._project_spec.project_name == "testproject"
    assert pm.hook.hello() == ["world"]

    Comment on lines +37 to +48
    def test_project_spec_custom_plugin_manager_class() -> None:
    class CustomPluginManager(PluginManager):
    def __init__(self, project_name: str | ProjectSpec) -> None:
    super().__init__(project_name)
    self.custom_attr = "custom_value"

    project = ProjectSpec("testproject", plugin_manager_cls=CustomPluginManager)
    pm = project.create_plugin_manager()

    assert isinstance(pm, CustomPluginManager)
    assert pm.project_name == "testproject"
    assert pm.custom_attr == "custom_value"

    Copy link
    Copy Markdown

    Choose a reason for hiding this comment

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

    suggestion (testing): Strengthen tests to assert that custom PluginManager instances are independent across multiple calls

    The ProjectSpec docstring guarantees that create_plugin_manager() returns fresh, independent instances. For the custom PluginManager case, this test currently only validates type and attributes for a single instance. Please also verify independence by, for example:

    • Calling create_plugin_manager() twice with CustomPluginManager and asserting pm1 is not pm2.
    • Optionally registering different plugins on each and asserting they remain isolated.

    This would mirror test_project_spec_multiple_plugin_managers_independent() and ensure the independence contract holds when plugin_manager_cls is customized.

    Suggested change
    def test_project_spec_custom_plugin_manager_class() -> None:
    class CustomPluginManager(PluginManager):
    def __init__(self, project_name: str | ProjectSpec) -> None:
    super().__init__(project_name)
    self.custom_attr = "custom_value"
    project = ProjectSpec("testproject", plugin_manager_cls=CustomPluginManager)
    pm = project.create_plugin_manager()
    assert isinstance(pm, CustomPluginManager)
    assert pm.project_name == "testproject"
    assert pm.custom_attr == "custom_value"
    def test_project_spec_custom_plugin_manager_class() -> None:
    class CustomPluginManager(PluginManager):
    def __init__(self, project_name: str | ProjectSpec) -> None:
    super().__init__(project_name)
    self.custom_attr = "custom_value"
    project = ProjectSpec("testproject", plugin_manager_cls=CustomPluginManager)
    pm1 = project.create_plugin_manager()
    pm2 = project.create_plugin_manager()
    # type and attribute guarantees
    assert isinstance(pm1, CustomPluginManager)
    assert isinstance(pm2, CustomPluginManager)
    assert pm1.project_name == "testproject"
    assert pm2.project_name == "testproject"
    assert pm1.custom_attr == "custom_value"
    assert pm2.custom_attr == "custom_value"
    # independence guarantees: fresh instances on each call
    assert pm1 is not pm2
    # isolation guarantees: plugins registered on each manager remain independent
    plugin1 = object()
    plugin2 = object()
    pm1.register(plugin1)
    pm2.register(plugin2)
    plugins1 = set(pm1.get_plugins())
    plugins2 = set(pm2.get_plugins())
    assert plugin1 in plugins1
    assert plugin1 not in plugins2
    assert plugin2 in plugins2
    assert plugin2 not in plugins1

    Comment thread changelog/708.feature.rst Outdated
    @RonnyPfannschmidt
    RonnyPfannschmidt force-pushed the refactor/hookcaller-and-execution branch from a2063ae to ea1ab94 Compare July 24, 2026 16:38
    @RonnyPfannschmidt
    RonnyPfannschmidt force-pushed the refactor/hookcaller-and-execution branch from ea1ab94 to 0bd7b6b Compare July 24, 2026 16:54
    @RonnyPfannschmidt
    RonnyPfannschmidt force-pushed the refactor/hookcaller-and-execution branch from 0bd7b6b to 9e5b222 Compare August 12, 2026 10:50
    @RonnyPfannschmidt
    RonnyPfannschmidt force-pushed the refactor/hookcaller-and-execution branch from 9e5b222 to 15e5147 Compare August 12, 2026 10:57
    Complete design step 06: ProjectSpec bundles hookspec/hookimpl markers
    and plugin manager creation under a single project name, with
    get_hookspec_config / get_hookimpl_config helpers. Markers and
    PluginManager accept str | ProjectSpec (strings still work; markers and
    manager now expose project_name as a property delegating to the spec).
    
    Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
    Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

    Labels

    None yet

    Projects

    None yet

    Development

    Successfully merging this pull request may close these issues.

    1 participant