From 08a9db74a485ea06a024925db70aee90635f042b Mon Sep 17 00:00:00 2001 From: Hynek Schlawack Date: Fri, 30 Dec 2022 14:59:29 +0100 Subject: [PATCH] Drop 3.6 & move to Hatch + static metadata Use ubuntu-latest again --- .github/workflows/ci.yml | 3 +- MANIFEST.in | 25 ------ README.md | 2 - pyproject.toml | 114 ++++++++++++++++++++++++++- setup.py | 163 --------------------------------------- src/attr/__init__.py | 91 +++++++++++++++------- src/attr/validators.py | 7 +- src/attrs/__init__.py | 13 +--- tests/test_packaging.py | 136 ++++++++++++++++++++++++++++++++ tox.ini | 37 +++------ 10 files changed, 327 insertions(+), 264 deletions(-) delete mode 100644 MANIFEST.in delete mode 100644 setup.py create mode 100644 tests/test_packaging.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 433af99c5..f356277a1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -22,13 +22,12 @@ permissions: jobs: tests: name: tox on ${{ matrix.python-version }} - runs-on: ubuntu-20.04 + runs-on: ubuntu-latest strategy: fail-fast: false matrix: python-version: - - "3.6" - "3.7" - "3.8" - "3.9" diff --git a/MANIFEST.in b/MANIFEST.in deleted file mode 100644 index f3a96ec5c..000000000 --- a/MANIFEST.in +++ /dev/null @@ -1,25 +0,0 @@ -include LICENSE *.md *.toml *.yml *.yaml *.ini CITATION.cff -graft .github - -# Stubs -recursive-include src *.pyi -recursive-include src py.typed - -# Tests -include tox.ini conftest.py -recursive-include tests *.py -recursive-include tests *.yml - -# Documentation -include docs/Makefile -recursive-include docs *.png -recursive-include docs *.svg -recursive-include docs *.py -recursive-include docs *.rst -recursive-include docs *.md -prune docs/_build - -# Just to keep check-manifest happy; on releases those files are gone. -# Last rule wins! -exclude changelog.d/*.md -include changelog.d/towncrier_template.md.jinja diff --git a/README.md b/README.md index 1c4837ab0..33371ae9c 100644 --- a/README.md +++ b/README.md @@ -7,8 +7,6 @@

- -

Documentation diff --git a/pyproject.toml b/pyproject.toml index 98fa365b6..58e8576fc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,8 +1,118 @@ # SPDX-License-Identifier: MIT [build-system] -requires = ["setuptools>=40.6.0", "wheel"] -build-backend = "setuptools.build_meta" +requires = ["hatchling", "hatch-fancy-pypi-readme"] +build-backend = "hatchling.build" + + +[project] +name = "attrs" +version = "23.1.0.dev0" +authors = [{ name = "Hynek Schlawack", email = "hs@ox.cx" }] +license = { file = "LICENSE" } +requires-python = ">=3.7" +description = "Classes Without Boilerplate" +keywords = ["class", "attribute", "boilerplate"] +classifiers = [ + "Development Status :: 5 - Production/Stable", + "Intended Audience :: Developers", + "Natural Language :: English", + "License :: OSI Approved :: MIT License", + "Operating System :: OS Independent", + "Programming Language :: Python", + "Programming Language :: Python :: 3", + "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 :: Implementation :: CPython", + "Programming Language :: Python :: Implementation :: PyPy", + "Topic :: Software Development :: Libraries :: Python Modules", +] +dependencies = ["importlib_metadata;python_version<'3.8'"] +dynamic = ["readme"] + +[project.optional-dependencies] +tests-no-zope = [ + # For regression test to ensure cloudpickle compat doesn't break. + 'cloudpickle; python_implementation == "CPython"', + "hypothesis", + "pympler", + # 4.3.0 dropped last use of `convert` + "pytest>=4.3.0", + "pytest-xdist[psutil]", + # Since the mypy error messages keep changing, we have to keep updating this + # pin. + 'mypy>=0.971,<0.990; python_implementation == "CPython"', + 'pytest-mypy-plugins; python_implementation == "CPython" and python_version<"3.11"', +] +tests = ["attrs[tests-no-zope]", "zope.interface"] +cov = [ + "attrs[tests]", + # Makes coverage work with pytest-xdist. + "coverage-enable-subprocess", + # Ensure coverage is new enough for `source_pkgs`. + "coverage>=5.3", +] +docs = [ + "furo", + "myst-parser", + "sphinx", + "zope.interface", + "sphinx-notfound-page", + "sphinxcontrib-towncrier", + "towncrier", +] +dev = ["attrs[tests,docs]", "pre-commit"] + +[project.urls] +Documentation = "https://www.attrs.org/" +Changelog = "https://www.attrs.org/en/stable/changelog.html" +"Bug Tracker" = "https://github.com/python-attrs/attrs/issues" +"Source Code" = "https://github.com/python-attrs/attrs" +Funding = "https://github.com/sponsors/hynek" +Tidelift = "https://tidelift.com/subscription/pkg/pypi-attrs?utm_source=pypi-attrs&utm_medium=pypi" +Ko-fi = "https://ko-fi.com/the_hynek" + + +[tool.hatch.build.targets.wheel] +packages = ["src/attr", "src/attrs"] + +[tool.hatch.metadata.hooks.fancy-pypi-readme] +content-type = "text/markdown" + +# PyPI doesn't support the tag. +[[tool.hatch.metadata.hooks.fancy-pypi-readme.fragments]] +text = """

