diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 90a59d3..2bebc83 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -19,13 +19,13 @@ jobs: strategy: matrix: - python-version: ["3.6", "3.7", "3.8", "3.9", "pypy3"] + python-version: ["3.10", "3.11", "3.12", "3.13", "3.14", "pypy3"] # Steps represent a sequence of tasks that will be executed as part of the job steps: # Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it - - uses: "actions/checkout@v2" - - uses: "actions/setup-python@v2" + - uses: "actions/checkout@v6" + - uses: "actions/setup-python@v6" with: python-version: "${{ matrix.python-version }}" - name: "Install dependencies" @@ -34,15 +34,16 @@ jobs: python -VV python -m site python -m pip install --upgrade pip setuptools wheel - python -m pip install --upgrade virtualenv tox tox-gh-actions + # FIXME: Make tox.ini compatible with newer versions of tox + python -m pip install --upgrade virtualenv 'tox<4' tox-gh-actions - name: "Run tox targets for ${{ matrix.python-version }}" run: "python -m tox" - name: "Report to coveralls" - # coverage is only created in the py39 environment + # coverage is only created in the py314 environment # --service=github is a workaround for bug # https://github.com/coveralls-clients/coveralls-python/issues/251 - if: "matrix.python-version == '3.9'" + if: "matrix.python-version == '3.14'" run: | pip install coveralls coveralls --service=github diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 99300c9..e18d7ba 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,14 +1,14 @@ repos: - repo: https://github.com/psf/black - rev: 20.8b1 + rev: 26.3.1 hooks: - id: black - repo: https://github.com/PyCQA/flake8 - rev: "3.8.4" + rev: "7.3.0" hooks: - id: flake8 - repo: https://github.com/asottile/pyupgrade - rev: v2.7.4 + rev: v3.21.2 hooks: - id: pyupgrade args: [--py36-plus] diff --git a/CHANGES.txt b/CHANGES.txt index f6eec0c..db19350 100644 --- a/CHANGES.txt +++ b/CHANGES.txt @@ -4,7 +4,19 @@ CHANGES 0.13 (unreleased) ================= -- Drop support for Python below 3.6 +- ``reg.arginfo`` is now implemented manually using + ``inspect.signature``, instead of relying the deprecated + compatibility method in ``inspect``, in order to better + support Python 3.14+. + + It is recommended to migrate from ``reg.arginfo`` to pure + ``inspect.signature``, since the utility this module provides + is now very limited, compared to ``inspect.signature``, since + that already properly handles ``self`` parameters. + +- Added support for Python 3.10, 3.11, 3.12, 3.13 and 3.14 + +- Drop support for Python below 3.10 - Use GitHub Actions for CI diff --git a/develop_requirements.txt b/develop_requirements.txt index f846d38..980b2f2 100644 --- a/develop_requirements.txt +++ b/develop_requirements.txt @@ -1,7 +1,8 @@ # development -e '.[test,coverage,pep8,docs]' pre-commit -tox >= 2.4.1 +# FIXME: Make tox.ini compatible with newer versions of tox +tox >= 2.4.1, < 4 radon # releaser diff --git a/reg/arginfo.py b/reg/arginfo.py index 7368202..2b86c41 100644 --- a/reg/arginfo.py +++ b/reg/arginfo.py @@ -1,4 +1,18 @@ import inspect +import sys + +if sys.version_info < (3, 14): + + def get_signature(callable): + """A compatibility wrapper for `inspect.signature`.""" + return inspect.signature(callable) + +else: + from annotationlib import Format + + def get_signature(callable): + """A compatibility wrapper for `inspect.signature`.""" + return inspect.signature(callable, annotation_format=Format.FORWARDREF) def arginfo(callable): @@ -10,7 +24,10 @@ def arginfo(callable): :func:`inspect.getfullargspec` returns information about the arguments of a function. arginfo also works for classes and instances with a __call__ defined. Unlike getfullargspec, arginfo treats bound methods - like functions, so that the self argument is not reported. + like functions, so that the self argument is not reported. Another + difference is the handling of decorated functions. This will return + the original signature, rather than the signature of the wrapper, if + wrapped via :func:`functools.wraps`. arginfo returns ``None`` if given something that is not callable. @@ -29,22 +46,67 @@ def arginfo(callable): return arginfo._cache[callable.__call__] except (AttributeError, KeyError): pass - func, cache_key, remove_self = get_callable_info(callable) - if func is None: - return None - result = inspect.getfullargspec(func) - if remove_self: - args = result.args[1:] - result = inspect.FullArgSpec( - args, - result.varargs, - result.varkw, - result.defaults, - result.kwonlyargs, - result.kwonlydefaults, - result.annotations, - ) - arginfo._cache[cache_key] = result + + if inspect.isfunction(callable): + cache_key = callable + elif inspect.ismethod(callable): + cache_key = callable + elif inspect.isclass(callable): + cache_key = callable + if callable.__init__ is WRAPPER_DESCRIPTOR: + # Only in this specific case do we replace the callable + # into `inspect.signature` with something else, to ensure + # we don't get a `ValueError` and instead end up with + # an empty signature. + callable = fake_empty_init + else: + # Since arbitrary callable objects may not be hashable + # we instead retrieve their call method, which should be + try: + cache_key = callable.__call__ + except AttributeError: + return None + + signature = get_signature(callable) + args = [] + varargs = None + varkw = None + defaults = [] + kwonlyargs = [] + kwonlydefaults = {} + annotations = {} + for parameter in signature.parameters.values(): + if ( + parameter.kind is parameter.POSITIONAL_OR_KEYWORD + or parameter.kind is parameter.POSITIONAL_ONLY + ): + args.append(parameter.name) + if parameter.default is not parameter.empty: + defaults.append(parameter.default) + elif parameter.kind is parameter.KEYWORD_ONLY: + kwonlyargs.append(parameter.name) + if parameter.default is not parameter.empty: + kwonlydefaults[parameter.name] = parameter.default + elif parameter.kind is parameter.VAR_POSITIONAL: + varargs = parameter.name + elif parameter.kind is parameter.VAR_KEYWORD: + varkw = parameter.name + + if parameter.annotation is not parameter.empty: + annotations[parameter.name] = parameter.annotation + + if signature.return_annotation is not signature.empty: + annotations["return"] = signature.return_annotation + + result = arginfo._cache[cache_key] = inspect.FullArgSpec( + args, + varargs, + varkw, + tuple(defaults) if defaults else None, + kwonlyargs, + kwonlydefaults, + annotations, + ) return result @@ -58,35 +120,6 @@ def is_cached(callable): arginfo.is_cached = is_cached -def get_callable_info(callable): - """Get information about a callable. - - Returns a tuple of: - - * actual function/method that can be inspected with inspect.getfullargspec. - - * cache key to use to cache results. - - * whether to remove self or not. - - Note that in Python 3, __init__ is not a method, but we still - want to remove self from it. - - If not inspectable (None, None, False) is returned. - """ - if inspect.isfunction(callable): - return callable, callable, False - if inspect.ismethod(callable): - return callable, callable, True - if inspect.isclass(callable): - return get_class_init(callable), callable, True - try: - callable = getattr(callable, "__call__") - return callable, callable, True - except AttributeError: - return None, None, False - - def fake_empty_init(): pass # pragma: nocoverage @@ -96,13 +129,3 @@ class Dummy: WRAPPER_DESCRIPTOR = Dummy.__init__ - - -def get_class_init(class_): - func = class_.__init__ - - # If this is a new-style class and there is no __init__ - # defined this is a WRAPPER_DESCRIPTOR. - if func is WRAPPER_DESCRIPTOR: - return fake_empty_init - return func diff --git a/reg/tests/test_docgen.py b/reg/tests/test_docgen.py index 1c53460..6754b05 100644 --- a/reg/tests/test_docgen.py +++ b/reg/tests/test_docgen.py @@ -1,4 +1,5 @@ import pydoc +import sys from sphinx.application import Sphinx from .fixtures.module import Foo, foo @@ -31,12 +32,13 @@ class Foo({builtins}.object) | Data descriptors defined here: | | __dict__ - | dictionary for instance variables (if defined) + | dictionary for instance variables{postamble} | | __weakref__ - | list of weak references to the object (if defined) + | list of weak references to the object{postamble} """.format( - builtins=object.__module__ + builtins=object.__module__, + postamble=" (if defined)" if sys.version_info < (3, 11) else "", ) ) @@ -44,29 +46,23 @@ class Foo({builtins}.object) def test_dispatch_method_help(capsys): pydoc.help(Foo.bar) out, err = capsys.readouterr() - assert ( - rstrip_lines(out) - == """\ + assert rstrip_lines(out) == """\ Help on function bar in module reg.tests.fixtures.module: bar(self, obj) Return the bar of an object. """ - ) def test_dispatch_help(capsys): pydoc.help(foo) out, err = capsys.readouterr() - assert ( - rstrip_lines(out) - == """\ + assert rstrip_lines(out) == """\ Help on function foo in module reg.tests.fixtures.module: foo(obj) return the foo of an object. """ - ) def test_autodoc(tmpdir): @@ -80,9 +76,7 @@ def test_autodoc(tmpdir): # remove it. app = Sphinx(root, root, root + "/build", root, "text", status=None) app.build() - assert ( - tmpdir.join("build/contents.txt").read() - == """\ + assert tmpdir.join("build/contents.txt").read() == """\ Sample module for testing autodoc. class reg.tests.fixtures.module.Foo @@ -101,4 +95,3 @@ class reg.tests.fixtures.module.Foo return the foo of an object. """ - ) diff --git a/setup.py b/setup.py index ab05895..5c21023 100644 --- a/setup.py +++ b/setup.py @@ -23,10 +23,11 @@ "Intended Audience :: Developers", "License :: OSI Approved :: BSD License", "Topic :: Software Development :: Libraries :: Python Modules", - "Programming Language :: Python :: 3.6", - "Programming Language :: Python :: 3.7", - "Programming Language :: Python :: 3.8", - "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", "Programming Language :: Python :: Implementation :: PyPy", "Development Status :: 5 - Production/Stable", ], diff --git a/tox.ini b/tox.ini index fc246bc..4c6f837 100644 --- a/tox.ini +++ b/tox.ini @@ -1,12 +1,11 @@ [tox] -envlist = py36, py37, py38, py39, pypy3, coverage, pre-commit, docs, perf +envlist = py310, py311, py312, py313, py314, pypy3, coverage, pre-commit, docs, perf skipsdist = True skip_missing_interpreters = True [testenv] usedevelop = True extras = test - commands = pytest {posargs} [testenv:coverage] @@ -34,10 +33,11 @@ commands = python {toxinidir}/tox_perf.py [gh-actions] python = - 3.6: py36 - 3.7: py37, perf - 3.8: py38 - 3.9: py39, pre-commit, mypy, coverage + 3.10: py310, perf + 3.11: py311 + 3.12: py312 + 3.13: py313 + 3.14: py314, pre-commit, coverage [flake8] max-line-length = 88