├── .coveragerc ├── .editorconfig ├── .github ├── FUNDING.yml └── workflows │ └── main.yml ├── .pre-commit-config.yaml ├── .readthedocs.yaml ├── NEWS.rst ├── README.rst ├── SECURITY.md ├── conftest.py ├── docs ├── conf.py ├── history.rst └── index.rst ├── jaraco └── functools │ ├── __init__.py │ ├── __init__.pyi │ └── py.typed ├── mypy.ini ├── newsfragments └── 29.bugfix.rst ├── pyproject.toml ├── pytest.ini ├── ruff.toml ├── test_functools.py ├── towncrier.toml └── tox.ini /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | omit = 3 | # leading `*/` for pytest-dev/pytest-cov#456 4 | */.tox/* 5 | disable_warnings = 6 | couldnt-parse 7 | 8 | [report] 9 | show_missing = True 10 | exclude_also = 11 | # Exclude common false positives per 12 | # https://coverage.readthedocs.io/en/latest/excluding.html#advanced-exclusion 13 | # Ref jaraco/skeleton#97 and jaraco/skeleton#135 14 | class .*\bProtocol\): 15 | if TYPE_CHECKING: 16 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | indent_style = tab 6 | indent_size = 4 7 | insert_final_newline = true 8 | end_of_line = lf 9 | 10 | [*.py] 11 | indent_style = space 12 | max_line_length = 88 13 | 14 | [*.{yml,yaml}] 15 | indent_style = space 16 | indent_size = 2 17 | 18 | [*.rst] 19 | indent_style = space 20 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | tidelift: pypi/jaraco.functools 2 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: tests 2 | 3 | on: 4 | merge_group: 5 | push: 6 | branches-ignore: 7 | # temporary GH branches relating to merge queues (jaraco/skeleton#93) 8 | - gh-readonly-queue/** 9 | tags: 10 | # required if branches-ignore is supplied (jaraco/skeleton#103) 11 | - '**' 12 | pull_request: 13 | workflow_dispatch: 14 | 15 | permissions: 16 | contents: read 17 | 18 | env: 19 | # Environment variable to support color support (jaraco/skeleton#66) 20 | FORCE_COLOR: 1 21 | 22 | # Suppress noisy pip warnings 23 | PIP_DISABLE_PIP_VERSION_CHECK: 'true' 24 | PIP_NO_WARN_SCRIPT_LOCATION: 'true' 25 | 26 | # Ensure tests can sense settings about the environment 27 | TOX_OVERRIDE: >- 28 | testenv.pass_env+=GITHUB_*,FORCE_COLOR 29 | 30 | 31 | jobs: 32 | test: 33 | strategy: 34 | # https://blog.jaraco.com/efficient-use-of-ci-resources/ 35 | matrix: 36 | python: 37 | - "3.9" 38 | - "3.13" 39 | platform: 40 | - ubuntu-latest 41 | - macos-latest 42 | - windows-latest 43 | include: 44 | - python: "3.10" 45 | platform: ubuntu-latest 46 | - python: "3.11" 47 | platform: ubuntu-latest 48 | - python: "3.12" 49 | platform: ubuntu-latest 50 | - python: "3.14" 51 | platform: ubuntu-latest 52 | - python: pypy3.10 53 | platform: ubuntu-latest 54 | runs-on: ${{ matrix.platform }} 55 | continue-on-error: ${{ matrix.python == '3.14' }} 56 | steps: 57 | - uses: actions/checkout@v4 58 | - name: Install build dependencies 59 | # Install dependencies for building packages on pre-release Pythons 60 | # jaraco/skeleton#161 61 | if: matrix.python == '3.14' && matrix.platform == 'ubuntu-latest' 62 | run: | 63 | sudo apt update 64 | sudo apt install -y libxml2-dev libxslt-dev 65 | - name: Setup Python 66 | uses: actions/setup-python@v5 67 | with: 68 | python-version: ${{ matrix.python }} 69 | allow-prereleases: true 70 | - name: Install tox 71 | run: python -m pip install tox 72 | - name: Run 73 | run: tox 74 | 75 | collateral: 76 | strategy: 77 | fail-fast: false 78 | matrix: 79 | job: 80 | - diffcov 81 | - docs 82 | runs-on: ubuntu-latest 83 | steps: 84 | - uses: actions/checkout@v4 85 | with: 86 | fetch-depth: 0 87 | - name: Setup Python 88 | uses: actions/setup-python@v5 89 | with: 90 | python-version: 3.x 91 | - name: Install tox 92 | run: python -m pip install tox 93 | - name: Eval ${{ matrix.job }} 94 | run: tox -e ${{ matrix.job }} 95 | 96 | check: # This job does nothing and is only used for the branch protection 97 | if: always() 98 | 99 | needs: 100 | - test 101 | - collateral 102 | 103 | runs-on: ubuntu-latest 104 | 105 | steps: 106 | - name: Decide whether the needed jobs succeeded or failed 107 | uses: re-actors/alls-green@release/v1 108 | with: 109 | jobs: ${{ toJSON(needs) }} 110 | 111 | release: 112 | permissions: 113 | contents: write 114 | needs: 115 | - check 116 | if: github.event_name == 'push' && contains(github.ref, 'refs/tags/') 117 | runs-on: ubuntu-latest 118 | 119 | steps: 120 | - uses: actions/checkout@v4 121 | - name: Setup Python 122 | uses: actions/setup-python@v5 123 | with: 124 | python-version: 3.x 125 | - name: Install tox 126 | run: python -m pip install tox 127 | - name: Run 128 | run: tox -e release 129 | env: 130 | TWINE_PASSWORD: ${{ secrets.PYPI_TOKEN }} 131 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 132 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/astral-sh/ruff-pre-commit 3 | rev: v0.9.9 4 | hooks: 5 | - id: ruff 6 | args: [--fix, --unsafe-fixes] 7 | - id: ruff-format 8 | -------------------------------------------------------------------------------- /.readthedocs.yaml: -------------------------------------------------------------------------------- 1 | version: 2 2 | python: 3 | install: 4 | - path: . 5 | extra_requirements: 6 | - doc 7 | 8 | sphinx: 9 | configuration: docs/conf.py 10 | 11 | # required boilerplate readthedocs/readthedocs.org#10401 12 | build: 13 | os: ubuntu-lts-latest 14 | tools: 15 | python: latest 16 | # post-checkout job to ensure the clone isn't shallow jaraco/skeleton#114 17 | jobs: 18 | post_checkout: 19 | - git fetch --unshallow || true 20 | -------------------------------------------------------------------------------- /NEWS.rst: -------------------------------------------------------------------------------- 1 | v4.1.0 2 | ====== 3 | 4 | Features 5 | -------- 6 | 7 | - Added chainable decorator. 8 | 9 | 10 | v4.0.2 11 | ====== 12 | 13 | No significant changes. 14 | 15 | 16 | v4.0.1 17 | ====== 18 | 19 | No significant changes. 20 | 21 | 22 | v4.0.0 23 | ====== 24 | 25 | Features 26 | -------- 27 | 28 | - Added ``splat`` function. 29 | 30 | 31 | Deprecations and Removals 32 | ------------------------- 33 | 34 | - Removed deprecated 'call_aside'. (#21) 35 | 36 | 37 | v3.9.0 38 | ====== 39 | 40 | Features 41 | -------- 42 | 43 | - Enhanced type hints and declare the package as typed. Module is now a package. (#22) 44 | 45 | 46 | v3.8.1 47 | ====== 48 | 49 | Bugfixes 50 | -------- 51 | 52 | - Restored type checking and repaired broken exclusion. (#50550895) 53 | 54 | 55 | v3.8.0 56 | ====== 57 | 58 | Features 59 | -------- 60 | 61 | - Require Python 3.8 or later. 62 | 63 | 64 | v3.7.0 65 | ====== 66 | 67 | Added ``bypass_unless`` and ``bypass_when`` and ``identity``. 68 | 69 | v3.6.0 70 | ====== 71 | 72 | #21: Renamed ``call_aside`` to ``invoke``, deprecating ``call_aside``. 73 | 74 | v3.5.2 75 | ====== 76 | 77 | Refreshed packaging. 78 | 79 | v3.5.1 80 | ====== 81 | 82 | Packaging refresh. 83 | 84 | Enrolled with Tidelift. 85 | 86 | v3.5.0 87 | ====== 88 | 89 | * #19: Add type annotations to ``method_cache``. 90 | * Require Python 3.7. 91 | 92 | v3.4.0 93 | ====== 94 | 95 | ``apply`` now uses ``functools.wraps`` to ensure docstring 96 | passthrough. 97 | 98 | v3.3.0 99 | ====== 100 | 101 | #18: In method_cache, support cache_clear before cache 102 | is initialized. 103 | 104 | v3.2.1 105 | ====== 106 | 107 | Refreshed package metadata. 108 | 109 | v3.2.0 110 | ====== 111 | 112 | Switched to PEP 420 for ``jaraco`` namespace. 113 | 114 | v3.1.0 115 | ====== 116 | 117 | Added ``except_`` decorator. 118 | 119 | v3.0.1 120 | ====== 121 | 122 | #14: Removed unnecessary compatibility libraries in testing. 123 | 124 | v3.0.0 125 | ====== 126 | 127 | Require Python 3.6 or later. 128 | 129 | 2.0 130 | === 131 | 132 | Switch to `pkgutil namespace technique 133 | `_ 134 | for the ``jaraco`` namespace. 135 | 136 | 1.20 137 | ==== 138 | 139 | Added ``save_method_args``, adopted from ``irc.functools``. 140 | 141 | 1.19 142 | ==== 143 | 144 | Added ``.reset`` support to ``once``. 145 | 146 | 1.18 147 | ==== 148 | 149 | Add ``result_invoke`` decorator. 150 | 151 | 1.17 152 | ==== 153 | 154 | Add ``retry`` decorator. 155 | 156 | 1.16 157 | ==== 158 | 159 | #7: ``retry_call`` now accepts infinity for the ``retries`` 160 | parameter. 161 | 162 | 1.15.2 163 | ====== 164 | 165 | Refresh packaging. 166 | 167 | 1.15.1 168 | ====== 169 | 170 | Fix assign_params on Python 2. 171 | 172 | 1.15 173 | ==== 174 | 175 | Add ``assign_params`` function. 176 | 177 | 1.14 178 | ==== 179 | 180 | Add ``pass_none`` decorator function. 181 | 182 | 1.13 183 | ==== 184 | 185 | Add ``print_yielded`` func implementing the func of the same 186 | name found in autocommand docs. 187 | 188 | 1.12 189 | ==== 190 | 191 | Issue #6: Added a bit of documentation and xfail tests showing 192 | that the ``method_cache`` can't be used with other decorators 193 | such as ``property``. 194 | 195 | 1.11 196 | ==== 197 | 198 | Include dates and links in changelog. 199 | 200 | 1.10 201 | ==== 202 | 203 | Use Github for continuous deployment to PyPI. 204 | 205 | 1.9 206 | === 207 | 208 | Add ``retry_call``, a general-purpose function retry mechanism. 209 | See ``test_functools`` for tests and example usage. 210 | 211 | 1.8 212 | === 213 | 214 | More generous handling of missing lru_cache when installed on 215 | Python 2 and older pip. Now all functools except ``method_cache`` 216 | will continue to work even if ``backports.functools_lru_cache`` 217 | is not installed. Also allows functools32 as a fallback if 218 | available. 219 | 220 | 1.7 221 | === 222 | 223 | Moved hosting to github. 224 | 225 | 1.6 226 | === 227 | 228 | ``method_cache`` now accepts a cache_wrapper parameter, allowing 229 | for custom parameters to an ``lru_cache`` or an entirely different 230 | cache implementation. 231 | 232 | Use ``backports.functools_lru_cache`` to provide ``lru_cache`` for 233 | Python 2. 234 | 235 | 1.5 236 | === 237 | 238 | Implement ``Throttler`` as a descriptor so it may be used to decorate 239 | methods. Introduces ``first_invoke`` function. 240 | 241 | Fixed failure in Throttler on Python 2 due to improper use of integer 242 | division. 243 | 244 | 1.4 245 | === 246 | 247 | Added ``Throttler`` class from `irc `_. 248 | 249 | 1.3 250 | === 251 | 252 | Added ``call_aside`` decorator. 253 | 254 | 1.2 255 | === 256 | 257 | Added ``apply`` decorator. 258 | 259 | 1.0 260 | === 261 | 262 | Initial release drawn from jaraco.util. 263 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | .. image:: https://img.shields.io/pypi/v/jaraco.functools.svg 2 | :target: https://pypi.org/project/jaraco.functools 3 | 4 | .. image:: https://img.shields.io/pypi/pyversions/jaraco.functools.svg 5 | 6 | .. image:: https://github.com/jaraco/jaraco.functools/actions/workflows/main.yml/badge.svg 7 | :target: https://github.com/jaraco/jaraco.functools/actions?query=workflow%3A%22tests%22 8 | :alt: tests 9 | 10 | .. image:: https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json 11 | :target: https://github.com/astral-sh/ruff 12 | :alt: Ruff 13 | 14 | .. image:: https://readthedocs.org/projects/jaracofunctools/badge/?version=latest 15 | :target: https://jaracofunctools.readthedocs.io/en/latest/?badge=latest 16 | 17 | .. image:: https://img.shields.io/badge/skeleton-2025-informational 18 | :target: https://blog.jaraco.com/skeleton 19 | 20 | .. image:: https://tidelift.com/badges/package/pypi/jaraco.functools 21 | :target: https://tidelift.com/subscription/pkg/pypi-jaraco.functools?utm_source=pypi-jaraco.functools&utm_medium=readme 22 | 23 | Additional functools in the spirit of stdlib's functools. 24 | 25 | For Enterprise 26 | ============== 27 | 28 | Available as part of the Tidelift Subscription. 29 | 30 | This project and the maintainers of thousands of other packages are working with Tidelift to deliver one enterprise subscription that covers all of the open source you use. 31 | 32 | `Learn more `_. 33 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Contact 2 | 3 | To report a security vulnerability, please use the [Tidelift security contact](https://tidelift.com/security). Tidelift will coordinate the fix and disclosure. 4 | -------------------------------------------------------------------------------- /conftest.py: -------------------------------------------------------------------------------- 1 | import re 2 | import sys 3 | 4 | import jaraco.functools 5 | 6 | 7 | def pytest_configure() -> None: 8 | patch_for_issue_12() 9 | 10 | 11 | def patch_for_issue_12() -> None: # pragma: nocover 12 | """ 13 | Issue #12 revealed that Python 3.7.3 had a subtle 14 | change in the C implementation of functools that 15 | broke the assumptions around the method_cache (or 16 | any caller using possibly empty keyword arguments). 17 | This patch adjusts the docstring for that test so it 18 | can pass on that Python version. 19 | """ 20 | affected_ver = 3, 7, 3 21 | if sys.version_info[:3] != affected_ver: 22 | return 23 | mc = jaraco.functools.method_cache 24 | mc.__doc__ = re.sub(r'^(\s+)75', r'\g<1>76', mc.__doc__ or "", flags=re.M) 25 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | extensions = [ 4 | 'sphinx.ext.autodoc', 5 | 'jaraco.packaging.sphinx', 6 | ] 7 | 8 | master_doc = "index" 9 | html_theme = "furo" 10 | 11 | # Link dates and other references in the changelog 12 | extensions += ['rst.linker'] 13 | link_files = { 14 | '../NEWS.rst': dict( 15 | using=dict(GH='https://github.com'), 16 | replace=[ 17 | dict( 18 | pattern=r'(Issue #|\B#)(?P\d+)', 19 | url='{package_url}/issues/{issue}', 20 | ), 21 | dict( 22 | pattern=r'(?m:^((?Pv?\d+(\.\d+){1,2}))\n[-=]+\n)', 23 | with_scm='{text}\n{rev[timestamp]:%d %b %Y}\n', 24 | ), 25 | dict( 26 | pattern=r'PEP[- ](?P\d+)', 27 | url='https://peps.python.org/pep-{pep_number:0>4}/', 28 | ), 29 | ], 30 | ) 31 | } 32 | 33 | # Be strict about any broken references 34 | nitpicky = True 35 | nitpick_ignore: list[tuple[str, str]] = [] 36 | 37 | # Include Python intersphinx mapping to prevent failures 38 | # jaraco/skeleton#51 39 | extensions += ['sphinx.ext.intersphinx'] 40 | intersphinx_mapping = { 41 | 'python': ('https://docs.python.org/3', None), 42 | } 43 | 44 | # Preserve authored syntax for defaults 45 | autodoc_preserve_defaults = True 46 | 47 | # Add support for linking usernames, PyPI projects, Wikipedia pages 48 | github_url = 'https://github.com/' 49 | extlinks = { 50 | 'user': (f'{github_url}%s', '@%s'), 51 | 'pypi': ('https://pypi.org/project/%s', '%s'), 52 | 'wiki': ('https://wikipedia.org/wiki/%s', '%s'), 53 | } 54 | extensions += ['sphinx.ext.extlinks'] 55 | 56 | # local 57 | 58 | extensions += ['jaraco.tidelift'] 59 | 60 | nitpick_ignore.append( 61 | ('py:class', 'jaraco.functools._T'), 62 | ) 63 | -------------------------------------------------------------------------------- /docs/history.rst: -------------------------------------------------------------------------------- 1 | :tocdepth: 2 2 | 3 | .. _changes: 4 | 5 | History 6 | ******* 7 | 8 | .. include:: ../NEWS (links).rst 9 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | Welcome to |project| documentation! 2 | =================================== 3 | 4 | .. sidebar-links:: 5 | :home: 6 | :pypi: 7 | 8 | .. toctree:: 9 | :maxdepth: 1 10 | 11 | history 12 | 13 | 14 | .. tidelift-referral-banner:: 15 | 16 | .. automodule:: jaraco.functools 17 | :members: 18 | :undoc-members: 19 | :show-inheritance: 20 | 21 | 22 | Indices and tables 23 | ================== 24 | 25 | * :ref:`genindex` 26 | * :ref:`modindex` 27 | * :ref:`search` 28 | -------------------------------------------------------------------------------- /jaraco/functools/__init__.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import collections.abc 4 | import functools 5 | import inspect 6 | import itertools 7 | import operator 8 | import time 9 | import types 10 | import warnings 11 | from typing import Callable, TypeVar 12 | 13 | import more_itertools 14 | 15 | 16 | def compose(*funcs): 17 | """ 18 | Compose any number of unary functions into a single unary function. 19 | 20 | Comparable to 21 | `function composition `_ 22 | in mathematics: 23 | 24 | ``h = g ∘ f`` implies ``h(x) = g(f(x))``. 25 | 26 | In Python, ``h = compose(g, f)``. 27 | 28 | >>> import textwrap 29 | >>> expected = str.strip(textwrap.dedent(compose.__doc__)) 30 | >>> strip_and_dedent = compose(str.strip, textwrap.dedent) 31 | >>> strip_and_dedent(compose.__doc__) == expected 32 | True 33 | 34 | Compose also allows the innermost function to take arbitrary arguments. 35 | 36 | >>> round_three = lambda x: round(x, ndigits=3) 37 | >>> f = compose(round_three, int.__truediv__) 38 | >>> [f(3*x, x+1) for x in range(1,10)] 39 | [1.5, 2.0, 2.25, 2.4, 2.5, 2.571, 2.625, 2.667, 2.7] 40 | """ 41 | 42 | def compose_two(f1, f2): 43 | return lambda *args, **kwargs: f1(f2(*args, **kwargs)) 44 | 45 | return functools.reduce(compose_two, funcs) 46 | 47 | 48 | def once(func): 49 | """ 50 | Decorate func so it's only ever called the first time. 51 | 52 | This decorator can ensure that an expensive or non-idempotent function 53 | will not be expensive on subsequent calls and is idempotent. 54 | 55 | >>> add_three = once(lambda a: a+3) 56 | >>> add_three(3) 57 | 6 58 | >>> add_three(9) 59 | 6 60 | >>> add_three('12') 61 | 6 62 | 63 | To reset the stored value, simply clear the property ``saved_result``. 64 | 65 | >>> del add_three.saved_result 66 | >>> add_three(9) 67 | 12 68 | >>> add_three(8) 69 | 12 70 | 71 | Or invoke 'reset()' on it. 72 | 73 | >>> add_three.reset() 74 | >>> add_three(-3) 75 | 0 76 | >>> add_three(0) 77 | 0 78 | """ 79 | 80 | @functools.wraps(func) 81 | def wrapper(*args, **kwargs): 82 | if not hasattr(wrapper, 'saved_result'): 83 | wrapper.saved_result = func(*args, **kwargs) 84 | return wrapper.saved_result 85 | 86 | wrapper.reset = lambda: vars(wrapper).__delitem__('saved_result') 87 | return wrapper 88 | 89 | 90 | def method_cache(method, cache_wrapper=functools.lru_cache()): 91 | """ 92 | Wrap lru_cache to support storing the cache data in the object instances. 93 | 94 | Abstracts the common paradigm where the method explicitly saves an 95 | underscore-prefixed protected property on first call and returns that 96 | subsequently. 97 | 98 | >>> class MyClass: 99 | ... calls = 0 100 | ... 101 | ... @method_cache 102 | ... def method(self, value): 103 | ... self.calls += 1 104 | ... return value 105 | 106 | >>> a = MyClass() 107 | >>> a.method(3) 108 | 3 109 | >>> for x in range(75): 110 | ... res = a.method(x) 111 | >>> a.calls 112 | 75 113 | 114 | Note that the apparent behavior will be exactly like that of lru_cache 115 | except that the cache is stored on each instance, so values in one 116 | instance will not flush values from another, and when an instance is 117 | deleted, so are the cached values for that instance. 118 | 119 | >>> b = MyClass() 120 | >>> for x in range(35): 121 | ... res = b.method(x) 122 | >>> b.calls 123 | 35 124 | >>> a.method(0) 125 | 0 126 | >>> a.calls 127 | 75 128 | 129 | Note that if method had been decorated with ``functools.lru_cache()``, 130 | a.calls would have been 76 (due to the cached value of 0 having been 131 | flushed by the 'b' instance). 132 | 133 | Clear the cache with ``.cache_clear()`` 134 | 135 | >>> a.method.cache_clear() 136 | 137 | Same for a method that hasn't yet been called. 138 | 139 | >>> c = MyClass() 140 | >>> c.method.cache_clear() 141 | 142 | Another cache wrapper may be supplied: 143 | 144 | >>> cache = functools.lru_cache(maxsize=2) 145 | >>> MyClass.method2 = method_cache(lambda self: 3, cache_wrapper=cache) 146 | >>> a = MyClass() 147 | >>> a.method2() 148 | 3 149 | 150 | Caution - do not subsequently wrap the method with another decorator, such 151 | as ``@property``, which changes the semantics of the function. 152 | 153 | See also 154 | http://code.activestate.com/recipes/577452-a-memoize-decorator-for-instance-methods/ 155 | for another implementation and additional justification. 156 | """ 157 | 158 | def wrapper(self, *args, **kwargs): 159 | # it's the first call, replace the method with a cached, bound method 160 | bound_method = types.MethodType(method, self) 161 | cached_method = cache_wrapper(bound_method) 162 | setattr(self, method.__name__, cached_method) 163 | return cached_method(*args, **kwargs) 164 | 165 | # Support cache clear even before cache has been created. 166 | wrapper.cache_clear = lambda: None 167 | 168 | return _special_method_cache(method, cache_wrapper) or wrapper 169 | 170 | 171 | def _special_method_cache(method, cache_wrapper): 172 | """ 173 | Because Python treats special methods differently, it's not 174 | possible to use instance attributes to implement the cached 175 | methods. 176 | 177 | Instead, install the wrapper method under a different name 178 | and return a simple proxy to that wrapper. 179 | 180 | https://github.com/jaraco/jaraco.functools/issues/5 181 | """ 182 | name = method.__name__ 183 | special_names = '__getattr__', '__getitem__' 184 | 185 | if name not in special_names: 186 | return None 187 | 188 | wrapper_name = '__cached' + name 189 | 190 | def proxy(self, /, *args, **kwargs): 191 | if wrapper_name not in vars(self): 192 | bound = types.MethodType(method, self) 193 | cache = cache_wrapper(bound) 194 | setattr(self, wrapper_name, cache) 195 | else: 196 | cache = getattr(self, wrapper_name) 197 | return cache(*args, **kwargs) 198 | 199 | return proxy 200 | 201 | 202 | def apply(transform): 203 | """ 204 | Decorate a function with a transform function that is 205 | invoked on results returned from the decorated function. 206 | 207 | >>> @apply(reversed) 208 | ... def get_numbers(start): 209 | ... "doc for get_numbers" 210 | ... return range(start, start+3) 211 | >>> list(get_numbers(4)) 212 | [6, 5, 4] 213 | >>> get_numbers.__doc__ 214 | 'doc for get_numbers' 215 | """ 216 | 217 | def wrap(func): 218 | return functools.wraps(func)(compose(transform, func)) 219 | 220 | return wrap 221 | 222 | 223 | def result_invoke(action): 224 | r""" 225 | Decorate a function with an action function that is 226 | invoked on the results returned from the decorated 227 | function (for its side effect), then return the original 228 | result. 229 | 230 | >>> @result_invoke(print) 231 | ... def add_two(a, b): 232 | ... return a + b 233 | >>> x = add_two(2, 3) 234 | 5 235 | >>> x 236 | 5 237 | """ 238 | 239 | def wrap(func): 240 | @functools.wraps(func) 241 | def wrapper(*args, **kwargs): 242 | result = func(*args, **kwargs) 243 | action(result) 244 | return result 245 | 246 | return wrapper 247 | 248 | return wrap 249 | 250 | 251 | def invoke(f, /, *args, **kwargs): 252 | """ 253 | Call a function for its side effect after initialization. 254 | 255 | The benefit of using the decorator instead of simply invoking a function 256 | after defining it is that it makes explicit the author's intent for the 257 | function to be called immediately. Whereas if one simply calls the 258 | function immediately, it's less obvious if that was intentional or 259 | incidental. It also avoids repeating the name - the two actions, defining 260 | the function and calling it immediately are modeled separately, but linked 261 | by the decorator construct. 262 | 263 | The benefit of having a function construct (opposed to just invoking some 264 | behavior inline) is to serve as a scope in which the behavior occurs. It 265 | avoids polluting the global namespace with local variables, provides an 266 | anchor on which to attach documentation (docstring), keeps the behavior 267 | logically separated (instead of conceptually separated or not separated at 268 | all), and provides potential to re-use the behavior for testing or other 269 | purposes. 270 | 271 | This function is named as a pithy way to communicate, "call this function 272 | primarily for its side effect", or "while defining this function, also 273 | take it aside and call it". It exists because there's no Python construct 274 | for "define and call" (nor should there be, as decorators serve this need 275 | just fine). The behavior happens immediately and synchronously. 276 | 277 | >>> @invoke 278 | ... def func(): print("called") 279 | called 280 | >>> func() 281 | called 282 | 283 | Use functools.partial to pass parameters to the initial call 284 | 285 | >>> @functools.partial(invoke, name='bingo') 286 | ... def func(name): print('called with', name) 287 | called with bingo 288 | """ 289 | f(*args, **kwargs) 290 | return f 291 | 292 | 293 | class Throttler: 294 | """Rate-limit a function (or other callable).""" 295 | 296 | def __init__(self, func, max_rate=float('Inf')): 297 | if isinstance(func, Throttler): 298 | func = func.func 299 | self.func = func 300 | self.max_rate = max_rate 301 | self.reset() 302 | 303 | def reset(self): 304 | self.last_called = 0 305 | 306 | def __call__(self, *args, **kwargs): 307 | self._wait() 308 | return self.func(*args, **kwargs) 309 | 310 | def _wait(self): 311 | """Ensure at least 1/max_rate seconds from last call.""" 312 | elapsed = time.time() - self.last_called 313 | must_wait = 1 / self.max_rate - elapsed 314 | time.sleep(max(0, must_wait)) 315 | self.last_called = time.time() 316 | 317 | def __get__(self, obj, owner=None): 318 | return first_invoke(self._wait, functools.partial(self.func, obj)) 319 | 320 | 321 | def first_invoke(func1, func2): 322 | """ 323 | Return a function that when invoked will invoke func1 without 324 | any parameters (for its side effect) and then invoke func2 325 | with whatever parameters were passed, returning its result. 326 | """ 327 | 328 | def wrapper(*args, **kwargs): 329 | func1() 330 | return func2(*args, **kwargs) 331 | 332 | return wrapper 333 | 334 | 335 | method_caller = first_invoke( 336 | lambda: warnings.warn( 337 | '`jaraco.functools.method_caller` is deprecated, ' 338 | 'use `operator.methodcaller` instead', 339 | DeprecationWarning, 340 | stacklevel=3, 341 | ), 342 | operator.methodcaller, 343 | ) 344 | 345 | 346 | def retry_call(func, cleanup=lambda: None, retries=0, trap=()): 347 | """ 348 | Given a callable func, trap the indicated exceptions 349 | for up to 'retries' times, invoking cleanup on the 350 | exception. On the final attempt, allow any exceptions 351 | to propagate. 352 | """ 353 | attempts = itertools.count() if retries == float('inf') else range(retries) 354 | for _ in attempts: 355 | try: 356 | return func() 357 | except trap: 358 | cleanup() 359 | 360 | return func() 361 | 362 | 363 | def retry(*r_args, **r_kwargs): 364 | """ 365 | Decorator wrapper for retry_call. Accepts arguments to retry_call 366 | except func and then returns a decorator for the decorated function. 367 | 368 | Ex: 369 | 370 | >>> @retry(retries=3) 371 | ... def my_func(a, b): 372 | ... "this is my funk" 373 | ... print(a, b) 374 | >>> my_func.__doc__ 375 | 'this is my funk' 376 | """ 377 | 378 | def decorate(func): 379 | @functools.wraps(func) 380 | def wrapper(*f_args, **f_kwargs): 381 | bound = functools.partial(func, *f_args, **f_kwargs) 382 | return retry_call(bound, *r_args, **r_kwargs) 383 | 384 | return wrapper 385 | 386 | return decorate 387 | 388 | 389 | def print_yielded(func): 390 | """ 391 | Convert a generator into a function that prints all yielded elements. 392 | 393 | >>> @print_yielded 394 | ... def x(): 395 | ... yield 3; yield None 396 | >>> x() 397 | 3 398 | None 399 | """ 400 | print_all = functools.partial(map, print) 401 | print_results = compose(more_itertools.consume, print_all, func) 402 | return functools.wraps(func)(print_results) 403 | 404 | 405 | def pass_none(func): 406 | """ 407 | Wrap func so it's not called if its first param is None. 408 | 409 | >>> print_text = pass_none(print) 410 | >>> print_text('text') 411 | text 412 | >>> print_text(None) 413 | """ 414 | 415 | @functools.wraps(func) 416 | def wrapper(param, /, *args, **kwargs): 417 | if param is not None: 418 | return func(param, *args, **kwargs) 419 | return None 420 | 421 | return wrapper 422 | 423 | 424 | def assign_params(func, namespace): 425 | """ 426 | Assign parameters from namespace where func solicits. 427 | 428 | >>> def func(x, y=3): 429 | ... print(x, y) 430 | >>> assigned = assign_params(func, dict(x=2, z=4)) 431 | >>> assigned() 432 | 2 3 433 | 434 | The usual errors are raised if a function doesn't receive 435 | its required parameters: 436 | 437 | >>> assigned = assign_params(func, dict(y=3, z=4)) 438 | >>> assigned() 439 | Traceback (most recent call last): 440 | TypeError: func() ...argument... 441 | 442 | It even works on methods: 443 | 444 | >>> class Handler: 445 | ... def meth(self, arg): 446 | ... print(arg) 447 | >>> assign_params(Handler().meth, dict(arg='crystal', foo='clear'))() 448 | crystal 449 | """ 450 | sig = inspect.signature(func) 451 | params = sig.parameters.keys() 452 | call_ns = {k: namespace[k] for k in params if k in namespace} 453 | return functools.partial(func, **call_ns) 454 | 455 | 456 | def save_method_args(method): 457 | """ 458 | Wrap a method such that when it is called, the args and kwargs are 459 | saved on the method. 460 | 461 | >>> class MyClass: 462 | ... @save_method_args 463 | ... def method(self, a, b): 464 | ... print(a, b) 465 | >>> my_ob = MyClass() 466 | >>> my_ob.method(1, 2) 467 | 1 2 468 | >>> my_ob._saved_method.args 469 | (1, 2) 470 | >>> my_ob._saved_method.kwargs 471 | {} 472 | >>> my_ob.method(a=3, b='foo') 473 | 3 foo 474 | >>> my_ob._saved_method.args 475 | () 476 | >>> my_ob._saved_method.kwargs == dict(a=3, b='foo') 477 | True 478 | 479 | The arguments are stored on the instance, allowing for 480 | different instance to save different args. 481 | 482 | >>> your_ob = MyClass() 483 | >>> your_ob.method({str('x'): 3}, b=[4]) 484 | {'x': 3} [4] 485 | >>> your_ob._saved_method.args 486 | ({'x': 3},) 487 | >>> my_ob._saved_method.args 488 | () 489 | """ 490 | args_and_kwargs = collections.namedtuple('args_and_kwargs', 'args kwargs') # noqa: PYI024 # Internal; stubs used for typing 491 | 492 | @functools.wraps(method) 493 | def wrapper(self, /, *args, **kwargs): 494 | attr_name = '_saved_' + method.__name__ 495 | attr = args_and_kwargs(args, kwargs) 496 | setattr(self, attr_name, attr) 497 | return method(self, *args, **kwargs) 498 | 499 | return wrapper 500 | 501 | 502 | def except_(*exceptions, replace=None, use=None): 503 | """ 504 | Replace the indicated exceptions, if raised, with the indicated 505 | literal replacement or evaluated expression (if present). 506 | 507 | >>> safe_int = except_(ValueError)(int) 508 | >>> safe_int('five') 509 | >>> safe_int('5') 510 | 5 511 | 512 | Specify a literal replacement with ``replace``. 513 | 514 | >>> safe_int_r = except_(ValueError, replace=0)(int) 515 | >>> safe_int_r('five') 516 | 0 517 | 518 | Provide an expression to ``use`` to pass through particular parameters. 519 | 520 | >>> safe_int_pt = except_(ValueError, use='args[0]')(int) 521 | >>> safe_int_pt('five') 522 | 'five' 523 | 524 | """ 525 | 526 | def decorate(func): 527 | @functools.wraps(func) 528 | def wrapper(*args, **kwargs): 529 | try: 530 | return func(*args, **kwargs) 531 | except exceptions: 532 | try: 533 | return eval(use) 534 | except TypeError: 535 | return replace 536 | 537 | return wrapper 538 | 539 | return decorate 540 | 541 | 542 | def identity(x): 543 | """ 544 | Return the argument. 545 | 546 | >>> o = object() 547 | >>> identity(o) is o 548 | True 549 | """ 550 | return x 551 | 552 | 553 | def bypass_when(check, *, _op=identity): 554 | """ 555 | Decorate a function to return its parameter when ``check``. 556 | 557 | >>> bypassed = [] # False 558 | 559 | >>> @bypass_when(bypassed) 560 | ... def double(x): 561 | ... return x * 2 562 | >>> double(2) 563 | 4 564 | >>> bypassed[:] = [object()] # True 565 | >>> double(2) 566 | 2 567 | """ 568 | 569 | def decorate(func): 570 | @functools.wraps(func) 571 | def wrapper(param, /): 572 | return param if _op(check) else func(param) 573 | 574 | return wrapper 575 | 576 | return decorate 577 | 578 | 579 | def bypass_unless(check): 580 | """ 581 | Decorate a function to return its parameter unless ``check``. 582 | 583 | >>> enabled = [object()] # True 584 | 585 | >>> @bypass_unless(enabled) 586 | ... def double(x): 587 | ... return x * 2 588 | >>> double(2) 589 | 4 590 | >>> del enabled[:] # False 591 | >>> double(2) 592 | 2 593 | """ 594 | return bypass_when(check, _op=operator.not_) 595 | 596 | 597 | @functools.singledispatch 598 | def _splat_inner(args, func): 599 | """Splat args to func.""" 600 | return func(*args) 601 | 602 | 603 | @_splat_inner.register 604 | def _(args: collections.abc.Mapping, func): 605 | """Splat kargs to func as kwargs.""" 606 | return func(**args) 607 | 608 | 609 | def splat(func): 610 | """ 611 | Wrap func to expect its parameters to be passed positionally in a tuple. 612 | 613 | Has a similar effect to that of ``itertools.starmap`` over 614 | simple ``map``. 615 | 616 | >>> pairs = [(-1, 1), (0, 2)] 617 | >>> more_itertools.consume(itertools.starmap(print, pairs)) 618 | -1 1 619 | 0 2 620 | >>> more_itertools.consume(map(splat(print), pairs)) 621 | -1 1 622 | 0 2 623 | 624 | The approach generalizes to other iterators that don't have a "star" 625 | equivalent, such as a "starfilter". 626 | 627 | >>> list(filter(splat(operator.add), pairs)) 628 | [(0, 2)] 629 | 630 | Splat also accepts a mapping argument. 631 | 632 | >>> def is_nice(msg, code): 633 | ... return "smile" in msg or code == 0 634 | >>> msgs = [ 635 | ... dict(msg='smile!', code=20), 636 | ... dict(msg='error :(', code=1), 637 | ... dict(msg='unknown', code=0), 638 | ... ] 639 | >>> for msg in filter(splat(is_nice), msgs): 640 | ... print(msg) 641 | {'msg': 'smile!', 'code': 20} 642 | {'msg': 'unknown', 'code': 0} 643 | """ 644 | return functools.wraps(func)(functools.partial(_splat_inner, func=func)) 645 | 646 | 647 | _T = TypeVar('_T') 648 | 649 | 650 | def chainable(method: Callable[[_T, ...], None]) -> Callable[[_T, ...], _T]: 651 | """ 652 | Wrap an instance method to always return self. 653 | 654 | 655 | >>> class Dingus: 656 | ... @chainable 657 | ... def set_attr(self, name, val): 658 | ... setattr(self, name, val) 659 | >>> d = Dingus().set_attr('a', 'eh!') 660 | >>> d.a 661 | 'eh!' 662 | >>> d2 = Dingus().set_attr('a', 'eh!').set_attr('b', 'bee!') 663 | >>> d2.a + d2.b 664 | 'eh!bee!' 665 | 666 | Enforces that the return value is null. 667 | 668 | >>> class BorkedDingus: 669 | ... @chainable 670 | ... def set_attr(self, name, val): 671 | ... setattr(self, name, val) 672 | ... return len(name) 673 | >>> BorkedDingus().set_attr('a', 'eh!') 674 | Traceback (most recent call last): 675 | ... 676 | AssertionError 677 | """ 678 | 679 | @functools.wraps(method) 680 | def wrapper(self, *args, **kwargs): 681 | assert method(self, *args, **kwargs) is None 682 | return self 683 | 684 | return wrapper 685 | -------------------------------------------------------------------------------- /jaraco/functools/__init__.pyi: -------------------------------------------------------------------------------- 1 | from collections.abc import Callable, Hashable, Iterator 2 | from functools import partial 3 | from operator import methodcaller 4 | from typing import ( 5 | Any, 6 | Generic, 7 | Protocol, 8 | TypeVar, 9 | overload, 10 | ) 11 | 12 | from typing_extensions import Concatenate, ParamSpec, TypeVarTuple, Unpack 13 | 14 | _P = ParamSpec('_P') 15 | _R = TypeVar('_R') 16 | _T = TypeVar('_T') 17 | _Ts = TypeVarTuple('_Ts') 18 | _R1 = TypeVar('_R1') 19 | _R2 = TypeVar('_R2') 20 | _V = TypeVar('_V') 21 | _S = TypeVar('_S') 22 | _R_co = TypeVar('_R_co', covariant=True) 23 | 24 | class _OnceCallable(Protocol[_P, _R]): 25 | saved_result: _R 26 | reset: Callable[[], None] 27 | def __call__(self, *args: _P.args, **kwargs: _P.kwargs) -> _R: ... 28 | 29 | class _ProxyMethodCacheWrapper(Protocol[_R_co]): 30 | cache_clear: Callable[[], None] 31 | def __call__(self, *args: Hashable, **kwargs: Hashable) -> _R_co: ... 32 | 33 | class _MethodCacheWrapper(Protocol[_R_co]): 34 | def cache_clear(self) -> None: ... 35 | def __call__(self, *args: Hashable, **kwargs: Hashable) -> _R_co: ... 36 | 37 | # `compose()` overloads below will cover most use cases. 38 | 39 | @overload 40 | def compose( 41 | __func1: Callable[[_R], _T], 42 | __func2: Callable[_P, _R], 43 | /, 44 | ) -> Callable[_P, _T]: ... 45 | @overload 46 | def compose( 47 | __func1: Callable[[_R], _T], 48 | __func2: Callable[[_R1], _R], 49 | __func3: Callable[_P, _R1], 50 | /, 51 | ) -> Callable[_P, _T]: ... 52 | @overload 53 | def compose( 54 | __func1: Callable[[_R], _T], 55 | __func2: Callable[[_R2], _R], 56 | __func3: Callable[[_R1], _R2], 57 | __func4: Callable[_P, _R1], 58 | /, 59 | ) -> Callable[_P, _T]: ... 60 | def once(func: Callable[_P, _R]) -> _OnceCallable[_P, _R]: ... 61 | def method_cache( 62 | method: Callable[..., _R], 63 | cache_wrapper: Callable[[Callable[..., _R]], _MethodCacheWrapper[_R]] = ..., 64 | ) -> _MethodCacheWrapper[_R] | _ProxyMethodCacheWrapper[_R]: ... 65 | def apply( 66 | transform: Callable[[_R], _T], 67 | ) -> Callable[[Callable[_P, _R]], Callable[_P, _T]]: ... 68 | def result_invoke( 69 | action: Callable[[_R], Any], 70 | ) -> Callable[[Callable[_P, _R]], Callable[_P, _R]]: ... 71 | def invoke( 72 | f: Callable[_P, _R], /, *args: _P.args, **kwargs: _P.kwargs 73 | ) -> Callable[_P, _R]: ... 74 | 75 | class Throttler(Generic[_R]): 76 | last_called: float 77 | func: Callable[..., _R] 78 | max_rate: float 79 | def __init__( 80 | self, func: Callable[..., _R] | Throttler[_R], max_rate: float = ... 81 | ) -> None: ... 82 | def reset(self) -> None: ... 83 | def __call__(self, *args: Any, **kwargs: Any) -> _R: ... 84 | def __get__(self, obj: Any, owner: type[Any] | None = ...) -> Callable[..., _R]: ... 85 | 86 | def first_invoke( 87 | func1: Callable[..., Any], func2: Callable[_P, _R] 88 | ) -> Callable[_P, _R]: ... 89 | 90 | method_caller: Callable[..., methodcaller] 91 | 92 | def retry_call( 93 | func: Callable[..., _R], 94 | cleanup: Callable[..., None] = ..., 95 | retries: float = ..., 96 | trap: type[BaseException] | tuple[type[BaseException], ...] = ..., 97 | ) -> _R: ... 98 | def retry( 99 | cleanup: Callable[..., None] = ..., 100 | retries: float = ..., 101 | trap: type[BaseException] | tuple[type[BaseException], ...] = ..., 102 | ) -> Callable[[Callable[..., _R]], Callable[..., _R]]: ... 103 | def print_yielded(func: Callable[_P, Iterator[Any]]) -> Callable[_P, None]: ... 104 | def pass_none( 105 | func: Callable[Concatenate[_T, _P], _R], 106 | ) -> Callable[Concatenate[_T, _P], _R]: ... 107 | def assign_params( 108 | func: Callable[..., _R], namespace: dict[str, Any] 109 | ) -> partial[_R]: ... 110 | def save_method_args( 111 | method: Callable[Concatenate[_S, _P], _R], 112 | ) -> Callable[Concatenate[_S, _P], _R]: ... 113 | def except_( 114 | *exceptions: type[BaseException], replace: Any = ..., use: Any = ... 115 | ) -> Callable[[Callable[_P, Any]], Callable[_P, Any]]: ... 116 | def identity(x: _T) -> _T: ... 117 | def bypass_when( 118 | check: _V, *, _op: Callable[[_V], Any] = ... 119 | ) -> Callable[[Callable[[_T], _R]], Callable[[_T], _T | _R]]: ... 120 | def bypass_unless( 121 | check: Any, 122 | ) -> Callable[[Callable[[_T], _R]], Callable[[_T], _T | _R]]: ... 123 | def splat(func: Callable[[Unpack[_Ts]], _R]) -> Callable[[tuple[Unpack[_Ts]]], _R]: ... 124 | -------------------------------------------------------------------------------- /jaraco/functools/py.typed: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jaraco/jaraco.functools/c3a9785c985da57c6f3d40eea483b742a766a659/jaraco/functools/py.typed -------------------------------------------------------------------------------- /mypy.ini: -------------------------------------------------------------------------------- 1 | [mypy] 2 | # Is the project well-typed? 3 | strict = True 4 | 5 | # Early opt-in even when strict = False 6 | warn_unused_ignores = True 7 | warn_redundant_casts = True 8 | enable_error_code = ignore-without-code 9 | 10 | # Support namespace packages per https://github.com/python/mypy/issues/14057 11 | explicit_package_bases = True 12 | 13 | disable_error_code = 14 | # Disable due to many false positives 15 | overload-overlap, 16 | -------------------------------------------------------------------------------- /newsfragments/29.bugfix.rst: -------------------------------------------------------------------------------- 1 | Added missing `splat` in stubs -- by :user:`Avasam` 2 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = [ 3 | "setuptools>=77", 4 | "setuptools_scm[toml]>=3.4.1", 5 | # jaraco/skeleton#174 6 | "coherent.licensed", 7 | ] 8 | build-backend = "setuptools.build_meta" 9 | 10 | [project] 11 | name = "jaraco.functools" 12 | authors = [ 13 | { name = "Jason R. Coombs", email = "jaraco@jaraco.com" }, 14 | ] 15 | description = "Functools like those found in stdlib" 16 | readme = "README.rst" 17 | classifiers = [ 18 | "Development Status :: 5 - Production/Stable", 19 | "Intended Audience :: Developers", 20 | "Programming Language :: Python :: 3", 21 | "Programming Language :: Python :: 3 :: Only", 22 | ] 23 | requires-python = ">=3.9" 24 | license = "MIT" 25 | dependencies = [ 26 | "more_itertools", 27 | ] 28 | dynamic = ["version"] 29 | 30 | [project.urls] 31 | Source = "https://github.com/jaraco/jaraco.functools" 32 | 33 | [project.optional-dependencies] 34 | test = [ 35 | # upstream 36 | "pytest >= 6, != 8.1.*", 37 | 38 | # local 39 | "jaraco.classes", 40 | ] 41 | 42 | doc = [ 43 | # upstream 44 | "sphinx >= 3.5", 45 | "jaraco.packaging >= 9.3", 46 | "rst.linker >= 1.9", 47 | "furo", 48 | "sphinx-lint", 49 | 50 | # tidelift 51 | "jaraco.tidelift >= 1.4", 52 | 53 | # local 54 | ] 55 | 56 | check = [ 57 | "pytest-checkdocs >= 2.4", 58 | "pytest-ruff >= 0.2.1; sys_platform != 'cygwin'", 59 | ] 60 | 61 | cover = [ 62 | "pytest-cov", 63 | ] 64 | 65 | enabler = [ 66 | "pytest-enabler >= 2.2", 67 | ] 68 | 69 | type = [ 70 | # upstream 71 | "pytest-mypy", 72 | 73 | # local 74 | ] 75 | 76 | 77 | [tool.setuptools_scm] 78 | -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | norecursedirs=dist build .tox .eggs 3 | addopts= 4 | --doctest-modules 5 | --import-mode importlib 6 | consider_namespace_packages=true 7 | filterwarnings= 8 | ## upstream 9 | 10 | # Ensure ResourceWarnings are emitted 11 | default::ResourceWarning 12 | 13 | # realpython/pytest-mypy#152 14 | ignore:'encoding' argument not specified::pytest_mypy 15 | 16 | # python/cpython#100750 17 | ignore:'encoding' argument not specified::platform 18 | 19 | # pypa/build#615 20 | ignore:'encoding' argument not specified::build.env 21 | 22 | # dateutil/dateutil#1284 23 | ignore:datetime.datetime.utcfromtimestamp:DeprecationWarning:dateutil.tz.tz 24 | 25 | ## end upstream 26 | -------------------------------------------------------------------------------- /ruff.toml: -------------------------------------------------------------------------------- 1 | [lint] 2 | extend-select = [ 3 | # upstream 4 | 5 | "C901", # complex-structure 6 | "I", # isort 7 | "PERF401", # manual-list-comprehension 8 | 9 | # Ensure modern type annotation syntax and best practices 10 | # Not including those covered by type-checkers or exclusive to Python 3.11+ 11 | "FA", # flake8-future-annotations 12 | "F404", # late-future-import 13 | "PYI", # flake8-pyi 14 | "UP006", # non-pep585-annotation 15 | "UP007", # non-pep604-annotation 16 | "UP010", # unnecessary-future-import 17 | "UP035", # deprecated-import 18 | "UP037", # quoted-annotation 19 | "UP043", # unnecessary-default-type-args 20 | 21 | # local 22 | ] 23 | ignore = [ 24 | # upstream 25 | 26 | # Typeshed rejects complex or non-literal defaults for maintenance and testing reasons, 27 | # irrelevant to this project. 28 | "PYI011", # typed-argument-default-in-stub 29 | # https://docs.astral.sh/ruff/formatter/#conflicting-lint-rules 30 | "W191", 31 | "E111", 32 | "E114", 33 | "E117", 34 | "D206", 35 | "D300", 36 | "Q000", 37 | "Q001", 38 | "Q002", 39 | "Q003", 40 | "COM812", 41 | "COM819", 42 | 43 | # local 44 | ] 45 | 46 | [format] 47 | # Enable preview to get hugged parenthesis unwrapping and other nice surprises 48 | # See https://github.com/jaraco/skeleton/pull/133#issuecomment-2239538373 49 | preview = true 50 | # https://docs.astral.sh/ruff/settings/#format_quote-style 51 | quote-style = "preserve" 52 | -------------------------------------------------------------------------------- /test_functools.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import copy 4 | import functools 5 | import itertools 6 | import os 7 | import platform 8 | import random 9 | import time 10 | from typing import Literal, TypeVar 11 | from unittest import mock 12 | 13 | import pytest 14 | 15 | from jaraco.classes import properties 16 | from jaraco.functools import Throttler, method_cache, retry, retry_call 17 | 18 | _T = TypeVar("_T") 19 | 20 | 21 | class TestThrottler: 22 | @pytest.mark.xfail( 23 | 'GITHUB_ACTIONS' in os.environ and platform.system() in ('Darwin', 'Windows'), 24 | reason="Performance is heavily throttled on Github Actions Mac/Windows runs", 25 | ) 26 | def test_function_throttled(self) -> None: 27 | """ 28 | Ensure the throttler actually throttles calls. 29 | """ 30 | # set up a function to be called 31 | counter = itertools.count() 32 | # set up a version of `next` that is only called 30 times per second 33 | limited_next = Throttler(next, 30) 34 | # for one second, call next as fast as possible 35 | deadline = time.time() + 1 36 | while time.time() < deadline: 37 | limited_next(counter) 38 | # ensure the counter was advanced about 30 times 39 | assert 28 <= next(counter) <= 32 40 | 41 | # ensure that another burst of calls after some idle period will also 42 | # get throttled 43 | time.sleep(1) 44 | deadline = time.time() + 1 45 | counter = itertools.count() 46 | while time.time() < deadline: 47 | limited_next(counter) 48 | assert 28 <= next(counter) <= 32 49 | 50 | def test_reconstruct_unwraps(self) -> None: 51 | """ 52 | The throttler should be re-usable - if one wants to throttle a 53 | function that's aready throttled, the original function should be 54 | used. 55 | """ 56 | wrapped = Throttler(next, 30) 57 | wrapped_again = Throttler(wrapped, 60) 58 | assert wrapped_again.func is next 59 | assert wrapped_again.max_rate == 60 60 | 61 | def test_throttled_method(self) -> None: 62 | class ThrottledMethodClass: 63 | @Throttler 64 | def echo(self, arg: _T) -> _T: 65 | return arg 66 | 67 | tmc = ThrottledMethodClass() 68 | assert tmc.echo('foo') == 'foo' 69 | 70 | 71 | class TestMethodCache: 72 | bad_vers = '(3, 5, 0) <= sys.version_info < (3, 5, 2)' 73 | 74 | @pytest.mark.skipif(bad_vers, reason="https://bugs.python.org/issue25447") 75 | def test_deepcopy(self) -> None: 76 | """ 77 | A deepcopy of an object with a method cache should still 78 | succeed. 79 | """ 80 | 81 | class ClassUnderTest: 82 | calls = 0 83 | 84 | @method_cache 85 | def method(self, value: _T) -> _T: 86 | self.calls += 1 87 | return value 88 | 89 | ob = ClassUnderTest() 90 | copy.deepcopy(ob) 91 | ob.method(1) 92 | copy.deepcopy(ob) 93 | 94 | def test_special_methods(self) -> None: 95 | """ 96 | Test method_cache with __getitem__ and __getattr__. 97 | """ 98 | 99 | class ClassUnderTest: 100 | getitem_calls = 0 101 | getattr_calls = 0 102 | 103 | @method_cache 104 | def __getitem__(self, item: _T) -> _T: 105 | self.getitem_calls += 1 106 | return item 107 | 108 | @method_cache 109 | def __getattr__(self, name: _T) -> _T: 110 | self.getattr_calls += 1 111 | return name 112 | 113 | ob = ClassUnderTest() 114 | 115 | # __getitem__ 116 | ob[1] + ob[1] 117 | assert ob.getitem_calls == 1 118 | 119 | # __getattr__ 120 | ob.one + ob.one # type: ignore[operator] # Using ParamSpec on methods is still limited 121 | assert ob.getattr_calls == 1 122 | 123 | @pytest.mark.xfail(reason="can't replace property with cache; #6") 124 | def test_property(self) -> None: 125 | """ 126 | Can a method_cache decorated method also be a property? 127 | """ 128 | 129 | class ClassUnderTest: 130 | @property 131 | @method_cache 132 | def mything(self) -> float: # pragma: nocover 133 | return random.random() 134 | 135 | ob = ClassUnderTest() 136 | 137 | assert ob.mything == ob.mything 138 | 139 | @pytest.mark.xfail(reason="can't replace property with cache; #6") 140 | def test_non_data_property(self) -> None: 141 | """ 142 | A non-data property also does not work because the property 143 | gets replaced with a method. 144 | """ 145 | 146 | class ClassUnderTest: 147 | @properties.NonDataProperty 148 | @method_cache 149 | def mything(self) -> float: 150 | return random.random() 151 | 152 | ob = ClassUnderTest() 153 | 154 | assert ob.mything == ob.mything 155 | 156 | 157 | class TestRetry: 158 | def attempt(self, arg: mock.Mock | None = None) -> Literal['Success']: 159 | if next(self.fails_left): 160 | raise ValueError("Failed!") 161 | if arg: 162 | arg.touch() 163 | return "Success" 164 | 165 | def set_to_fail(self, times: int) -> None: 166 | self.fails_left = itertools.count(times, -1) 167 | 168 | def test_set_to_fail(self) -> None: 169 | """ 170 | Test this test's internal failure mechanism. 171 | """ 172 | self.set_to_fail(times=2) 173 | with pytest.raises(ValueError): 174 | self.attempt() 175 | with pytest.raises(ValueError): 176 | self.attempt() 177 | assert self.attempt() == 'Success' 178 | 179 | def test_retry_call_succeeds(self) -> None: 180 | self.set_to_fail(times=2) 181 | res = retry_call(self.attempt, retries=2, trap=ValueError) 182 | assert res == "Success" 183 | 184 | def test_retry_call_fails(self) -> None: 185 | """ 186 | Failing more than the number of retries should 187 | raise the underlying error. 188 | """ 189 | self.set_to_fail(times=3) 190 | with pytest.raises(ValueError) as res: 191 | retry_call(self.attempt, retries=2, trap=ValueError) 192 | assert str(res.value) == 'Failed!' 193 | 194 | def test_retry_multiple_exceptions(self) -> None: 195 | self.set_to_fail(times=2) 196 | errors = ValueError, NameError 197 | res = retry_call(self.attempt, retries=2, trap=errors) 198 | assert res == "Success" 199 | 200 | def test_retry_exception_superclass(self) -> None: 201 | self.set_to_fail(times=2) 202 | res = retry_call(self.attempt, retries=2, trap=Exception) 203 | assert res == "Success" 204 | 205 | def test_default_traps_nothing(self) -> None: 206 | self.set_to_fail(times=1) 207 | with pytest.raises(ValueError): 208 | retry_call(self.attempt, retries=1) 209 | 210 | def test_default_does_not_retry(self) -> None: 211 | self.set_to_fail(times=1) 212 | with pytest.raises(ValueError): 213 | retry_call(self.attempt, trap=Exception) 214 | 215 | def test_cleanup_called_on_exception(self) -> None: 216 | calls = random.randint(1, 10) 217 | cleanup = mock.Mock() 218 | self.set_to_fail(times=calls) 219 | retry_call(self.attempt, retries=calls, cleanup=cleanup, trap=Exception) 220 | assert cleanup.call_count == calls 221 | cleanup.assert_called_with() 222 | 223 | def test_infinite_retries(self) -> None: 224 | self.set_to_fail(times=999) 225 | cleanup = mock.Mock() 226 | retry_call(self.attempt, retries=float('inf'), cleanup=cleanup, trap=Exception) 227 | assert cleanup.call_count == 999 228 | 229 | def test_with_arg(self) -> None: 230 | self.set_to_fail(times=0) 231 | arg = mock.Mock() 232 | bound = functools.partial(self.attempt, arg) 233 | res = retry_call(bound) 234 | assert res == 'Success' 235 | assert arg.touch.called 236 | 237 | def test_decorator(self) -> None: 238 | self.set_to_fail(times=1) 239 | attempt = retry(retries=1, trap=Exception)(self.attempt) 240 | res = attempt() 241 | assert res == "Success" 242 | 243 | def test_decorator_with_arg(self) -> None: 244 | self.set_to_fail(times=0) 245 | attempt = retry()(self.attempt) 246 | arg = mock.Mock() 247 | res = attempt(arg) 248 | assert res == 'Success' 249 | assert arg.touch.called 250 | -------------------------------------------------------------------------------- /towncrier.toml: -------------------------------------------------------------------------------- 1 | [tool.towncrier] 2 | title_format = "{version}" 3 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [testenv] 2 | description = perform primary checks (tests, style, types, coverage) 3 | deps = 4 | setenv = 5 | PYTHONWARNDEFAULTENCODING = 1 6 | commands = 7 | pytest {posargs} 8 | usedevelop = True 9 | extras = 10 | test 11 | check 12 | cover 13 | enabler 14 | type 15 | passenv = 16 | GITHUB_ACTIONS 17 | 18 | [testenv:diffcov] 19 | description = run tests and check that diff from main is covered 20 | deps = 21 | {[testenv]deps} 22 | diff-cover 23 | commands = 24 | pytest {posargs} --cov-report xml 25 | diff-cover coverage.xml --compare-branch=origin/main --html-report diffcov.html 26 | diff-cover coverage.xml --compare-branch=origin/main --fail-under=100 27 | 28 | [testenv:docs] 29 | description = build the documentation 30 | extras = 31 | doc 32 | test 33 | changedir = docs 34 | commands = 35 | python -m sphinx -W --keep-going . {toxinidir}/build/html 36 | python -m sphinxlint 37 | 38 | [testenv:finalize] 39 | description = assemble changelog and tag a release 40 | skip_install = True 41 | deps = 42 | towncrier 43 | jaraco.develop >= 7.23 44 | pass_env = * 45 | commands = 46 | python -m jaraco.develop.finalize 47 | 48 | 49 | [testenv:release] 50 | description = publish the package to PyPI and GitHub 51 | skip_install = True 52 | deps = 53 | build 54 | twine>=3 55 | jaraco.develop>=7.1 56 | pass_env = 57 | TWINE_PASSWORD 58 | GITHUB_TOKEN 59 | setenv = 60 | TWINE_USERNAME = {env:TWINE_USERNAME:__token__} 61 | commands = 62 | python -c "import shutil; shutil.rmtree('dist', ignore_errors=True)" 63 | python -m build 64 | python -m twine upload dist/* 65 | python -m jaraco.develop.create-github-release 66 | --------------------------------------------------------------------------------