+ + attrs + +

+""" + +[[tool.hatch.metadata.hooks.fancy-pypi-readme.fragments]] +path = "README.md" +start-after = "" + +[[tool.hatch.metadata.hooks.fancy-pypi-readme.fragments]] +text = """ + +## Release Information + +""" + +[[tool.hatch.metadata.hooks.fancy-pypi-readme.fragments]] +path = "CHANGELOG.md" +pattern = "\n(###.+?\n)## " + +[[tool.hatch.metadata.hooks.fancy-pypi-readme.fragments]] +text = """ + +--- + +[Full changelog](https://www.structlog.org/en/stable/changelog.html) +""" [tool.pytest.ini_options] diff --git a/setup.py b/setup.py deleted file mode 100644 index 02782155c..000000000 --- a/setup.py +++ /dev/null @@ -1,163 +0,0 @@ -# SPDX-License-Identifier: MIT - -import codecs -import os -import re - -from setuptools import find_packages, setup - - -############################################################################### - -NAME = "attrs" -PACKAGES = find_packages(where="src") -META_PATH = os.path.join("src", "attr", "__init__.py") -KEYWORDS = ["class", "attribute", "boilerplate", "dataclass"] -PROJECT_URLS = { - "Documentation": "https://www.attrs.org/", - "Changelog": "https://www.attrs.org/en/stable/changelog.html", - "Bug Tracker": "https://github.com/python-attrs/attrs/issues", - "Source Code": "https://github.com/python-attrs/attrs", - "Funding": "https://github.com/sponsors/hynek", - "Tidelift": "https://tidelift.com/subscription/pkg/pypi-attrs?" - "utm_source=pypi-attrs&utm_medium=pypi", - "Ko-fi": "https://ko-fi.com/the_hynek", -} -CLASSIFIERS = [ - "Development Status :: 5 - Production/Stable", - "Intended Audience :: Developers", - "Natural Language :: English", - "License :: OSI Approved :: MIT License", - "Operating System :: OS Independent", - "Programming Language :: Python", - "Programming Language :: Python :: 3", - "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 :: Implementation :: CPython", - "Programming Language :: Python :: Implementation :: PyPy", - "Topic :: Software Development :: Libraries :: Python Modules", -] -INSTALL_REQUIRES = [] -EXTRAS_REQUIRE = { - "docs": [ - "furo", - "sphinx", - "myst-parser", - "zope.interface", - "sphinx-notfound-page", - "sphinxcontrib-towncrier", - "towncrier", - ], - "tests-no-zope": [ - # For regression test to ensure cloudpickle compat doesn't break. - 'cloudpickle; python_implementation == "CPython"', - "hypothesis", - "pympler", - # 4.3.0 dropped last use of `convert` - "pytest>=4.3.0", - # psutil extra is needed for correct core count detection. - "pytest-xdist[psutil]", - # Since the mypy error messages keep changing, we have to keep updating - # this pin. - "mypy>=0.971,<0.990; python_implementation == 'CPython'", - "pytest-mypy-plugins; python_implementation == 'CPython' and " - "python_version<'3.11'", - ], - "tests": [ - "attrs[tests-no-zope]", - "zope.interface", - ], - "cov": [ - "attrs[tests]", - "coverage-enable-subprocess", - # Ensure coverage is new enough for `source_pkgs`. - "coverage[toml]>=5.3", - ], - "dev": ["attrs[tests,docs]"], -} -# Don't break Paul unnecessarily just yet. C.f. #685 -EXTRAS_REQUIRE["tests_no_zope"] = EXTRAS_REQUIRE["tests-no-zope"] - - -############################################################################### - -HERE = os.path.abspath(os.path.dirname(__file__)) - - -def read(*parts): - """ - Build an absolute path from *parts* and return the contents of the - resulting file. Assume UTF-8 encoding. - """ - with codecs.open(os.path.join(HERE, *parts), "rb", "utf-8") as f: - return f.read() - - -META_FILE = read(META_PATH) - - -def find_meta(meta): - """ - Extract __*meta*__ from META_FILE. - """ - meta_match = re.search( - rf"^__{meta}__ = ['\"]([^'\"]*)['\"]", META_FILE, re.M - ) - if meta_match: - return meta_match.group(1) - raise RuntimeError(f"Unable to find __{meta}__ string.") - - -VERSION = find_meta("version") -URL = find_meta("url") - -# PyPI doesn't support the tag. -LOGO = """

