diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ce919ff..d98cfbc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -53,6 +53,8 @@ jobs: - name: install run: python -m pip install -e .[test] + env: + HATCH_BUILD_HOOKS_ENABLE: "1" - name: Run benchmarks uses: CodSpeedHQ/action@v2 diff --git a/Makefile b/Makefile index 288895a..cca83dc 100644 --- a/Makefile +++ b/Makefile @@ -1,19 +1,11 @@ -.PHONY: build build-trace check clean cleanc benchmark-all benchmark-compare +.PHONY: build check clean benchmark-all benchmark-compare build: - python setup.py build_ext --inplace - cleanc - -build-trace: - python setup.py build_ext --force --inplace --define CYTHON_TRACE - cleanc + HATCH_BUILD_HOOKS_ENABLE=1 pip install -e . check: pre-commit run --all-files -cleanc: - rm -f src/in_n_out/*.c - clean: rm -rf `find . -name __pycache__` rm -f `find . -type f -name '*.py[co]' ` diff --git a/asv.conf.json b/asv.conf.json index 6718cf0..e747036 100644 --- a/asv.conf.json +++ b/asv.conf.json @@ -1,17 +1,24 @@ { - "version": 1, - "project": "in-n-out", - "project_url": "https://github.com/pyapp-kit/in-n-out", - "repo": ".", - "branches": ["main"], - "dvcs": "git", - "environment_type": "conda", - "conda_channels": ["conda-forge"], - "install_timeout": 600, - "show_commit_url": "https://github.com/pyapp-kit/in-n-out/commit/", - "pythons": ["3.10"], - "build_command": ["pip install build", "python -m build --wheel -o {build_cache_dir} {build_dir}"], - "env_dir": "/tmp/.asv/env", - "results_dir": ".asv/results", - "html_dir": ".asv/html" -} + "version": 1, + "project": "in-n-out", + "project_url": "https://github.com/pyapp-kit/in-n-out", + "repo": ".", + "branches": ["main"], + "dvcs": "git", + "environment_type": "conda", + "install_timeout": 600, + "show_commit_url": "https://github.com/pyapp-kit/in-n-out/commit/", + "pythons": ["3.10"], + "build_command": [ + "python -m pip install build", + "python -m build --wheel -o {build_cache_dir} {build_dir}" + ], + "matrix": { + "env": { + "HATCH_BUILD_HOOKS_ENABLE": "1" + } + }, + "env_dir": "/tmp/.asv/env", + "results_dir": ".asv/results", + "html_dir": ".asv/html" + } diff --git a/pyproject.toml b/pyproject.toml index 4d4322f..c412525 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ # https://peps.python.org/pep-0517/ [build-system] -requires = ["hatchling", "hatch-vcs"] +requires = ["hatchling>=1.8.0", "hatch-vcs"] build-backend = "hatchling.build" # https://peps.python.org/pep-0621/ @@ -30,12 +30,13 @@ dependencies = [] source = "vcs" [tool.hatch.build.targets.sdist] -include = ["/src", "/tests"] +include = ["src", "tests", "CHANGELOG.md"] [tool.hatch.build.targets.wheel] only-include = ["src"] sources = ["src"] + # extras # https://peps.python.org/pep-0621/#dependencies-optional-dependencies [project.optional-dependencies] @@ -62,7 +63,21 @@ homepage = "https://github.com/pyapp-kit/in-n-out" repository = "https://github.com/pyapp-kit/in-n-out" documentations = "https://ino.rtfd.io" -# https://docs.astral.sh/ruff + +[tool.hatch.build.targets.wheel.hooks.mypyc] +mypy-args = ["--ignore-missing-imports"] +enable-by-default = false +require-runtime-dependencies = true +dependencies = ["hatch-mypyc>=0.13.0", "mypy>=0.991"] +include = [ + 'src/in_n_out/_store.py', + 'src/in_n_out/_global.py', + 'src/in_n_out/_type_resolution.py', + 'src/in_n_out/_util.py', +] + + +# https://github.com/charliermarsh/ruff [tool.ruff] line-length = 88 src = ["src", "tests"] @@ -127,6 +142,10 @@ disallow_any_generics = false show_error_codes = true pretty = true +[[tool.mypy.overrides]] +module = ["cython", "toolz"] +ignore_missing_imports = true + [[tool.mypy.overrides]] module = ["tests.*"] disallow_untyped_defs = false diff --git a/src/in_n_out/_global.py b/src/in_n_out/_global.py index cc3898a..db80a1f 100644 --- a/src/in_n_out/_global.py +++ b/src/in_n_out/_global.py @@ -1,7 +1,8 @@ from __future__ import annotations +import contextlib from textwrap import indent -from typing import TYPE_CHECKING, Any, Callable, Iterable, Literal, overload +from typing import TYPE_CHECKING, Any, Callable, Iterable, Literal, TypeVar, overload from ._store import InjectionContext, Store @@ -25,12 +26,14 @@ global store is used. """ _STORE_PARAM = indent(_STORE_PARAM.strip(), " ") +F = TypeVar("F", bound=Callable[..., Any]) -def _add_store_to_doc(func: T) -> T: +def _add_store_to_doc(func: F) -> F: new_doc: list[str] = [] - store_doc: str = getattr(Store, func.__name__).__doc__ # type: ignore + name: str = func.__name__ + store_doc: str = getattr(Store, name).__doc__ or "" for n, line in enumerate(store_doc.splitlines()): if line.lstrip().startswith("Returns"): new_doc.insert(n - 1, _STORE_PARAM) @@ -38,7 +41,8 @@ def _add_store_to_doc(func: T) -> T: # TODO: use re.sub instead new_doc.append(line.replace(" store.", " ").replace("@store.", "@")) - func.__doc__ = "\n".join(new_doc) + with contextlib.suppress(AttributeError): # when compiled + func.__doc__ = "\n".join(new_doc) return func @@ -223,7 +227,7 @@ def process( @overload def inject( - func: Callable[P, R], + func: Callable[..., R], *, providers: bool = True, processors: bool = False, @@ -255,7 +259,7 @@ def inject( @_add_store_to_doc def inject( - func: Callable[P, R] | None = None, + func: Callable[..., R] | None = None, *, providers: bool = True, processors: bool = False, @@ -282,7 +286,7 @@ def inject( @overload def inject_processors( - func: Callable[P, R], + func: Callable[..., R], *, hint: object | type[T] | None = None, first_processor_only: bool = False, @@ -304,7 +308,7 @@ def inject_processors( @_add_store_to_doc def inject_processors( - func: Callable[P, R] | None = None, + func: Callable[..., R] | None = None, *, hint: object | type[T] | None = None, first_processor_only: bool = False, diff --git a/src/in_n_out/_store.py b/src/in_n_out/_store.py index fee18fd..78d9004 100644 --- a/src/in_n_out/_store.py +++ b/src/in_n_out/_store.py @@ -4,7 +4,7 @@ import types import warnings import weakref -from functools import cached_property, wraps +from functools import wraps from inspect import CO_VARARGS, isgeneratorfunction, unwrap from logging import getLogger from types import CodeType @@ -14,6 +14,7 @@ Callable, ClassVar, ContextManager, + Generator, Iterable, Iterator, Literal, @@ -33,14 +34,14 @@ logger = getLogger("in_n_out") -if TYPE_CHECKING: - from typing_extensions import ParamSpec +from typing_extensions import ParamSpec +if TYPE_CHECKING: from ._type_resolution import RaiseWarnReturnIgnore - P = ParamSpec("P") R = TypeVar("R") +P = ParamSpec("P") T = TypeVar("T") Provider = Callable[[], Any] # provider should be able to take no arguments @@ -79,7 +80,8 @@ _GLOBAL = "global" -class _NullSentinel: ... +class _NullSentinel: + pass class _RegisteredCallback(NamedTuple): @@ -95,6 +97,14 @@ class _CachedMap(NamedTuple): subclassable: dict[type, list[Processor | Provider]] +def fexec() -> int: + return 1 + + +def gexec() -> Generator[int, None, None]: + yield from (1, 2, 3) + + class InjectionContext(ContextManager): """Context manager for registering callbacks. @@ -127,7 +137,7 @@ def cleanup(self) -> Any: class Store: """A Store is a collection of providers and processors.""" - _NULL = _NullSentinel() + _NULL: ClassVar = _NullSentinel() _instances: ClassVar[dict[str, Store]] = {} @classmethod @@ -214,6 +224,8 @@ def __init__(self, name: str) -> None: self.on_unresolved_required_args: RaiseWarnReturnIgnore = "warn" self.on_unannotated_required_args: RaiseWarnReturnIgnore = "warn" self.guess_self: bool = True + self._cached_provider_map_: _CachedMap | None = None + self._cached_processor_map_: _CachedMap | None = None @property def name(self) -> str: @@ -224,10 +236,8 @@ def clear(self) -> None: """Clear all providers and processors.""" self._providers.clear() self._processors.clear() - with contextlib.suppress(AttributeError): - del self._cached_processor_map - with contextlib.suppress(AttributeError): - del self._cached_provider_map + self._cached_processor_map_ = None + self._cached_provider_map_ = None @property def namespace(self) -> dict[str, object]: @@ -533,9 +543,9 @@ def provide(self, type_hint: type[T] | object) -> T | None: providers return a value. """ for provider in self.iter_providers(type_hint): - result = provider() + result = cast("Any", provider()) if result is not None: - return result + return cast("T", result) return None def process( @@ -595,7 +605,7 @@ def process( @overload def inject( self, - func: Callable[P, R], + func: Callable[..., R], *, providers: bool = True, processors: bool = False, @@ -624,7 +634,7 @@ def inject( def inject( self, - func: Callable[P, R] | None = None, + func: Callable[..., R] | None = None, *, providers: bool = True, processors: bool = False, @@ -632,7 +642,7 @@ def inject( on_unresolved_required_args: RaiseWarnReturnIgnore | None = None, on_unannotated_required_args: RaiseWarnReturnIgnore | None = None, guess_self: bool | None = None, - ) -> Callable[..., R] | Callable[[Callable[P, R]], Callable[..., R]]: + ) -> Callable[..., R] | Callable[[Callable[..., R]], Callable[..., R]]: """Decorate `func` to inject dependencies at calltime. Assuming `providers` is True (the default), this will attempt retrieve @@ -728,7 +738,7 @@ def inject( _guess_self = guess_self or self.guess_self # inner decorator, allows for optional decorator arguments - def _inner(func: Callable[P, R]) -> Callable[P, R]: + def _inner(func: Callable[..., R]) -> Callable[..., R]: # if the function takes no arguments and has no return annotation # there's nothing to be done if not providers: @@ -760,7 +770,7 @@ def _inner(func: Callable[P, R]) -> Callable[P, R]: # get provider functions for each required parameter @wraps(func) - def _exec(*args: P.args, **kwargs: P.kwargs) -> R: + def _exec(*args: Any, **kwargs: Any) -> R: # we're actually calling the "injected function" now logger.debug( "Executing @injected %s%s with args: %r, kwargs: %r", @@ -779,7 +789,7 @@ def _exec(*args: P.args, **kwargs: P.kwargs) -> R: _injected_names: set[str] = set() for param in sig.parameters.values(): if param.name not in bound.arguments: - provided = self.provide(param.annotation) + provided: Any = self.provide(param.annotation) if provided is not None or is_optional(param.annotation): logger.debug( " injecting %s: %s = %r", @@ -827,16 +837,13 @@ def _exec(*args: P.args, **kwargs: P.kwargs) -> R: return result - out = _exec - # if it came in as a generatorfunction, it needs to go out as one. - if isgeneratorfunction(func): + if not isgeneratorfunction(func): + out = _exec + else: + from ._uncompiled import _wrap_generator - @wraps(func) - def _gexec(*args: P.args, **kwargs: P.kwargs) -> R: # type: ignore - yield from _exec(*args, **kwargs) # type: ignore - - out = _gexec + out = _wrap_generator(func, _exec) # type: ignore # update some metadata on the decorated function. out.__signature__ = sig # type: ignore [attr-defined] @@ -857,7 +864,7 @@ def _gexec(*args: P.args, **kwargs: P.kwargs) -> R: # type: ignore @overload def inject_processors( self, - func: Callable[P, R], + func: Callable[..., R], *, type_hint: type[T] | object | None = None, first_processor_only: bool = False, @@ -876,12 +883,12 @@ def inject_processors( def inject_processors( self, - func: Callable[P, R] | None = None, + func: Callable[..., R] | None = None, *, type_hint: type[T] | object | None = None, first_processor_only: bool = False, raise_exception: bool = False, - ) -> Callable[[Callable[P, R]], Callable[P, R]] | Callable[P, R]: + ) -> Callable[[Callable[..., R]], Callable[..., R]] | Callable[..., R]: """Decorate a function to process its output. Variant of [`inject`][in_n_out.Store.inject], but only injects processors @@ -914,7 +921,7 @@ def inject_processors( `store.process(return_value)` """ - def _deco(func: Callable[P, R]) -> Callable[P, R]: + def _deco(func: Callable[..., R]) -> Callable[..., R]: if isgeneratorfunction(func): raise TypeError( "Cannot decorate a generator function with inject_processors" @@ -927,7 +934,7 @@ def _deco(func: Callable[P, R]) -> Callable[P, R]: type_hint = annotations["return"] @wraps(func) - def _exec(*args: P.args, **kwargs: P.kwargs) -> R: + def _exec(*args: Any, **kwargs: Any) -> R: result = func(*args, **kwargs) if result is not None: self.process( @@ -945,15 +952,19 @@ def _exec(*args: P.args, **kwargs: P.kwargs) -> R: # ---------------------- Private methods ----------------------- # - @cached_property + @property def _cached_provider_map(self) -> _CachedMap: - logger.debug("Rebuilding provider map cache") - return self._build_map(self._providers) + if self._cached_provider_map_ is None: + logger.debug("Rebuilding provider map cache") + self._cached_provider_map_ = self._build_map(self._providers) + return self._cached_provider_map_ - @cached_property + @property def _cached_processor_map(self) -> _CachedMap: - logger.debug("Rebuilding processor map cache") - return self._build_map(self._processors) + if self._cached_processor_map_ is None: + logger.debug("Rebuilding processor map cache") + self._cached_processor_map_ = self._build_map(self._processors) + return self._cached_processor_map_ def _build_map(self, registry: list[_RegisteredCallback]) -> _CachedMap: """Build a map of type hints to callbacks. @@ -1038,7 +1049,7 @@ def _type_from_hints(hints: dict[str, Any]) -> Any: _callbacks: Iterable[CallbackTuple] if isinstance(callbacks, Mapping): - _callbacks = ((v, k) for k, v in callbacks.items()) # type: ignore # dunno + _callbacks = callbacks.items() else: _callbacks = callbacks @@ -1047,11 +1058,11 @@ def _type_from_hints(hints: dict[str, Any]) -> Any: for tup in _callbacks: callback, *rest = tup type_: THint | None = None - weight: float = 0 + weight: float = 0.0 if rest: if len(rest) == 1: type_ = rest[0] - weight = 0 + weight = 0.0 elif len(rest) == 2: type_, weight = cast(Tuple[Optional[THint], float], rest) else: # pragma: no cover @@ -1102,15 +1113,11 @@ def _dispose() -> None: logger.debug( "Unregistering %s of %s: %s", regname, p.origin, p.callback ) - # attribute error in case the cache was never built - with contextlib.suppress(AttributeError): - delattr(self, cache_map) + setattr(self, cache_map + "_", None) if to_register: reg.extend(to_register) - # attribute error in case the cache was never built - with contextlib.suppress(AttributeError): - delattr(self, cache_map) + setattr(self, cache_map + "_", None) return _dispose diff --git a/src/in_n_out/_uncompiled.py b/src/in_n_out/_uncompiled.py new file mode 100644 index 0000000..9d68a1d --- /dev/null +++ b/src/in_n_out/_uncompiled.py @@ -0,0 +1,17 @@ +from functools import wraps +from typing import Callable, Generator, Iterable, TypeVar + +from typing_extensions import ParamSpec + +P = ParamSpec("P") +R = TypeVar("R") + + +def _wrap_generator( + func: Callable, _exec: Callable[P, Iterable[R]] +) -> Callable[P, Generator[R, None, None]]: + @wraps(func) + def _gexec(*args: P.args, **kwargs: P.kwargs) -> Generator[R, None, None]: + yield from _exec(*args, **kwargs) + + return _gexec diff --git a/tests/test_injection.py b/tests/test_injection.py index f0fd052..ce8aa2d 100644 --- a/tests/test_injection.py +++ b/tests/test_injection.py @@ -14,7 +14,7 @@ import pytest -from in_n_out import Store, _compiled, inject, inject_processors, register +from in_n_out import Store, inject, inject_processors, register if TYPE_CHECKING: from in_n_out._type_resolution import RaiseWarnReturnIgnore @@ -220,7 +220,7 @@ def test_inject_instance_into_unbound_method(): # https://github.com/cython/cython/issues/4888 -@pytest.mark.xfail(bool(_compiled), reason="Cython doesn't support this") +# @pytest.mark.xfail(bool(_compiled), reason="Cython doesn't support this") def test_generators(): def generator_func() -> Generator: yield 1