diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 00000000..23de9d2e --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,12 @@ +{ + "permissions": { + "allow": [ + "Bash(uv run pytest:*)", + "Bash(git add:*)", + "Bash(uv run mypy:*)", + "Bash(uv run pre-commit:*)" + + ], + "deny": [] + } +} diff --git a/.gitignore b/.gitignore index 1a33c625..aaf65a83 100644 --- a/.gitignore +++ b/.gitignore @@ -67,3 +67,6 @@ pip-wheel-metadata/ # pytest-benchmark .benchmarks/ + +# Claude Code local settings +.claude/settings.local.json diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 1f76e4e6..3b61691a 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -48,4 +48,4 @@ repos: - id: mypy files: ^(src/|testing/) args: [] - additional_dependencies: [pytest] + additional_dependencies: [pytest, types-greenlet] diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..5cd6557e --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,50 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +Pluggy is a minimalist production-ready plugin system that serves as the core framework for pytest, datasette and devpi. +It provides hook specification and implementation mechanisms through a plugin manager system. + +## Development Commands + +### Testing +- `uv run pytest` - Run all tests, prefer runnign all tests to quickly get feedback +- `uv run pytest testing/benchmark.py` runs the benchmark tests + +### Code Quality +- `uv run pre-commit run -a` - Run all pre-commit hooks - gives linting and typing errors + corrects files +- reread files that get fixed by pre-commit + + +## Development process + +- always read `src/pluggy/*.py` to get a full picture +- consider backward compatibility +- always run all tests +- always run pre-commit before try to commit +- prefer running full pre-commit over ruff/mypy alone + + + +## Core Architecture + +### Main Components + +- **PluginManager** (`src/pluggy/_manager.py`): Central registry that manages plugins and coordinates hook calls +- **HookCaller** (`src/pluggy/_hooks.py`): Executes hook implementations with proper argument binding +- **HookImpl/HookSpec** (`src/pluggy/_hooks.py`): Represent hook implementations and specifications +- **Result** (`src/pluggy/_result.py`): Handles hook call results and exception propagation +- **Multicall** (`src/pluggy/_callers.py`): Core execution engine for calling multiple hook implementations + +### Package Structure +- `src/pluggy/` - Main package source +- `testing/` - Test suite using pytest +- `docs/` - Sphinx documentation and examples +- `changelog/` - Towncrier fragments for changelog generation + +## Configuration Files +- `pyproject.toml` - Project metadata, build system, tool configuration (ruff, mypy, setuptools-scm) +- `tox.ini` - Multi-environment testing configuration +- `.pre-commit-config.yaml` - Code quality automation (ruff, mypy, flake8, etc.) diff --git a/pyproject.toml b/pyproject.toml index 429e9d0b..92a4bc58 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -35,9 +35,15 @@ readme = {file = "README.rst", content-type = "text/x-rst"} requires-python = ">=3.9" dynamic = ["version"] + [project.optional-dependencies] -dev = ["pre-commit", "tox"] -testing = ["pytest", "pytest-benchmark", "coverage"] +async = ["greenlet"] + +[dependency-groups] +dev = ["pre-commit", "tox", "mypy", "ruff"] +testing = ["pytest", "pytest-benchmark", "coverage", "greenlet", "types-greenlet"] + + [tool.setuptools] packages = ["pluggy"] @@ -45,6 +51,7 @@ package-dir = {""="src"} package-data = {"pluggy" = ["py.typed"]} + [tool.ruff.lint] extend-select = [ "I", # isort @@ -67,6 +74,9 @@ lines-after-imports = 2 [tool.setuptools_scm] +[tool.uv] +default-groups = ["dev", "testing"] + [tool.towncrier] package = "pluggy" package_dir = "src/pluggy" @@ -107,6 +117,7 @@ template = "changelog/_template.rst" [tool.mypy] mypy_path = "src" +python="3.9" check_untyped_defs = true # Hopefully we can set this someday! # disallow_any_expr = true diff --git a/src/pluggy/__init__.py b/src/pluggy/__init__.py index 3d81d0a3..9f2f9ab9 100644 --- a/src/pluggy/__init__.py +++ b/src/pluggy/__init__.py @@ -3,32 +3,43 @@ "PluginManager", "PluginValidationError", "HookCaller", + "HistoricHookCaller", "HookCallError", "HookspecOpts", "HookimplOpts", + "HookspecConfiguration", + "HookimplConfiguration", "HookImpl", "HookRelay", "HookspecMarker", "HookimplMarker", + "ProjectSpec", "Result", "PluggyWarning", "PluggyTeardownRaisedWarning", ] -from ._hooks import HookCaller -from ._hooks import HookImpl -from ._hooks import HookimplMarker -from ._hooks import HookimplOpts -from ._hooks import HookRelay -from ._hooks import HookspecMarker -from ._hooks import HookspecOpts +from ._hook_callers import HistoricHookCaller +from ._hook_callers import HookCaller +from ._hook_callers import HookImpl +from ._hook_callers import HookRelay +from ._hook_config import HookimplConfiguration +from ._hook_config import HookimplOpts +from ._hook_config import HookspecConfiguration +from ._hook_config import HookspecOpts +from ._hook_markers import HookimplMarker +from ._hook_markers import HookspecMarker from ._manager import PluginManager from ._manager import PluginValidationError +from ._project import ProjectSpec from ._result import HookCallError from ._result import Result from ._warnings import PluggyTeardownRaisedWarning from ._warnings import PluggyWarning +__version__: str + + def __getattr__(name: str) -> str: if name == "__version__": from importlib.metadata import version diff --git a/src/pluggy/_async.py b/src/pluggy/_async.py new file mode 100644 index 00000000..2d28c2c3 --- /dev/null +++ b/src/pluggy/_async.py @@ -0,0 +1,198 @@ +""" +Async support for pluggy using greenlets. + +This module provides async functionality for pluggy, allowing hook implementations +to return awaitable objects that are automatically awaited when running in an +async context. +""" + +from __future__ import annotations + +from collections.abc import AsyncGenerator +from collections.abc import Awaitable +from collections.abc import Callable +from collections.abc import Generator +from typing import Any +from typing import TYPE_CHECKING +from typing import TypeVar + + +_T = TypeVar("_T") +_Y = TypeVar("_Y") +_S = TypeVar("_S") +_R = TypeVar("_R") + +if TYPE_CHECKING: + import greenlet + + +def make_greenlet(func: Callable[..., Any]) -> greenlet.greenlet: + """indirection to defer import""" + import greenlet + + return greenlet.greenlet(func) + + +class Submitter: + # practice we expect te root greenlet to be the key submitter + _active_submitter: greenlet.greenlet | None + + def __init__(self) -> None: + self._active_submitter = None + + def __repr__(self) -> str: + return f"" + + def maybe_submit(self, coro: Awaitable[_T]) -> _T | Awaitable[_T]: + """await an awaitable if active, else return it + + this enables backward compatibility for datasette + and https://simonwillison.net/2020/Sep/2/await-me-maybe/ + """ + active = self._active_submitter + if active is not None: + # We're in a greenlet context, switch with the awaitable + # The parent will await it and switch back with the result + res: _T = active.switch(coro) + return res + else: + return coro + + def require_await(self, coro: Awaitable[_T]) -> _T: + """await an awaitable, raising an error if not in async context + + this is for cases where async context is required + """ + active = self._active_submitter + if active is not None: + # Switch to the active submitter greenlet with the awaitable + # The active submitter will await it and switch back with the result + res: _T = active.switch(coro) + return res + else: + raise RuntimeError("require_await called outside of async context") + + async def run(self, sync_func: Callable[[], _T]) -> _T: + """Run a synchronous function with async support.""" + try: + import greenlet + except ImportError: + raise RuntimeError("greenlet is required for async support") + + if self._active_submitter is not None: + raise RuntimeError("Submitter is already active") + + # Set the current greenlet as the main async context + main_greenlet = greenlet.getcurrent() + result: _T | None = None + exception: BaseException | None = None + + def greenlet_func() -> None: + nonlocal result, exception + try: + result = sync_func() + except BaseException as e: + exception = e + + # Create the worker greenlet + worker_greenlet = greenlet.greenlet(greenlet_func) + # Set the active submitter to the main greenlet so maybe_submit can switch back + self._active_submitter = main_greenlet + + try: + # Switch to the worker greenlet and handle any awaitables it passes back + awaitable = worker_greenlet.switch() + while awaitable is not None: + # Await the awaitable and send the result back to the greenlet + awaited_result = await awaitable + awaitable = worker_greenlet.switch(awaited_result) + except Exception as e: + # If something goes wrong, try to send the exception to the greenlet + try: + worker_greenlet.throw(e) + except BaseException as inner_e: + exception = inner_e + finally: + self._active_submitter = None + + if exception is not None: + raise exception + if result is None: + raise RuntimeError("Function completed without setting result") + return result + + +def async_generator_to_sync( + async_gen: AsyncGenerator[_Y, _S], submitter: Submitter +) -> Generator[_Y, _S, None]: + """Convert an async generator to a sync generator using a Submitter. + + This helper allows wrapper implementations to use async generators while + maintaining compatibility with the sync generator interface expected by + the hook system. + + Args: + async_gen: The async generator to convert + submitter: The Submitter to use for awaiting async operations + + Yields: + Values from the async generator + + Returns: + None (async generators don't return values) + + Example: + async def my_async_wrapper(): + yield # Setup phase + result = await some_async_operation() + + # In a wrapper hook implementation: + def my_wrapper_hook(): + async_gen = my_async_wrapper() + gen = async_generator_to_sync(async_gen, submitter) + try: + while True: + value = next(gen) + yield value + except StopIteration: + return + """ + try: + # Start the async generator + value = submitter.require_await(async_gen.__anext__()) + + while True: + try: + # Yield the value and get the sent value + sent_value = yield value + + # Send the value to the async generator and get the next value + try: + value = submitter.require_await(async_gen.asend(sent_value)) + except StopAsyncIteration: + # Async generator completed + return + + except GeneratorExit: + # Generator is being closed, close the async generator + try: + submitter.require_await(async_gen.aclose()) + except StopAsyncIteration: + pass + raise + + except BaseException as exc: + # Exception was thrown into the generator, + # throw it into the async generator + try: + value = submitter.require_await(async_gen.athrow(exc)) + except StopAsyncIteration: + # Async generator completed + return + except StopIteration as sync_stop_exc: + # Re-raise StopIteration as it was passed through + raise sync_stop_exc + + except StopAsyncIteration: + # Async generator completed normally + return diff --git a/src/pluggy/_callers.py b/src/pluggy/_callers.py index 472d5dd0..fde8e3dc 100644 --- a/src/pluggy/_callers.py +++ b/src/pluggy/_callers.py @@ -4,15 +4,20 @@ from __future__ import annotations +from collections.abc import Awaitable from collections.abc import Generator from collections.abc import Mapping from collections.abc import Sequence from typing import cast from typing import NoReturn +from typing import TYPE_CHECKING import warnings -from ._hooks import HookImpl -from ._result import HookCallError + +if TYPE_CHECKING: + from ._async import Submitter +from ._hook_callers import HookImpl +from ._hook_callers import WrapperImpl from ._result import Result from ._warnings import PluggyTeardownRaisedWarning @@ -23,7 +28,7 @@ def run_old_style_hookwrapper( - hook_impl: HookImpl, hook_name: str, args: Sequence[object] + hook_impl: WrapperImpl, hook_name: str, args: Sequence[object] ) -> Teardown: """ backward compatibility wrapper to run a old style hookwrapper as a wrapper @@ -64,20 +69,22 @@ def _raise_wrapfail( def _warn_teardown_exception( - hook_name: str, hook_impl: HookImpl, e: BaseException + hook_name: str, hook_impl: WrapperImpl, e: BaseException ) -> None: msg = "A plugin raised an exception during an old-style hookwrapper teardown.\n" msg += f"Plugin: {hook_impl.plugin_name}, Hook: {hook_name}\n" msg += f"{type(e).__name__}: {e}\n" msg += "For more information see https://pluggy.readthedocs.io/en/stable/api_reference.html#pluggy.PluggyTeardownRaisedWarning" # noqa: E501 - warnings.warn(PluggyTeardownRaisedWarning(msg), stacklevel=6) + warnings.warn(PluggyTeardownRaisedWarning(msg), stacklevel=7) def _multicall( hook_name: str, - hook_impls: Sequence[HookImpl], + normal_impls: Sequence[HookImpl], + wrapper_impls: Sequence[WrapperImpl], caller_kwargs: Mapping[str, object], firstresult: bool, + async_submitter: Submitter, ) -> object | list[object]: """Execute a call into multiple python functions/methods and return the result(s). @@ -87,81 +94,44 @@ def _multicall( __tracebackhide__ = True results: list[object] = [] exception = None - try: # run impl and wrapper setup functions in a loop - teardowns: list[Teardown] = [] - try: - for hook_impl in reversed(hook_impls): - try: - args = [caller_kwargs[argname] for argname in hook_impl.argnames] - except KeyError as e: - # coverage bug - this is tested - for argname in hook_impl.argnames: # pragma: no cover - if argname not in caller_kwargs: - raise HookCallError( - f"hook call must provide argument {argname!r}" - ) from e - - if hook_impl.hookwrapper: - function_gen = run_old_style_hookwrapper(hook_impl, hook_name, args) - - next(function_gen) # first yield - teardowns.append(function_gen) - - elif hook_impl.wrapper: - try: - # If this cast is not valid, a type error is raised below, - # which is the desired response. - res = hook_impl.function(*args) - function_gen = cast(Generator[None, object, object], res) - next(function_gen) # first yield - teardowns.append(function_gen) - except StopIteration: - _raise_wrapfail(function_gen, "did not yield") - else: - res = hook_impl.function(*args) - if res is not None: - results.append(res) - if firstresult: # halt further impl calls - break - except BaseException as exc: - exception = exc - finally: - if firstresult: # first result hooks return a single value - result = results[0] if results else None - else: - result = results - - # run all wrapper post-yield blocks - for teardown in reversed(teardowns): - try: - if exception is not None: - try: - teardown.throw(exception) - except RuntimeError as re: - # StopIteration from generator causes RuntimeError - # even for coroutine usage - see #544 - if ( - isinstance(exception, StopIteration) - and re.__cause__ is exception - ): - teardown.close() - continue - else: - raise - else: - teardown.send(result) - # Following is unreachable for a well behaved hook wrapper. - # Try to force finalizers otherwise postponed till GC action. - # Note: close() may raise if generator handles GeneratorExit. - teardown.close() - except StopIteration as si: - result = si.value - exception = None - continue - except BaseException as e: - exception = e - continue - _raise_wrapfail(teardown, "has second yield") + + # Set up wrapper completion hooks + from ._hook_callers import CompletionHook + + completion_hooks: list[CompletionHook] = [] + + try: + # Set up all wrappers and collect their completion hooks + for wrapper_impl in reversed(wrapper_impls): + completion_hook = wrapper_impl.setup_and_get_completion_hook( + hook_name, caller_kwargs + ) + completion_hooks.append(completion_hook) + + # Process normal implementations (in reverse order for correct execution) + # Caller ensures normal_impls contains only non-wrapper implementations + for normal_impl in reversed(normal_impls): + args = normal_impl._get_call_args(caller_kwargs) + res = normal_impl.function(*args) + if res is not None: + # Handle awaitable results using maybe_submit + if isinstance(res, Awaitable): + res = async_submitter.maybe_submit(res) + results.append(res) + if firstresult: # halt further impl calls + break + except BaseException as exc: + exception = exc + + # Determine final result before teardown + if firstresult: # first result hooks return a single value + result = results[0] if results else None + else: + result = results + + # Run completion hooks in reverse order (LIFO - Last In, First Out) + for completion_hook in reversed(completion_hooks): + result, exception = completion_hook(result, exception) if exception is not None: raise exception diff --git a/src/pluggy/_hook_callers.py b/src/pluggy/_hook_callers.py new file mode 100644 index 00000000..46bc4b16 --- /dev/null +++ b/src/pluggy/_hook_callers.py @@ -0,0 +1,866 @@ +""" +Hook caller implementations and hook implementation classes. +""" + +from __future__ import annotations + +from collections.abc import Generator +from collections.abc import Mapping +from collections.abc import MutableSequence +from collections.abc import Sequence +from collections.abc import Set +from typing import Any +from typing import Callable +from typing import cast +from typing import Final +from typing import final +from typing import Optional +from typing import Protocol +from typing import runtime_checkable +from typing import TYPE_CHECKING +from typing import TypeVar + +from ._hook_config import _HookExec +from ._hook_config import _HookImplFunction +from ._hook_config import _Namespace +from ._hook_config import _Plugin +from ._hook_config import HookimplConfiguration +from ._hook_config import HookspecConfiguration +from ._hook_config import HookspecOpts +from ._hook_markers import HookSpec +from ._hook_markers import varnames +from ._result import HookCallError + + +if TYPE_CHECKING: + from ._async import Submitter + + +_T_HookImpl = TypeVar("_T_HookImpl", bound="HookImpl") + + +# Type alias for completion hook functions +class CompletionHook(Protocol): + """completion hooks are used to express the teardown of hookwrappers + as python has no builtin way to change the result using a + """ + + def __call__( + self, result: object | list[object] | None, exception: BaseException | None + ) -> tuple[object | list[object] | None, BaseException | None]: ... + + +def _insert_hookimpl_into_list( + hookimpl: _T_HookImpl, target_list: MutableSequence[_T_HookImpl] +) -> None: + """Insert a hookimpl into the target list maintaining proper ordering. + + The ordering is: [trylast, normal, tryfirst] + """ + if hookimpl.trylast: + target_list.insert(0, hookimpl) + elif hookimpl.tryfirst: + target_list.append(hookimpl) + else: + # find last non-tryfirst method + i = len(target_list) - 1 + while i >= 0 and target_list[i].tryfirst: + i -= 1 + target_list.insert(i + 1, hookimpl) + + +@runtime_checkable +class HookCaller(Protocol): + """Protocol defining the interface for hook callers.""" + + @property + def name(self) -> str: + """Name of the hook getting called.""" + ... + + @property + def spec(self) -> HookSpec | None: + """The hook specification, if any.""" + ... + + def has_spec(self) -> bool: + """Whether this caller has a hook specification.""" + ... + + def is_historic(self) -> bool: + """Whether this caller is historic.""" + ... + + def get_hookimpls(self) -> list[HookImpl]: + """Get all registered hook implementations for this hook.""" + ... + + def set_specification( + self, + specmodule_or_class: _Namespace, + _spec_opts_or_config: HookspecOpts | HookspecConfiguration | None = None, + *, + spec_opts: HookspecOpts | None = None, + spec_config: HookspecConfiguration | None = None, + ) -> None: + """Set the hook specification.""" + ... + + def __call__(self, **kwargs: object) -> Any: + """Call the hook with given kwargs.""" + ... + + def call_historic( + self, + result_callback: Callable[[Any], None] | None = None, + kwargs: Mapping[str, object] | None = None, + ) -> None: + """Call the hook historically.""" + ... + + def call_extra( + self, methods: Sequence[Callable[..., object]], kwargs: Mapping[str, object] + ) -> Any: + """Call the hook with additional methods.""" + ... + + def __repr__(self) -> str: + """String representation of the hook caller.""" + ... + + +@final +class HookRelay: + """Hook holder object for performing 1:N hook calls where N is the number + of registered plugins.""" + + __slots__ = ("__dict__",) + __dict__: dict[str, NormalHookCaller | HistoricHookCaller] + + def __init__(self) -> None: + """:meta private:""" + + if TYPE_CHECKING: + + def __getattr__(self, name: str) -> NormalHookCaller | HistoricHookCaller: ... + + +# Historical name (pluggy<=1.2), kept for backward compatibility. +_HookRelay = HookRelay + + +_CallHistory = list[tuple[Mapping[str, object], Optional[Callable[[Any], None]]]] + + +class HistoricHookCaller: + """A caller for historic hook specifications that memorizes and replays calls. + + Historic hooks memorize every call and replay them on plugins registered + after the call was made. Historic hooks do not support wrappers. + """ + + __slots__ = ( + "name", + "spec", + "_hookexec", + "_hookimpls", + "_call_history", + "_async_submitter", + ) + name: Final[str] + spec: Final[HookSpec] + _hookexec: Final[_HookExec] + _hookimpls: Final[list[HookImpl]] + _call_history: Final[_CallHistory] + _async_submitter: Final[Submitter] + + def __init__( + self, + name: str, + hook_execute: _HookExec, + specmodule_or_class: _Namespace, + spec_config: HookspecConfiguration, + async_submitter: Submitter, + ) -> None: + """:meta private:""" + assert spec_config.historic, "HistoricHookCaller requires historic=True" + #: Name of the hook getting called. + self.name = name + self._hookexec = hook_execute + self._async_submitter = async_submitter + # The hookimpls list for historic hooks (no wrappers supported) + self._hookimpls = [] + self._call_history = [] + # TODO: Document, or make private. + self.spec = HookSpec(specmodule_or_class, name, spec_config) + + def has_spec(self) -> bool: + return True # HistoricHookCaller always has a spec + + def set_specification( + self, + specmodule_or_class: _Namespace, + _spec_opts_or_config: HookspecOpts | HookspecConfiguration | None = None, + *, + spec_opts: HookspecOpts | None = None, + spec_config: HookspecConfiguration | None = None, + ) -> None: + """Historic hooks cannot have their specification changed after creation.""" + raise ValueError( + f"HistoricHookCaller {self.name!r} already has a specification. " + "Historic hooks cannot have their specification changed." + ) + + def is_historic(self) -> bool: + """Whether this caller is :ref:`historic `.""" + return True # HistoricHookCaller is always historic + + def _remove_plugin(self, plugin: _Plugin) -> None: + for i, method in enumerate(self._hookimpls): + if method.plugin == plugin: + del self._hookimpls[i] + return + raise ValueError(f"plugin {plugin!r} not found") + + def get_hookimpls(self) -> list[HookImpl]: + """Get all registered hook implementations for this hook.""" + return cast(list[HookImpl], [*self._hookimpls]) + + def _add_hookimpl(self, hookimpl: HookImpl) -> None: + """Add an implementation to the callback chain.""" + # Historic hooks don't support wrappers - simpler ordering + _insert_hookimpl_into_list(hookimpl, self._hookimpls) + + # Apply history to the newly added hookimpl + self._maybe_apply_history(hookimpl) + + def __repr__(self) -> str: + return f"" + + def __call__(self, **kwargs: object) -> Any: + """Call the hook. + + Historic hooks cannot be called directly. Use call_historic instead. + """ + raise RuntimeError( + "Cannot directly call a historic hook - use call_historic instead." + ) + + def call_historic( + self, + result_callback: Callable[[Any], None] | None = None, + kwargs: Mapping[str, object] | None = None, + ) -> None: + """Call the hook with given ``kwargs`` for all registered plugins and + for all plugins which will be registered afterwards, see + :ref:`historic`. + + :param result_callback: + If provided, will be called for each non-``None`` result obtained + from a hook implementation. + """ + kwargs = kwargs or {} + self.spec.verify_all_args_are_provided(kwargs) + self._call_history.append((kwargs, result_callback)) + # Historizing hooks don't return results. + # Remember firstresult isn't compatible with historic. + # Copy because plugins may register other plugins during iteration (#438). + res = self._hookexec( + self.name, self._hookimpls.copy(), [], kwargs, False, self._async_submitter + ) + if result_callback is None: + return + if isinstance(res, list): + for x in res: + result_callback(x) + + def call_extra( + self, methods: Sequence[Callable[..., object]], kwargs: Mapping[str, object] + ) -> Any: + """Call the hook with some additional temporarily participating + methods using the specified ``kwargs`` as call parameters, see + :ref:`call_extra`.""" + raise RuntimeError( + "Cannot call call_extra on a historic hook - use call_historic instead." + ) + + def _maybe_apply_history(self, method: HookImpl) -> None: + """Apply call history to a new hookimpl if it is marked as historic.""" + for kwargs, result_callback in self._call_history: + res = self._hookexec( + self.name, [method], [], kwargs, False, self._async_submitter + ) + if res and result_callback is not None: + # XXX: remember firstresult isn't compat with historic + assert isinstance(res, list) + result_callback(res[0]) + + +class NormalHookCaller: + """A caller of all registered implementations of a hook specification.""" + + __slots__ = ( + "name", + "spec", + "_hookexec", + "_normal_hookimpls", + "_wrapper_hookimpls", + "_async_submitter", + ) + name: Final[str] + spec: HookSpec | None + _hookexec: Final[_HookExec] + _normal_hookimpls: Final[list[HookImpl]] + _wrapper_hookimpls: Final[list[WrapperImpl]] + _async_submitter: Final[Submitter] + + def __init__( + self, + name: str, + hook_execute: _HookExec, + async_submitter: Submitter, + specmodule_or_class: _Namespace | None = None, + spec_config: HookspecConfiguration | None = None, + ) -> None: + """:meta private:""" + #: Name of the hook getting called. + self.name = name + self._hookexec = hook_execute + self._async_submitter = async_submitter + # Split hook implementations into two lists for simpler management: + # Normal hooks: [trylast, normal, tryfirst] + # Wrapper hooks: [trylast, normal, tryfirst] + # Combined execution order: normal_hooks + wrapper_hooks (reversed) + self._normal_hookimpls = [] + self._wrapper_hookimpls = [] + # TODO: Document, or make private. + self.spec: HookSpec | None = None + if specmodule_or_class is not None: + assert spec_config is not None + self.set_specification(specmodule_or_class, spec_config=spec_config) + + # TODO: Document, or make private. + def has_spec(self) -> bool: + return self.spec is not None + + # TODO: Document, or make private. + def set_specification( + self, + specmodule_or_class: _Namespace, + _spec_opts_or_config: HookspecOpts | HookspecConfiguration | None = None, + *, + spec_opts: HookspecOpts | None = None, + spec_config: HookspecConfiguration | None = None, + ) -> None: + if self.spec is not None: + raise ValueError( + f"Hook {self.spec.name!r} is already registered " + f"within namespace {self.spec.namespace}" + ) + + # Handle the dual parameter - set the appropriate typed parameter + if _spec_opts_or_config is not None: + assert spec_opts is None and spec_config is None, ( + "Cannot provide both positional and keyword spec arguments" + ) + + if isinstance(_spec_opts_or_config, dict): + spec_opts = _spec_opts_or_config + else: + spec_config = _spec_opts_or_config + + # Require exactly one of the typed parameters to be set + if spec_opts is not None: + assert spec_config is None, "Cannot provide both spec_opts and spec_config" + final_config = HookspecConfiguration(**spec_opts) + elif spec_config is not None: + final_config = spec_config + else: + raise TypeError("Must provide either spec_opts or spec_config") + + if final_config.historic: + raise ValueError( + f"HookCaller cannot handle historic hooks. " + f"Use HistoricHookCaller for {self.name!r}" + ) + self.spec = HookSpec(specmodule_or_class, self.name, final_config) + + def is_historic(self) -> bool: + """Whether this caller is :ref:`historic `.""" + return False # HookCaller is never historic + + def _remove_plugin(self, plugin: _Plugin) -> None: + # Try to remove from normal hookimpls first + for i, normal_method in enumerate(self._normal_hookimpls): + if normal_method.plugin == plugin: + del self._normal_hookimpls[i] + return + # Then try wrapper hookimpls + for i, wrapper_method in enumerate(self._wrapper_hookimpls): + if wrapper_method.plugin == plugin: + del self._wrapper_hookimpls[i] + return + raise ValueError(f"plugin {plugin!r} not found") + + def get_hookimpls(self) -> list[HookImpl]: + """Get all registered hook implementations for this hook.""" + # Combine normal hooks and wrapper hooks in the correct order + # Normal hooks come first, then wrapper hooks (execution order is reversed) + return cast(list[HookImpl], [*self._normal_hookimpls, *self._wrapper_hookimpls]) + + def _add_hookimpl(self, hookimpl: HookImpl | WrapperImpl) -> None: + """Add an implementation to the callback chain.""" + # Choose the appropriate list based on type + if isinstance(hookimpl, WrapperImpl): + _insert_hookimpl_into_list(hookimpl, self._wrapper_hookimpls) + else: + _insert_hookimpl_into_list(hookimpl, self._normal_hookimpls) + + def __repr__(self) -> str: + return f"" + + def __call__(self, **kwargs: object) -> Any: + """Call the hook. + + Only accepts keyword arguments, which should match the hook + specification. + + Returns the result(s) of calling all registered plugins, see + :ref:`calling`. + """ + if self.spec: + self.spec.verify_all_args_are_provided(kwargs) + firstresult = self.spec.config.firstresult if self.spec else False + # Copy because plugins may register other plugins during iteration (#438). + return self._hookexec( + self.name, + self._normal_hookimpls.copy(), + self._wrapper_hookimpls.copy(), + kwargs, + firstresult, + self._async_submitter, + ) + + def call_historic( + self, + result_callback: Callable[[Any], None] | None = None, + kwargs: Mapping[str, object] | None = None, + ) -> None: + """Call the hook with given ``kwargs`` for all registered plugins and + for all plugins which will be registered afterwards, see + :ref:`historic`. + + This method should not be called on non-historic hooks. + """ + raise AssertionError( + f"Hook {self.name!r} is not historic - cannot call call_historic" + ) + + def call_extra( + self, methods: Sequence[Callable[..., object]], kwargs: Mapping[str, object] + ) -> Any: + """Call the hook with some additional temporarily participating + methods using the specified ``kwargs`` as call parameters, see + :ref:`call_extra`.""" + if self.spec: + self.spec.verify_all_args_are_provided(kwargs) + config = HookimplConfiguration() + # Start with copies of our separate lists + normal_hookimpls = self._normal_hookimpls.copy() + + for method in methods: + hookimpl = config.create_hookimpl(None, "", method) + # call_extra only supports normal implementations + assert isinstance(hookimpl, HookImpl) + # Add temporary methods to the normal hookimpls list + _insert_hookimpl_into_list(hookimpl, normal_hookimpls) + + firstresult = self.spec.config.firstresult if self.spec else False + return self._hookexec( + self.name, + normal_hookimpls, + self._wrapper_hookimpls.copy(), + kwargs, + firstresult, + self._async_submitter, + ) + + +# Historical name (pluggy<=1.2), kept for backward compatibility. +_HookCaller = NormalHookCaller + + +class SubsetHookCaller: + """A proxy to another HookCaller which manages calls to all registered + plugins except the ones from remove_plugins.""" + + __slots__ = ( + "_orig", + "_remove_plugins", + ) + _orig: NormalHookCaller | HistoricHookCaller + _remove_plugins: Set[_Plugin] + + def __init__( + self, + orig: NormalHookCaller | HistoricHookCaller, + remove_plugins: Set[_Plugin], + ) -> None: + self._orig = orig + self._remove_plugins = remove_plugins + + @property + def name(self) -> str: + return self._orig.name + + @property + def spec(self) -> HookSpec | None: + return self._orig.spec + + def has_spec(self) -> bool: + return self._orig.has_spec() + + def is_historic(self) -> bool: + return self._orig.is_historic() + + def _get_filtered(self, hooks: Sequence[_T_HookImpl]) -> list[_T_HookImpl]: + """Filter out hook implementations from removed plugins.""" + return [impl for impl in hooks if impl.plugin not in self._remove_plugins] + + def get_hookimpls(self) -> list[HookImpl]: + """Get filtered hook implementations for this hook.""" + return self._get_filtered(self._orig.get_hookimpls()) + + def set_specification( + self, + specmodule_or_class: _Namespace, + _spec_opts_or_config: HookspecOpts | HookspecConfiguration | None = None, + *, + spec_opts: HookspecOpts | None = None, + spec_config: HookspecConfiguration | None = None, + ) -> None: + """SubsetHookCaller cannot set specifications - they are read-only proxies.""" + raise RuntimeError( + f"Cannot set specification on SubsetHookCaller {self.name!r} - " + "it is a read-only proxy to another hook caller" + ) + + def __call__(self, **kwargs: object) -> Any: + """Call the hook with filtered implementations.""" + if self.is_historic(): + raise RuntimeError( + "Cannot directly call a historic hook - use call_historic instead." + ) + assert isinstance(self._orig, NormalHookCaller) + if self.spec: + self.spec.verify_all_args_are_provided(kwargs) + firstresult = self.spec.config.firstresult if self.spec else False + hookexec = getattr(self._orig, "_hookexec") + + normal_impls = self._get_filtered(self._orig._normal_hookimpls) + wrapper_impls = self._get_filtered(self._orig._wrapper_hookimpls) + # Both NormalHookCaller and HistoricHookCaller now have async_submitter + return hookexec( + self.name, + normal_impls, + wrapper_impls, + kwargs, + firstresult, + self._orig._async_submitter, + ) + + def call_historic( + self, + result_callback: Callable[[Any], None] | None = None, + kwargs: Mapping[str, object] | None = None, + ) -> None: + """Call the hook with given ``kwargs`` for all registered plugins and + for all plugins which will be registered afterwards, see + :ref:`historic`. + """ + if not self.is_historic(): + raise AssertionError( + f"Hook {self.name!r} is not historic - cannot call call_historic" + ) + assert isinstance(self._orig, HistoricHookCaller) + # For subset hook callers, we need to manually handle the history and execution + kwargs = kwargs or {} + if self.spec: + self.spec.verify_all_args_are_provided(kwargs) + + self._orig._call_history.append((kwargs, result_callback)) + + # Execute with filtered hookimpls (historic hooks don't support wrappers) + hookexec = getattr(self._orig, "_hookexec") + + normal_impls = self._get_filtered(self._orig._hookimpls) + wrapper_impls: list[WrapperImpl] = [] + + # Historic hooks should have empty wrapper list + assert not wrapper_impls, "Historic hooks don't support wrappers" + empty_wrappers = cast(list[WrapperImpl], []) + # Historic hooks need async_submitter + res = hookexec( + self.name, + normal_impls, + empty_wrappers, + kwargs, + False, + self._orig._async_submitter, + ) + if result_callback is None: + return + if isinstance(res, list): + for x in res: + result_callback(x) + + def call_extra( + self, methods: Sequence[Callable[..., object]], kwargs: Mapping[str, object] + ) -> Any: + """Call the hook with some additional temporarily participating methods.""" + if self.is_historic(): + raise RuntimeError( + "Cannot call call_extra on a historic hook - use call_historic instead." + ) + assert isinstance(self._orig, NormalHookCaller) + if self.spec: + self.spec.verify_all_args_are_provided(kwargs) + config = HookimplConfiguration() + normal_impls = self._get_filtered(self._orig._normal_hookimpls) + wrapper_impls = self._get_filtered(self._orig._wrapper_hookimpls) + + # Add extra methods to normal implementations list + for method in methods: + hookimpl = config.create_hookimpl(None, "", method) + # call_extra only supports normal implementations + assert isinstance(hookimpl, HookImpl) + # Use the same insertion logic as NormalHookCaller.call_extra + _insert_hookimpl_into_list(hookimpl, normal_impls) + + firstresult = self.spec.config.firstresult if self.spec else False + hookexec = getattr(self._orig, "_hookexec") + # Both NormalHookCaller and HistoricHookCaller now have async_submitter + return hookexec( + self.name, + normal_impls, + wrapper_impls, + kwargs, + firstresult, + self._orig._async_submitter, + ) + + def __repr__(self) -> str: + return f"" + + +# Historical name (pluggy<=1.2), kept for backward compatibility. +_SubsetHookCaller = SubsetHookCaller + + +class HookImpl: + """Base class for hook implementations in a :class:`HookCaller`.""" + + __slots__ = ( + "function", + "argnames", + "kwargnames", + "plugin", + "plugin_name", + "wrapper", + "hookwrapper", + "optionalhook", + "tryfirst", + "trylast", + "hookimpl_config", + ) + + function: Final[_HookImplFunction[object]] + argnames: Final[tuple[str, ...]] + kwargnames: Final[tuple[str, ...]] + plugin: Final[_Plugin] + plugin_name: Final[str] + wrapper: Final[bool] + hookwrapper: Final[bool] + optionalhook: Final[bool] + tryfirst: Final[bool] + trylast: Final[bool] + hookimpl_config: Final[HookimplConfiguration] + + def __init__( + self, + plugin: _Plugin, + plugin_name: str, + function: _HookImplFunction[object], + hook_impl_config: HookimplConfiguration, + ) -> None: + """:meta private:""" + #: The hook implementation function. + self.function = function + argnames, kwargnames = varnames(self.function) + #: The positional parameter names of ``function```. + self.argnames = argnames + #: The keyword parameter names of ``function```. + self.kwargnames = kwargnames + #: The plugin which defined this hook implementation. + self.plugin = plugin + #: The :class:`HookimplConfiguration` used to configure this hook + #: implementation. + self.hookimpl_config = hook_impl_config + #: The name of the plugin which defined this hook implementation. + self.plugin_name = plugin_name + #: Whether the hook implementation is a :ref:`wrapper `. + self.wrapper = hook_impl_config.wrapper + #: Whether the hook implementation is an :ref:`old-style wrapper + #: `. + self.hookwrapper = hook_impl_config.hookwrapper + #: Whether validation against a hook specification is :ref:`optional + #: `. + self.optionalhook = hook_impl_config.optionalhook + #: Whether to try to order this hook implementation :ref:`first + #: `. + self.tryfirst = hook_impl_config.tryfirst + #: Whether to try to order this hook implementation :ref:`last + #: `. + self.trylast = hook_impl_config.trylast + + def _get_call_args(self, caller_kwargs: Mapping[str, object]) -> list[object]: + """Extract arguments for calling this hook implementation. + + Args: + caller_kwargs: Keyword arguments passed to the hook call + + Returns: + List of arguments in the order expected by the hook implementation + + Raises: + HookCallError: If required arguments are missing + """ + try: + return [caller_kwargs[argname] for argname in self.argnames] + except KeyError as e: + # Find the first missing argument for a clearer error message + for argname in self.argnames: # pragma: no cover + if argname not in caller_kwargs: + raise HookCallError( + f"hook call must provide argument {argname!r}" + ) from e + # This should never be reached but keep the original exception just in case + raise # pragma: no cover + + def __repr__(self) -> str: + return ( + f"<{self.__class__.__name__} " + f"plugin_name={self.plugin_name!r}, plugin={self.plugin!r}>" + ) + + +@final +class NormalImpl(HookImpl): + """A normal hook implementation in a :class:`HookCaller`.""" + + def __init__( + self, + plugin: _Plugin, + plugin_name: str, + function: _HookImplFunction[object], + hook_impl_config: HookimplConfiguration, + ) -> None: + """:meta private:""" + if hook_impl_config.wrapper or hook_impl_config.hookwrapper: + raise ValueError( + "HookImpl cannot be used for wrapper implementations. " + "Use WrapperImpl instead." + ) + super().__init__(plugin, plugin_name, function, hook_impl_config) + + +@final +class WrapperImpl(HookImpl): + """A wrapper hook implementation in a :class:`HookCaller`.""" + + def __init__( + self, + plugin: _Plugin, + plugin_name: str, + function: _HookImplFunction[object], + hook_impl_config: HookimplConfiguration, + ) -> None: + """:meta private:""" + if not (hook_impl_config.wrapper or hook_impl_config.hookwrapper): + raise ValueError( + "WrapperImpl can only be used for wrapper implementations. " + "Use HookImpl for normal implementations." + ) + super().__init__(plugin, plugin_name, function, hook_impl_config) + + def setup_and_get_completion_hook( + self, hook_name: str, caller_kwargs: Mapping[str, object] + ) -> CompletionHook: + """Set up wrapper and return a completion hook for teardown processing. + + This method provides a streamlined way to handle wrapper setup and teardown. + Both old-style hookwrappers and new-style wrappers are handled uniformly + by converting old-style wrappers to the new protocol using + run_old_style_hookwrapper. + + Args: + hook_name: Name of the hook being called + caller_kwargs: Keyword arguments passed to the hook call + + Returns: + A completion hook function that handles the teardown process + """ + args = self._get_call_args(caller_kwargs) + + # Use run_old_style_hookwrapper for old-style, direct generator for new-style + if self.hookwrapper: + from ._callers import run_old_style_hookwrapper + + wrapper_gen = run_old_style_hookwrapper(self, hook_name, args) + else: + # New-style wrapper handling + res = self.function(*args) + wrapper_gen = cast(Generator[None, object, object], res) + + # Start the wrapper generator - this is where "did not yield" is checked + try: + next(wrapper_gen) # first yield + except StopIteration: + from ._callers import _raise_wrapfail + + _raise_wrapfail(wrapper_gen, "did not yield") + + def completion_hook( + result: object | list[object] | None, exception: BaseException | None + ) -> tuple[object | list[object] | None, BaseException | None]: + """Unified completion hook for both old-style and new-style wrappers.""" + try: + if exception is not None: + try: + wrapper_gen.throw(exception) + except RuntimeError as re: + # StopIteration from generator causes RuntimeError + # even for coroutine usage - see #544 + if ( + isinstance(exception, StopIteration) + and re.__cause__ is exception + ): + wrapper_gen.close() + return result, exception + else: + raise + else: + wrapper_gen.send(result) + # Following is unreachable for a well behaved hook wrapper. + # Try to force finalizers otherwise postponed till GC action. + # Note: close() may raise if generator handles GeneratorExit. + wrapper_gen.close() + from ._callers import _raise_wrapfail + + _raise_wrapfail(wrapper_gen, "has second yield") + except StopIteration as si: + return si.value, None + except BaseException as e: + return result, e + + return completion_hook diff --git a/src/pluggy/_hook_config.py b/src/pluggy/_hook_config.py new file mode 100644 index 00000000..b63708f1 --- /dev/null +++ b/src/pluggy/_hook_config.py @@ -0,0 +1,233 @@ +""" +Hook configuration classes and type definitions. +""" + +from __future__ import annotations + +from collections.abc import Generator +from collections.abc import Mapping +from collections.abc import Sequence +from types import ModuleType +from typing import Callable +from typing import Final +from typing import final +from typing import Protocol +from typing import TYPE_CHECKING +from typing import TypedDict +from typing import TypeVar +from typing import Union + + +if TYPE_CHECKING: + from ._async import Submitter + +from . import _hook_callers # import as partial module for forward refs +from ._result import Result + + +_T = TypeVar("_T") +_F = TypeVar("_F", bound=Callable[..., object]) +_Namespace = Union[ModuleType, type] +_Plugin = object + + +class _HookExec(Protocol): + def __call__( + self, + hook_name: str, + normal_impls: Sequence[_hook_callers.HookImpl], + wrapper_impls: Sequence[_hook_callers.WrapperImpl], + caller_kwargs: Mapping[str, object], + firstresult: bool, + async_submitter: Submitter, + ) -> object | list[object]: ... + + +_HookImplFunction = Callable[..., Union[_T, Generator[None, Result[_T], None]]] + + +class HookspecOpts(TypedDict): + """Options for a hook specification.""" + + #: Whether the hook is :ref:`first result only `. + firstresult: bool + #: Whether the hook is :ref:`historic `. + historic: bool + #: Whether the hook :ref:`warns when implemented `. + warn_on_impl: Warning | None + #: Whether the hook warns when :ref:`certain arguments are requested + #: `. + #: + #: .. versionadded:: 1.5 + warn_on_impl_args: Mapping[str, Warning] | None + + +class HookimplOpts(TypedDict): + """Options for a hook implementation.""" + + #: Whether the hook implementation is a :ref:`wrapper `. + wrapper: bool + #: Whether the hook implementation is an :ref:`old-style wrapper + #: `. + hookwrapper: bool + #: Whether validation against a hook specification is :ref:`optional + #: `. + optionalhook: bool + #: Whether to try to order this hook implementation :ref:`first + #: `. + tryfirst: bool + #: Whether to try to order this hook implementation :ref:`last + #: `. + trylast: bool + #: The name of the hook specification to match, see :ref:`specname`. + specname: str | None + + +@final +class HookspecConfiguration: + """Configuration class for hook specifications. + + This class is intended to replace HookspecOpts in future versions. + It provides a more structured and extensible way to configure hook specifications. + """ + + __slots__ = ( + "firstresult", + "historic", + "warn_on_impl", + "warn_on_impl_args", + ) + firstresult: Final[bool] + historic: Final[bool] + warn_on_impl: Final[Warning | None] + warn_on_impl_args: Final[Mapping[str, Warning] | None] + + def __init__( + self, + firstresult: bool = False, + historic: bool = False, + warn_on_impl: Warning | None = None, + warn_on_impl_args: Mapping[str, Warning] | None = None, + ) -> None: + """Initialize hook specification configuration. + + :param firstresult: + Whether the hook is :ref:`first result only `. + :param historic: + Whether the hook is :ref:`historic `. + :param warn_on_impl: + Whether the hook :ref:`warns when implemented `. + :param warn_on_impl_args: + Whether the hook warns when :ref:`certain arguments are requested + `. + """ + if historic and firstresult: + raise ValueError("cannot have a historic firstresult hook") + #: Whether the hook is :ref:`first result only `. + self.firstresult = firstresult + #: Whether the hook is :ref:`historic `. + self.historic = historic + #: Whether the hook :ref:`warns when implemented `. + self.warn_on_impl = warn_on_impl + #: Whether the hook warns when :ref:`certain arguments are requested + #: `. + self.warn_on_impl_args = warn_on_impl_args + + def __repr__(self) -> str: + attrs = [] + for slot in self.__slots__: + value = getattr(self, slot) + if value: + attrs.append(f"{slot}={value!r}") + attrs_str = ", ".join(attrs) + return f"HookspecConfiguration({attrs_str})" + + +@final +class HookimplConfiguration: + """Configuration class for hook implementations. + + This class is intended to replace HookimplOpts in future versions. + It provides a more structured and extensible way to configure hook implementations. + """ + + __slots__ = ( + "wrapper", + "hookwrapper", + "optionalhook", + "tryfirst", + "trylast", + "specname", + ) + wrapper: Final[bool] + hookwrapper: Final[bool] + optionalhook: Final[bool] + tryfirst: Final[bool] + trylast: Final[bool] + specname: Final[str | None] + + def __init__( + self, + wrapper: bool = False, + hookwrapper: bool = False, + optionalhook: bool = False, + tryfirst: bool = False, + trylast: bool = False, + specname: str | None = None, + ) -> None: + """Initialize hook implementation configuration. + + :param wrapper: + Whether the hook implementation is a :ref:`wrapper `. + :param hookwrapper: + Whether the hook implementation is an :ref:`old-style wrapper + `. + :param optionalhook: + Whether validation against a hook specification is :ref:`optional + `. + :param tryfirst: + Whether to try to order this hook implementation :ref:`first + `. + :param trylast: + Whether to try to order this hook implementation :ref:`last + `. + :param specname: + The name of the hook specification to match, see :ref:`specname`. + """ + #: Whether the hook implementation is a :ref:`wrapper `. + self.wrapper = wrapper + #: Whether the hook implementation is an :ref:`old-style wrapper + #: `. + self.hookwrapper = hookwrapper + #: Whether validation against a hook specification is :ref:`optional + #: `. + self.optionalhook = optionalhook + #: Whether to try to order this hook implementation :ref:`first + #: `. + self.tryfirst = tryfirst + #: Whether to try to order this hook implementation :ref:`last + #: `. + self.trylast = trylast + #: The name of the hook specification to match, see :ref:`specname`. + self.specname = specname + + def create_hookimpl( + self, + plugin: _Plugin, + plugin_name: str, + function: _HookImplFunction[object], + ) -> _hook_callers.HookImpl | _hook_callers.WrapperImpl: + """Create the appropriate HookImpl subclass based on configuration.""" + if self.wrapper or self.hookwrapper: + return _hook_callers.WrapperImpl(plugin, plugin_name, function, self) + else: + return _hook_callers.HookImpl(plugin, plugin_name, function, self) + + def __repr__(self) -> str: + attrs = [] + for slot in self.__slots__: + value = getattr(self, slot) + if value: + attrs.append(f"{slot}={value!r}") + attrs_str = ", ".join(attrs) + return f"HookimplConfiguration({attrs_str})" diff --git a/src/pluggy/_hook_markers.py b/src/pluggy/_hook_markers.py new file mode 100644 index 00000000..5d73d53f --- /dev/null +++ b/src/pluggy/_hook_markers.py @@ -0,0 +1,364 @@ +""" +Hook marker decorators and hook specification classes. +""" + +from __future__ import annotations + +from collections.abc import Mapping +import inspect +import sys +from typing import Callable +from typing import Final +from typing import final +from typing import overload +from typing import TypeVar +import warnings + +from . import _project +from ._hook_config import _Namespace +from ._hook_config import HookimplConfiguration +from ._hook_config import HookspecConfiguration + + +_F = TypeVar("_F", bound=Callable[..., object]) + +_PYPY = hasattr(sys, "pypy_version_info") + + +def varnames(func: object) -> tuple[tuple[str, ...], tuple[str, ...]]: + """Return tuple of positional and keywrord argument names for a function, + method, class or callable. + + In case of a class, its ``__init__`` method is considered. + For methods the ``self`` parameter is not included. + """ + if inspect.isclass(func): + try: + func = func.__init__ + except AttributeError: # pragma: no cover - pypy special case + return (), () + elif not inspect.isroutine(func): # callable object? + try: + func = getattr(func, "__call__", func) + except Exception: # pragma: no cover - pypy special case + return (), () + + try: + # func MUST be a function or method here or we won't parse any args. + sig = inspect.signature( + func.__func__ if inspect.ismethod(func) else func # type:ignore[arg-type] + ) + except TypeError: # pragma: no cover + return (), () + + _valid_param_kinds = ( + inspect.Parameter.POSITIONAL_ONLY, + inspect.Parameter.POSITIONAL_OR_KEYWORD, + ) + _valid_params = { + name: param + for name, param in sig.parameters.items() + if param.kind in _valid_param_kinds + } + args = tuple(_valid_params) + defaults = ( + tuple( + param.default + for param in _valid_params.values() + if param.default is not param.empty + ) + or None + ) + + if defaults: + index = -len(defaults) + args, kwargs = args[:index], tuple(args[index:]) + else: + kwargs = () + + # strip any implicit instance arg + # pypy3 uses "obj" instead of "self" for default dunder methods + if not _PYPY: + implicit_names: tuple[str, ...] = ("self",) + else: # pragma: no cover + implicit_names = ("self", "obj") + if args: + qualname: str = getattr(func, "__qualname__", "") + if inspect.ismethod(func) or ("." in qualname and args[0] in implicit_names): + args = args[1:] + + return args, kwargs + + +@final +class HookspecMarker: + """Decorator for marking functions as hook specifications. + + Instantiate it with a project_name or ProjectSpec to get a decorator. + Calling :meth:`PluginManager.add_hookspecs` later will discover all marked + functions if the :class:`PluginManager` uses the same project name. + """ + + __slots__ = ("_project_spec",) + _project_spec: Final[_project.ProjectSpec] + + def __init__(self, project_name_or_spec: str | _project.ProjectSpec) -> None: + self._project_spec = ( + _project.ProjectSpec(project_name_or_spec) + if isinstance(project_name_or_spec, str) + else project_name_or_spec + ) + + @property + def project_name(self) -> str: + """The project name from the associated ProjectSpec.""" + return self._project_spec.project_name + + @overload + def __call__( + self, + function: _F, + firstresult: bool = False, + historic: bool = False, + warn_on_impl: Warning | None = None, + warn_on_impl_args: Mapping[str, Warning] | None = None, + ) -> _F: ... + + @overload # noqa: F811 + def __call__( # noqa: F811 + self, + function: None = ..., + firstresult: bool = ..., + historic: bool = ..., + warn_on_impl: Warning | None = ..., + warn_on_impl_args: Mapping[str, Warning] | None = ..., + ) -> Callable[[_F], _F]: ... + + def __call__( # noqa: F811 + self, + function: _F | None = None, + firstresult: bool = False, + historic: bool = False, + warn_on_impl: Warning | None = None, + warn_on_impl_args: Mapping[str, Warning] | None = None, + ) -> _F | Callable[[_F], _F]: + """If passed a function, directly sets attributes on the function + which will make it discoverable to :meth:`PluginManager.add_hookspecs`. + + If passed no function, returns a decorator which can be applied to a + function later using the attributes supplied. + + :param firstresult: + If ``True``, the 1:N hook call (N being the number of registered + hook implementation functions) will stop at I<=N when the I'th + function returns a non-``None`` result. See :ref:`firstresult`. + + :param historic: + If ``True``, every call to the hook will be memorized and replayed + on plugins registered after the call was made. See :ref:`historic`. + + :param warn_on_impl: + If given, every implementation of this hook will trigger the given + warning. See :ref:`warn_on_impl`. + + :param warn_on_impl_args: + If given, every implementation of this hook which requests one of + the arguments in the dict will trigger the corresponding warning. + See :ref:`warn_on_impl`. + + .. versionadded:: 1.5 + """ + + def setattr_hookspec_opts(func: _F) -> _F: + config = HookspecConfiguration( + firstresult=firstresult, + historic=historic, + warn_on_impl=warn_on_impl, + warn_on_impl_args=warn_on_impl_args, + ) + setattr(func, self.project_name + "_spec", config) + return func + + if function is not None: + return setattr_hookspec_opts(function) + else: + return setattr_hookspec_opts + + +@final +class HookimplMarker: + """Decorator for marking functions as hook implementations. + + Instantiate it with a ``project_name`` or ProjectSpec to get a decorator. + Calling :meth:`PluginManager.register` later will discover all marked + functions if the :class:`PluginManager` uses the same project name. + """ + + __slots__ = ("_project_spec",) + _project_spec: Final[_project.ProjectSpec] + + def __init__(self, project_name_or_spec: str | _project.ProjectSpec) -> None: + self._project_spec = ( + _project.ProjectSpec(project_name_or_spec) + if isinstance(project_name_or_spec, str) + else project_name_or_spec + ) + + @property + def project_name(self) -> str: + """The project name from the associated ProjectSpec.""" + return self._project_spec.project_name + + @overload + def __call__( + self, + function: _F, + hookwrapper: bool = ..., + optionalhook: bool = ..., + tryfirst: bool = ..., + trylast: bool = ..., + specname: str | None = ..., + wrapper: bool = ..., + ) -> _F: ... + + @overload # noqa: F811 + def __call__( # noqa: F811 + self, + function: None = ..., + hookwrapper: bool = ..., + optionalhook: bool = ..., + tryfirst: bool = ..., + trylast: bool = ..., + specname: str | None = ..., + wrapper: bool = ..., + ) -> Callable[[_F], _F]: ... + + def __call__( # noqa: F811 + self, + function: _F | None = None, + hookwrapper: bool = False, + optionalhook: bool = False, + tryfirst: bool = False, + trylast: bool = False, + specname: str | None = None, + wrapper: bool = False, + ) -> _F | Callable[[_F], _F]: + """If passed a function, directly sets attributes on the function + which will make it discoverable to :meth:`PluginManager.register`. + + If passed no function, returns a decorator which can be applied to a + function later using the attributes supplied. + + :param optionalhook: + If ``True``, a missing matching hook specification will not result + in an error (by default it is an error if no matching spec is + found). See :ref:`optionalhook`. + + :param tryfirst: + If ``True``, this hook implementation will run as early as possible + in the chain of N hook implementations for a specification. See + :ref:`callorder`. + + :param trylast: + If ``True``, this hook implementation will run as late as possible + in the chain of N hook implementations for a specification. See + :ref:`callorder`. + + :param wrapper: + If ``True`` ("new-style hook wrapper"), the hook implementation + needs to execute exactly one ``yield``. The code before the + ``yield`` is run early before any non-hook-wrapper function is run. + The code after the ``yield`` is run after all non-hook-wrapper + functions have run. The ``yield`` receives the result value of the + inner calls, or raises the exception of inner calls (including + earlier hook wrapper calls). The return value of the function + becomes the return value of the hook, and a raised exception becomes + the exception of the hook. See :ref:`hookwrapper`. + + :param hookwrapper: + If ``True`` ("old-style hook wrapper"), the hook implementation + needs to execute exactly one ``yield``. The code before the + ``yield`` is run early before any non-hook-wrapper function is run. + The code after the ``yield`` is run after all non-hook-wrapper + function have run The ``yield`` receives a :class:`Result` object + representing the exception or result outcome of the inner calls + (including earlier hook wrapper calls). This option is mutually + exclusive with ``wrapper``. See :ref:`old_style_hookwrapper`. + + :param specname: + If provided, the given name will be used instead of the function + name when matching this hook implementation to a hook specification + during registration. See :ref:`specname`. + + .. versionadded:: 1.2.0 + The ``wrapper`` parameter. + """ + + def setattr_hookimpl_opts(func: _F) -> _F: + config = HookimplConfiguration( + wrapper=wrapper, + hookwrapper=hookwrapper, + optionalhook=optionalhook, + tryfirst=tryfirst, + trylast=trylast, + specname=specname, + ) + setattr(func, self.project_name + "_impl", config) + return func + + if function is None: + return setattr_hookimpl_opts + else: + return setattr_hookimpl_opts(function) + + +@final +class HookSpec: + __slots__ = ( + "namespace", + "function", + "name", + "argnames", + "kwargnames", + "config", + "warn_on_impl", + "warn_on_impl_args", + ) + namespace: _Namespace + function: Callable[..., object] + name: str + argnames: tuple[str, ...] + kwargnames: tuple[str, ...] + config: HookspecConfiguration + warn_on_impl: Warning | None + warn_on_impl_args: Mapping[str, Warning] | None + + def __init__( + self, namespace: _Namespace, name: str, config: HookspecConfiguration + ) -> None: + self.namespace = namespace + self.function: Callable[..., object] = getattr(namespace, name) + self.name = name + self.argnames, self.kwargnames = varnames(self.function) + self.config = config + self.warn_on_impl = config.warn_on_impl + self.warn_on_impl_args = config.warn_on_impl_args + + def verify_all_args_are_provided(self, kwargs: Mapping[str, object]) -> None: + """Verify that all required arguments are provided in kwargs.""" + # This is written to avoid expensive operations when not needed. + for argname in self.argnames: + if argname not in kwargs: + notincall = ", ".join( + repr(argname) + for argname in self.argnames + # Avoid self.argnames - kwargs.keys() + # it doesn't preserve order. + if argname not in kwargs.keys() + ) + warnings.warn( + f"Argument(s) {notincall} which are declared in the hookspec " + "cannot be found in this hook call", + stacklevel=3, # Adjusted for delegation + ) + break diff --git a/src/pluggy/_hooks.py b/src/pluggy/_hooks.py index 97fef0d7..7ce22ab8 100644 --- a/src/pluggy/_hooks.py +++ b/src/pluggy/_hooks.py @@ -1,714 +1,54 @@ """ -Internal hook annotation, representation and calling machinery. -""" - -from __future__ import annotations - -from collections.abc import Generator -from collections.abc import Mapping -from collections.abc import Sequence -from collections.abc import Set -import inspect -import sys -from types import ModuleType -from typing import Any -from typing import Callable -from typing import Final -from typing import final -from typing import Optional -from typing import overload -from typing import TYPE_CHECKING -from typing import TypedDict -from typing import TypeVar -from typing import Union -import warnings - -from ._result import Result +Backward compatibility re-exports for hook functionality. +This module re-exports all hook-related classes and functions from their +new organized modules to maintain backward compatibility. +""" -_T = TypeVar("_T") -_F = TypeVar("_F", bound=Callable[..., object]) -_Namespace = Union[ModuleType, type] -_Plugin = object -_HookExec = Callable[ - [str, Sequence["HookImpl"], Mapping[str, object], bool], - Union[object, list[object]], +# Configuration classes and types +from ._hook_callers import _HookCaller +from ._hook_callers import _HookRelay +from ._hook_callers import _SubsetHookCaller +from ._hook_callers import HistoricHookCaller + +# Hook callers and implementations +from ._hook_callers import HookCaller +from ._hook_callers import HookImpl +from ._hook_callers import HookRelay +from ._hook_callers import NormalHookCaller +from ._hook_callers import SubsetHookCaller +from ._hook_config import HookimplConfiguration +from ._hook_config import HookimplOpts +from ._hook_config import HookspecConfiguration +from ._hook_config import HookspecOpts +from ._hook_markers import HookimplMarker +from ._hook_markers import HookSpec + +# Hook markers and specifications +from ._hook_markers import HookspecMarker +from ._hook_markers import varnames + + +# Re-export all public symbols for backward compatibility +__all__ = [ + # Configuration + "HookspecOpts", + "HookimplOpts", + "HookspecConfiguration", + "HookimplConfiguration", + # Markers and specifications + "HookspecMarker", + "HookimplMarker", + "HookSpec", + "varnames", + # Callers and implementations + "HookCaller", + "HookRelay", + "_HookRelay", + "HistoricHookCaller", + "NormalHookCaller", + "_HookCaller", + "SubsetHookCaller", + "_SubsetHookCaller", + "HookImpl", ] -_HookImplFunction = Callable[..., Union[_T, Generator[None, Result[_T], None]]] - - -class HookspecOpts(TypedDict): - """Options for a hook specification.""" - - #: Whether the hook is :ref:`first result only `. - firstresult: bool - #: Whether the hook is :ref:`historic `. - historic: bool - #: Whether the hook :ref:`warns when implemented `. - warn_on_impl: Warning | None - #: Whether the hook warns when :ref:`certain arguments are requested - #: `. - #: - #: .. versionadded:: 1.5 - warn_on_impl_args: Mapping[str, Warning] | None - - -class HookimplOpts(TypedDict): - """Options for a hook implementation.""" - - #: Whether the hook implementation is a :ref:`wrapper `. - wrapper: bool - #: Whether the hook implementation is an :ref:`old-style wrapper - #: `. - hookwrapper: bool - #: Whether validation against a hook specification is :ref:`optional - #: `. - optionalhook: bool - #: Whether to try to order this hook implementation :ref:`first - #: `. - tryfirst: bool - #: Whether to try to order this hook implementation :ref:`last - #: `. - trylast: bool - #: The name of the hook specification to match, see :ref:`specname`. - specname: str | None - - -@final -class HookspecMarker: - """Decorator for marking functions as hook specifications. - - Instantiate it with a project_name to get a decorator. - Calling :meth:`PluginManager.add_hookspecs` later will discover all marked - functions if the :class:`PluginManager` uses the same project name. - """ - - __slots__ = ("project_name",) - - def __init__(self, project_name: str) -> None: - self.project_name: Final = project_name - - @overload - def __call__( - self, - function: _F, - firstresult: bool = False, - historic: bool = False, - warn_on_impl: Warning | None = None, - warn_on_impl_args: Mapping[str, Warning] | None = None, - ) -> _F: ... - - @overload # noqa: F811 - def __call__( # noqa: F811 - self, - function: None = ..., - firstresult: bool = ..., - historic: bool = ..., - warn_on_impl: Warning | None = ..., - warn_on_impl_args: Mapping[str, Warning] | None = ..., - ) -> Callable[[_F], _F]: ... - - def __call__( # noqa: F811 - self, - function: _F | None = None, - firstresult: bool = False, - historic: bool = False, - warn_on_impl: Warning | None = None, - warn_on_impl_args: Mapping[str, Warning] | None = None, - ) -> _F | Callable[[_F], _F]: - """If passed a function, directly sets attributes on the function - which will make it discoverable to :meth:`PluginManager.add_hookspecs`. - - If passed no function, returns a decorator which can be applied to a - function later using the attributes supplied. - - :param firstresult: - If ``True``, the 1:N hook call (N being the number of registered - hook implementation functions) will stop at I<=N when the I'th - function returns a non-``None`` result. See :ref:`firstresult`. - - :param historic: - If ``True``, every call to the hook will be memorized and replayed - on plugins registered after the call was made. See :ref:`historic`. - - :param warn_on_impl: - If given, every implementation of this hook will trigger the given - warning. See :ref:`warn_on_impl`. - - :param warn_on_impl_args: - If given, every implementation of this hook which requests one of - the arguments in the dict will trigger the corresponding warning. - See :ref:`warn_on_impl`. - - .. versionadded:: 1.5 - """ - - def setattr_hookspec_opts(func: _F) -> _F: - if historic and firstresult: - raise ValueError("cannot have a historic firstresult hook") - opts: HookspecOpts = { - "firstresult": firstresult, - "historic": historic, - "warn_on_impl": warn_on_impl, - "warn_on_impl_args": warn_on_impl_args, - } - setattr(func, self.project_name + "_spec", opts) - return func - - if function is not None: - return setattr_hookspec_opts(function) - else: - return setattr_hookspec_opts - - -@final -class HookimplMarker: - """Decorator for marking functions as hook implementations. - - Instantiate it with a ``project_name`` to get a decorator. - Calling :meth:`PluginManager.register` later will discover all marked - functions if the :class:`PluginManager` uses the same project name. - """ - - __slots__ = ("project_name",) - - def __init__(self, project_name: str) -> None: - self.project_name: Final = project_name - - @overload - def __call__( - self, - function: _F, - hookwrapper: bool = ..., - optionalhook: bool = ..., - tryfirst: bool = ..., - trylast: bool = ..., - specname: str | None = ..., - wrapper: bool = ..., - ) -> _F: ... - - @overload # noqa: F811 - def __call__( # noqa: F811 - self, - function: None = ..., - hookwrapper: bool = ..., - optionalhook: bool = ..., - tryfirst: bool = ..., - trylast: bool = ..., - specname: str | None = ..., - wrapper: bool = ..., - ) -> Callable[[_F], _F]: ... - - def __call__( # noqa: F811 - self, - function: _F | None = None, - hookwrapper: bool = False, - optionalhook: bool = False, - tryfirst: bool = False, - trylast: bool = False, - specname: str | None = None, - wrapper: bool = False, - ) -> _F | Callable[[_F], _F]: - """If passed a function, directly sets attributes on the function - which will make it discoverable to :meth:`PluginManager.register`. - - If passed no function, returns a decorator which can be applied to a - function later using the attributes supplied. - - :param optionalhook: - If ``True``, a missing matching hook specification will not result - in an error (by default it is an error if no matching spec is - found). See :ref:`optionalhook`. - - :param tryfirst: - If ``True``, this hook implementation will run as early as possible - in the chain of N hook implementations for a specification. See - :ref:`callorder`. - - :param trylast: - If ``True``, this hook implementation will run as late as possible - in the chain of N hook implementations for a specification. See - :ref:`callorder`. - - :param wrapper: - If ``True`` ("new-style hook wrapper"), the hook implementation - needs to execute exactly one ``yield``. The code before the - ``yield`` is run early before any non-hook-wrapper function is run. - The code after the ``yield`` is run after all non-hook-wrapper - functions have run. The ``yield`` receives the result value of the - inner calls, or raises the exception of inner calls (including - earlier hook wrapper calls). The return value of the function - becomes the return value of the hook, and a raised exception becomes - the exception of the hook. See :ref:`hookwrapper`. - - :param hookwrapper: - If ``True`` ("old-style hook wrapper"), the hook implementation - needs to execute exactly one ``yield``. The code before the - ``yield`` is run early before any non-hook-wrapper function is run. - The code after the ``yield`` is run after all non-hook-wrapper - function have run The ``yield`` receives a :class:`Result` object - representing the exception or result outcome of the inner calls - (including earlier hook wrapper calls). This option is mutually - exclusive with ``wrapper``. See :ref:`old_style_hookwrapper`. - - :param specname: - If provided, the given name will be used instead of the function - name when matching this hook implementation to a hook specification - during registration. See :ref:`specname`. - - .. versionadded:: 1.2.0 - The ``wrapper`` parameter. - """ - - def setattr_hookimpl_opts(func: _F) -> _F: - opts: HookimplOpts = { - "wrapper": wrapper, - "hookwrapper": hookwrapper, - "optionalhook": optionalhook, - "tryfirst": tryfirst, - "trylast": trylast, - "specname": specname, - } - setattr(func, self.project_name + "_impl", opts) - return func - - if function is None: - return setattr_hookimpl_opts - else: - return setattr_hookimpl_opts(function) - - -def normalize_hookimpl_opts(opts: HookimplOpts) -> None: - opts.setdefault("tryfirst", False) - opts.setdefault("trylast", False) - opts.setdefault("wrapper", False) - opts.setdefault("hookwrapper", False) - opts.setdefault("optionalhook", False) - opts.setdefault("specname", None) - - -_PYPY = hasattr(sys, "pypy_version_info") - - -def varnames(func: object) -> tuple[tuple[str, ...], tuple[str, ...]]: - """Return tuple of positional and keywrord argument names for a function, - method, class or callable. - - In case of a class, its ``__init__`` method is considered. - For methods the ``self`` parameter is not included. - """ - if inspect.isclass(func): - try: - func = func.__init__ - except AttributeError: # pragma: no cover - pypy special case - return (), () - elif not inspect.isroutine(func): # callable object? - try: - func = getattr(func, "__call__", func) - except Exception: # pragma: no cover - pypy special case - return (), () - - try: - # func MUST be a function or method here or we won't parse any args. - sig = inspect.signature( - func.__func__ if inspect.ismethod(func) else func # type:ignore[arg-type] - ) - except TypeError: # pragma: no cover - return (), () - - _valid_param_kinds = ( - inspect.Parameter.POSITIONAL_ONLY, - inspect.Parameter.POSITIONAL_OR_KEYWORD, - ) - _valid_params = { - name: param - for name, param in sig.parameters.items() - if param.kind in _valid_param_kinds - } - args = tuple(_valid_params) - defaults = ( - tuple( - param.default - for param in _valid_params.values() - if param.default is not param.empty - ) - or None - ) - - if defaults: - index = -len(defaults) - args, kwargs = args[:index], tuple(args[index:]) - else: - kwargs = () - - # strip any implicit instance arg - # pypy3 uses "obj" instead of "self" for default dunder methods - if not _PYPY: - implicit_names: tuple[str, ...] = ("self",) - else: # pragma: no cover - implicit_names = ("self", "obj") - if args: - qualname: str = getattr(func, "__qualname__", "") - if inspect.ismethod(func) or ("." in qualname and args[0] in implicit_names): - args = args[1:] - - return args, kwargs - - -@final -class HookRelay: - """Hook holder object for performing 1:N hook calls where N is the number - of registered plugins.""" - - __slots__ = ("__dict__",) - - def __init__(self) -> None: - """:meta private:""" - - if TYPE_CHECKING: - - def __getattr__(self, name: str) -> HookCaller: ... - - -# Historical name (pluggy<=1.2), kept for backward compatibility. -_HookRelay = HookRelay - - -_CallHistory = list[tuple[Mapping[str, object], Optional[Callable[[Any], None]]]] - - -class HookCaller: - """A caller of all registered implementations of a hook specification.""" - - __slots__ = ( - "name", - "spec", - "_hookexec", - "_hookimpls", - "_call_history", - ) - - def __init__( - self, - name: str, - hook_execute: _HookExec, - specmodule_or_class: _Namespace | None = None, - spec_opts: HookspecOpts | None = None, - ) -> None: - """:meta private:""" - #: Name of the hook getting called. - self.name: Final = name - self._hookexec: Final = hook_execute - # The hookimpls list. The caller iterates it *in reverse*. Format: - # 1. trylast nonwrappers - # 2. nonwrappers - # 3. tryfirst nonwrappers - # 4. trylast wrappers - # 5. wrappers - # 6. tryfirst wrappers - self._hookimpls: Final[list[HookImpl]] = [] - self._call_history: _CallHistory | None = None - # TODO: Document, or make private. - self.spec: HookSpec | None = None - if specmodule_or_class is not None: - assert spec_opts is not None - self.set_specification(specmodule_or_class, spec_opts) - - # TODO: Document, or make private. - def has_spec(self) -> bool: - return self.spec is not None - - # TODO: Document, or make private. - def set_specification( - self, - specmodule_or_class: _Namespace, - spec_opts: HookspecOpts, - ) -> None: - if self.spec is not None: - raise ValueError( - f"Hook {self.spec.name!r} is already registered " - f"within namespace {self.spec.namespace}" - ) - self.spec = HookSpec(specmodule_or_class, self.name, spec_opts) - if spec_opts.get("historic"): - self._call_history = [] - - def is_historic(self) -> bool: - """Whether this caller is :ref:`historic `.""" - return self._call_history is not None - - def _remove_plugin(self, plugin: _Plugin) -> None: - for i, method in enumerate(self._hookimpls): - if method.plugin == plugin: - del self._hookimpls[i] - return - raise ValueError(f"plugin {plugin!r} not found") - - def get_hookimpls(self) -> list[HookImpl]: - """Get all registered hook implementations for this hook.""" - return self._hookimpls.copy() - - def _add_hookimpl(self, hookimpl: HookImpl) -> None: - """Add an implementation to the callback chain.""" - for i, method in enumerate(self._hookimpls): - if method.hookwrapper or method.wrapper: - splitpoint = i - break - else: - splitpoint = len(self._hookimpls) - if hookimpl.hookwrapper or hookimpl.wrapper: - start, end = splitpoint, len(self._hookimpls) - else: - start, end = 0, splitpoint - - if hookimpl.trylast: - self._hookimpls.insert(start, hookimpl) - elif hookimpl.tryfirst: - self._hookimpls.insert(end, hookimpl) - else: - # find last non-tryfirst method - i = end - 1 - while i >= start and self._hookimpls[i].tryfirst: - i -= 1 - self._hookimpls.insert(i + 1, hookimpl) - - def __repr__(self) -> str: - return f"" - - def _verify_all_args_are_provided(self, kwargs: Mapping[str, object]) -> None: - # This is written to avoid expensive operations when not needed. - if self.spec: - for argname in self.spec.argnames: - if argname not in kwargs: - notincall = ", ".join( - repr(argname) - for argname in self.spec.argnames - # Avoid self.spec.argnames - kwargs.keys() - # it doesn't preserve order. - if argname not in kwargs.keys() - ) - warnings.warn( - f"Argument(s) {notincall} which are declared in the hookspec " - "cannot be found in this hook call", - stacklevel=2, - ) - break - - def __call__(self, **kwargs: object) -> Any: - """Call the hook. - - Only accepts keyword arguments, which should match the hook - specification. - - Returns the result(s) of calling all registered plugins, see - :ref:`calling`. - """ - assert not self.is_historic(), ( - "Cannot directly call a historic hook - use call_historic instead." - ) - self._verify_all_args_are_provided(kwargs) - firstresult = self.spec.opts.get("firstresult", False) if self.spec else False - # Copy because plugins may register other plugins during iteration (#438). - return self._hookexec(self.name, self._hookimpls.copy(), kwargs, firstresult) - - def call_historic( - self, - result_callback: Callable[[Any], None] | None = None, - kwargs: Mapping[str, object] | None = None, - ) -> None: - """Call the hook with given ``kwargs`` for all registered plugins and - for all plugins which will be registered afterwards, see - :ref:`historic`. - - :param result_callback: - If provided, will be called for each non-``None`` result obtained - from a hook implementation. - """ - assert self._call_history is not None - kwargs = kwargs or {} - self._verify_all_args_are_provided(kwargs) - self._call_history.append((kwargs, result_callback)) - # Historizing hooks don't return results. - # Remember firstresult isn't compatible with historic. - # Copy because plugins may register other plugins during iteration (#438). - res = self._hookexec(self.name, self._hookimpls.copy(), kwargs, False) - if result_callback is None: - return - if isinstance(res, list): - for x in res: - result_callback(x) - - def call_extra( - self, methods: Sequence[Callable[..., object]], kwargs: Mapping[str, object] - ) -> Any: - """Call the hook with some additional temporarily participating - methods using the specified ``kwargs`` as call parameters, see - :ref:`call_extra`.""" - assert not self.is_historic(), ( - "Cannot directly call a historic hook - use call_historic instead." - ) - self._verify_all_args_are_provided(kwargs) - opts: HookimplOpts = { - "wrapper": False, - "hookwrapper": False, - "optionalhook": False, - "trylast": False, - "tryfirst": False, - "specname": None, - } - hookimpls = self._hookimpls.copy() - for method in methods: - hookimpl = HookImpl(None, "", method, opts) - # Find last non-tryfirst nonwrapper method. - i = len(hookimpls) - 1 - while i >= 0 and ( - # Skip wrappers. - (hookimpls[i].hookwrapper or hookimpls[i].wrapper) - # Skip tryfirst nonwrappers. - or hookimpls[i].tryfirst - ): - i -= 1 - hookimpls.insert(i + 1, hookimpl) - firstresult = self.spec.opts.get("firstresult", False) if self.spec else False - return self._hookexec(self.name, hookimpls, kwargs, firstresult) - - def _maybe_apply_history(self, method: HookImpl) -> None: - """Apply call history to a new hookimpl if it is marked as historic.""" - if self.is_historic(): - assert self._call_history is not None - for kwargs, result_callback in self._call_history: - res = self._hookexec(self.name, [method], kwargs, False) - if res and result_callback is not None: - # XXX: remember firstresult isn't compat with historic - assert isinstance(res, list) - result_callback(res[0]) - - -# Historical name (pluggy<=1.2), kept for backward compatibility. -_HookCaller = HookCaller - - -class _SubsetHookCaller(HookCaller): - """A proxy to another HookCaller which manages calls to all registered - plugins except the ones from remove_plugins.""" - - # This class is unusual: in inhertits from `HookCaller` so all of - # the *code* runs in the class, but it delegates all underlying *data* - # to the original HookCaller. - # `subset_hook_caller` used to be implemented by creating a full-fledged - # HookCaller, copying all hookimpls from the original. This had problems - # with memory leaks (#346) and historic calls (#347), which make a proxy - # approach better. - # An alternative implementation is to use a `_getattr__`/`__getattribute__` - # proxy, however that adds more overhead and is more tricky to implement. - - __slots__ = ( - "_orig", - "_remove_plugins", - ) - - def __init__(self, orig: HookCaller, remove_plugins: Set[_Plugin]) -> None: - self._orig = orig - self._remove_plugins = remove_plugins - self.name = orig.name # type: ignore[misc] - self._hookexec = orig._hookexec # type: ignore[misc] - - @property # type: ignore[misc] - def _hookimpls(self) -> list[HookImpl]: - return [ - impl - for impl in self._orig._hookimpls - if impl.plugin not in self._remove_plugins - ] - - @property - def spec(self) -> HookSpec | None: # type: ignore[override] - return self._orig.spec - - @property - def _call_history(self) -> _CallHistory | None: # type: ignore[override] - return self._orig._call_history - - def __repr__(self) -> str: - return f"<_SubsetHookCaller {self.name!r}>" - - -@final -class HookImpl: - """A hook implementation in a :class:`HookCaller`.""" - - __slots__ = ( - "function", - "argnames", - "kwargnames", - "plugin", - "opts", - "plugin_name", - "wrapper", - "hookwrapper", - "optionalhook", - "tryfirst", - "trylast", - ) - - def __init__( - self, - plugin: _Plugin, - plugin_name: str, - function: _HookImplFunction[object], - hook_impl_opts: HookimplOpts, - ) -> None: - """:meta private:""" - #: The hook implementation function. - self.function: Final = function - argnames, kwargnames = varnames(self.function) - #: The positional parameter names of ``function```. - self.argnames: Final = argnames - #: The keyword parameter names of ``function```. - self.kwargnames: Final = kwargnames - #: The plugin which defined this hook implementation. - self.plugin: Final = plugin - #: The :class:`HookimplOpts` used to configure this hook implementation. - self.opts: Final = hook_impl_opts - #: The name of the plugin which defined this hook implementation. - self.plugin_name: Final = plugin_name - #: Whether the hook implementation is a :ref:`wrapper `. - self.wrapper: Final = hook_impl_opts["wrapper"] - #: Whether the hook implementation is an :ref:`old-style wrapper - #: `. - self.hookwrapper: Final = hook_impl_opts["hookwrapper"] - #: Whether validation against a hook specification is :ref:`optional - #: `. - self.optionalhook: Final = hook_impl_opts["optionalhook"] - #: Whether to try to order this hook implementation :ref:`first - #: `. - self.tryfirst: Final = hook_impl_opts["tryfirst"] - #: Whether to try to order this hook implementation :ref:`last - #: `. - self.trylast: Final = hook_impl_opts["trylast"] - - def __repr__(self) -> str: - return f"" - - -@final -class HookSpec: - __slots__ = ( - "namespace", - "function", - "name", - "argnames", - "kwargnames", - "opts", - "warn_on_impl", - "warn_on_impl_args", - ) - - def __init__(self, namespace: _Namespace, name: str, opts: HookspecOpts) -> None: - self.namespace = namespace - self.function: Callable[..., object] = getattr(namespace, name) - self.name = name - self.argnames, self.kwargnames = varnames(self.function) - self.opts = opts - self.warn_on_impl = opts.get("warn_on_impl") - self.warn_on_impl_args = opts.get("warn_on_impl_args") diff --git a/src/pluggy/_manager.py b/src/pluggy/_manager.py index ff1e3ce6..c1a94c78 100644 --- a/src/pluggy/_manager.py +++ b/src/pluggy/_manager.py @@ -10,20 +10,27 @@ from typing import cast from typing import Final from typing import TYPE_CHECKING +from typing import TypeVar import warnings +from . import _project from . import _tracing +from ._async import Submitter from ._callers import _multicall -from ._hooks import _HookImplFunction -from ._hooks import _Namespace -from ._hooks import _Plugin -from ._hooks import _SubsetHookCaller -from ._hooks import HookCaller -from ._hooks import HookImpl -from ._hooks import HookimplOpts -from ._hooks import HookRelay -from ._hooks import HookspecOpts -from ._hooks import normalize_hookimpl_opts +from ._hook_callers import HistoricHookCaller +from ._hook_callers import HookCaller +from ._hook_callers import HookImpl +from ._hook_callers import HookRelay +from ._hook_callers import NormalHookCaller +from ._hook_callers import SubsetHookCaller +from ._hook_callers import WrapperImpl +from ._hook_config import _HookImplFunction +from ._hook_config import _Namespace +from ._hook_config import _Plugin +from ._hook_config import HookimplConfiguration +from ._hook_config import HookimplOpts +from ._hook_config import HookspecConfiguration +from ._hook_config import HookspecOpts from ._result import Result @@ -32,6 +39,7 @@ import importlib.metadata +_T = TypeVar("_T") _BeforeTrace = Callable[[str, Sequence[HookImpl], Mapping[str, Any]], None] _AfterTrace = Callable[[Result[Any], str, Sequence[HookImpl], Mapping[str, Any]], None] @@ -90,13 +98,21 @@ class PluginManager: For debugging purposes you can call :meth:`PluginManager.enable_tracing` which will subsequently send debug information to the trace helper. - :param project_name: - The short project name. Prefer snake case. Make sure it's unique! + :param project_name_or_spec: + The short project name (string) or a ProjectSpec instance. """ - def __init__(self, project_name: str) -> None: - #: The project name. - self.project_name: Final = project_name + def __init__( + self, + project_name_or_spec: str | _project.ProjectSpec, + async_submitter: Submitter | None = None, + ) -> None: + self._project_spec: Final = ( + _project.ProjectSpec(project_name_or_spec) + if isinstance(project_name_or_spec, str) + else project_name_or_spec + ) + self._name2plugin: Final[dict[str, _Plugin]] = {} self._plugin_distinfo: Final[list[tuple[_Plugin, DistFacade]]] = [] #: The "hook relay", used to call a hook on all registered plugins. @@ -107,17 +123,32 @@ def __init__(self, project_name: str) -> None: "pluginmanage" ) self._inner_hookexec = _multicall + self._async_submitter: Submitter = async_submitter or Submitter() + + @property + def project_name(self) -> str: + """The project name from the associated ProjectSpec.""" + return self._project_spec.project_name def _hookexec( self, hook_name: str, - methods: Sequence[HookImpl], - kwargs: Mapping[str, object], + normal_impls: Sequence[HookImpl], + wrapper_impls: Sequence[WrapperImpl], + caller_kwargs: Mapping[str, object], firstresult: bool, + async_submitter: Submitter, ) -> object | list[object]: # called from all hookcaller instances. # enable_tracing will set its own wrapping function at self._inner_hookexec - return self._inner_hookexec(hook_name, methods, kwargs, firstresult) + return self._inner_hookexec( + hook_name, + normal_impls, + wrapper_impls, + caller_kwargs, + firstresult, + async_submitter, + ) def register(self, plugin: _Plugin, name: str | None = None) -> str | None: """Register a plugin and return its name. @@ -154,22 +185,99 @@ def register(self, plugin: _Plugin, name: str | None = None) -> str | None: # register matching hook implementations of the plugin for name in dir(plugin): - hookimpl_opts = self.parse_hookimpl_opts(plugin, name) - if hookimpl_opts is not None: - normalize_hookimpl_opts(hookimpl_opts) + hookimpl_config = self._parse_hookimpl(plugin, name) + if hookimpl_config is not None: method: _HookImplFunction[object] = getattr(plugin, name) - hookimpl = HookImpl(plugin, plugin_name, method, hookimpl_opts) - name = hookimpl_opts.get("specname") or name - hook: HookCaller | None = getattr(self.hook, name, None) + hookimpl = hookimpl_config.create_hookimpl(plugin, plugin_name, method) + hook_name = hookimpl_config.specname or name + hook: NormalHookCaller | HistoricHookCaller | None = getattr( + self.hook, hook_name, None + ) if hook is None: - hook = HookCaller(name, self._hookexec) - setattr(self.hook, name, hook) + hook = NormalHookCaller( + hook_name, self._hookexec, self._async_submitter + ) + setattr(self.hook, hook_name, hook) elif hook.has_spec(): self._verify_hook(hook, hookimpl) - hook._maybe_apply_history(hookimpl) - hook._add_hookimpl(hookimpl) + # With stronger types, we can access _add_hookimpl directly + # Historic hooks only accept HookImpl, not WrapperImpl + if hook.is_historic(): + if isinstance(hookimpl, WrapperImpl): + raise PluginValidationError( + hookimpl.plugin, + f"Plugin {hookimpl.plugin_name!r}\nhook {hook_name!r}\n" + "Historic hooks do not support wrappers.", + ) + # hook is HistoricHookCaller here + # We already checked that hookimpl is not WrapperImpl above + assert isinstance(hookimpl, HookImpl) + hook._add_hookimpl(hookimpl) + else: + # hook is NormalHookCaller here + assert isinstance(hook, NormalHookCaller) + hook._add_hookimpl(hookimpl) return plugin_name + def _parse_hookimpl( + self, plugin: _Plugin, name: str + ) -> HookimplConfiguration | None: + """Internal method to parse hook implementation configuration. + + :param plugin: The plugin object to inspect + :param name: The attribute name to check for hook implementation + :returns: HookimplConfiguration if found, None otherwise + """ + try: + method: object = getattr(plugin, name) + except Exception: # pragma: no cover + return None + + if not inspect.isroutine(method): + return None + + # Get hook implementation configuration using ProjectSpec + impl_config = self._project_spec.get_hookimpl_config(method) + if impl_config is not None: + return impl_config + + # Fall back to legacy parse_hookimpl_opts for compatibility + # (e.g. pytest override) + legacy_opts = self.parse_hookimpl_opts(plugin, name) + if legacy_opts is not None: + return HookimplConfiguration(**legacy_opts) + + return None + + def _parse_hookspec( + self, module_or_class: _Namespace, name: str + ) -> HookspecConfiguration | None: + """Internal method to parse hook specification configuration. + + :param module_or_class: The module or class to inspect + :param name: The attribute name to check for hook specification + :returns: HookspecConfiguration if found, None otherwise + """ + try: + method: object = getattr(module_or_class, name) + except Exception: # pragma: no cover + return None + + if not inspect.isroutine(method): + return None + + # Get hook specification configuration using ProjectSpec + spec_config = self._project_spec.get_hookspec_config(method) + if spec_config is not None: + return spec_config + + # Fall back to legacy parse_hookspec_opts for compatibility + legacy_opts = self.parse_hookspec_opts(module_or_class, name) + if legacy_opts is not None: + return HookspecConfiguration(**legacy_opts) + + return None + def parse_hookimpl_opts(self, plugin: _Plugin, name: str) -> HookimplOpts | None: """Try to obtain a hook implementation from an item with the given name in the given plugin which is being searched for hook impls. @@ -177,23 +285,15 @@ def parse_hookimpl_opts(self, plugin: _Plugin, name: str) -> HookimplOpts | None :returns: The parsed hookimpl options, or None to skip the given item. - This method can be overridden by ``PluginManager`` subclasses to - customize how hook implementation are picked up. By default, returns the - options for items decorated with :class:`HookimplMarker`. + .. deprecated:: + Customizing hook implementation parsing by overriding this method is + deprecated. This method is only kept as a compatibility shim for + legacy projects like pytest. New code should use the standard + :class:`HookimplMarker` decorators. """ - method: object = getattr(plugin, name) - if not inspect.isroutine(method): - return None - try: - res: HookimplOpts | None = getattr( - method, self.project_name + "_impl", None - ) - except Exception: # pragma: no cover - res = {} # type: ignore[assignment] #pragma: no cover - if res is not None and not isinstance(res, dict): - # false positive - res = None # type:ignore[unreachable] #pragma: no cover - return res + # Compatibility shim - only overridden by legacy projects like pytest + # Modern hook implementations are handled by _parse_hookimpl + return None def unregister( self, plugin: _Plugin | None = None, name: str | None = None @@ -218,7 +318,9 @@ def unregister( hookcallers = self.get_hookcallers(plugin) if hookcallers: for hookcaller in hookcallers: - hookcaller._remove_plugin(plugin) + # hookcaller is typed as Union[NormalHookCaller, HistoricHookCaller] + concrete_hook = hookcaller + concrete_hook._remove_plugin(plugin) # if self._name2plugin[name] == None registration was blocked: ignore if self._name2plugin.get(name): @@ -254,16 +356,57 @@ def add_hookspecs(self, module_or_class: _Namespace) -> None: """ names = [] for name in dir(module_or_class): - spec_opts = self.parse_hookspec_opts(module_or_class, name) - if spec_opts is not None: - hc: HookCaller | None = getattr(self.hook, name, None) + spec_config = self._parse_hookspec(module_or_class, name) + if spec_config is not None: + hc: NormalHookCaller | HistoricHookCaller | None = getattr( + self.hook, name, None + ) if hc is None: - hc = HookCaller(name, self._hookexec, module_or_class, spec_opts) + if spec_config.historic: + hc = HistoricHookCaller( + name, + self._hookexec, + module_or_class, + spec_config, + self._async_submitter, + ) + else: + hc = NormalHookCaller( + name, + self._hookexec, + self._async_submitter, + module_or_class, + spec_config, + ) setattr(self.hook, name, hc) else: # Plugins registered this hook without knowing the spec. - hc.set_specification(module_or_class, spec_opts) + if spec_config.historic and isinstance(hc, NormalHookCaller): + # Need to handover from HookCaller to HistoricHookCaller + old_hookimpls = hc.get_hookimpls() + hc = HistoricHookCaller( + name, + self._hookexec, + module_or_class, + spec_config, + self._async_submitter, + ) + # Re-add existing hookimpls (history applied by _add_hookimpl) + # Only normal implementations can be moved to historic hooks + for hookimpl in old_hookimpls: + if hookimpl.hookwrapper or hookimpl.wrapper: + raise PluginValidationError( + hookimpl.plugin, + f"Plugin {hookimpl.plugin_name!r}\nhook {name!r}\n" + "Historic hooks do not support wrappers.", + ) + # hc is HistoricHookCaller, access _add_hookimpl directly + hc._add_hookimpl(hookimpl) + setattr(self.hook, name, hc) + else: + hc.set_specification(module_or_class, spec_config) for hookfunction in hc.get_hookimpls(): + # hookfunction is now already typed as HookImpl self._verify_hook(hc, hookfunction) names.append(name) @@ -282,13 +425,15 @@ def parse_hookspec_opts( The parsed hookspec options for defining a hook, or None to skip the given item. - This method can be overridden by ``PluginManager`` subclasses to - customize how hook specifications are picked up. By default, returns the - options for items decorated with :class:`HookspecMarker`. + .. deprecated:: + Customizing hook specification parsing by overriding this method is + deprecated. This method is only kept as a compatibility shim for + legacy projects. New code should use the standard + :class:`HookspecMarker` decorators. """ - method = getattr(module_or_class, name) - opts: HookspecOpts | None = getattr(method, self.project_name + "_spec", None) - return opts + # Compatibility shim - only overridden by legacy projects + # Modern hook specifications are handled by _parse_hookspec + return None def get_plugins(self) -> set[Any]: """Return a set of all registered plugin objects.""" @@ -325,7 +470,11 @@ def get_name(self, plugin: _Plugin) -> str | None: return name return None - def _verify_hook(self, hook: HookCaller, hookimpl: HookImpl) -> None: + def _verify_hook( + self, + hook: NormalHookCaller | HistoricHookCaller, + hookimpl: HookImpl | WrapperImpl, + ) -> None: if hook.is_historic() and (hookimpl.hookwrapper or hookimpl.wrapper): raise PluginValidationError( hookimpl.plugin, @@ -380,7 +529,7 @@ def check_pending(self) -> None: for name in self.hook.__dict__: if name[0] == "_": continue - hook: HookCaller = getattr(self.hook, name) + hook: NormalHookCaller | HistoricHookCaller = getattr(self.hook, name) if not hook.has_spec(): for hookimpl in hook.get_hookimpls(): if not hookimpl.optionalhook: @@ -428,7 +577,9 @@ def list_name_plugin(self) -> list[tuple[str, _Plugin]]: """Return a list of (name, plugin) pairs for all registered plugins.""" return list(self._name2plugin.items()) - def get_hookcallers(self, plugin: _Plugin) -> list[HookCaller] | None: + def get_hookcallers( + self, plugin: _Plugin + ) -> list[NormalHookCaller | HistoricHookCaller] | None: """Get all hook callers for the specified plugin. :returns: @@ -437,7 +588,7 @@ def get_hookcallers(self, plugin: _Plugin) -> list[HookCaller] | None: """ if self.get_name(plugin) is None: return None - hookcallers = [] + hookcallers: list[NormalHookCaller | HistoricHookCaller] = [] for hookcaller in self.hook.__dict__.values(): for hookimpl in hookcaller.get_hookimpls(): if hookimpl.plugin is plugin: @@ -463,15 +614,26 @@ def add_hookcall_monitoring( def traced_hookexec( hook_name: str, - hook_impls: Sequence[HookImpl], + normal_impls: Sequence[HookImpl], + wrapper_impls: Sequence[WrapperImpl], caller_kwargs: Mapping[str, object], firstresult: bool, + async_submitter: Submitter, ) -> object | list[object]: - before(hook_name, hook_impls, caller_kwargs) + # For backward compatibility, combine the lists for tracing callbacks + combined_hook_impls = [*normal_impls, *wrapper_impls] + before(hook_name, combined_hook_impls, caller_kwargs) outcome = Result.from_call( - lambda: oldcall(hook_name, hook_impls, caller_kwargs, firstresult) + lambda: oldcall( + hook_name, + normal_impls, + wrapper_impls, + caller_kwargs, + firstresult, + self._async_submitter, + ) ) - after(outcome, hook_name, hook_impls, caller_kwargs) + after(outcome, hook_name, combined_hook_impls, caller_kwargs) return outcome.get_result() self._inner_hookexec = traced_hookexec @@ -512,11 +674,57 @@ def subset_hook_caller( """Return a proxy :class:`~pluggy.HookCaller` instance for the named method which manages calls to all registered plugins except the ones from remove_plugins.""" - orig: HookCaller = getattr(self.hook, name) + orig: NormalHookCaller | HistoricHookCaller = getattr(self.hook, name) plugins_to_remove = {plug for plug in remove_plugins if hasattr(plug, name)} if plugins_to_remove: - return _SubsetHookCaller(orig, plugins_to_remove) - return orig + return SubsetHookCaller(orig, plugins_to_remove) + # Return as HookCaller protocol for compatibility + return cast(HookCaller, orig) + + async def run_async(self, func: Callable[[], _T]) -> _T: + """Run a function with async support enabled for hook results. + + This method runs the provided function in a greenlet context that enables + awaiting async results returned by hooks. Hook results that are awaitable + will be automatically awaited when running in this context. + + :param func: The function to run with async support enabled + :returns: The result of the function + :raises RuntimeError: If greenlet is not available + + Example: + pm = PluginManager("myapp") + result = await pm.run_async(lambda: pm.hook.some_hook()) + """ + + def wrapper() -> _T: + # Store the async submitter in the plugin manager for hook execution + old_hookexec = self._inner_hookexec + + def async_hookexec( + hook_name: str, + normal_impls: Sequence[HookImpl], + wrapper_impls: Sequence[WrapperImpl], + caller_kwargs: Mapping[str, object], + firstresult: bool, + async_submitter: Submitter, + ) -> object | list[object]: + return old_hookexec( + hook_name, + normal_impls, + wrapper_impls, + caller_kwargs, + firstresult, + async_submitter, + ) + + try: + self._inner_hookexec = async_hookexec + return func() + finally: + self._inner_hookexec = old_hookexec + + return await self._async_submitter.run(wrapper) def _formatdef(func: Callable[..., object]) -> str: diff --git a/src/pluggy/_project.py b/src/pluggy/_project.py new file mode 100644 index 00000000..a8881de1 --- /dev/null +++ b/src/pluggy/_project.py @@ -0,0 +1,89 @@ +""" +Project configuration and management for pluggy projects. +""" + +from __future__ import annotations + +from typing import Callable +from typing import Final +from typing import TYPE_CHECKING + + +if TYPE_CHECKING: + from ._hook_config import HookimplConfiguration + from ._hook_config import HookspecConfiguration + from ._manager import PluginManager + + +class ProjectSpec: + """Manages hook markers and plugin manager creation for a pluggy project. + + This class provides a unified interface for creating and managing the core + components of a pluggy project: HookspecMarker, HookimplMarker, and PluginManager. + + All components share the same project_name, ensuring consistent behavior + across hook specifications, implementations, and plugin management. + + :param project_name: + The short project name. Prefer snake case. Make sure it's unique! + :param plugin_manager_cls: + Custom PluginManager subclass to use (defaults to PluginManager). + """ + + def __init__( + self, project_name: str, plugin_manager_cls: type[PluginManager] | None = None + ) -> None: + from ._hook_markers import HookimplMarker + from ._hook_markers import HookspecMarker + from ._manager import PluginManager as DefaultPluginManager + + #: The project name used across all components. + self.project_name: Final = project_name + #: The PluginManager class for creating new instances. + self._plugin_manager_cls: Final = plugin_manager_cls or DefaultPluginManager + + # Create marker instances (these are stateless decorators, safe to share) + #: Hook specification marker for decorating hook specification functions. + self.hookspec: Final = HookspecMarker(self) + #: Hook implementation marker for decorating hook implementation functions. + self.hookimpl: Final = HookimplMarker(self) + + def create_plugin_manager(self) -> PluginManager: + """Create a new PluginManager instance for this project. + + Each call returns a fresh, independent PluginManager instance + configured with this project's name and using the specified + PluginManager class (if provided during initialization). + + :returns: New PluginManager instance. + """ + return self._plugin_manager_cls(self) + + def get_hookspec_config( + self, func: Callable[..., object] + ) -> HookspecConfiguration | None: + """Extract hook specification configuration from a decorated function. + + :param func: A function that may be decorated with this project's + hookspec marker + :return: HookspecConfiguration object if found, None if not decorated + with this project's hookspec marker + """ + attr_name = self.project_name + "_spec" + return getattr(func, attr_name, None) + + def get_hookimpl_config( + self, func: Callable[..., object] + ) -> HookimplConfiguration | None: + """Extract hook implementation configuration from a decorated function. + + :param func: A function that may be decorated with this project's + hookimpl marker + :return: HookimplConfiguration object if found, None if not decorated + with this project's hookimpl marker + """ + attr_name = self.project_name + "_impl" + return getattr(func, attr_name, None) + + def __repr__(self) -> str: + return f"ProjectSpec(project_name={self.project_name!r})" diff --git a/src/pluggy/_result.py b/src/pluggy/_result.py index 656a5841..33854bb3 100644 --- a/src/pluggy/_result.py +++ b/src/pluggy/_result.py @@ -27,6 +27,9 @@ class Result(Generic[ResultType]): `.""" __slots__ = ("_result", "_exception", "_traceback") + _result: ResultType | None + _exception: BaseException | None + _traceback: TracebackType | None def __init__( self, diff --git a/testing/benchmark.py b/testing/benchmark.py index cc3be4eb..87782cf6 100644 --- a/testing/benchmark.py +++ b/testing/benchmark.py @@ -6,15 +6,15 @@ import pytest -from pluggy import HookimplMarker -from pluggy import HookspecMarker from pluggy import PluginManager +from pluggy import ProjectSpec from pluggy._callers import _multicall -from pluggy._hooks import HookImpl +from pluggy._hook_callers import WrapperImpl -hookspec = HookspecMarker("example") -hookimpl = HookimplMarker("example") +project_spec = ProjectSpec("example") +hookspec = project_spec.hookspec +hookimpl = project_spec.hookimpl @hookimpl @@ -40,13 +40,26 @@ def wrappers(request: Any) -> list[object]: def test_hook_and_wrappers_speed(benchmark, hooks, wrappers) -> None: def setup(): hook_name = "foo" - hook_impls = [] + normal_impls = [] + wrapper_impls = [] + for method in hooks + wrappers: - f = HookImpl(None, "", method, method.example_impl) - hook_impls.append(f) + config = project_spec.get_hookimpl_config(method) + assert config is not None # Benchmark functions should be decorated + f = config.create_hookimpl( + None, + "", + method, + ) + # Separate normal and wrapper implementations + if isinstance(f, WrapperImpl): + wrapper_impls.append(f) + else: + normal_impls.append(f) + caller_kwargs = {"arg1": 1, "arg2": 2, "arg3": 3} firstresult = False - return (hook_name, hook_impls, caller_kwargs, firstresult), {} + return (hook_name, normal_impls, wrapper_impls, caller_kwargs, firstresult), {} benchmark.pedantic(_multicall, setup=setup, rounds=10) diff --git a/testing/test_async.py b/testing/test_async.py new file mode 100644 index 00000000..1a0650b6 --- /dev/null +++ b/testing/test_async.py @@ -0,0 +1,212 @@ +import asyncio +from collections.abc import AsyncGenerator +from typing import Any +from typing import cast + +import pytest + +import pluggy +from pluggy._async import async_generator_to_sync +from pluggy._async import Submitter + + +pytest_plugins = ["pytester"] + +hookimpl = pluggy.HookimplMarker("test") +hookspec = pluggy.HookspecMarker("test") + + +class AsyncPlugin: + @hookimpl + async def test_hook(self): + await asyncio.sleep(0.01) # Small delay to make it actually async + return "async_result" + + +class SyncPlugin: + @hookimpl + def test_hook(self): + return "sync_result" + + +def test_run_async_with_greenlet_available(): + """Test that run_async works when greenlet is available.""" + pytest.importorskip("greenlet") + + pm = pluggy.PluginManager("test") + + class HookSpec: + @hookspec + def test_hook(self): + pass + + pm.add_hookspecs(HookSpec) + pm.register(AsyncPlugin()) + pm.register(SyncPlugin()) + + async def run_test() -> list[str]: + result = await pm.run_async(lambda: pm.hook.test_hook()) + return cast(list[str], result) + + # Run the async function + result: list[str] = asyncio.run(run_test()) + + # Should have both sync and async results + assert "sync_result" in result + assert "async_result" in result + + +def test_run_async_without_greenlet(): + """Test that run_async raises appropriate error when greenlet is not available.""" + # We can't easily mock the greenlet import since it's already loaded, + # so we'll skip this test if greenlet is available + try: + import greenlet # noqa: F401 + + pytest.skip("greenlet is available, cannot test the error case") + except ImportError: + pm = pluggy.PluginManager("test") + + async def run_test() -> None: + with pytest.raises(RuntimeError, match="greenlet is required"): + await pm.run_async(lambda: "test") + + asyncio.run(run_test()) + + +def test_run_async_with_sync_hooks_only(): + """Test that run_async works even with only sync hooks.""" + pytest.importorskip("greenlet") + + pm = pluggy.PluginManager("test") + + class HookSpec: + @hookspec + def test_hook(self): + pass + + pm.add_hookspecs(HookSpec) + pm.register(SyncPlugin()) + + async def run_test() -> list[str]: + result = await pm.run_async(lambda: pm.hook.test_hook()) + return cast(list[str], result) + + result: list[str] = asyncio.run(run_test()) + assert result == ["sync_result"] + + +def test_async_generator_to_sync_basic(): + """Test basic async generator to sync generator conversion.""" + pytest.importorskip("greenlet") + + async def simple_async_gen() -> AsyncGenerator[str, None]: + yield "first" + yield "second" + + submitter = Submitter() + + def test_func() -> tuple[list[str], Any]: + async_gen = simple_async_gen() + sync_gen = async_generator_to_sync(async_gen, submitter) + + values = [] + try: + while True: + value = next(sync_gen) + values.append(value) + except StopIteration as e: + return values, e.value + + values, final_value = asyncio.run(submitter.run(test_func)) + assert values == ["first", "second"] + assert final_value is None + + +def test_async_generator_to_sync_with_send(): + """Test async generator to sync generator with send values.""" + pytest.importorskip("greenlet") + + async def async_gen_with_send() -> AsyncGenerator[str, str]: + sent1 = yield "initial" + sent2 = yield f"got_{sent1}" + yield f"final_{sent2}" + + submitter = Submitter() + + def test_func() -> tuple[str, str, str, Any]: + async_gen = async_gen_with_send() + sync_gen = async_generator_to_sync(async_gen, submitter) + + # Get first value + value1 = next(sync_gen) + # Send a value + value2 = sync_gen.send("hello") + # Send another value and get final result + value3 = sync_gen.send("world") + try: + next(sync_gen) + assert False, "Should have raised StopIteration" + except StopIteration as e: + return value1, value2, value3, e.value + + value1, value2, value3, final = asyncio.run(submitter.run(test_func)) + assert value1 == "initial" + assert value2 == "got_hello" + assert value3 == "final_world" + assert final is None + + +def test_async_generator_to_sync_with_exception(): + """Test async generator to sync generator with exception handling.""" + pytest.importorskip("greenlet") + + async def async_gen_with_exception() -> AsyncGenerator[str, None]: + try: + yield "before_exception" + yield "should_not_reach" + except ValueError as e: + yield f"caught_{e}" + + submitter = Submitter() + + def test_func() -> tuple[str, str, Any]: + async_gen = async_gen_with_exception() + sync_gen = async_generator_to_sync(async_gen, submitter) + + # Get first value + value1 = next(sync_gen) + + # Throw exception into generator + value2 = sync_gen.throw(ValueError("test_error")) + # Get final result + try: + next(sync_gen) + assert False, "Should have raised StopIteration" + except StopIteration as e: + return value1, value2, e.value + + res = asyncio.run(submitter.run(test_func)) + + value1, value2, final = res + assert value1 == "before_exception" + assert value2 == "caught_test_error" + assert final is None + + +def test_async_generator_to_sync_inactive_submitter(): + """Test that async_generator_to_sync raises error with inactive submitter.""" + pytest.importorskip("greenlet") + + async def simple_async_gen() -> AsyncGenerator[str, None]: + yield "test" + + submitter = Submitter() + # Don't activate the submitter + + with pytest.raises( + RuntimeError, match="require_await called outside of async context" + ): + async_gen = simple_async_gen() + sync_gen = async_generator_to_sync(async_gen, submitter) + next(sync_gen) diff --git a/testing/test_hookcaller.py b/testing/test_hookcaller.py index f6ca9577..30bc66e0 100644 --- a/testing/test_hookcaller.py +++ b/testing/test_hookcaller.py @@ -5,26 +5,30 @@ import pytest -from pluggy import HookimplMarker -from pluggy import HookspecMarker +from pluggy import HookspecConfiguration from pluggy import PluginManager from pluggy import PluginValidationError +from pluggy import ProjectSpec +from pluggy._hooks import HistoricHookCaller from pluggy._hooks import HookCaller from pluggy._hooks import HookImpl +from pluggy._hooks import NormalHookCaller -hookspec = HookspecMarker("example") -hookimpl = HookimplMarker("example") +project_spec = ProjectSpec("example") +hookspec = project_spec.hookspec +hookimpl = project_spec.hookimpl @pytest.fixture -def hc(pm: PluginManager) -> HookCaller: +def hc(pm: PluginManager) -> NormalHookCaller: class Hooks: @hookspec def he_method1(self, arg: object) -> None: pass pm.add_hookspecs(Hooks) + assert isinstance(pm.hook.he_method1, NormalHookCaller) return pm.hook.he_method1 @@ -32,7 +36,7 @@ def he_method1(self, arg: object) -> None: class AddMeth: - def __init__(self, hc: HookCaller) -> None: + def __init__(self, hc: NormalHookCaller) -> None: self.hc = hc def __call__( @@ -43,22 +47,24 @@ def __call__( wrapper: bool = False, ) -> Callable[[FuncT], FuncT]: def wrap(func: FuncT) -> FuncT: - hookimpl( + project_spec.hookimpl( tryfirst=tryfirst, trylast=trylast, hookwrapper=hookwrapper, wrapper=wrapper, )(func) - self.hc._add_hookimpl( - HookImpl(None, "", func, func.example_impl), # type: ignore[attr-defined] - ) + config = project_spec.get_hookimpl_config(func) + assert config is not None # Test functions should be decorated + # Create hookimpl and add to hook caller + hookimpl = config.create_hookimpl(None, "", func) + self.hc._add_hookimpl(hookimpl) return func return wrap @pytest.fixture -def addmeth(hc: HookCaller) -> AddMeth: +def addmeth(hc: NormalHookCaller) -> AddMeth: return AddMeth(hc) @@ -317,11 +323,11 @@ def he_myhook3(arg1) -> None: pm.add_hookspecs(HookSpec) assert pm.hook.he_myhook1.spec is not None - assert not pm.hook.he_myhook1.spec.opts["firstresult"] + assert not pm.hook.he_myhook1.spec.config.firstresult assert pm.hook.he_myhook2.spec is not None - assert pm.hook.he_myhook2.spec.opts["firstresult"] + assert pm.hook.he_myhook2.spec.config.firstresult assert pm.hook.he_myhook3.spec is not None - assert not pm.hook.he_myhook3.spec.opts["firstresult"] + assert not pm.hook.he_myhook3.spec.config.firstresult @pytest.mark.parametrize("name", ["hookwrapper", "optionalhook", "tryfirst", "trylast"]) @@ -332,7 +338,7 @@ def he_myhook1(arg1) -> None: pass if val: - assert he_myhook1.example_impl.get(name) + assert getattr(he_myhook1.example_impl, name) else: assert not hasattr(he_myhook1, name) @@ -511,3 +517,182 @@ def extra2() -> str: "2", "3", ] + + +def test_hookspec_configuration() -> None: + """Test HookspecConfiguration class and its integration.""" + PluginManager("example") + + # Test HookspecConfiguration creation + config = HookspecConfiguration( + firstresult=True, + historic=False, + warn_on_impl=DeprecationWarning("test warning"), + warn_on_impl_args={"arg1": UserWarning("arg warning")}, + ) + + assert config.firstresult is True + assert config.historic is False + assert isinstance(config.warn_on_impl, DeprecationWarning) + assert config.warn_on_impl_args is not None + assert "arg1" in config.warn_on_impl_args + + # Test __repr__ + repr_str = repr(config) + assert "HookspecConfiguration" in repr_str + assert "firstresult=True" in repr_str + assert "warn_on_impl" in repr_str + + # Test validation (historic + firstresult should raise) + with pytest.raises(ValueError, match="cannot have a historic firstresult hook"): + HookspecConfiguration(firstresult=True, historic=True) + + +def test_hookspec_marker_config_extraction() -> None: + """Test that ProjectSpec can extract HookspecConfiguration correctly.""" + test_project_spec = ProjectSpec("test") + marker = test_project_spec.hookspec + + @marker(firstresult=True, historic=False) + def test_hook(arg1: str) -> str: + return arg1 + + # Test config extraction method via ProjectSpec + config = test_project_spec.get_hookspec_config(test_hook) + assert isinstance(config, HookspecConfiguration) + assert config.firstresult is True + assert config.historic is False + assert config.warn_on_impl is None + assert config.warn_on_impl_args is None + + +def test_hookspec_configuration_backward_compatibility() -> None: + """Test that HookspecConfiguration integrates properly with existing systems.""" + pm = PluginManager("example") + + class TestSpecs: + @hookspec(firstresult=True, historic=False) + def test_hook1(self, arg1: str) -> str: + return arg1 + + @hookspec(firstresult=False, historic=False) + def test_hook2(self, arg1: int) -> None: + pass + + pm.add_hookspecs(TestSpecs) + + # Verify specs are created correctly + hook1_spec = pm.hook.test_hook1.spec + hook2_spec = pm.hook.test_hook2.spec + + assert hook1_spec is not None + assert isinstance(hook1_spec.config, HookspecConfiguration) + assert hook1_spec.config.firstresult is True + assert hook1_spec.config.historic is False + + assert hook2_spec is not None + assert isinstance(hook2_spec.config, HookspecConfiguration) + assert hook2_spec.config.firstresult is False + assert hook2_spec.config.historic is False + + # Test that hook calling respects the configuration + results = [] + + class Plugin1: + @hookimpl + def test_hook1(self, arg1: str) -> str: + results.append("plugin1") + return "result1" + + @hookimpl + def test_hook2(self, arg1: int) -> None: + results.append("plugin2-hook2") + + class Plugin2: + @hookimpl + def test_hook1(self, arg1: str) -> str: + results.append("plugin2") + return "result2" + + @hookimpl + def test_hook2(self, arg1: int) -> None: + results.append("plugin1-hook2") + + pm.register(Plugin1()) + pm.register(Plugin2()) + + # test_hook1 has firstresult=True, should return first non-None result + result1 = pm.hook.test_hook1(arg1="test") + assert result1 in ["result1", "result2"] # Either could be first + + # test_hook2 has firstresult=False, should call all implementations + results.clear() + pm.hook.test_hook2(arg1=42) + assert len(results) == 2 + assert "plugin2-hook2" in results + assert "plugin1-hook2" in results + + +def test_set_specification_backward_compatibility() -> None: + """Test that HookCaller.set_specification supports both old and new interfaces.""" + from pluggy._hooks import HookspecOpts + from pluggy._hooks import NormalHookCaller + from pluggy._manager import PluginManager + + pm = PluginManager("test") + hook_caller = NormalHookCaller("test_hook", pm._hookexec, pm._async_submitter) + + # Test with new HookspecConfiguration interface + config = HookspecConfiguration(firstresult=True, historic=False) + + class TestSpec: + def test_hook(self, arg1: str) -> str: + return arg1 + + hook_caller.set_specification(TestSpec, spec_config=config) + assert hook_caller.spec is not None + assert hook_caller.spec.config.firstresult is True + assert hook_caller.spec.config.historic is False + + # Test with old HookspecOpts interface (positional) - use HistoricHookCaller + old_opts: HookspecOpts = { + "firstresult": False, + "historic": True, + "warn_on_impl": None, + "warn_on_impl_args": None, + } + historic_config = HookspecConfiguration(**old_opts) + historic_hook_caller = HistoricHookCaller( + "test_hook", pm._hookexec, TestSpec, historic_config, pm._async_submitter + ) + assert historic_hook_caller.spec is not None + assert historic_hook_caller.spec.config.firstresult is False + assert historic_hook_caller.spec.config.historic is True + + # Test with old HookspecOpts interface (keyword) - use HistoricHookCaller + historic_hook_caller2 = HistoricHookCaller( + "test_hook", pm._hookexec, TestSpec, historic_config, pm._async_submitter + ) + assert historic_hook_caller2.spec is not None + assert historic_hook_caller2.spec.config.firstresult is False + assert historic_hook_caller2.spec.config.historic is True + + # Test error cases + hook_caller4 = NormalHookCaller("test_hook", pm._hookexec, pm._async_submitter) + + # Cannot provide both positional and keyword + with pytest.raises( + AssertionError, + match="Cannot provide both positional and keyword spec arguments", + ): + hook_caller4.set_specification(TestSpec, old_opts, spec_config=config) + + # Cannot provide both spec_opts and spec_config + with pytest.raises( + AssertionError, match="Cannot provide both spec_opts and spec_config" + ): + hook_caller4.set_specification(TestSpec, spec_opts=old_opts, spec_config=config) + + # Must provide at least one + with pytest.raises(TypeError, match="Must provide either spec_opts or spec_config"): + hook_caller4.set_specification(TestSpec) diff --git a/testing/test_multicall.py b/testing/test_multicall.py index 304c12f8..09783059 100644 --- a/testing/test_multicall.py +++ b/testing/test_multicall.py @@ -6,14 +6,16 @@ import pytest from pluggy import HookCallError -from pluggy import HookimplMarker -from pluggy import HookspecMarker +from pluggy import ProjectSpec +from pluggy._async import Submitter from pluggy._callers import _multicall -from pluggy._hooks import HookImpl +from pluggy._hook_callers import HookImpl +from pluggy._hook_callers import WrapperImpl -hookspec = HookspecMarker("example") -hookimpl = HookimplMarker("example") +project_spec = ProjectSpec("example") +hookspec = project_spec.hookspec +hookimpl = project_spec.hookimpl def MC( @@ -24,9 +26,23 @@ def MC( caller = _multicall hookfuncs = [] for method in methods: - f = HookImpl(None, "", method, method.example_impl) # type: ignore[attr-defined] + config = project_spec.get_hookimpl_config(method) + assert config is not None # Test functions should be decorated + f = config.create_hookimpl(None, "", method) hookfuncs.append(f) - return caller("foo", hookfuncs, kwargs, firstresult) + + # Separate normal and wrapper implementations for new signature + normal_impls: list[HookImpl] = [] + wrapper_impls: list[WrapperImpl] = [] + for hookfunc in hookfuncs: + if isinstance(hookfunc, WrapperImpl): + wrapper_impls.append(hookfunc) + else: + normal_impls.append(hookfunc) + + # Create a submitter for test purposes + submitter = Submitter() + return caller("foo", normal_impls, wrapper_impls, kwargs, firstresult, submitter) def test_keyword_args() -> None: diff --git a/testing/test_pluginmanager.py b/testing/test_pluginmanager.py index f80b1b55..12827f36 100644 --- a/testing/test_pluginmanager.py +++ b/testing/test_pluginmanager.py @@ -494,7 +494,110 @@ class PluginNo: pm.hook.he_method1(arg=1) assert out == [10] - assert repr(hc) == "<_SubsetHookCaller 'he_method1'>" + assert repr(hc) == "" + + # Test that SubsetHookCaller cannot set specifications + from pluggy._hooks import HookspecConfiguration + + with pytest.raises( + RuntimeError, match="Cannot set specification on SubsetHookCaller" + ): + hc.set_specification(Hooks) + + with pytest.raises( + RuntimeError, match="Cannot set specification on SubsetHookCaller" + ): + config = HookspecConfiguration(firstresult=True) + hc.set_specification(Hooks, spec_config=config) + + +def test_subset_hook_caller_maintains_invariants(pm: PluginManager) -> None: + """Test that SubsetHookCaller maintains the same invariants as the original hook.""" + + # Test with normal (non-historic) hook + class NormalHooks: + @hookspec + def normal_hook(self, arg): + pass + + pm.add_hookspecs(NormalHooks) + + class Plugin1: + @hookimpl + def normal_hook(self, arg): + return f"plugin1-{arg}" + + class Plugin2: + @hookimpl + def normal_hook(self, arg): + return f"plugin2-{arg}" + + plugin1, plugin2 = Plugin1(), Plugin2() + pm.register(plugin1) + pm.register(plugin2) + + # Create subset hook caller for normal hook + normal_hc = pm.subset_hook_caller("normal_hook", [plugin1]) + + # Normal SubsetHookCaller should allow __call__ and call_extra + result = normal_hc(arg="test") + assert result == ["plugin2-test"] # Only plugin2 should be called + + # Normal SubsetHookCaller should allow call_extra + def extra_method(arg): + return f"extra-{arg}" + + result = normal_hc.call_extra([extra_method], {"arg": "test"}) + assert "plugin2-test" in result + assert "extra-test" in result + + # Normal SubsetHookCaller should NOT allow call_historic + with pytest.raises( + AssertionError, match="is not historic - cannot call call_historic" + ): + normal_hc.call_historic(kwargs={"arg": "test"}) + + # Test with historic hook + class HistoricHooks: + @hookspec(historic=True) + def historic_hook(self, arg): + pass + + pm.add_hookspecs(HistoricHooks) + + class Plugin3: + @hookimpl + def historic_hook(self, arg): + return f"plugin3-{arg}" + + class Plugin4: + @hookimpl + def historic_hook(self, arg): + return f"plugin4-{arg}" + + plugin3, plugin4 = Plugin3(), Plugin4() + pm.register(plugin3) + pm.register(plugin4) + + # Create subset hook caller for historic hook + historic_hc = pm.subset_hook_caller("historic_hook", [plugin3]) + + # Historic SubsetHookCaller should NOT allow direct __call__ + with pytest.raises(RuntimeError, match="Cannot directly call a historic hook"): + historic_hc(arg="test") + + # Historic SubsetHookCaller should NOT allow call_extra + with pytest.raises(RuntimeError, match="Cannot call call_extra on a historic hook"): + historic_hc.call_extra([extra_method], {"arg": "test"}) + + # Historic SubsetHookCaller should allow call_historic + results = [] + + def result_callback(result): + results.append(result) + + historic_hc.call_historic(result_callback=result_callback, kwargs={"arg": "test"}) + assert results == ["plugin4-test"] # Only plugin4 should be called def test_get_hookimpls(pm: PluginManager) -> None: diff --git a/testing/test_project_spec.py b/testing/test_project_spec.py new file mode 100644 index 00000000..778726e6 --- /dev/null +++ b/testing/test_project_spec.py @@ -0,0 +1,249 @@ +""" +Tests for ProjectSpec functionality. +""" + +from pluggy import HookimplMarker +from pluggy import HookspecMarker +from pluggy import PluginManager +from pluggy import ProjectSpec + + +def test_project_spec_basic_creation() -> None: + """Test basic ProjectSpec creation and properties.""" + project = ProjectSpec("testproject") + + assert project.project_name == "testproject" + assert isinstance(project.hookspec, HookspecMarker) + assert isinstance(project.hookimpl, HookimplMarker) + assert project.hookspec.project_name == "testproject" + assert project.hookimpl.project_name == "testproject" + + +def test_project_spec_plugin_manager_creation() -> None: + """Test that create_plugin_manager returns fresh instances.""" + project = ProjectSpec("testproject") + + pm1 = project.create_plugin_manager() + pm2 = project.create_plugin_manager() + + # Should be different instances + assert pm1 is not pm2 + + # But should have same project name + assert pm1.project_name == "testproject" + assert pm2.project_name == "testproject" + + # Should be PluginManager instances + assert isinstance(pm1, PluginManager) + assert isinstance(pm2, PluginManager) + + +def test_project_spec_custom_plugin_manager_class() -> None: + """Test ProjectSpec with custom PluginManager subclass.""" + + class CustomPluginManager(PluginManager): + def __init__(self, project_name: str) -> 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 hasattr(pm, "custom_attr") + assert pm.custom_attr == "custom_value" + + +def test_project_spec_functional_integration() -> None: + """Test that ProjectSpec components work together functionally.""" + project = ProjectSpec("testproject") + + hookspec = project.hookspec + hookimpl = project.hookimpl + + # Define a hook spec + class HookSpecs: + @hookspec + def my_hook(self, arg: int) -> int: # type: ignore[empty-body] + ... + + # Define a plugin with hook implementation + class Plugin: + @hookimpl + def my_hook(self, arg: int) -> int: + return arg * 2 + + # Create plugin manager and test integration + pm = project.create_plugin_manager() + pm.add_hookspecs(HookSpecs) + pm.register(Plugin()) + + # Test hook calling + result = pm.hook.my_hook(arg=5) + assert result == [10] + + +def test_project_spec_multiple_plugin_managers_independent() -> None: + """Test that multiple PluginManager instances are independent.""" + project = ProjectSpec("testproject") + + pm1 = project.create_plugin_manager() + pm2 = project.create_plugin_manager() + + # Register different plugins on each manager + class Plugin1: + pass + + class Plugin2: + pass + + pm1.register(Plugin1(), name="plugin1") + pm2.register(Plugin2(), name="plugin2") + + # Each manager should only have its own plugin + assert pm1.has_plugin("plugin1") + assert not pm1.has_plugin("plugin2") + assert pm2.has_plugin("plugin2") + assert not pm2.has_plugin("plugin1") + + +def test_project_spec_different_project_names() -> None: + """Test that different ProjectSpecs have different project names.""" + project1 = ProjectSpec("project1") + project2 = ProjectSpec("project2") + + assert project1.project_name == "project1" + assert project2.project_name == "project2" + assert project1.hookspec.project_name == "project1" + assert project2.hookspec.project_name == "project2" + assert project1.hookimpl.project_name == "project1" + assert project2.hookimpl.project_name == "project2" + + +def test_project_spec_hook_attribute_naming() -> None: + """Test that hook attributes are created with correct project names.""" + project = ProjectSpec("myproject") + + hookspec = project.hookspec + hookimpl = project.hookimpl + + # Create test functions + @hookspec + def test_hook() -> None: + pass + + @hookimpl + def test_hook_impl() -> None: + pass + + # Check that attributes are set with correct project name + assert hasattr(test_hook, "myproject_spec") + assert hasattr(test_hook_impl, "myproject_impl") + + +def test_project_spec_with_get_hookconfig() -> None: + """Test that ProjectSpec works with get_hookimpl_config().""" + project = ProjectSpec("testproject") + hookimpl = project.hookimpl + + # Create a decorated function + @hookimpl(tryfirst=True, optionalhook=True) + def my_hook_impl() -> None: + pass + + # Get the configuration using ProjectSpec + config = project.get_hookimpl_config(my_hook_impl) + + assert config is not None + assert config.tryfirst is True + assert config.optionalhook is True + assert config.wrapper is False + assert config.hookwrapper is False + assert config.trylast is False + + +def test_marker_classes_accept_project_spec() -> None: + """Test that marker classes can accept ProjectSpec instances.""" + project = ProjectSpec("testproject") + + # Test creating markers with ProjectSpec instance + hookspec_from_project = HookspecMarker(project) + hookimpl_from_project = HookimplMarker(project) + + # Should have same project name + assert hookspec_from_project.project_name == "testproject" + assert hookimpl_from_project.project_name == "testproject" + + # Should store the ProjectSpec reference + assert hookspec_from_project._project_spec is project + assert hookimpl_from_project._project_spec is project + + +def test_marker_classes_accept_string() -> None: + """Test that marker classes still work with string project names.""" + # Test creating markers with string (legacy behavior) + hookspec_from_string = HookspecMarker("testproject") + hookimpl_from_string = HookimplMarker("testproject") + + # Should have correct project name + assert hookspec_from_string.project_name == "testproject" + assert hookimpl_from_string.project_name == "testproject" + + # Should have created internal ProjectSpec instances + assert hookspec_from_string._project_spec is not None + assert hookimpl_from_string._project_spec is not None + assert hookspec_from_string._project_spec.project_name == "testproject" + assert hookimpl_from_string._project_spec.project_name == "testproject" + + +def test_plugin_manager_accepts_project_spec() -> None: + """Test that PluginManager can accept ProjectSpec instances.""" + project = ProjectSpec("testproject") + + # Test creating PluginManager with ProjectSpec instance + pm_from_project = PluginManager(project) + + # Should have same project name + assert pm_from_project.project_name == "testproject" + + # Should store the ProjectSpec reference + assert pm_from_project._project_spec is project + + +def test_plugin_manager_accepts_string() -> None: + """Test that PluginManager still works with string project names.""" + # Test creating PluginManager with string (legacy behavior) + pm_from_string = PluginManager("testproject") + + # Should have correct project name + assert pm_from_string.project_name == "testproject" + + # Should have created internal ProjectSpec instance + assert pm_from_string._project_spec is not None + assert pm_from_string._project_spec.project_name == "testproject" + + +def test_project_name_is_property() -> None: + """Test that project_name is now a property that delegates to ProjectSpec.""" + project = ProjectSpec("testproject") + + # Test with markers created from ProjectSpec + hookspec = project.hookspec + hookimpl = project.hookimpl + pm = project.create_plugin_manager() + + # All should have same project name via property + assert hookspec.project_name == "testproject" + assert hookimpl.project_name == "testproject" + assert pm.project_name == "testproject" + + # Test with markers created from string + hookspec_str = HookspecMarker("anotherproject") + hookimpl_str = HookimplMarker("anotherproject") + pm_str = PluginManager("anotherproject") + + # All should have correct project name via property + assert hookspec_str.project_name == "anotherproject" + assert hookimpl_str.project_name == "anotherproject" + assert pm_str.project_name == "anotherproject" diff --git a/tox.ini b/tox.ini index 02a75ef9..a1d32dec 100644 --- a/tox.ini +++ b/tox.ini @@ -9,7 +9,7 @@ commands= setenv= _PYTEST_SETUP_SKIP_PLUGGY_DEP=1 coverage: _PLUGGY_TOX_CMD=coverage run -m pytest -extras=testing +dependency_groups=testing deps= coverage: coverage pytestmain: git+https://github.com/pytest-dev/pytest.git@main diff --git a/uv.lock b/uv.lock new file mode 100644 index 00000000..277a079d --- /dev/null +++ b/uv.lock @@ -0,0 +1,604 @@ +version = 1 +revision = 2 +requires-python = ">=3.9" + +[[package]] +name = "cachetools" +version = "6.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8a/89/817ad5d0411f136c484d535952aef74af9b25e0d99e90cdffbe121e6d628/cachetools-6.1.0.tar.gz", hash = "sha256:b4c4f404392848db3ce7aac34950d17be4d864da4b8b66911008e430bc544587", size = 30714, upload-time = "2025-06-16T18:51:03.07Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/00/f0/2ef431fe4141f5e334759d73e81120492b23b2824336883a91ac04ba710b/cachetools-6.1.0-py3-none-any.whl", hash = "sha256:1c7bb3cf9193deaf3508b7c5f2a79986c13ea38965c5adcff1f84519cf39163e", size = 11189, upload-time = "2025-06-16T18:51:01.514Z" }, +] + +[[package]] +name = "cfgv" +version = "3.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/11/74/539e56497d9bd1d484fd863dd69cbbfa653cd2aa27abfe35653494d85e94/cfgv-3.4.0.tar.gz", hash = "sha256:e52591d4c5f5dead8e0f673fb16db7949d2cfb3f7da4582893288f0ded8fe560", size = 7114, upload-time = "2023-08-12T20:38:17.776Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c5/55/51844dd50c4fc7a33b653bfaba4c2456f06955289ca770a5dbd5fd267374/cfgv-3.4.0-py2.py3-none-any.whl", hash = "sha256:b7265b1f29fd3316bfcd2b330d63d024f2bfd8bcb8b0272f8e19a504856c48f9", size = 7249, upload-time = "2023-08-12T20:38:16.269Z" }, +] + +[[package]] +name = "chardet" +version = "5.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f3/0d/f7b6ab21ec75897ed80c17d79b15951a719226b9fababf1e40ea74d69079/chardet-5.2.0.tar.gz", hash = "sha256:1b3b6ff479a8c414bc3fa2c0852995695c4a026dcd6d0633b2dd092ca39c1cf7", size = 2069618, upload-time = "2023-08-01T19:23:02.662Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/38/6f/f5fbc992a329ee4e0f288c1fe0e2ad9485ed064cac731ed2fe47dcc38cbf/chardet-5.2.0-py3-none-any.whl", hash = "sha256:e1cf59446890a00105fe7b7912492ea04b6e6f06d4b742b2c788469e34c82970", size = 199385, upload-time = "2023-08-01T19:23:00.661Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "coverage" +version = "7.9.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/e0/98670a80884f64578f0c22cd70c5e81a6e07b08167721c7487b4d70a7ca0/coverage-7.9.1.tar.gz", hash = "sha256:6cf43c78c4282708a28e466316935ec7489a9c487518a77fa68f716c67909cec", size = 813650, upload-time = "2025-06-13T13:02:28.627Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c1/78/1c1c5ec58f16817c09cbacb39783c3655d54a221b6552f47ff5ac9297603/coverage-7.9.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:cc94d7c5e8423920787c33d811c0be67b7be83c705f001f7180c7b186dcf10ca", size = 212028, upload-time = "2025-06-13T13:00:29.293Z" }, + { url = "https://files.pythonhosted.org/packages/98/db/e91b9076f3a888e3b4ad7972ea3842297a52cc52e73fd1e529856e473510/coverage-7.9.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:16aa0830d0c08a2c40c264cef801db8bc4fc0e1892782e45bcacbd5889270509", size = 212420, upload-time = "2025-06-13T13:00:34.027Z" }, + { url = "https://files.pythonhosted.org/packages/0e/d0/2b3733412954576b0aea0a16c3b6b8fbe95eb975d8bfa10b07359ead4252/coverage-7.9.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cf95981b126f23db63e9dbe4cf65bd71f9a6305696fa5e2262693bc4e2183f5b", size = 241529, upload-time = "2025-06-13T13:00:35.786Z" }, + { url = "https://files.pythonhosted.org/packages/b3/00/5e2e5ae2e750a872226a68e984d4d3f3563cb01d1afb449a17aa819bc2c4/coverage-7.9.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f05031cf21699785cd47cb7485f67df619e7bcdae38e0fde40d23d3d0210d3c3", size = 239403, upload-time = "2025-06-13T13:00:37.399Z" }, + { url = "https://files.pythonhosted.org/packages/37/3b/a2c27736035156b0a7c20683afe7df498480c0dfdf503b8c878a21b6d7fb/coverage-7.9.1-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bb4fbcab8764dc072cb651a4bcda4d11fb5658a1d8d68842a862a6610bd8cfa3", size = 240548, upload-time = "2025-06-13T13:00:39.647Z" }, + { url = "https://files.pythonhosted.org/packages/98/f5/13d5fc074c3c0e0dc80422d9535814abf190f1254d7c3451590dc4f8b18c/coverage-7.9.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:0f16649a7330ec307942ed27d06ee7e7a38417144620bb3d6e9a18ded8a2d3e5", size = 240459, upload-time = "2025-06-13T13:00:40.934Z" }, + { url = "https://files.pythonhosted.org/packages/36/24/24b9676ea06102df824c4a56ffd13dc9da7904478db519efa877d16527d5/coverage-7.9.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:cea0a27a89e6432705fffc178064503508e3c0184b4f061700e771a09de58187", size = 239128, upload-time = "2025-06-13T13:00:42.343Z" }, + { url = "https://files.pythonhosted.org/packages/be/05/242b7a7d491b369ac5fee7908a6e5ba42b3030450f3ad62c645b40c23e0e/coverage-7.9.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:e980b53a959fa53b6f05343afbd1e6f44a23ed6c23c4b4c56c6662bbb40c82ce", size = 239402, upload-time = "2025-06-13T13:00:43.634Z" }, + { url = "https://files.pythonhosted.org/packages/73/e0/4de7f87192fa65c9c8fbaeb75507e124f82396b71de1797da5602898be32/coverage-7.9.1-cp310-cp310-win32.whl", hash = "sha256:70760b4c5560be6ca70d11f8988ee6542b003f982b32f83d5ac0b72476607b70", size = 214518, upload-time = "2025-06-13T13:00:45.622Z" }, + { url = "https://files.pythonhosted.org/packages/d5/ab/5e4e2fe458907d2a65fab62c773671cfc5ac704f1e7a9ddd91996f66e3c2/coverage-7.9.1-cp310-cp310-win_amd64.whl", hash = "sha256:a66e8f628b71f78c0e0342003d53b53101ba4e00ea8dabb799d9dba0abbbcebe", size = 215436, upload-time = "2025-06-13T13:00:47.245Z" }, + { url = "https://files.pythonhosted.org/packages/60/34/fa69372a07d0903a78ac103422ad34db72281c9fc625eba94ac1185da66f/coverage-7.9.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:95c765060e65c692da2d2f51a9499c5e9f5cf5453aeaf1420e3fc847cc060582", size = 212146, upload-time = "2025-06-13T13:00:48.496Z" }, + { url = "https://files.pythonhosted.org/packages/27/f0/da1894915d2767f093f081c42afeba18e760f12fdd7a2f4acbe00564d767/coverage-7.9.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:ba383dc6afd5ec5b7a0d0c23d38895db0e15bcba7fb0fa8901f245267ac30d86", size = 212536, upload-time = "2025-06-13T13:00:51.535Z" }, + { url = "https://files.pythonhosted.org/packages/10/d5/3fc33b06e41e390f88eef111226a24e4504d216ab8e5d1a7089aa5a3c87a/coverage-7.9.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:37ae0383f13cbdcf1e5e7014489b0d71cc0106458878ccde52e8a12ced4298ed", size = 245092, upload-time = "2025-06-13T13:00:52.883Z" }, + { url = "https://files.pythonhosted.org/packages/0a/39/7aa901c14977aba637b78e95800edf77f29f5a380d29768c5b66f258305b/coverage-7.9.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:69aa417a030bf11ec46149636314c24c8d60fadb12fc0ee8f10fda0d918c879d", size = 242806, upload-time = "2025-06-13T13:00:54.571Z" }, + { url = "https://files.pythonhosted.org/packages/43/fc/30e5cfeaf560b1fc1989227adedc11019ce4bb7cce59d65db34fe0c2d963/coverage-7.9.1-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0a4be2a28656afe279b34d4f91c3e26eccf2f85500d4a4ff0b1f8b54bf807338", size = 244610, upload-time = "2025-06-13T13:00:56.932Z" }, + { url = "https://files.pythonhosted.org/packages/bf/15/cca62b13f39650bc87b2b92bb03bce7f0e79dd0bf2c7529e9fc7393e4d60/coverage-7.9.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:382e7ddd5289f140259b610e5f5c58f713d025cb2f66d0eb17e68d0a94278875", size = 244257, upload-time = "2025-06-13T13:00:58.545Z" }, + { url = "https://files.pythonhosted.org/packages/cd/1a/c0f2abe92c29e1464dbd0ff9d56cb6c88ae2b9e21becdb38bea31fcb2f6c/coverage-7.9.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:e5532482344186c543c37bfad0ee6069e8ae4fc38d073b8bc836fc8f03c9e250", size = 242309, upload-time = "2025-06-13T13:00:59.836Z" }, + { url = "https://files.pythonhosted.org/packages/57/8d/c6fd70848bd9bf88fa90df2af5636589a8126d2170f3aade21ed53f2b67a/coverage-7.9.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:a39d18b3f50cc121d0ce3838d32d58bd1d15dab89c910358ebefc3665712256c", size = 242898, upload-time = "2025-06-13T13:01:02.506Z" }, + { url = "https://files.pythonhosted.org/packages/c2/9e/6ca46c7bff4675f09a66fe2797cd1ad6a24f14c9c7c3b3ebe0470a6e30b8/coverage-7.9.1-cp311-cp311-win32.whl", hash = "sha256:dd24bd8d77c98557880def750782df77ab2b6885a18483dc8588792247174b32", size = 214561, upload-time = "2025-06-13T13:01:04.012Z" }, + { url = "https://files.pythonhosted.org/packages/a1/30/166978c6302010742dabcdc425fa0f938fa5a800908e39aff37a7a876a13/coverage-7.9.1-cp311-cp311-win_amd64.whl", hash = "sha256:6b55ad10a35a21b8015eabddc9ba31eb590f54adc9cd39bcf09ff5349fd52125", size = 215493, upload-time = "2025-06-13T13:01:05.702Z" }, + { url = "https://files.pythonhosted.org/packages/60/07/a6d2342cd80a5be9f0eeab115bc5ebb3917b4a64c2953534273cf9bc7ae6/coverage-7.9.1-cp311-cp311-win_arm64.whl", hash = "sha256:6ad935f0016be24c0e97fc8c40c465f9c4b85cbbe6eac48934c0dc4d2568321e", size = 213869, upload-time = "2025-06-13T13:01:09.345Z" }, + { url = "https://files.pythonhosted.org/packages/68/d9/7f66eb0a8f2fce222de7bdc2046ec41cb31fe33fb55a330037833fb88afc/coverage-7.9.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a8de12b4b87c20de895f10567639c0797b621b22897b0af3ce4b4e204a743626", size = 212336, upload-time = "2025-06-13T13:01:10.909Z" }, + { url = "https://files.pythonhosted.org/packages/20/20/e07cb920ef3addf20f052ee3d54906e57407b6aeee3227a9c91eea38a665/coverage-7.9.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5add197315a054e92cee1b5f686a2bcba60c4c3e66ee3de77ace6c867bdee7cb", size = 212571, upload-time = "2025-06-13T13:01:12.518Z" }, + { url = "https://files.pythonhosted.org/packages/78/f8/96f155de7e9e248ca9c8ff1a40a521d944ba48bec65352da9be2463745bf/coverage-7.9.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:600a1d4106fe66f41e5d0136dfbc68fe7200a5cbe85610ddf094f8f22e1b0300", size = 246377, upload-time = "2025-06-13T13:01:14.87Z" }, + { url = "https://files.pythonhosted.org/packages/3e/cf/1d783bd05b7bca5c10ded5f946068909372e94615a4416afadfe3f63492d/coverage-7.9.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2a876e4c3e5a2a1715a6608906aa5a2e0475b9c0f68343c2ada98110512ab1d8", size = 243394, upload-time = "2025-06-13T13:01:16.23Z" }, + { url = "https://files.pythonhosted.org/packages/02/dd/e7b20afd35b0a1abea09fb3998e1abc9f9bd953bee548f235aebd2b11401/coverage-7.9.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:81f34346dd63010453922c8e628a52ea2d2ccd73cb2487f7700ac531b247c8a5", size = 245586, upload-time = "2025-06-13T13:01:17.532Z" }, + { url = "https://files.pythonhosted.org/packages/4e/38/b30b0006fea9d617d1cb8e43b1bc9a96af11eff42b87eb8c716cf4d37469/coverage-7.9.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:888f8eee13f2377ce86d44f338968eedec3291876b0b8a7289247ba52cb984cd", size = 245396, upload-time = "2025-06-13T13:01:19.164Z" }, + { url = "https://files.pythonhosted.org/packages/31/e4/4d8ec1dc826e16791f3daf1b50943e8e7e1eb70e8efa7abb03936ff48418/coverage-7.9.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:9969ef1e69b8c8e1e70d591f91bbc37fc9a3621e447525d1602801a24ceda898", size = 243577, upload-time = "2025-06-13T13:01:22.433Z" }, + { url = "https://files.pythonhosted.org/packages/25/f4/b0e96c5c38e6e40ef465c4bc7f138863e2909c00e54a331da335faf0d81a/coverage-7.9.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:60c458224331ee3f1a5b472773e4a085cc27a86a0b48205409d364272d67140d", size = 244809, upload-time = "2025-06-13T13:01:24.143Z" }, + { url = "https://files.pythonhosted.org/packages/8a/65/27e0a1fa5e2e5079bdca4521be2f5dabf516f94e29a0defed35ac2382eb2/coverage-7.9.1-cp312-cp312-win32.whl", hash = "sha256:5f646a99a8c2b3ff4c6a6e081f78fad0dde275cd59f8f49dc4eab2e394332e74", size = 214724, upload-time = "2025-06-13T13:01:25.435Z" }, + { url = "https://files.pythonhosted.org/packages/9b/a8/d5b128633fd1a5e0401a4160d02fa15986209a9e47717174f99dc2f7166d/coverage-7.9.1-cp312-cp312-win_amd64.whl", hash = "sha256:30f445f85c353090b83e552dcbbdad3ec84c7967e108c3ae54556ca69955563e", size = 215535, upload-time = "2025-06-13T13:01:27.861Z" }, + { url = "https://files.pythonhosted.org/packages/a3/37/84bba9d2afabc3611f3e4325ee2c6a47cd449b580d4a606b240ce5a6f9bf/coverage-7.9.1-cp312-cp312-win_arm64.whl", hash = "sha256:af41da5dca398d3474129c58cb2b106a5d93bbb196be0d307ac82311ca234342", size = 213904, upload-time = "2025-06-13T13:01:29.202Z" }, + { url = "https://files.pythonhosted.org/packages/d0/a7/a027970c991ca90f24e968999f7d509332daf6b8c3533d68633930aaebac/coverage-7.9.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:31324f18d5969feef7344a932c32428a2d1a3e50b15a6404e97cba1cc9b2c631", size = 212358, upload-time = "2025-06-13T13:01:30.909Z" }, + { url = "https://files.pythonhosted.org/packages/f2/48/6aaed3651ae83b231556750280682528fea8ac7f1232834573472d83e459/coverage-7.9.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0c804506d624e8a20fb3108764c52e0eef664e29d21692afa375e0dd98dc384f", size = 212620, upload-time = "2025-06-13T13:01:32.256Z" }, + { url = "https://files.pythonhosted.org/packages/6c/2a/f4b613f3b44d8b9f144847c89151992b2b6b79cbc506dee89ad0c35f209d/coverage-7.9.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ef64c27bc40189f36fcc50c3fb8f16ccda73b6a0b80d9bd6e6ce4cffcd810bbd", size = 245788, upload-time = "2025-06-13T13:01:33.948Z" }, + { url = "https://files.pythonhosted.org/packages/04/d2/de4fdc03af5e4e035ef420ed26a703c6ad3d7a07aff2e959eb84e3b19ca8/coverage-7.9.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d4fe2348cc6ec372e25adec0219ee2334a68d2f5222e0cba9c0d613394e12d86", size = 243001, upload-time = "2025-06-13T13:01:35.285Z" }, + { url = "https://files.pythonhosted.org/packages/f5/e8/eed18aa5583b0423ab7f04e34659e51101135c41cd1dcb33ac1d7013a6d6/coverage-7.9.1-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:34ed2186fe52fcc24d4561041979a0dec69adae7bce2ae8d1c49eace13e55c43", size = 244985, upload-time = "2025-06-13T13:01:36.712Z" }, + { url = "https://files.pythonhosted.org/packages/17/f8/ae9e5cce8885728c934eaa58ebfa8281d488ef2afa81c3dbc8ee9e6d80db/coverage-7.9.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:25308bd3d00d5eedd5ae7d4357161f4df743e3c0240fa773ee1b0f75e6c7c0f1", size = 245152, upload-time = "2025-06-13T13:01:39.303Z" }, + { url = "https://files.pythonhosted.org/packages/5a/c8/272c01ae792bb3af9b30fac14d71d63371db227980682836ec388e2c57c0/coverage-7.9.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:73e9439310f65d55a5a1e0564b48e34f5369bee943d72c88378f2d576f5a5751", size = 243123, upload-time = "2025-06-13T13:01:40.727Z" }, + { url = "https://files.pythonhosted.org/packages/8c/d0/2819a1e3086143c094ab446e3bdf07138527a7b88cb235c488e78150ba7a/coverage-7.9.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:37ab6be0859141b53aa89412a82454b482c81cf750de4f29223d52268a86de67", size = 244506, upload-time = "2025-06-13T13:01:42.184Z" }, + { url = "https://files.pythonhosted.org/packages/8b/4e/9f6117b89152df7b6112f65c7a4ed1f2f5ec8e60c4be8f351d91e7acc848/coverage-7.9.1-cp313-cp313-win32.whl", hash = "sha256:64bdd969456e2d02a8b08aa047a92d269c7ac1f47e0c977675d550c9a0863643", size = 214766, upload-time = "2025-06-13T13:01:44.482Z" }, + { url = "https://files.pythonhosted.org/packages/27/0f/4b59f7c93b52c2c4ce7387c5a4e135e49891bb3b7408dcc98fe44033bbe0/coverage-7.9.1-cp313-cp313-win_amd64.whl", hash = "sha256:be9e3f68ca9edb897c2184ad0eee815c635565dbe7a0e7e814dc1f7cbab92c0a", size = 215568, upload-time = "2025-06-13T13:01:45.772Z" }, + { url = "https://files.pythonhosted.org/packages/09/1e/9679826336f8c67b9c39a359352882b24a8a7aee48d4c9cad08d38d7510f/coverage-7.9.1-cp313-cp313-win_arm64.whl", hash = "sha256:1c503289ffef1d5105d91bbb4d62cbe4b14bec4d13ca225f9c73cde9bb46207d", size = 213939, upload-time = "2025-06-13T13:01:47.087Z" }, + { url = "https://files.pythonhosted.org/packages/bb/5b/5c6b4e7a407359a2e3b27bf9c8a7b658127975def62077d441b93a30dbe8/coverage-7.9.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0b3496922cb5f4215bf5caaef4cf12364a26b0be82e9ed6d050f3352cf2d7ef0", size = 213079, upload-time = "2025-06-13T13:01:48.554Z" }, + { url = "https://files.pythonhosted.org/packages/a2/22/1e2e07279fd2fd97ae26c01cc2186e2258850e9ec125ae87184225662e89/coverage-7.9.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:9565c3ab1c93310569ec0d86b017f128f027cab0b622b7af288696d7ed43a16d", size = 213299, upload-time = "2025-06-13T13:01:49.997Z" }, + { url = "https://files.pythonhosted.org/packages/14/c0/4c5125a4b69d66b8c85986d3321520f628756cf524af810baab0790c7647/coverage-7.9.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2241ad5dbf79ae1d9c08fe52b36d03ca122fb9ac6bca0f34439e99f8327ac89f", size = 256535, upload-time = "2025-06-13T13:01:51.314Z" }, + { url = "https://files.pythonhosted.org/packages/81/8b/e36a04889dda9960be4263e95e777e7b46f1bb4fc32202612c130a20c4da/coverage-7.9.1-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3bb5838701ca68b10ebc0937dbd0eb81974bac54447c55cd58dea5bca8451029", size = 252756, upload-time = "2025-06-13T13:01:54.403Z" }, + { url = "https://files.pythonhosted.org/packages/98/82/be04eff8083a09a4622ecd0e1f31a2c563dbea3ed848069e7b0445043a70/coverage-7.9.1-cp313-cp313t-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b30a25f814591a8c0c5372c11ac8967f669b97444c47fd794926e175c4047ece", size = 254912, upload-time = "2025-06-13T13:01:56.769Z" }, + { url = "https://files.pythonhosted.org/packages/0f/25/c26610a2c7f018508a5ab958e5b3202d900422cf7cdca7670b6b8ca4e8df/coverage-7.9.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:2d04b16a6062516df97969f1ae7efd0de9c31eb6ebdceaa0d213b21c0ca1a683", size = 256144, upload-time = "2025-06-13T13:01:58.19Z" }, + { url = "https://files.pythonhosted.org/packages/c5/8b/fb9425c4684066c79e863f1e6e7ecebb49e3a64d9f7f7860ef1688c56f4a/coverage-7.9.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:7931b9e249edefb07cd6ae10c702788546341d5fe44db5b6108a25da4dca513f", size = 254257, upload-time = "2025-06-13T13:01:59.645Z" }, + { url = "https://files.pythonhosted.org/packages/93/df/27b882f54157fc1131e0e215b0da3b8d608d9b8ef79a045280118a8f98fe/coverage-7.9.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:52e92b01041151bf607ee858e5a56c62d4b70f4dac85b8c8cb7fb8a351ab2c10", size = 255094, upload-time = "2025-06-13T13:02:01.37Z" }, + { url = "https://files.pythonhosted.org/packages/41/5f/cad1c3dbed8b3ee9e16fa832afe365b4e3eeab1fb6edb65ebbf745eabc92/coverage-7.9.1-cp313-cp313t-win32.whl", hash = "sha256:684e2110ed84fd1ca5f40e89aa44adf1729dc85444004111aa01866507adf363", size = 215437, upload-time = "2025-06-13T13:02:02.905Z" }, + { url = "https://files.pythonhosted.org/packages/99/4d/fad293bf081c0e43331ca745ff63673badc20afea2104b431cdd8c278b4c/coverage-7.9.1-cp313-cp313t-win_amd64.whl", hash = "sha256:437c576979e4db840539674e68c84b3cda82bc824dd138d56bead1435f1cb5d7", size = 216605, upload-time = "2025-06-13T13:02:05.638Z" }, + { url = "https://files.pythonhosted.org/packages/1f/56/4ee027d5965fc7fc126d7ec1187529cc30cc7d740846e1ecb5e92d31b224/coverage-7.9.1-cp313-cp313t-win_arm64.whl", hash = "sha256:18a0912944d70aaf5f399e350445738a1a20b50fbea788f640751c2ed9208b6c", size = 214392, upload-time = "2025-06-13T13:02:07.642Z" }, + { url = "https://files.pythonhosted.org/packages/a5/d6/c41dd9b02bf16ec001aaf1cbef665537606899a3db1094e78f5ae17540ca/coverage-7.9.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:6f424507f57878e424d9a95dc4ead3fbdd72fd201e404e861e465f28ea469951", size = 212029, upload-time = "2025-06-13T13:02:09.058Z" }, + { url = "https://files.pythonhosted.org/packages/f8/c0/40420d81d731f84c3916dcdf0506b3e6c6570817bff2576b83f780914ae6/coverage-7.9.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:535fde4001b2783ac80865d90e7cc7798b6b126f4cd8a8c54acfe76804e54e58", size = 212407, upload-time = "2025-06-13T13:02:11.151Z" }, + { url = "https://files.pythonhosted.org/packages/9b/87/f0db7d62d0e09f14d6d2f6ae8c7274a2f09edf74895a34b412a0601e375a/coverage-7.9.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:02532fd3290bb8fa6bec876520842428e2a6ed6c27014eca81b031c2d30e3f71", size = 241160, upload-time = "2025-06-13T13:02:12.864Z" }, + { url = "https://files.pythonhosted.org/packages/a9/b7/3337c064f058a5d7696c4867159651a5b5fb01a5202bcf37362f0c51400e/coverage-7.9.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:56f5eb308b17bca3bbff810f55ee26d51926d9f89ba92707ee41d3c061257e55", size = 239027, upload-time = "2025-06-13T13:02:14.294Z" }, + { url = "https://files.pythonhosted.org/packages/7e/a9/5898a283f66d1bd413c32c2e0e05408196fd4f37e206e2b06c6e0c626e0e/coverage-7.9.1-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bfa447506c1a52271f1b0de3f42ea0fa14676052549095e378d5bff1c505ff7b", size = 240145, upload-time = "2025-06-13T13:02:15.745Z" }, + { url = "https://files.pythonhosted.org/packages/e0/33/d96e3350078a3c423c549cb5b2ba970de24c5257954d3e4066e2b2152d30/coverage-7.9.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:9ca8e220006966b4a7b68e8984a6aee645a0384b0769e829ba60281fe61ec4f7", size = 239871, upload-time = "2025-06-13T13:02:17.344Z" }, + { url = "https://files.pythonhosted.org/packages/1d/6e/6fb946072455f71a820cac144d49d11747a0f1a21038060a68d2d0200499/coverage-7.9.1-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:49f1d0788ba5b7ba65933f3a18864117c6506619f5ca80326b478f72acf3f385", size = 238122, upload-time = "2025-06-13T13:02:18.849Z" }, + { url = "https://files.pythonhosted.org/packages/e4/5c/bc43f25c8586840ce25a796a8111acf6a2b5f0909ba89a10d41ccff3920d/coverage-7.9.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:68cd53aec6f45b8e4724c0950ce86eacb775c6be01ce6e3669fe4f3a21e768ed", size = 239058, upload-time = "2025-06-13T13:02:21.423Z" }, + { url = "https://files.pythonhosted.org/packages/11/d8/ce2007418dd7fd00ff8c8b898bb150bb4bac2d6a86df05d7b88a07ff595f/coverage-7.9.1-cp39-cp39-win32.whl", hash = "sha256:95335095b6c7b1cc14c3f3f17d5452ce677e8490d101698562b2ffcacc304c8d", size = 214532, upload-time = "2025-06-13T13:02:22.857Z" }, + { url = "https://files.pythonhosted.org/packages/20/21/334e76fa246e92e6d69cab217f7c8a70ae0cc8f01438bd0544103f29528e/coverage-7.9.1-cp39-cp39-win_amd64.whl", hash = "sha256:e1b5191d1648acc439b24721caab2fd0c86679d8549ed2c84d5a7ec1bedcc244", size = 215439, upload-time = "2025-06-13T13:02:24.268Z" }, + { url = "https://files.pythonhosted.org/packages/3e/e5/c723545c3fd3204ebde3b4cc4b927dce709d3b6dc577754bb57f63ca4a4a/coverage-7.9.1-pp39.pp310.pp311-none-any.whl", hash = "sha256:db0f04118d1db74db6c9e1cb1898532c7dcc220f1d2718f058601f7c3f499514", size = 204009, upload-time = "2025-06-13T13:02:25.787Z" }, + { url = "https://files.pythonhosted.org/packages/08/b8/7ddd1e8ba9701dea08ce22029917140e6f66a859427406579fd8d0ca7274/coverage-7.9.1-py3-none-any.whl", hash = "sha256:66b974b145aa189516b6bf2d8423e888b742517d37872f6ee4c5be0073bd9a3c", size = 204000, upload-time = "2025-06-13T13:02:27.173Z" }, +] + +[[package]] +name = "distlib" +version = "0.3.9" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0d/dd/1bec4c5ddb504ca60fc29472f3d27e8d4da1257a854e1d96742f15c1d02d/distlib-0.3.9.tar.gz", hash = "sha256:a60f20dea646b8a33f3e7772f74dc0b2d0772d2837ee1342a00645c81edf9403", size = 613923, upload-time = "2024-10-09T18:35:47.551Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/91/a1/cf2472db20f7ce4a6be1253a81cfdf85ad9c7885ffbed7047fb72c24cf87/distlib-0.3.9-py2.py3-none-any.whl", hash = "sha256:47f8c22fd27c27e25a65601af709b38e4f0a45ea4fc2e710f65755fa8caaaf87", size = 468973, upload-time = "2024-10-09T18:35:44.272Z" }, +] + +[[package]] +name = "exceptiongroup" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0b/9f/a65090624ecf468cdca03533906e7c69ed7588582240cfe7cc9e770b50eb/exceptiongroup-1.3.0.tar.gz", hash = "sha256:b241f5885f560bc56a59ee63ca4c6a8bfa46ae4ad651af316d4e81817bb9fd88", size = 29749, upload-time = "2025-05-10T17:42:51.123Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/36/f4/c6e662dade71f56cd2f3735141b265c3c79293c109549c1e6933b0651ffc/exceptiongroup-1.3.0-py3-none-any.whl", hash = "sha256:4d111e6e0c13d0644cad6ddaa7ed0261a0b36971f6d23e7ec9b4b9097da78a10", size = 16674, upload-time = "2025-05-10T17:42:49.33Z" }, +] + +[[package]] +name = "filelock" +version = "3.18.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0a/10/c23352565a6544bdc5353e0b15fc1c563352101f30e24bf500207a54df9a/filelock-3.18.0.tar.gz", hash = "sha256:adbc88eabb99d2fec8c9c1b229b171f18afa655400173ddc653d5d01501fb9f2", size = 18075, upload-time = "2025-03-14T07:11:40.47Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4d/36/2a115987e2d8c300a974597416d9de88f2444426de9571f4b59b2cca3acc/filelock-3.18.0-py3-none-any.whl", hash = "sha256:c401f4f8377c4464e6db25fff06205fd89bdd83b65eb0488ed1b160f780e21de", size = 16215, upload-time = "2025-03-14T07:11:39.145Z" }, +] + +[[package]] +name = "greenlet" +version = "3.2.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c9/92/bb85bd6e80148a4d2e0c59f7c0c2891029f8fd510183afc7d8d2feeed9b6/greenlet-3.2.3.tar.gz", hash = "sha256:8b0dd8ae4c0d6f5e54ee55ba935eeb3d735a9b58a8a1e5b5cbab64e01a39f365", size = 185752, upload-time = "2025-06-05T16:16:09.955Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/92/db/b4c12cff13ebac2786f4f217f06588bccd8b53d260453404ef22b121fc3a/greenlet-3.2.3-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:1afd685acd5597349ee6d7a88a8bec83ce13c106ac78c196ee9dde7c04fe87be", size = 268977, upload-time = "2025-06-05T16:10:24.001Z" }, + { url = "https://files.pythonhosted.org/packages/52/61/75b4abd8147f13f70986df2801bf93735c1bd87ea780d70e3b3ecda8c165/greenlet-3.2.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:761917cac215c61e9dc7324b2606107b3b292a8349bdebb31503ab4de3f559ac", size = 627351, upload-time = "2025-06-05T16:38:50.685Z" }, + { url = "https://files.pythonhosted.org/packages/35/aa/6894ae299d059d26254779a5088632874b80ee8cf89a88bca00b0709d22f/greenlet-3.2.3-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:a433dbc54e4a37e4fff90ef34f25a8c00aed99b06856f0119dcf09fbafa16392", size = 638599, upload-time = "2025-06-05T16:41:34.057Z" }, + { url = "https://files.pythonhosted.org/packages/30/64/e01a8261d13c47f3c082519a5e9dbf9e143cc0498ed20c911d04e54d526c/greenlet-3.2.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:72e77ed69312bab0434d7292316d5afd6896192ac4327d44f3d613ecb85b037c", size = 634482, upload-time = "2025-06-05T16:48:16.26Z" }, + { url = "https://files.pythonhosted.org/packages/47/48/ff9ca8ba9772d083a4f5221f7b4f0ebe8978131a9ae0909cf202f94cd879/greenlet-3.2.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:68671180e3849b963649254a882cd544a3c75bfcd2c527346ad8bb53494444db", size = 633284, upload-time = "2025-06-05T16:13:01.599Z" }, + { url = "https://files.pythonhosted.org/packages/e9/45/626e974948713bc15775b696adb3eb0bd708bec267d6d2d5c47bb47a6119/greenlet-3.2.3-cp310-cp310-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:49c8cfb18fb419b3d08e011228ef8a25882397f3a859b9fe1436946140b6756b", size = 582206, upload-time = "2025-06-05T16:12:48.51Z" }, + { url = "https://files.pythonhosted.org/packages/b1/8e/8b6f42c67d5df7db35b8c55c9a850ea045219741bb14416255616808c690/greenlet-3.2.3-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:efc6dc8a792243c31f2f5674b670b3a95d46fa1c6a912b8e310d6f542e7b0712", size = 1111412, upload-time = "2025-06-05T16:36:45.479Z" }, + { url = "https://files.pythonhosted.org/packages/05/46/ab58828217349500a7ebb81159d52ca357da747ff1797c29c6023d79d798/greenlet-3.2.3-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:731e154aba8e757aedd0781d4b240f1225b075b4409f1bb83b05ff410582cf00", size = 1135054, upload-time = "2025-06-05T16:12:36.478Z" }, + { url = "https://files.pythonhosted.org/packages/68/7f/d1b537be5080721c0f0089a8447d4ef72839039cdb743bdd8ffd23046e9a/greenlet-3.2.3-cp310-cp310-win_amd64.whl", hash = "sha256:96c20252c2f792defe9a115d3287e14811036d51e78b3aaddbee23b69b216302", size = 296573, upload-time = "2025-06-05T16:34:26.521Z" }, + { url = "https://files.pythonhosted.org/packages/fc/2e/d4fcb2978f826358b673f779f78fa8a32ee37df11920dc2bb5589cbeecef/greenlet-3.2.3-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:784ae58bba89fa1fa5733d170d42486580cab9decda3484779f4759345b29822", size = 270219, upload-time = "2025-06-05T16:10:10.414Z" }, + { url = "https://files.pythonhosted.org/packages/16/24/929f853e0202130e4fe163bc1d05a671ce8dcd604f790e14896adac43a52/greenlet-3.2.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0921ac4ea42a5315d3446120ad48f90c3a6b9bb93dd9b3cf4e4d84a66e42de83", size = 630383, upload-time = "2025-06-05T16:38:51.785Z" }, + { url = "https://files.pythonhosted.org/packages/d1/b2/0320715eb61ae70c25ceca2f1d5ae620477d246692d9cc284c13242ec31c/greenlet-3.2.3-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:d2971d93bb99e05f8c2c0c2f4aa9484a18d98c4c3bd3c62b65b7e6ae33dfcfaf", size = 642422, upload-time = "2025-06-05T16:41:35.259Z" }, + { url = "https://files.pythonhosted.org/packages/bd/49/445fd1a210f4747fedf77615d941444349c6a3a4a1135bba9701337cd966/greenlet-3.2.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:c667c0bf9d406b77a15c924ef3285e1e05250948001220368e039b6aa5b5034b", size = 638375, upload-time = "2025-06-05T16:48:18.235Z" }, + { url = "https://files.pythonhosted.org/packages/7e/c8/ca19760cf6eae75fa8dc32b487e963d863b3ee04a7637da77b616703bc37/greenlet-3.2.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:592c12fb1165be74592f5de0d70f82bc5ba552ac44800d632214b76089945147", size = 637627, upload-time = "2025-06-05T16:13:02.858Z" }, + { url = "https://files.pythonhosted.org/packages/65/89/77acf9e3da38e9bcfca881e43b02ed467c1dedc387021fc4d9bd9928afb8/greenlet-3.2.3-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:29e184536ba333003540790ba29829ac14bb645514fbd7e32af331e8202a62a5", size = 585502, upload-time = "2025-06-05T16:12:49.642Z" }, + { url = "https://files.pythonhosted.org/packages/97/c6/ae244d7c95b23b7130136e07a9cc5aadd60d59b5951180dc7dc7e8edaba7/greenlet-3.2.3-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:93c0bb79844a367782ec4f429d07589417052e621aa39a5ac1fb99c5aa308edc", size = 1114498, upload-time = "2025-06-05T16:36:46.598Z" }, + { url = "https://files.pythonhosted.org/packages/89/5f/b16dec0cbfd3070658e0d744487919740c6d45eb90946f6787689a7efbce/greenlet-3.2.3-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:751261fc5ad7b6705f5f76726567375bb2104a059454e0226e1eef6c756748ba", size = 1139977, upload-time = "2025-06-05T16:12:38.262Z" }, + { url = "https://files.pythonhosted.org/packages/66/77/d48fb441b5a71125bcac042fc5b1494c806ccb9a1432ecaa421e72157f77/greenlet-3.2.3-cp311-cp311-win_amd64.whl", hash = "sha256:83a8761c75312361aa2b5b903b79da97f13f556164a7dd2d5448655425bd4c34", size = 297017, upload-time = "2025-06-05T16:25:05.225Z" }, + { url = "https://files.pythonhosted.org/packages/f3/94/ad0d435f7c48debe960c53b8f60fb41c2026b1d0fa4a99a1cb17c3461e09/greenlet-3.2.3-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:25ad29caed5783d4bd7a85c9251c651696164622494c00802a139c00d639242d", size = 271992, upload-time = "2025-06-05T16:11:23.467Z" }, + { url = "https://files.pythonhosted.org/packages/93/5d/7c27cf4d003d6e77749d299c7c8f5fd50b4f251647b5c2e97e1f20da0ab5/greenlet-3.2.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:88cd97bf37fe24a6710ec6a3a7799f3f81d9cd33317dcf565ff9950c83f55e0b", size = 638820, upload-time = "2025-06-05T16:38:52.882Z" }, + { url = "https://files.pythonhosted.org/packages/c6/7e/807e1e9be07a125bb4c169144937910bf59b9d2f6d931578e57f0bce0ae2/greenlet-3.2.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:baeedccca94880d2f5666b4fa16fc20ef50ba1ee353ee2d7092b383a243b0b0d", size = 653046, upload-time = "2025-06-05T16:41:36.343Z" }, + { url = "https://files.pythonhosted.org/packages/9d/ab/158c1a4ea1068bdbc78dba5a3de57e4c7aeb4e7fa034320ea94c688bfb61/greenlet-3.2.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:be52af4b6292baecfa0f397f3edb3c6092ce071b499dd6fe292c9ac9f2c8f264", size = 647701, upload-time = "2025-06-05T16:48:19.604Z" }, + { url = "https://files.pythonhosted.org/packages/cc/0d/93729068259b550d6a0288da4ff72b86ed05626eaf1eb7c0d3466a2571de/greenlet-3.2.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0cc73378150b8b78b0c9fe2ce56e166695e67478550769536a6742dca3651688", size = 649747, upload-time = "2025-06-05T16:13:04.628Z" }, + { url = "https://files.pythonhosted.org/packages/f6/f6/c82ac1851c60851302d8581680573245c8fc300253fc1ff741ae74a6c24d/greenlet-3.2.3-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:706d016a03e78df129f68c4c9b4c4f963f7d73534e48a24f5f5a7101ed13dbbb", size = 605461, upload-time = "2025-06-05T16:12:50.792Z" }, + { url = "https://files.pythonhosted.org/packages/98/82/d022cf25ca39cf1200650fc58c52af32c90f80479c25d1cbf57980ec3065/greenlet-3.2.3-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:419e60f80709510c343c57b4bb5a339d8767bf9aef9b8ce43f4f143240f88b7c", size = 1121190, upload-time = "2025-06-05T16:36:48.59Z" }, + { url = "https://files.pythonhosted.org/packages/f5/e1/25297f70717abe8104c20ecf7af0a5b82d2f5a980eb1ac79f65654799f9f/greenlet-3.2.3-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:93d48533fade144203816783373f27a97e4193177ebaaf0fc396db19e5d61163", size = 1149055, upload-time = "2025-06-05T16:12:40.457Z" }, + { url = "https://files.pythonhosted.org/packages/1f/8f/8f9e56c5e82eb2c26e8cde787962e66494312dc8cb261c460e1f3a9c88bc/greenlet-3.2.3-cp312-cp312-win_amd64.whl", hash = "sha256:7454d37c740bb27bdeddfc3f358f26956a07d5220818ceb467a483197d84f849", size = 297817, upload-time = "2025-06-05T16:29:49.244Z" }, + { url = "https://files.pythonhosted.org/packages/b1/cf/f5c0b23309070ae93de75c90d29300751a5aacefc0a3ed1b1d8edb28f08b/greenlet-3.2.3-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:500b8689aa9dd1ab26872a34084503aeddefcb438e2e7317b89b11eaea1901ad", size = 270732, upload-time = "2025-06-05T16:10:08.26Z" }, + { url = "https://files.pythonhosted.org/packages/48/ae/91a957ba60482d3fecf9be49bc3948f341d706b52ddb9d83a70d42abd498/greenlet-3.2.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a07d3472c2a93117af3b0136f246b2833fdc0b542d4a9799ae5f41c28323faef", size = 639033, upload-time = "2025-06-05T16:38:53.983Z" }, + { url = "https://files.pythonhosted.org/packages/6f/df/20ffa66dd5a7a7beffa6451bdb7400d66251374ab40b99981478c69a67a8/greenlet-3.2.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:8704b3768d2f51150626962f4b9a9e4a17d2e37c8a8d9867bbd9fa4eb938d3b3", size = 652999, upload-time = "2025-06-05T16:41:37.89Z" }, + { url = "https://files.pythonhosted.org/packages/51/b4/ebb2c8cb41e521f1d72bf0465f2f9a2fd803f674a88db228887e6847077e/greenlet-3.2.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:5035d77a27b7c62db6cf41cf786cfe2242644a7a337a0e155c80960598baab95", size = 647368, upload-time = "2025-06-05T16:48:21.467Z" }, + { url = "https://files.pythonhosted.org/packages/8e/6a/1e1b5aa10dced4ae876a322155705257748108b7fd2e4fae3f2a091fe81a/greenlet-3.2.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2d8aa5423cd4a396792f6d4580f88bdc6efcb9205891c9d40d20f6e670992efb", size = 650037, upload-time = "2025-06-05T16:13:06.402Z" }, + { url = "https://files.pythonhosted.org/packages/26/f2/ad51331a157c7015c675702e2d5230c243695c788f8f75feba1af32b3617/greenlet-3.2.3-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2c724620a101f8170065d7dded3f962a2aea7a7dae133a009cada42847e04a7b", size = 608402, upload-time = "2025-06-05T16:12:51.91Z" }, + { url = "https://files.pythonhosted.org/packages/26/bc/862bd2083e6b3aff23300900a956f4ea9a4059de337f5c8734346b9b34fc/greenlet-3.2.3-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:873abe55f134c48e1f2a6f53f7d1419192a3d1a4e873bace00499a4e45ea6af0", size = 1119577, upload-time = "2025-06-05T16:36:49.787Z" }, + { url = "https://files.pythonhosted.org/packages/86/94/1fc0cc068cfde885170e01de40a619b00eaa8f2916bf3541744730ffb4c3/greenlet-3.2.3-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:024571bbce5f2c1cfff08bf3fbaa43bbc7444f580ae13b0099e95d0e6e67ed36", size = 1147121, upload-time = "2025-06-05T16:12:42.527Z" }, + { url = "https://files.pythonhosted.org/packages/27/1a/199f9587e8cb08a0658f9c30f3799244307614148ffe8b1e3aa22f324dea/greenlet-3.2.3-cp313-cp313-win_amd64.whl", hash = "sha256:5195fb1e75e592dd04ce79881c8a22becdfa3e6f500e7feb059b1e6fdd54d3e3", size = 297603, upload-time = "2025-06-05T16:20:12.651Z" }, + { url = "https://files.pythonhosted.org/packages/d8/ca/accd7aa5280eb92b70ed9e8f7fd79dc50a2c21d8c73b9a0856f5b564e222/greenlet-3.2.3-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:3d04332dddb10b4a211b68111dabaee2e1a073663d117dc10247b5b1642bac86", size = 271479, upload-time = "2025-06-05T16:10:47.525Z" }, + { url = "https://files.pythonhosted.org/packages/55/71/01ed9895d9eb49223280ecc98a557585edfa56b3d0e965b9fa9f7f06b6d9/greenlet-3.2.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8186162dffde068a465deab08fc72c767196895c39db26ab1c17c0b77a6d8b97", size = 683952, upload-time = "2025-06-05T16:38:55.125Z" }, + { url = "https://files.pythonhosted.org/packages/ea/61/638c4bdf460c3c678a0a1ef4c200f347dff80719597e53b5edb2fb27ab54/greenlet-3.2.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f4bfbaa6096b1b7a200024784217defedf46a07c2eee1a498e94a1b5f8ec5728", size = 696917, upload-time = "2025-06-05T16:41:38.959Z" }, + { url = "https://files.pythonhosted.org/packages/22/cc/0bd1a7eb759d1f3e3cc2d1bc0f0b487ad3cc9f34d74da4b80f226fde4ec3/greenlet-3.2.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:ed6cfa9200484d234d8394c70f5492f144b20d4533f69262d530a1a082f6ee9a", size = 692443, upload-time = "2025-06-05T16:48:23.113Z" }, + { url = "https://files.pythonhosted.org/packages/67/10/b2a4b63d3f08362662e89c103f7fe28894a51ae0bc890fabf37d1d780e52/greenlet-3.2.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:02b0df6f63cd15012bed5401b47829cfd2e97052dc89da3cfaf2c779124eb892", size = 692995, upload-time = "2025-06-05T16:13:07.972Z" }, + { url = "https://files.pythonhosted.org/packages/5a/c6/ad82f148a4e3ce9564056453a71529732baf5448ad53fc323e37efe34f66/greenlet-3.2.3-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:86c2d68e87107c1792e2e8d5399acec2487a4e993ab76c792408e59394d52141", size = 655320, upload-time = "2025-06-05T16:12:53.453Z" }, + { url = "https://files.pythonhosted.org/packages/5c/4f/aab73ecaa6b3086a4c89863d94cf26fa84cbff63f52ce9bc4342b3087a06/greenlet-3.2.3-cp314-cp314-win_amd64.whl", hash = "sha256:8c47aae8fbbfcf82cc13327ae802ba13c9c36753b67e760023fd116bc124a62a", size = 301236, upload-time = "2025-06-05T16:15:20.111Z" }, + { url = "https://files.pythonhosted.org/packages/3d/d9/a3114df5fba2bf9823e0acc01e9e2abdcd8ea4c5487cf1c3dcd4cc0b48cf/greenlet-3.2.3-cp39-cp39-macosx_11_0_universal2.whl", hash = "sha256:42efc522c0bd75ffa11a71e09cd8a399d83fafe36db250a87cf1dacfaa15dc64", size = 267769, upload-time = "2025-06-05T16:10:44.802Z" }, + { url = "https://files.pythonhosted.org/packages/bc/da/47dfc50f6e5673116e66a737dc58d1eca651db9a9aa8797c1d27e940e211/greenlet-3.2.3-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d760f9bdfe79bff803bad32b4d8ffb2c1d2ce906313fc10a83976ffb73d64ca7", size = 625472, upload-time = "2025-06-05T16:38:56.882Z" }, + { url = "https://files.pythonhosted.org/packages/f5/74/f6ef9f85d981b2fcd665bbee3e69e3c0a10fb962eb4c6a5889ac3b6debfa/greenlet-3.2.3-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:8324319cbd7b35b97990090808fdc99c27fe5338f87db50514959f8059999805", size = 637253, upload-time = "2025-06-05T16:41:40.542Z" }, + { url = "https://files.pythonhosted.org/packages/66/69/4919bb1c9e43bfc16dc886e7a37fe1bc04bfa4101aba177936a10f313cad/greenlet-3.2.3-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:8c37ef5b3787567d322331d5250e44e42b58c8c713859b8a04c6065f27efbf72", size = 632611, upload-time = "2025-06-05T16:48:24.976Z" }, + { url = "https://files.pythonhosted.org/packages/6b/8d/97d988d019f40b6b360b0c71c99e5b4c877a3d92666fe48b081d0e1ea1cd/greenlet-3.2.3-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ce539fb52fb774d0802175d37fcff5c723e2c7d249c65916257f0a940cee8904", size = 631843, upload-time = "2025-06-05T16:13:09.476Z" }, + { url = "https://files.pythonhosted.org/packages/59/24/d5e1504ec00768755d4ccc2168b76d9f4524e96694a14ad45bd87796e9bb/greenlet-3.2.3-cp39-cp39-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:003c930e0e074db83559edc8705f3a2d066d4aa8c2f198aff1e454946efd0f26", size = 580781, upload-time = "2025-06-05T16:12:55.029Z" }, + { url = "https://files.pythonhosted.org/packages/9c/df/d009bcca566dbfd2283b306b4e424f4c0e59bf984868f8b789802fe9e607/greenlet-3.2.3-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:7e70ea4384b81ef9e84192e8a77fb87573138aa5d4feee541d8014e452b434da", size = 1109903, upload-time = "2025-06-05T16:36:51.491Z" }, + { url = "https://files.pythonhosted.org/packages/33/54/5036097197a78388aa6901a5b90b562f3a154a9fbee89c301a26f56f3942/greenlet-3.2.3-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:22eb5ba839c4b2156f18f76768233fe44b23a31decd9cc0d4cc8141c211fd1b4", size = 1133975, upload-time = "2025-06-05T16:12:43.866Z" }, + { url = "https://files.pythonhosted.org/packages/e2/15/b001456a430805fdd8b600a788d19a790664eee8863739523395f68df752/greenlet-3.2.3-cp39-cp39-win32.whl", hash = "sha256:4532f0d25df67f896d137431b13f4cdce89f7e3d4a96387a41290910df4d3a57", size = 279320, upload-time = "2025-06-05T16:43:34.043Z" }, + { url = "https://files.pythonhosted.org/packages/6c/4c/bf2100cbc1bd07f39bee3b09e7eef39beffe29f5453dc2477a2693737913/greenlet-3.2.3-cp39-cp39-win_amd64.whl", hash = "sha256:aaa7aae1e7f75eaa3ae400ad98f8644bb81e1dc6ba47ce8a93d3f17274e08322", size = 296444, upload-time = "2025-06-05T16:39:22.664Z" }, +] + +[[package]] +name = "identify" +version = "2.6.12" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/88/d193a27416618628a5eea64e3223acd800b40749a96ffb322a9b55a49ed1/identify-2.6.12.tar.gz", hash = "sha256:d8de45749f1efb108badef65ee8386f0f7bb19a7f26185f74de6367bffbaf0e6", size = 99254, upload-time = "2025-05-23T20:37:53.3Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7a/cd/18f8da995b658420625f7ef13f037be53ae04ec5ad33f9b718240dcfd48c/identify-2.6.12-py2.py3-none-any.whl", hash = "sha256:ad9672d5a72e0d2ff7c5c8809b62dfa60458626352fb0eb7b55e69bdc45334a2", size = 99145, upload-time = "2025-05-23T20:37:51.495Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f2/97/ebf4da567aa6827c909642694d71c9fcf53e5b504f2d96afea02718862f3/iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7", size = 4793, upload-time = "2025-03-19T20:09:59.721Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/e1/e6716421ea10d38022b952c159d5161ca1193197fb744506875fbb87ea7b/iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760", size = 6050, upload-time = "2025-03-19T20:10:01.071Z" }, +] + +[[package]] +name = "mypy" +version = "1.16.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mypy-extensions" }, + { name = "pathspec" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/81/69/92c7fa98112e4d9eb075a239caa4ef4649ad7d441545ccffbd5e34607cbb/mypy-1.16.1.tar.gz", hash = "sha256:6bd00a0a2094841c5e47e7374bb42b83d64c527a502e3334e1173a0c24437bab", size = 3324747, upload-time = "2025-06-16T16:51:35.145Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8e/12/2bf23a80fcef5edb75de9a1e295d778e0f46ea89eb8b115818b663eff42b/mypy-1.16.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b4f0fed1022a63c6fec38f28b7fc77fca47fd490445c69d0a66266c59dd0b88a", size = 10958644, upload-time = "2025-06-16T16:51:11.649Z" }, + { url = "https://files.pythonhosted.org/packages/08/50/bfe47b3b278eacf348291742fd5e6613bbc4b3434b72ce9361896417cfe5/mypy-1.16.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:86042bbf9f5a05ea000d3203cf87aa9d0ccf9a01f73f71c58979eb9249f46d72", size = 10087033, upload-time = "2025-06-16T16:35:30.089Z" }, + { url = "https://files.pythonhosted.org/packages/21/de/40307c12fe25675a0776aaa2cdd2879cf30d99eec91b898de00228dc3ab5/mypy-1.16.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ea7469ee5902c95542bea7ee545f7006508c65c8c54b06dc2c92676ce526f3ea", size = 11875645, upload-time = "2025-06-16T16:35:48.49Z" }, + { url = "https://files.pythonhosted.org/packages/a6/d8/85bdb59e4a98b7a31495bd8f1a4445d8ffc86cde4ab1f8c11d247c11aedc/mypy-1.16.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:352025753ef6a83cb9e7f2427319bb7875d1fdda8439d1e23de12ab164179574", size = 12616986, upload-time = "2025-06-16T16:48:39.526Z" }, + { url = "https://files.pythonhosted.org/packages/0e/d0/bb25731158fa8f8ee9e068d3e94fcceb4971fedf1424248496292512afe9/mypy-1.16.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:ff9fa5b16e4c1364eb89a4d16bcda9987f05d39604e1e6c35378a2987c1aac2d", size = 12878632, upload-time = "2025-06-16T16:36:08.195Z" }, + { url = "https://files.pythonhosted.org/packages/2d/11/822a9beb7a2b825c0cb06132ca0a5183f8327a5e23ef89717c9474ba0bc6/mypy-1.16.1-cp310-cp310-win_amd64.whl", hash = "sha256:1256688e284632382f8f3b9e2123df7d279f603c561f099758e66dd6ed4e8bd6", size = 9484391, upload-time = "2025-06-16T16:37:56.151Z" }, + { url = "https://files.pythonhosted.org/packages/9a/61/ec1245aa1c325cb7a6c0f8570a2eee3bfc40fa90d19b1267f8e50b5c8645/mypy-1.16.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:472e4e4c100062488ec643f6162dd0d5208e33e2f34544e1fc931372e806c0cc", size = 10890557, upload-time = "2025-06-16T16:37:21.421Z" }, + { url = "https://files.pythonhosted.org/packages/6b/bb/6eccc0ba0aa0c7a87df24e73f0ad34170514abd8162eb0c75fd7128171fb/mypy-1.16.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:ea16e2a7d2714277e349e24d19a782a663a34ed60864006e8585db08f8ad1782", size = 10012921, upload-time = "2025-06-16T16:51:28.659Z" }, + { url = "https://files.pythonhosted.org/packages/5f/80/b337a12e2006715f99f529e732c5f6a8c143bb58c92bb142d5ab380963a5/mypy-1.16.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:08e850ea22adc4d8a4014651575567b0318ede51e8e9fe7a68f25391af699507", size = 11802887, upload-time = "2025-06-16T16:50:53.627Z" }, + { url = "https://files.pythonhosted.org/packages/d9/59/f7af072d09793d581a745a25737c7c0a945760036b16aeb620f658a017af/mypy-1.16.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:22d76a63a42619bfb90122889b903519149879ddbf2ba4251834727944c8baca", size = 12531658, upload-time = "2025-06-16T16:33:55.002Z" }, + { url = "https://files.pythonhosted.org/packages/82/c4/607672f2d6c0254b94a646cfc45ad589dd71b04aa1f3d642b840f7cce06c/mypy-1.16.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:2c7ce0662b6b9dc8f4ed86eb7a5d505ee3298c04b40ec13b30e572c0e5ae17c4", size = 12732486, upload-time = "2025-06-16T16:37:03.301Z" }, + { url = "https://files.pythonhosted.org/packages/b6/5e/136555ec1d80df877a707cebf9081bd3a9f397dedc1ab9750518d87489ec/mypy-1.16.1-cp311-cp311-win_amd64.whl", hash = "sha256:211287e98e05352a2e1d4e8759c5490925a7c784ddc84207f4714822f8cf99b6", size = 9479482, upload-time = "2025-06-16T16:47:37.48Z" }, + { url = "https://files.pythonhosted.org/packages/b4/d6/39482e5fcc724c15bf6280ff5806548c7185e0c090712a3736ed4d07e8b7/mypy-1.16.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:af4792433f09575d9eeca5c63d7d90ca4aeceda9d8355e136f80f8967639183d", size = 11066493, upload-time = "2025-06-16T16:47:01.683Z" }, + { url = "https://files.pythonhosted.org/packages/e6/e5/26c347890efc6b757f4d5bb83f4a0cf5958b8cf49c938ac99b8b72b420a6/mypy-1.16.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:66df38405fd8466ce3517eda1f6640611a0b8e70895e2a9462d1d4323c5eb4b9", size = 10081687, upload-time = "2025-06-16T16:48:19.367Z" }, + { url = "https://files.pythonhosted.org/packages/44/c7/b5cb264c97b86914487d6a24bd8688c0172e37ec0f43e93b9691cae9468b/mypy-1.16.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:44e7acddb3c48bd2713994d098729494117803616e116032af192871aed80b79", size = 11839723, upload-time = "2025-06-16T16:49:20.912Z" }, + { url = "https://files.pythonhosted.org/packages/15/f8/491997a9b8a554204f834ed4816bda813aefda31cf873bb099deee3c9a99/mypy-1.16.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0ab5eca37b50188163fa7c1b73c685ac66c4e9bdee4a85c9adac0e91d8895e15", size = 12722980, upload-time = "2025-06-16T16:37:40.929Z" }, + { url = "https://files.pythonhosted.org/packages/df/f0/2bd41e174b5fd93bc9de9a28e4fb673113633b8a7f3a607fa4a73595e468/mypy-1.16.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:dedb6229b2c9086247e21a83c309754b9058b438704ad2f6807f0d8227f6ebdd", size = 12903328, upload-time = "2025-06-16T16:34:35.099Z" }, + { url = "https://files.pythonhosted.org/packages/61/81/5572108a7bec2c46b8aff7e9b524f371fe6ab5efb534d38d6b37b5490da8/mypy-1.16.1-cp312-cp312-win_amd64.whl", hash = "sha256:1f0435cf920e287ff68af3d10a118a73f212deb2ce087619eb4e648116d1fe9b", size = 9562321, upload-time = "2025-06-16T16:48:58.823Z" }, + { url = "https://files.pythonhosted.org/packages/28/e3/96964af4a75a949e67df4b95318fe2b7427ac8189bbc3ef28f92a1c5bc56/mypy-1.16.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ddc91eb318c8751c69ddb200a5937f1232ee8efb4e64e9f4bc475a33719de438", size = 11063480, upload-time = "2025-06-16T16:47:56.205Z" }, + { url = "https://files.pythonhosted.org/packages/f5/4d/cd1a42b8e5be278fab7010fb289d9307a63e07153f0ae1510a3d7b703193/mypy-1.16.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:87ff2c13d58bdc4bbe7dc0dedfe622c0f04e2cb2a492269f3b418df2de05c536", size = 10090538, upload-time = "2025-06-16T16:46:43.92Z" }, + { url = "https://files.pythonhosted.org/packages/c9/4f/c3c6b4b66374b5f68bab07c8cabd63a049ff69796b844bc759a0ca99bb2a/mypy-1.16.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0a7cfb0fe29fe5a9841b7c8ee6dffb52382c45acdf68f032145b75620acfbd6f", size = 11836839, upload-time = "2025-06-16T16:36:28.039Z" }, + { url = "https://files.pythonhosted.org/packages/b4/7e/81ca3b074021ad9775e5cb97ebe0089c0f13684b066a750b7dc208438403/mypy-1.16.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:051e1677689c9d9578b9c7f4d206d763f9bbd95723cd1416fad50db49d52f359", size = 12715634, upload-time = "2025-06-16T16:50:34.441Z" }, + { url = "https://files.pythonhosted.org/packages/e9/95/bdd40c8be346fa4c70edb4081d727a54d0a05382d84966869738cfa8a497/mypy-1.16.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:d5d2309511cc56c021b4b4e462907c2b12f669b2dbeb68300110ec27723971be", size = 12895584, upload-time = "2025-06-16T16:34:54.857Z" }, + { url = "https://files.pythonhosted.org/packages/5a/fd/d486a0827a1c597b3b48b1bdef47228a6e9ee8102ab8c28f944cb83b65dc/mypy-1.16.1-cp313-cp313-win_amd64.whl", hash = "sha256:4f58ac32771341e38a853c5d0ec0dfe27e18e27da9cdb8bbc882d2249c71a3ee", size = 9573886, upload-time = "2025-06-16T16:36:43.589Z" }, + { url = "https://files.pythonhosted.org/packages/49/5e/ed1e6a7344005df11dfd58b0fdd59ce939a0ba9f7ed37754bf20670b74db/mypy-1.16.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:7fc688329af6a287567f45cc1cefb9db662defeb14625213a5b7da6e692e2069", size = 10959511, upload-time = "2025-06-16T16:47:21.945Z" }, + { url = "https://files.pythonhosted.org/packages/30/88/a7cbc2541e91fe04f43d9e4577264b260fecedb9bccb64ffb1a34b7e6c22/mypy-1.16.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:5e198ab3f55924c03ead626ff424cad1732d0d391478dfbf7bb97b34602395da", size = 10075555, upload-time = "2025-06-16T16:50:14.084Z" }, + { url = "https://files.pythonhosted.org/packages/93/f7/c62b1e31a32fbd1546cca5e0a2e5f181be5761265ad1f2e94f2a306fa906/mypy-1.16.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:09aa4f91ada245f0a45dbc47e548fd94e0dd5a8433e0114917dc3b526912a30c", size = 11874169, upload-time = "2025-06-16T16:49:42.276Z" }, + { url = "https://files.pythonhosted.org/packages/c8/15/db580a28034657fb6cb87af2f8996435a5b19d429ea4dcd6e1c73d418e60/mypy-1.16.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:13c7cd5b1cb2909aa318a90fd1b7e31f17c50b242953e7dd58345b2a814f6383", size = 12610060, upload-time = "2025-06-16T16:34:15.215Z" }, + { url = "https://files.pythonhosted.org/packages/ec/78/c17f48f6843048fa92d1489d3095e99324f2a8c420f831a04ccc454e2e51/mypy-1.16.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:58e07fb958bc5d752a280da0e890c538f1515b79a65757bbdc54252ba82e0b40", size = 12875199, upload-time = "2025-06-16T16:35:14.448Z" }, + { url = "https://files.pythonhosted.org/packages/bc/d6/ed42167d0a42680381653fd251d877382351e1bd2c6dd8a818764be3beb1/mypy-1.16.1-cp39-cp39-win_amd64.whl", hash = "sha256:f895078594d918f93337a505f8add9bd654d1a24962b4c6ed9390e12531eb31b", size = 9487033, upload-time = "2025-06-16T16:49:57.907Z" }, + { url = "https://files.pythonhosted.org/packages/cf/d3/53e684e78e07c1a2bf7105715e5edd09ce951fc3f47cf9ed095ec1b7a037/mypy-1.16.1-py3-none-any.whl", hash = "sha256:5fc2ac4027d0ef28d6ba69a0343737a23c4d1b83672bf38d1fe237bdc0643b37", size = 2265923, upload-time = "2025-06-16T16:48:02.366Z" }, +] + +[[package]] +name = "mypy-extensions" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, +] + +[[package]] +name = "nodeenv" +version = "1.9.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/43/16/fc88b08840de0e0a72a2f9d8c6bae36be573e475a6326ae854bcc549fc45/nodeenv-1.9.1.tar.gz", hash = "sha256:6ec12890a2dab7946721edbfbcd91f3319c6ccc9aec47be7c7e6b7011ee6645f", size = 47437, upload-time = "2024-06-04T18:44:11.171Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d2/1d/1b658dbd2b9fa9c4c9f32accbfc0205d532c8c6194dc0f2a4c0428e7128a/nodeenv-1.9.1-py2.py3-none-any.whl", hash = "sha256:ba11c9782d29c27c70ffbdda2d7415098754709be8a7056d79a737cd901155c9", size = 22314, upload-time = "2024-06-04T18:44:08.352Z" }, +] + +[[package]] +name = "packaging" +version = "25.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727, upload-time = "2025-04-19T11:48:59.673Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" }, +] + +[[package]] +name = "pathspec" +version = "0.12.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ca/bc/f35b8446f4531a7cb215605d100cd88b7ac6f44ab3fc94870c120ab3adbf/pathspec-0.12.1.tar.gz", hash = "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712", size = 51043, upload-time = "2023-12-10T22:30:45Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cc/20/ff623b09d963f88bfde16306a54e12ee5ea43e9b597108672ff3a408aad6/pathspec-0.12.1-py3-none-any.whl", hash = "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08", size = 31191, upload-time = "2023-12-10T22:30:43.14Z" }, +] + +[[package]] +name = "platformdirs" +version = "4.3.8" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fe/8b/3c73abc9c759ecd3f1f7ceff6685840859e8070c4d947c93fae71f6a0bf2/platformdirs-4.3.8.tar.gz", hash = "sha256:3d512d96e16bcb959a814c9f348431070822a6496326a4be0911c40b5a74c2bc", size = 21362, upload-time = "2025-05-07T22:47:42.121Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fe/39/979e8e21520d4e47a0bbe349e2713c0aac6f3d853d0e5b34d76206c439aa/platformdirs-4.3.8-py3-none-any.whl", hash = "sha256:ff7059bb7eb1179e2685604f4aaf157cfd9535242bd23742eadc3c13542139b4", size = 18567, upload-time = "2025-05-07T22:47:40.376Z" }, +] + +[[package]] +name = "pluggy" +source = { editable = "." } + +[package.optional-dependencies] +async = [ + { name = "greenlet" }, +] + +[package.dev-dependencies] +dev = [ + { name = "mypy" }, + { name = "pre-commit" }, + { name = "ruff" }, + { name = "tox" }, +] +testing = [ + { name = "coverage" }, + { name = "greenlet" }, + { name = "pytest" }, + { name = "pytest-benchmark" }, + { name = "types-greenlet" }, +] + +[package.metadata] +requires-dist = [{ name = "greenlet", marker = "extra == 'async'" }] +provides-extras = ["async"] + +[package.metadata.requires-dev] +dev = [ + { name = "mypy" }, + { name = "pre-commit" }, + { name = "ruff" }, + { name = "tox" }, +] +testing = [ + { name = "coverage" }, + { name = "greenlet" }, + { name = "pytest" }, + { name = "pytest-benchmark" }, + { name = "types-greenlet" }, +] + +[[package]] +name = "pre-commit" +version = "4.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cfgv" }, + { name = "identify" }, + { name = "nodeenv" }, + { name = "pyyaml" }, + { name = "virtualenv" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/08/39/679ca9b26c7bb2999ff122d50faa301e49af82ca9c066ec061cfbc0c6784/pre_commit-4.2.0.tar.gz", hash = "sha256:601283b9757afd87d40c4c4a9b2b5de9637a8ea02eaff7adc2d0fb4e04841146", size = 193424, upload-time = "2025-03-18T21:35:20.987Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/74/a88bf1b1efeae488a0c0b7bdf71429c313722d1fc0f377537fbe554e6180/pre_commit-4.2.0-py2.py3-none-any.whl", hash = "sha256:a009ca7205f1eb497d10b845e52c838a98b6cdd2102a6c8e4540e94ee75c58bd", size = 220707, upload-time = "2025-03-18T21:35:19.343Z" }, +] + +[[package]] +name = "py-cpuinfo" +version = "9.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/37/a8/d832f7293ebb21690860d2e01d8115e5ff6f2ae8bbdc953f0eb0fa4bd2c7/py-cpuinfo-9.0.0.tar.gz", hash = "sha256:3cdbbf3fac90dc6f118bfd64384f309edeadd902d7c8fb17f02ffa1fc3f49690", size = 104716, upload-time = "2022-10-25T20:38:06.303Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e0/a9/023730ba63db1e494a271cb018dcd361bd2c917ba7004c3e49d5daf795a2/py_cpuinfo-9.0.0-py3-none-any.whl", hash = "sha256:859625bc251f64e21f077d099d4162689c762b5d6a4c3c97553d56241c9674d5", size = 22335, upload-time = "2022-10-25T20:38:27.636Z" }, +] + +[[package]] +name = "pygments" +version = "2.19.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7c/2d/c3338d48ea6cc0feb8446d8e6937e1408088a72a39937982cc6111d17f84/pygments-2.19.1.tar.gz", hash = "sha256:61c16d2a8576dc0649d9f39e089b5f02bcd27fba10d8fb4dcc28173f7a45151f", size = 4968581, upload-time = "2025-01-06T17:26:30.443Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/0b/9fcc47d19c48b59121088dd6da2488a49d5f72dacf8262e2790a1d2c7d15/pygments-2.19.1-py3-none-any.whl", hash = "sha256:9ea1544ad55cecf4b8242fab6dd35a93bbce657034b0611ee383099054ab6d8c", size = 1225293, upload-time = "2025-01-06T17:26:25.553Z" }, +] + +[[package]] +name = "pyproject-api" +version = "1.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "packaging" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/19/fd/437901c891f58a7b9096511750247535e891d2d5a5a6eefbc9386a2b41d5/pyproject_api-1.9.1.tar.gz", hash = "sha256:43c9918f49daab37e302038fc1aed54a8c7a91a9fa935d00b9a485f37e0f5335", size = 22710, upload-time = "2025-05-12T14:41:58.025Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/e6/c293c06695d4a3ab0260ef124a74ebadba5f4c511ce3a4259e976902c00b/pyproject_api-1.9.1-py3-none-any.whl", hash = "sha256:7d6238d92f8962773dd75b5f0c4a6a27cce092a14b623b811dba656f3b628948", size = 13158, upload-time = "2025-05-12T14:41:56.217Z" }, +] + +[[package]] +name = "pytest" +version = "8.4.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/08/ba/45911d754e8eba3d5a841a5ce61a65a685ff1798421ac054f85aa8747dfb/pytest-8.4.1.tar.gz", hash = "sha256:7c67fd69174877359ed9371ec3af8a3d2b04741818c51e5e99cc1742251fa93c", size = 1517714, upload-time = "2025-06-18T05:48:06.109Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/29/16/c8a903f4c4dffe7a12843191437d7cd8e32751d5de349d45d3fe69544e87/pytest-8.4.1-py3-none-any.whl", hash = "sha256:539c70ba6fcead8e78eebbf1115e8b589e7565830d7d006a8723f19ac8a0afb7", size = 365474, upload-time = "2025-06-18T05:48:03.955Z" }, +] + +[[package]] +name = "pytest-benchmark" +version = "5.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "py-cpuinfo" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/39/d0/a8bd08d641b393db3be3819b03e2d9bb8760ca8479080a26a5f6e540e99c/pytest-benchmark-5.1.0.tar.gz", hash = "sha256:9ea661cdc292e8231f7cd4c10b0319e56a2118e2c09d9f50e1b3d150d2aca105", size = 337810, upload-time = "2024-10-30T11:51:48.521Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9e/d6/b41653199ea09d5969d4e385df9bbfd9a100f28ca7e824ce7c0a016e3053/pytest_benchmark-5.1.0-py3-none-any.whl", hash = "sha256:922de2dfa3033c227c96da942d1878191afa135a29485fb942e85dff1c592c89", size = 44259, upload-time = "2024-10-30T11:51:45.94Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/54/ed/79a089b6be93607fa5cdaedf301d7dfb23af5f25c398d5ead2525b063e17/pyyaml-6.0.2.tar.gz", hash = "sha256:d584d9ec91ad65861cc08d42e834324ef890a082e591037abe114850ff7bbc3e", size = 130631, upload-time = "2024-08-06T20:33:50.674Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9b/95/a3fac87cb7158e231b5a6012e438c647e1a87f09f8e0d123acec8ab8bf71/PyYAML-6.0.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0a9a2848a5b7feac301353437eb7d5957887edbf81d56e903999a75a3d743086", size = 184199, upload-time = "2024-08-06T20:31:40.178Z" }, + { url = "https://files.pythonhosted.org/packages/c7/7a/68bd47624dab8fd4afbfd3c48e3b79efe09098ae941de5b58abcbadff5cb/PyYAML-6.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:29717114e51c84ddfba879543fb232a6ed60086602313ca38cce623c1d62cfbf", size = 171758, upload-time = "2024-08-06T20:31:42.173Z" }, + { url = "https://files.pythonhosted.org/packages/49/ee/14c54df452143b9ee9f0f29074d7ca5516a36edb0b4cc40c3f280131656f/PyYAML-6.0.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8824b5a04a04a047e72eea5cec3bc266db09e35de6bdfe34c9436ac5ee27d237", size = 718463, upload-time = "2024-08-06T20:31:44.263Z" }, + { url = "https://files.pythonhosted.org/packages/4d/61/de363a97476e766574650d742205be468921a7b532aa2499fcd886b62530/PyYAML-6.0.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7c36280e6fb8385e520936c3cb3b8042851904eba0e58d277dca80a5cfed590b", size = 719280, upload-time = "2024-08-06T20:31:50.199Z" }, + { url = "https://files.pythonhosted.org/packages/6b/4e/1523cb902fd98355e2e9ea5e5eb237cbc5f3ad5f3075fa65087aa0ecb669/PyYAML-6.0.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ec031d5d2feb36d1d1a24380e4db6d43695f3748343d99434e6f5f9156aaa2ed", size = 751239, upload-time = "2024-08-06T20:31:52.292Z" }, + { url = "https://files.pythonhosted.org/packages/b7/33/5504b3a9a4464893c32f118a9cc045190a91637b119a9c881da1cf6b7a72/PyYAML-6.0.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:936d68689298c36b53b29f23c6dbb74de12b4ac12ca6cfe0e047bedceea56180", size = 695802, upload-time = "2024-08-06T20:31:53.836Z" }, + { url = "https://files.pythonhosted.org/packages/5c/20/8347dcabd41ef3a3cdc4f7b7a2aff3d06598c8779faa189cdbf878b626a4/PyYAML-6.0.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:23502f431948090f597378482b4812b0caae32c22213aecf3b55325e049a6c68", size = 720527, upload-time = "2024-08-06T20:31:55.565Z" }, + { url = "https://files.pythonhosted.org/packages/be/aa/5afe99233fb360d0ff37377145a949ae258aaab831bde4792b32650a4378/PyYAML-6.0.2-cp310-cp310-win32.whl", hash = "sha256:2e99c6826ffa974fe6e27cdb5ed0021786b03fc98e5ee3c5bfe1fd5015f42b99", size = 144052, upload-time = "2024-08-06T20:31:56.914Z" }, + { url = "https://files.pythonhosted.org/packages/b5/84/0fa4b06f6d6c958d207620fc60005e241ecedceee58931bb20138e1e5776/PyYAML-6.0.2-cp310-cp310-win_amd64.whl", hash = "sha256:a4d3091415f010369ae4ed1fc6b79def9416358877534caf6a0fdd2146c87a3e", size = 161774, upload-time = "2024-08-06T20:31:58.304Z" }, + { url = "https://files.pythonhosted.org/packages/f8/aa/7af4e81f7acba21a4c6be026da38fd2b872ca46226673c89a758ebdc4fd2/PyYAML-6.0.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:cc1c1159b3d456576af7a3e4d1ba7e6924cb39de8f67111c735f6fc832082774", size = 184612, upload-time = "2024-08-06T20:32:03.408Z" }, + { url = "https://files.pythonhosted.org/packages/8b/62/b9faa998fd185f65c1371643678e4d58254add437edb764a08c5a98fb986/PyYAML-6.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1e2120ef853f59c7419231f3bf4e7021f1b936f6ebd222406c3b60212205d2ee", size = 172040, upload-time = "2024-08-06T20:32:04.926Z" }, + { url = "https://files.pythonhosted.org/packages/ad/0c/c804f5f922a9a6563bab712d8dcc70251e8af811fce4524d57c2c0fd49a4/PyYAML-6.0.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5d225db5a45f21e78dd9358e58a98702a0302f2659a3c6cd320564b75b86f47c", size = 736829, upload-time = "2024-08-06T20:32:06.459Z" }, + { url = "https://files.pythonhosted.org/packages/51/16/6af8d6a6b210c8e54f1406a6b9481febf9c64a3109c541567e35a49aa2e7/PyYAML-6.0.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5ac9328ec4831237bec75defaf839f7d4564be1e6b25ac710bd1a96321cc8317", size = 764167, upload-time = "2024-08-06T20:32:08.338Z" }, + { url = "https://files.pythonhosted.org/packages/75/e4/2c27590dfc9992f73aabbeb9241ae20220bd9452df27483b6e56d3975cc5/PyYAML-6.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3ad2a3decf9aaba3d29c8f537ac4b243e36bef957511b4766cb0057d32b0be85", size = 762952, upload-time = "2024-08-06T20:32:14.124Z" }, + { url = "https://files.pythonhosted.org/packages/9b/97/ecc1abf4a823f5ac61941a9c00fe501b02ac3ab0e373c3857f7d4b83e2b6/PyYAML-6.0.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:ff3824dc5261f50c9b0dfb3be22b4567a6f938ccce4587b38952d85fd9e9afe4", size = 735301, upload-time = "2024-08-06T20:32:16.17Z" }, + { url = "https://files.pythonhosted.org/packages/45/73/0f49dacd6e82c9430e46f4a027baa4ca205e8b0a9dce1397f44edc23559d/PyYAML-6.0.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:797b4f722ffa07cc8d62053e4cff1486fa6dc094105d13fea7b1de7d8bf71c9e", size = 756638, upload-time = "2024-08-06T20:32:18.555Z" }, + { url = "https://files.pythonhosted.org/packages/22/5f/956f0f9fc65223a58fbc14459bf34b4cc48dec52e00535c79b8db361aabd/PyYAML-6.0.2-cp311-cp311-win32.whl", hash = "sha256:11d8f3dd2b9c1207dcaf2ee0bbbfd5991f571186ec9cc78427ba5bd32afae4b5", size = 143850, upload-time = "2024-08-06T20:32:19.889Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/8da0bbe2ab9dcdd11f4f4557ccaf95c10b9811b13ecced089d43ce59c3c8/PyYAML-6.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:e10ce637b18caea04431ce14fabcf5c64a1c61ec9c56b071a4b7ca131ca52d44", size = 161980, upload-time = "2024-08-06T20:32:21.273Z" }, + { url = "https://files.pythonhosted.org/packages/86/0c/c581167fc46d6d6d7ddcfb8c843a4de25bdd27e4466938109ca68492292c/PyYAML-6.0.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:c70c95198c015b85feafc136515252a261a84561b7b1d51e3384e0655ddf25ab", size = 183873, upload-time = "2024-08-06T20:32:25.131Z" }, + { url = "https://files.pythonhosted.org/packages/a8/0c/38374f5bb272c051e2a69281d71cba6fdb983413e6758b84482905e29a5d/PyYAML-6.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ce826d6ef20b1bc864f0a68340c8b3287705cae2f8b4b1d932177dcc76721725", size = 173302, upload-time = "2024-08-06T20:32:26.511Z" }, + { url = "https://files.pythonhosted.org/packages/c3/93/9916574aa8c00aa06bbac729972eb1071d002b8e158bd0e83a3b9a20a1f7/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1f71ea527786de97d1a0cc0eacd1defc0985dcf6b3f17bb77dcfc8c34bec4dc5", size = 739154, upload-time = "2024-08-06T20:32:28.363Z" }, + { url = "https://files.pythonhosted.org/packages/95/0f/b8938f1cbd09739c6da569d172531567dbcc9789e0029aa070856f123984/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9b22676e8097e9e22e36d6b7bda33190d0d400f345f23d4065d48f4ca7ae0425", size = 766223, upload-time = "2024-08-06T20:32:30.058Z" }, + { url = "https://files.pythonhosted.org/packages/b9/2b/614b4752f2e127db5cc206abc23a8c19678e92b23c3db30fc86ab731d3bd/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:80bab7bfc629882493af4aa31a4cfa43a4c57c83813253626916b8c7ada83476", size = 767542, upload-time = "2024-08-06T20:32:31.881Z" }, + { url = "https://files.pythonhosted.org/packages/d4/00/dd137d5bcc7efea1836d6264f049359861cf548469d18da90cd8216cf05f/PyYAML-6.0.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:0833f8694549e586547b576dcfaba4a6b55b9e96098b36cdc7ebefe667dfed48", size = 731164, upload-time = "2024-08-06T20:32:37.083Z" }, + { url = "https://files.pythonhosted.org/packages/c9/1f/4f998c900485e5c0ef43838363ba4a9723ac0ad73a9dc42068b12aaba4e4/PyYAML-6.0.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8b9c7197f7cb2738065c481a0461e50ad02f18c78cd75775628afb4d7137fb3b", size = 756611, upload-time = "2024-08-06T20:32:38.898Z" }, + { url = "https://files.pythonhosted.org/packages/df/d1/f5a275fdb252768b7a11ec63585bc38d0e87c9e05668a139fea92b80634c/PyYAML-6.0.2-cp312-cp312-win32.whl", hash = "sha256:ef6107725bd54b262d6dedcc2af448a266975032bc85ef0172c5f059da6325b4", size = 140591, upload-time = "2024-08-06T20:32:40.241Z" }, + { url = "https://files.pythonhosted.org/packages/0c/e8/4f648c598b17c3d06e8753d7d13d57542b30d56e6c2dedf9c331ae56312e/PyYAML-6.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:7e7401d0de89a9a855c839bc697c079a4af81cf878373abd7dc625847d25cbd8", size = 156338, upload-time = "2024-08-06T20:32:41.93Z" }, + { url = "https://files.pythonhosted.org/packages/ef/e3/3af305b830494fa85d95f6d95ef7fa73f2ee1cc8ef5b495c7c3269fb835f/PyYAML-6.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:efdca5630322a10774e8e98e1af481aad470dd62c3170801852d752aa7a783ba", size = 181309, upload-time = "2024-08-06T20:32:43.4Z" }, + { url = "https://files.pythonhosted.org/packages/45/9f/3b1c20a0b7a3200524eb0076cc027a970d320bd3a6592873c85c92a08731/PyYAML-6.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:50187695423ffe49e2deacb8cd10510bc361faac997de9efef88badc3bb9e2d1", size = 171679, upload-time = "2024-08-06T20:32:44.801Z" }, + { url = "https://files.pythonhosted.org/packages/7c/9a/337322f27005c33bcb656c655fa78325b730324c78620e8328ae28b64d0c/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0ffe8360bab4910ef1b9e87fb812d8bc0a308b0d0eef8c8f44e0254ab3b07133", size = 733428, upload-time = "2024-08-06T20:32:46.432Z" }, + { url = "https://files.pythonhosted.org/packages/a3/69/864fbe19e6c18ea3cc196cbe5d392175b4cf3d5d0ac1403ec3f2d237ebb5/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:17e311b6c678207928d649faa7cb0d7b4c26a0ba73d41e99c4fff6b6c3276484", size = 763361, upload-time = "2024-08-06T20:32:51.188Z" }, + { url = "https://files.pythonhosted.org/packages/04/24/b7721e4845c2f162d26f50521b825fb061bc0a5afcf9a386840f23ea19fa/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:70b189594dbe54f75ab3a1acec5f1e3faa7e8cf2f1e08d9b561cb41b845f69d5", size = 759523, upload-time = "2024-08-06T20:32:53.019Z" }, + { url = "https://files.pythonhosted.org/packages/2b/b2/e3234f59ba06559c6ff63c4e10baea10e5e7df868092bf9ab40e5b9c56b6/PyYAML-6.0.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:41e4e3953a79407c794916fa277a82531dd93aad34e29c2a514c2c0c5fe971cc", size = 726660, upload-time = "2024-08-06T20:32:54.708Z" }, + { url = "https://files.pythonhosted.org/packages/fe/0f/25911a9f080464c59fab9027482f822b86bf0608957a5fcc6eaac85aa515/PyYAML-6.0.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:68ccc6023a3400877818152ad9a1033e3db8625d899c72eacb5a668902e4d652", size = 751597, upload-time = "2024-08-06T20:32:56.985Z" }, + { url = "https://files.pythonhosted.org/packages/14/0d/e2c3b43bbce3cf6bd97c840b46088a3031085179e596d4929729d8d68270/PyYAML-6.0.2-cp313-cp313-win32.whl", hash = "sha256:bc2fa7c6b47d6bc618dd7fb02ef6fdedb1090ec036abab80d4681424b84c1183", size = 140527, upload-time = "2024-08-06T20:33:03.001Z" }, + { url = "https://files.pythonhosted.org/packages/fa/de/02b54f42487e3d3c6efb3f89428677074ca7bf43aae402517bc7cca949f3/PyYAML-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:8388ee1976c416731879ac16da0aff3f63b286ffdd57cdeb95f3f2e085687563", size = 156446, upload-time = "2024-08-06T20:33:04.33Z" }, + { url = "https://files.pythonhosted.org/packages/65/d8/b7a1db13636d7fb7d4ff431593c510c8b8fca920ade06ca8ef20015493c5/PyYAML-6.0.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:688ba32a1cffef67fd2e9398a2efebaea461578b0923624778664cc1c914db5d", size = 184777, upload-time = "2024-08-06T20:33:25.896Z" }, + { url = "https://files.pythonhosted.org/packages/0a/02/6ec546cd45143fdf9840b2c6be8d875116a64076218b61d68e12548e5839/PyYAML-6.0.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:a8786accb172bd8afb8be14490a16625cbc387036876ab6ba70912730faf8e1f", size = 172318, upload-time = "2024-08-06T20:33:27.212Z" }, + { url = "https://files.pythonhosted.org/packages/0e/9a/8cc68be846c972bda34f6c2a93abb644fb2476f4dcc924d52175786932c9/PyYAML-6.0.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d8e03406cac8513435335dbab54c0d385e4a49e4945d2909a581c83647ca0290", size = 720891, upload-time = "2024-08-06T20:33:28.974Z" }, + { url = "https://files.pythonhosted.org/packages/e9/6c/6e1b7f40181bc4805e2e07f4abc10a88ce4648e7e95ff1abe4ae4014a9b2/PyYAML-6.0.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f753120cb8181e736c57ef7636e83f31b9c0d1722c516f7e86cf15b7aa57ff12", size = 722614, upload-time = "2024-08-06T20:33:34.157Z" }, + { url = "https://files.pythonhosted.org/packages/3d/32/e7bd8535d22ea2874cef6a81021ba019474ace0d13a4819c2a4bce79bd6a/PyYAML-6.0.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3b1fdb9dc17f5a7677423d508ab4f243a726dea51fa5e70992e59a7411c89d19", size = 737360, upload-time = "2024-08-06T20:33:35.84Z" }, + { url = "https://files.pythonhosted.org/packages/d7/12/7322c1e30b9be969670b672573d45479edef72c9a0deac3bb2868f5d7469/PyYAML-6.0.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:0b69e4ce7a131fe56b7e4d770c67429700908fc0752af059838b1cfb41960e4e", size = 699006, upload-time = "2024-08-06T20:33:37.501Z" }, + { url = "https://files.pythonhosted.org/packages/82/72/04fcad41ca56491995076630c3ec1e834be241664c0c09a64c9a2589b507/PyYAML-6.0.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:a9f8c2e67970f13b16084e04f134610fd1d374bf477b17ec1599185cf611d725", size = 723577, upload-time = "2024-08-06T20:33:39.389Z" }, + { url = "https://files.pythonhosted.org/packages/ed/5e/46168b1f2757f1fcd442bc3029cd8767d88a98c9c05770d8b420948743bb/PyYAML-6.0.2-cp39-cp39-win32.whl", hash = "sha256:6395c297d42274772abc367baaa79683958044e5d3835486c16da75d2a694631", size = 144593, upload-time = "2024-08-06T20:33:46.63Z" }, + { url = "https://files.pythonhosted.org/packages/19/87/5124b1c1f2412bb95c59ec481eaf936cd32f0fe2a7b16b97b81c4c017a6a/PyYAML-6.0.2-cp39-cp39-win_amd64.whl", hash = "sha256:39693e1f8320ae4f43943590b49779ffb98acb81f788220ea932a6b6c51004d8", size = 162312, upload-time = "2024-08-06T20:33:49.073Z" }, +] + +[[package]] +name = "ruff" +version = "0.12.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/24/90/5255432602c0b196a0da6720f6f76b93eb50baef46d3c9b0025e2f9acbf3/ruff-0.12.0.tar.gz", hash = "sha256:4d047db3662418d4a848a3fdbfaf17488b34b62f527ed6f10cb8afd78135bc5c", size = 4376101, upload-time = "2025-06-17T15:19:26.217Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e6/fd/b46bb20e14b11ff49dbc74c61de352e0dc07fb650189513631f6fb5fc69f/ruff-0.12.0-py3-none-linux_armv6l.whl", hash = "sha256:5652a9ecdb308a1754d96a68827755f28d5dfb416b06f60fd9e13f26191a8848", size = 10311554, upload-time = "2025-06-17T15:18:45.792Z" }, + { url = "https://files.pythonhosted.org/packages/e7/d3/021dde5a988fa3e25d2468d1dadeea0ae89dc4bc67d0140c6e68818a12a1/ruff-0.12.0-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:05ed0c914fabc602fc1f3b42c53aa219e5736cb030cdd85640c32dbc73da74a6", size = 11118435, upload-time = "2025-06-17T15:18:49.064Z" }, + { url = "https://files.pythonhosted.org/packages/07/a2/01a5acf495265c667686ec418f19fd5c32bcc326d4c79ac28824aecd6a32/ruff-0.12.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:07a7aa9b69ac3fcfda3c507916d5d1bca10821fe3797d46bad10f2c6de1edda0", size = 10466010, upload-time = "2025-06-17T15:18:51.341Z" }, + { url = "https://files.pythonhosted.org/packages/4c/57/7caf31dd947d72e7aa06c60ecb19c135cad871a0a8a251723088132ce801/ruff-0.12.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e7731c3eec50af71597243bace7ec6104616ca56dda2b99c89935fe926bdcd48", size = 10661366, upload-time = "2025-06-17T15:18:53.29Z" }, + { url = "https://files.pythonhosted.org/packages/e9/ba/aa393b972a782b4bc9ea121e0e358a18981980856190d7d2b6187f63e03a/ruff-0.12.0-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:952d0630eae628250ab1c70a7fffb641b03e6b4a2d3f3ec6c1d19b4ab6c6c807", size = 10173492, upload-time = "2025-06-17T15:18:55.262Z" }, + { url = "https://files.pythonhosted.org/packages/d7/50/9349ee777614bc3062fc6b038503a59b2034d09dd259daf8192f56c06720/ruff-0.12.0-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c021f04ea06966b02614d442e94071781c424ab8e02ec7af2f037b4c1e01cc82", size = 11761739, upload-time = "2025-06-17T15:18:58.906Z" }, + { url = "https://files.pythonhosted.org/packages/04/8f/ad459de67c70ec112e2ba7206841c8f4eb340a03ee6a5cabc159fe558b8e/ruff-0.12.0-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:7d235618283718ee2fe14db07f954f9b2423700919dc688eacf3f8797a11315c", size = 12537098, upload-time = "2025-06-17T15:19:01.316Z" }, + { url = "https://files.pythonhosted.org/packages/ed/50/15ad9c80ebd3c4819f5bd8883e57329f538704ed57bac680d95cb6627527/ruff-0.12.0-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0c0758038f81beec8cc52ca22de9685b8ae7f7cc18c013ec2050012862cc9165", size = 12154122, upload-time = "2025-06-17T15:19:03.727Z" }, + { url = "https://files.pythonhosted.org/packages/76/e6/79b91e41bc8cc3e78ee95c87093c6cacfa275c786e53c9b11b9358026b3d/ruff-0.12.0-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:139b3d28027987b78fc8d6cfb61165447bdf3740e650b7c480744873688808c2", size = 11363374, upload-time = "2025-06-17T15:19:05.875Z" }, + { url = "https://files.pythonhosted.org/packages/db/c3/82b292ff8a561850934549aa9dc39e2c4e783ab3c21debe55a495ddf7827/ruff-0.12.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:68853e8517b17bba004152aebd9dd77d5213e503a5f2789395b25f26acac0da4", size = 11587647, upload-time = "2025-06-17T15:19:08.246Z" }, + { url = "https://files.pythonhosted.org/packages/2b/42/d5760d742669f285909de1bbf50289baccb647b53e99b8a3b4f7ce1b2001/ruff-0.12.0-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:3a9512af224b9ac4757f7010843771da6b2b0935a9e5e76bb407caa901a1a514", size = 10527284, upload-time = "2025-06-17T15:19:10.37Z" }, + { url = "https://files.pythonhosted.org/packages/19/f6/fcee9935f25a8a8bba4adbae62495c39ef281256693962c2159e8b284c5f/ruff-0.12.0-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:b08df3d96db798e5beb488d4df03011874aff919a97dcc2dd8539bb2be5d6a88", size = 10158609, upload-time = "2025-06-17T15:19:12.286Z" }, + { url = "https://files.pythonhosted.org/packages/37/fb/057febf0eea07b9384787bfe197e8b3384aa05faa0d6bd844b94ceb29945/ruff-0.12.0-py3-none-musllinux_1_2_i686.whl", hash = "sha256:6a315992297a7435a66259073681bb0d8647a826b7a6de45c6934b2ca3a9ed51", size = 11141462, upload-time = "2025-06-17T15:19:15.195Z" }, + { url = "https://files.pythonhosted.org/packages/10/7c/1be8571011585914b9d23c95b15d07eec2d2303e94a03df58294bc9274d4/ruff-0.12.0-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:1e55e44e770e061f55a7dbc6e9aed47feea07731d809a3710feda2262d2d4d8a", size = 11641616, upload-time = "2025-06-17T15:19:17.6Z" }, + { url = "https://files.pythonhosted.org/packages/6a/ef/b960ab4818f90ff59e571d03c3f992828d4683561095e80f9ef31f3d58b7/ruff-0.12.0-py3-none-win32.whl", hash = "sha256:7162a4c816f8d1555eb195c46ae0bd819834d2a3f18f98cc63819a7b46f474fb", size = 10525289, upload-time = "2025-06-17T15:19:19.688Z" }, + { url = "https://files.pythonhosted.org/packages/34/93/8b16034d493ef958a500f17cda3496c63a537ce9d5a6479feec9558f1695/ruff-0.12.0-py3-none-win_amd64.whl", hash = "sha256:d00b7a157b8fb6d3827b49d3324da34a1e3f93492c1f97b08e222ad7e9b291e0", size = 11598311, upload-time = "2025-06-17T15:19:21.785Z" }, + { url = "https://files.pythonhosted.org/packages/d0/33/4d3e79e4a84533d6cd526bfb42c020a23256ae5e4265d858bd1287831f7d/ruff-0.12.0-py3-none-win_arm64.whl", hash = "sha256:8cd24580405ad8c1cc64d61725bca091d6b6da7eb3d36f72cc605467069d7e8b", size = 10724946, upload-time = "2025-06-17T15:19:23.952Z" }, +] + +[[package]] +name = "tomli" +version = "2.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/18/87/302344fed471e44a87289cf4967697d07e532f2421fdaf868a303cbae4ff/tomli-2.2.1.tar.gz", hash = "sha256:cd45e1dc79c835ce60f7404ec8119f2eb06d38b1deba146f07ced3bbc44505ff", size = 17175, upload-time = "2024-11-27T22:38:36.873Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/43/ca/75707e6efa2b37c77dadb324ae7d9571cb424e61ea73fad7c56c2d14527f/tomli-2.2.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:678e4fa69e4575eb77d103de3df8a895e1591b48e740211bd1067378c69e8249", size = 131077, upload-time = "2024-11-27T22:37:54.956Z" }, + { url = "https://files.pythonhosted.org/packages/c7/16/51ae563a8615d472fdbffc43a3f3d46588c264ac4f024f63f01283becfbb/tomli-2.2.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:023aa114dd824ade0100497eb2318602af309e5a55595f76b626d6d9f3b7b0a6", size = 123429, upload-time = "2024-11-27T22:37:56.698Z" }, + { url = "https://files.pythonhosted.org/packages/f1/dd/4f6cd1e7b160041db83c694abc78e100473c15d54620083dbd5aae7b990e/tomli-2.2.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ece47d672db52ac607a3d9599a9d48dcb2f2f735c6c2d1f34130085bb12b112a", size = 226067, upload-time = "2024-11-27T22:37:57.63Z" }, + { url = "https://files.pythonhosted.org/packages/a9/6b/c54ede5dc70d648cc6361eaf429304b02f2871a345bbdd51e993d6cdf550/tomli-2.2.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6972ca9c9cc9f0acaa56a8ca1ff51e7af152a9f87fb64623e31d5c83700080ee", size = 236030, upload-time = "2024-11-27T22:37:59.344Z" }, + { url = "https://files.pythonhosted.org/packages/1f/47/999514fa49cfaf7a92c805a86c3c43f4215621855d151b61c602abb38091/tomli-2.2.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c954d2250168d28797dd4e3ac5cf812a406cd5a92674ee4c8f123c889786aa8e", size = 240898, upload-time = "2024-11-27T22:38:00.429Z" }, + { url = "https://files.pythonhosted.org/packages/73/41/0a01279a7ae09ee1573b423318e7934674ce06eb33f50936655071d81a24/tomli-2.2.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8dd28b3e155b80f4d54beb40a441d366adcfe740969820caf156c019fb5c7ec4", size = 229894, upload-time = "2024-11-27T22:38:02.094Z" }, + { url = "https://files.pythonhosted.org/packages/55/18/5d8bc5b0a0362311ce4d18830a5d28943667599a60d20118074ea1b01bb7/tomli-2.2.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:e59e304978767a54663af13c07b3d1af22ddee3bb2fb0618ca1593e4f593a106", size = 245319, upload-time = "2024-11-27T22:38:03.206Z" }, + { url = "https://files.pythonhosted.org/packages/92/a3/7ade0576d17f3cdf5ff44d61390d4b3febb8a9fc2b480c75c47ea048c646/tomli-2.2.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:33580bccab0338d00994d7f16f4c4ec25b776af3ffaac1ed74e0b3fc95e885a8", size = 238273, upload-time = "2024-11-27T22:38:04.217Z" }, + { url = "https://files.pythonhosted.org/packages/72/6f/fa64ef058ac1446a1e51110c375339b3ec6be245af9d14c87c4a6412dd32/tomli-2.2.1-cp311-cp311-win32.whl", hash = "sha256:465af0e0875402f1d226519c9904f37254b3045fc5084697cefb9bdde1ff99ff", size = 98310, upload-time = "2024-11-27T22:38:05.908Z" }, + { url = "https://files.pythonhosted.org/packages/6a/1c/4a2dcde4a51b81be3530565e92eda625d94dafb46dbeb15069df4caffc34/tomli-2.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:2d0f2fdd22b02c6d81637a3c95f8cd77f995846af7414c5c4b8d0545afa1bc4b", size = 108309, upload-time = "2024-11-27T22:38:06.812Z" }, + { url = "https://files.pythonhosted.org/packages/52/e1/f8af4c2fcde17500422858155aeb0d7e93477a0d59a98e56cbfe75070fd0/tomli-2.2.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4a8f6e44de52d5e6c657c9fe83b562f5f4256d8ebbfe4ff922c495620a7f6cea", size = 132762, upload-time = "2024-11-27T22:38:07.731Z" }, + { url = "https://files.pythonhosted.org/packages/03/b8/152c68bb84fc00396b83e7bbddd5ec0bd3dd409db4195e2a9b3e398ad2e3/tomli-2.2.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8d57ca8095a641b8237d5b079147646153d22552f1c637fd3ba7f4b0b29167a8", size = 123453, upload-time = "2024-11-27T22:38:09.384Z" }, + { url = "https://files.pythonhosted.org/packages/c8/d6/fc9267af9166f79ac528ff7e8c55c8181ded34eb4b0e93daa767b8841573/tomli-2.2.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e340144ad7ae1533cb897d406382b4b6fede8890a03738ff1683af800d54192", size = 233486, upload-time = "2024-11-27T22:38:10.329Z" }, + { url = "https://files.pythonhosted.org/packages/5c/51/51c3f2884d7bab89af25f678447ea7d297b53b5a3b5730a7cb2ef6069f07/tomli-2.2.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:db2b95f9de79181805df90bedc5a5ab4c165e6ec3fe99f970d0e302f384ad222", size = 242349, upload-time = "2024-11-27T22:38:11.443Z" }, + { url = "https://files.pythonhosted.org/packages/ab/df/bfa89627d13a5cc22402e441e8a931ef2108403db390ff3345c05253935e/tomli-2.2.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:40741994320b232529c802f8bc86da4e1aa9f413db394617b9a256ae0f9a7f77", size = 252159, upload-time = "2024-11-27T22:38:13.099Z" }, + { url = "https://files.pythonhosted.org/packages/9e/6e/fa2b916dced65763a5168c6ccb91066f7639bdc88b48adda990db10c8c0b/tomli-2.2.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:400e720fe168c0f8521520190686ef8ef033fb19fc493da09779e592861b78c6", size = 237243, upload-time = "2024-11-27T22:38:14.766Z" }, + { url = "https://files.pythonhosted.org/packages/b4/04/885d3b1f650e1153cbb93a6a9782c58a972b94ea4483ae4ac5cedd5e4a09/tomli-2.2.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:02abe224de6ae62c19f090f68da4e27b10af2b93213d36cf44e6e1c5abd19fdd", size = 259645, upload-time = "2024-11-27T22:38:15.843Z" }, + { url = "https://files.pythonhosted.org/packages/9c/de/6b432d66e986e501586da298e28ebeefd3edc2c780f3ad73d22566034239/tomli-2.2.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b82ebccc8c8a36f2094e969560a1b836758481f3dc360ce9a3277c65f374285e", size = 244584, upload-time = "2024-11-27T22:38:17.645Z" }, + { url = "https://files.pythonhosted.org/packages/1c/9a/47c0449b98e6e7d1be6cbac02f93dd79003234ddc4aaab6ba07a9a7482e2/tomli-2.2.1-cp312-cp312-win32.whl", hash = "sha256:889f80ef92701b9dbb224e49ec87c645ce5df3fa2cc548664eb8a25e03127a98", size = 98875, upload-time = "2024-11-27T22:38:19.159Z" }, + { url = "https://files.pythonhosted.org/packages/ef/60/9b9638f081c6f1261e2688bd487625cd1e660d0a85bd469e91d8db969734/tomli-2.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:7fc04e92e1d624a4a63c76474610238576942d6b8950a2d7f908a340494e67e4", size = 109418, upload-time = "2024-11-27T22:38:20.064Z" }, + { url = "https://files.pythonhosted.org/packages/04/90/2ee5f2e0362cb8a0b6499dc44f4d7d48f8fff06d28ba46e6f1eaa61a1388/tomli-2.2.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f4039b9cbc3048b2416cc57ab3bda989a6fcf9b36cf8937f01a6e731b64f80d7", size = 132708, upload-time = "2024-11-27T22:38:21.659Z" }, + { url = "https://files.pythonhosted.org/packages/c0/ec/46b4108816de6b385141f082ba99e315501ccd0a2ea23db4a100dd3990ea/tomli-2.2.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:286f0ca2ffeeb5b9bd4fcc8d6c330534323ec51b2f52da063b11c502da16f30c", size = 123582, upload-time = "2024-11-27T22:38:22.693Z" }, + { url = "https://files.pythonhosted.org/packages/a0/bd/b470466d0137b37b68d24556c38a0cc819e8febe392d5b199dcd7f578365/tomli-2.2.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a92ef1a44547e894e2a17d24e7557a5e85a9e1d0048b0b5e7541f76c5032cb13", size = 232543, upload-time = "2024-11-27T22:38:24.367Z" }, + { url = "https://files.pythonhosted.org/packages/d9/e5/82e80ff3b751373f7cead2815bcbe2d51c895b3c990686741a8e56ec42ab/tomli-2.2.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9316dc65bed1684c9a98ee68759ceaed29d229e985297003e494aa825ebb0281", size = 241691, upload-time = "2024-11-27T22:38:26.081Z" }, + { url = "https://files.pythonhosted.org/packages/05/7e/2a110bc2713557d6a1bfb06af23dd01e7dde52b6ee7dadc589868f9abfac/tomli-2.2.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e85e99945e688e32d5a35c1ff38ed0b3f41f43fad8df0bdf79f72b2ba7bc5272", size = 251170, upload-time = "2024-11-27T22:38:27.921Z" }, + { url = "https://files.pythonhosted.org/packages/64/7b/22d713946efe00e0adbcdfd6d1aa119ae03fd0b60ebed51ebb3fa9f5a2e5/tomli-2.2.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ac065718db92ca818f8d6141b5f66369833d4a80a9d74435a268c52bdfa73140", size = 236530, upload-time = "2024-11-27T22:38:29.591Z" }, + { url = "https://files.pythonhosted.org/packages/38/31/3a76f67da4b0cf37b742ca76beaf819dca0ebef26d78fc794a576e08accf/tomli-2.2.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:d920f33822747519673ee656a4b6ac33e382eca9d331c87770faa3eef562aeb2", size = 258666, upload-time = "2024-11-27T22:38:30.639Z" }, + { url = "https://files.pythonhosted.org/packages/07/10/5af1293da642aded87e8a988753945d0cf7e00a9452d3911dd3bb354c9e2/tomli-2.2.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a198f10c4d1b1375d7687bc25294306e551bf1abfa4eace6650070a5c1ae2744", size = 243954, upload-time = "2024-11-27T22:38:31.702Z" }, + { url = "https://files.pythonhosted.org/packages/5b/b9/1ed31d167be802da0fc95020d04cd27b7d7065cc6fbefdd2f9186f60d7bd/tomli-2.2.1-cp313-cp313-win32.whl", hash = "sha256:d3f5614314d758649ab2ab3a62d4f2004c825922f9e370b29416484086b264ec", size = 98724, upload-time = "2024-11-27T22:38:32.837Z" }, + { url = "https://files.pythonhosted.org/packages/c7/32/b0963458706accd9afcfeb867c0f9175a741bf7b19cd424230714d722198/tomli-2.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:a38aa0308e754b0e3c67e344754dff64999ff9b513e691d0e786265c93583c69", size = 109383, upload-time = "2024-11-27T22:38:34.455Z" }, + { url = "https://files.pythonhosted.org/packages/6e/c2/61d3e0f47e2b74ef40a68b9e6ad5984f6241a942f7cd3bbfbdbd03861ea9/tomli-2.2.1-py3-none-any.whl", hash = "sha256:cb55c73c5f4408779d0cf3eef9f762b9c9f147a77de7b258bef0a5628adc85cc", size = 14257, upload-time = "2024-11-27T22:38:35.385Z" }, +] + +[[package]] +name = "tox" +version = "4.27.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cachetools" }, + { name = "chardet" }, + { name = "colorama" }, + { name = "filelock" }, + { name = "packaging" }, + { name = "platformdirs" }, + { name = "pluggy" }, + { name = "pyproject-api" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, + { name = "virtualenv" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a5/b7/19c01717747076f63c54d871ada081cd711a7c9a7572f2225675c3858b94/tox-4.27.0.tar.gz", hash = "sha256:b97d5ecc0c0d5755bcc5348387fef793e1bfa68eb33746412f4c60881d7f5f57", size = 198351, upload-time = "2025-06-17T15:17:50.585Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c1/3a/30889167f41ecaffb957ec4409e1cbc1d5d558a5bbbdfb734a5b9911930f/tox-4.27.0-py3-none-any.whl", hash = "sha256:2b8a7fb986b82aa2c830c0615082a490d134e0626dbc9189986da46a313c4f20", size = 173441, upload-time = "2025-06-17T15:17:48.689Z" }, +] + +[[package]] +name = "types-greenlet" +version = "3.2.0.20250417" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ad/a3/0a2583e1542bd79cfdfd3ffc0407c9f0de5f62642cff13f4d191e1291e40/types_greenlet-3.2.0.20250417.tar.gz", hash = "sha256:eb006afcf281ec5756a75c1fd4a6c8a7be5d0cc09b2e82c4856c764760cfa0e3", size = 8785, upload-time = "2025-04-17T02:58:13.007Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/ce/61b00f3ffc1ab468d6d994c0880f1470bc0ee9b198dae9fbd773a0029c9f/types_greenlet-3.2.0.20250417-py3-none-any.whl", hash = "sha256:7798b9fdf19d718a62e2d63351e112e7bee622898c6e6cec539296c3dec27808", size = 8819, upload-time = "2025-04-17T02:58:11.816Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.14.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d1/bc/51647cd02527e87d05cb083ccc402f93e441606ff1f01739a62c8ad09ba5/typing_extensions-4.14.0.tar.gz", hash = "sha256:8676b788e32f02ab42d9e7c61324048ae4c6d844a399eebace3d4979d75ceef4", size = 107423, upload-time = "2025-06-02T14:52:11.399Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/69/e0/552843e0d356fbb5256d21449fa957fa4eff3bbc135a74a691ee70c7c5da/typing_extensions-4.14.0-py3-none-any.whl", hash = "sha256:a1514509136dd0b477638fc68d6a91497af5076466ad0fa6c338e44e359944af", size = 43839, upload-time = "2025-06-02T14:52:10.026Z" }, +] + +[[package]] +name = "virtualenv" +version = "20.31.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "distlib" }, + { name = "filelock" }, + { name = "platformdirs" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/56/2c/444f465fb2c65f40c3a104fd0c495184c4f2336d65baf398e3c75d72ea94/virtualenv-20.31.2.tar.gz", hash = "sha256:e10c0a9d02835e592521be48b332b6caee6887f332c111aa79a09b9e79efc2af", size = 6076316, upload-time = "2025-05-08T17:58:23.811Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f3/40/b1c265d4b2b62b58576588510fc4d1fe60a86319c8de99fd8e9fec617d2c/virtualenv-20.31.2-py3-none-any.whl", hash = "sha256:36efd0d9650ee985f0cad72065001e66d49a6f24eb44d98980f630686243cf11", size = 6057982, upload-time = "2025-05-08T17:58:21.15Z" }, +]