- - attrs - -

-""" # noqa - -LONG = ( - LOGO - + read("README.md").split("", 1)[1] - + "\n\n## Changes in This Release\n" - + read("CHANGELOG.md") - .split("towncrier release notes start -->", 1)[1] - .strip() - .split("\n## ", 1)[0] - .strip() - .split("\n", 1)[1] - + "\n\n---\n\n[Full changelog]" - "(https://www.attrs.org/en/stable/changelog.html)\n" -) - -if __name__ == "__main__": - setup( - name=NAME, - description=find_meta("description"), - license=find_meta("license"), - url=URL, - project_urls=PROJECT_URLS, - version=VERSION, - author=find_meta("author"), - author_email=find_meta("email"), - maintainer=find_meta("author"), - maintainer_email=find_meta("email"), - keywords=KEYWORDS, - long_description=LONG, - long_description_content_type="text/markdown", - packages=PACKAGES, - package_dir={"": "src"}, - python_requires=">=3.6", - zip_safe=False, - classifiers=CLASSIFIERS, - install_requires=INSTALL_REQUIRES, - extras_require=EXTRAS_REQUIRE, - include_package_data=True, - ) diff --git a/src/attr/__init__.py b/src/attr/__init__.py index 0eea7a7e3..7cfa792f7 100644 --- a/src/attr/__init__.py +++ b/src/attr/__init__.py @@ -1,9 +1,11 @@ # SPDX-License-Identifier: MIT -import sys -import warnings +""" +Classes Without Boilerplate +""" from functools import partial +from typing import Callable from . import converters, exceptions, filters, setters, validators from ._cmp import cmp_using @@ -24,30 +26,6 @@ from ._version_info import VersionInfo -if sys.version_info < (3, 7): # pragma: no cover - warnings.warn( - "Running attrs on Python 3.6 is deprecated & we intend to drop " - "support soon. If that's a problem for you, please let us know why & " - "we MAY re-evaluate: ", - DeprecationWarning, - ) - -__version__ = "22.3.0.dev0" -__version_info__ = VersionInfo._from_version_string(__version__) - -__title__ = "attrs" -__description__ = "Classes Without Boilerplate" -__url__ = "https://www.attrs.org/" -__uri__ = __url__ -__doc__ = __description__ + " <" + __uri__ + ">" - -__author__ = "Hynek Schlawack" -__email__ = "hs@ox.cx" - -__license__ = "MIT" -__copyright__ = "Copyright (c) 2015 Hynek Schlawack" - - s = attributes = attrs ib = attr = attrib dataclass = partial(attrs, auto_attribs=True) # happy Easter ;) @@ -91,3 +69,64 @@ class AttrsInstance: "validate", "validators", ] + + +def _make_getattr(mod_name: str) -> Callable: + """ + Create a metadata proxy for packaging information that uses *mod_name* in + its warnings and errors. + """ + + def __getattr__(name: str) -> str: + dunder_to_metadata = { + "__title__": "Name", + "__copyright__": "", + "__version__": "version", + "__version_info__": "version", + "__description__": "summary", + "__uri__": "", + "__url__": "", + "__author__": "", + "__email__": "", + "__license__": "license", + } + if name not in dunder_to_metadata.keys(): + raise AttributeError(f"module {mod_name} has no attribute {name}") + + import sys + import warnings + + if sys.version_info < (3, 8): + from importlib_metadata import metadata + else: + from importlib.metadata import metadata + + if name != "__version_info__": + warnings.warn( + f"Accessing {mod_name}.{name} is deprecated and will be " + "removed in a future release. Use importlib.metadata directly " + "to query for attrs's packaging metadata.", + DeprecationWarning, + stacklevel=2, + ) + + meta = metadata("attrs") + if name == "__license__": + return "MIT" + elif name == "__copyright__": + return "Copyright (c) 2015 Hynek Schlawack" + elif name in ("__uri__", "__url__"): + return meta["Project-URL"].split(" ", 1)[-1] + elif name == "__version_info__": + return VersionInfo._from_version_string(meta["version"]) + elif name == "__author__": + return meta["Author-email"].rsplit(" ", 1)[0] + elif name == "__email__": + return meta["Author-email"].rsplit("<", 1)[1][:-1] + + return meta[dunder_to_metadata[name]] + + return __getattr__ + + +__getattr__ = _make_getattr(__name__) diff --git a/src/attr/validators.py b/src/attr/validators.py index 852ae965b..7168d5964 100644 --- a/src/attr/validators.py +++ b/src/attr/validators.py @@ -9,6 +9,7 @@ import re from contextlib import contextmanager +from re import Pattern from ._config import get_run_validators, set_run_validators from ._make import _AndValidator, and_, attrib, attrs @@ -16,12 +17,6 @@ from .exceptions import NotCallableError -try: - Pattern = re.Pattern -except AttributeError: # Python <3.7 lacks a Pattern type. - Pattern = type(re.compile("")) - - __all__ = [ "and_", "deep_iterable", diff --git a/src/attrs/__init__.py b/src/attrs/__init__.py index 81dd6b2f0..0c2481561 100644 --- a/src/attrs/__init__.py +++ b/src/attrs/__init__.py @@ -5,16 +5,7 @@ Attribute, AttrsInstance, Factory, - __author__, - __copyright__, - __description__, - __doc__, - __email__, - __license__, - __title__, - __url__, - __version__, - __version_info__, + _make_getattr, assoc, cmp_using, define, @@ -70,3 +61,5 @@ "validate", "validators", ] + +__getattr__ = _make_getattr(__name__) diff --git a/tests/test_packaging.py b/tests/test_packaging.py new file mode 100644 index 000000000..c197bcc40 --- /dev/null +++ b/tests/test_packaging.py @@ -0,0 +1,136 @@ +# SPDX-License-Identifier: MIT + +import sys + +import pytest + +import attr +import attrs + + +if sys.version_info < (3, 8): + import importlib_metadata as metadata +else: + from importlib import metadata + + +@pytest.fixture(name="mod", params=(attr, attrs)) +def _mod(request): + yield request.param + + +class TestLegacyMetadataHack: + def test_title(self, mod): + """ + __title__ returns attrs. + """ + with pytest.deprecated_call() as ws: + assert "attrs" == mod.__title__ + + assert ( + f"Accessing {mod.__name__}.__title__ is deprecated" + in ws.list[0].message.args[0] + ) + + def test_copyright(self, mod): + """ + __copyright__ returns the correct blurp. + """ + with pytest.deprecated_call() as ws: + assert "Copyright (c) 2015 Hynek Schlawack" == mod.__copyright__ + + assert ( + f"Accessing {mod.__name__}.__copyright__ is deprecated" + in ws.list[0].message.args[0] + ) + + def test_version(self, mod): + """ + __version__ returns the correct version. + """ + with pytest.deprecated_call() as ws: + assert metadata.version("attrs") == mod.__version__ + + assert ( + f"Accessing {mod.__name__}.__version__ is deprecated" + in ws.list[0].message.args[0] + ) + + def test_description(self, mod): + """ + __description__ returns the correct description. + """ + with pytest.deprecated_call() as ws: + assert "Classes Without Boilerplate" == mod.__description__ + + assert ( + f"Accessing {mod.__name__}.__description__ is deprecated" + in ws.list[0].message.args[0] + ) + + @pytest.mark.parametrize("name", ["__uri__", "__url__"]) + def test_uri(self, mod, name): + """ + __uri__ & __url__ returns the correct project URL. + """ + with pytest.deprecated_call() as ws: + assert "https://www.attrs.org/" == getattr(mod, name) + + assert ( + f"Accessing {mod.__name__}.{name} is deprecated" + in ws.list[0].message.args[0] + ) + + def test_author(self, mod): + """ + __author__ returns Hynek. + """ + with pytest.deprecated_call() as ws: + assert "Hynek Schlawack" == mod.__author__ + + assert ( + f"Accessing {mod.__name__}.__author__ is deprecated" + in ws.list[0].message.args[0] + ) + + def test_email(self, mod): + """ + __email__ returns Hynek's email address. + """ + with pytest.deprecated_call() as ws: + assert "hs@ox.cx" == mod.__email__ + + assert ( + f"Accessing {mod.__name__}.__email__ is deprecated" + in ws.list[0].message.args[0] + ) + + def test_license(self, mod): + """ + __license__ returns MIT. + """ + with pytest.deprecated_call() as ws: + assert "MIT" == mod.__license__ + + assert ( + f"Accessing {mod.__name__}.__license__ is deprecated" + in ws.list[0].message.args[0] + ) + + def test_does_not_exist(self, mod): + """ + Asking for unsupported dunders raises an AttributeError. + """ + with pytest.raises( + AttributeError, + match=f"module {mod.__name__} has no attribute __yolo__", + ): + mod.__yolo__ + + def test_version_info(self, recwarn, mod): + """ + ___version_info__ is not deprected, therefore doesn't raise a warning + and parses correctly. + """ + assert isinstance(mod.__version_info__, attr.VersionInfo) + assert [] == recwarn.list diff --git a/tox.ini b/tox.ini index 0a31e9c73..9969b932f 100644 --- a/tox.ini +++ b/tox.ini @@ -1,7 +1,6 @@ # Keep docs in sync with docs env and .readthedocs.yml. [gh-actions] python = - 3.6: py36, mypy 3.7: py37 3.8: py38, changelog 3.9: py39 @@ -12,7 +11,7 @@ python = [tox] -envlist = mypy,pre-commit,py36,py37,py38,py39,py310,py311,py312,pypy3,pyright,manifest,docs,pypi-description,changelog,coverage-report +envlist = mypy,pre-commit,py37,py38,py39,py310,py311,py312,pypy3,pyright,docs,changelog,coverage-report isolated_build = True @@ -24,12 +23,13 @@ commands = sphinx-build -n -T -W -b html -d {envtmpdir}/doctrees docs docs/_build/html sphinx-build -n -T -W -b doctest -d {envtmpdir}/doctrees docs docs/_build/html + [testenv] extras = tests commands = python -m pytest {posargs:-n auto} -[testenv:py36] +[testenv:py37] extras = cov setenv = COVERAGE_PROCESS_START={toxinidir}/pyproject.toml commands = coverage run -m pytest {posargs:-n auto} @@ -39,8 +39,8 @@ commands = coverage run -m pytest {posargs:-n auto} extras = cov setenv = PYTHONWARNINGS=d - {[testenv:py36]setenv} -commands = {[testenv:py36]commands} + {[testenv:py37]setenv} +commands = {[testenv:py37]commands} [testenv:py31{1,2}] @@ -50,14 +50,13 @@ extras = cov install_command = python -m pip install --no-compile {opts} {packages} setenv = PYTHONWARNINGS=d - {[testenv:py36]setenv} -# xdist is currently broken on 3.11rc2 -commands = coverage run -m pytest {posargs} + {[testenv:py37]setenv} +commands = {[testenv:py37]commands} [testenv:coverage-report] -basepython = python3.10 -depends = py36,py310 +basepython = python3.11 +depends = py37,py310,py311 skip_install = true deps = coverage[toml]>=5.3 commands = @@ -72,24 +71,6 @@ passenv = HOMEPATH # needed on Windows commands = pre-commit run --all-files --show-diff-on-failure -[testenv:manifest] -basepython = python3.10 -deps = check-manifest -skip_install = true -commands = check-manifest - - -[testenv:pypi-description] -basepython = python3.8 -skip_install = true -deps = - twine - pip >= 18.0.0 -commands = - pip wheel -w {envtmpdir}/build --no-deps . - twine check {envtmpdir}/build/* - - [testenv:changelog] basepython = python3.8 deps = towncrier