diff --git a/.gitignore b/.gitignore index f5c97f1c5..a25c8c8ee 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ *.egg-info *.pyc +.DS_Store .cache .coverage* .direnv diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 20c8761e4..954410b7e 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -26,12 +26,12 @@ repos: files: \.py$ - repo: https://github.com/asottile/yesqa - rev: v1.3.0 + rev: v1.4.0 hooks: - id: yesqa - repo: https://github.com/PyCQA/flake8 - rev: 5.0.2 + rev: 5.0.4 hooks: - id: flake8 diff --git a/AUTHORS.rst b/AUTHORS.rst index aa677e81d..0f3845741 100644 --- a/AUTHORS.rst +++ b/AUTHORS.rst @@ -3,7 +3,7 @@ Credits ``attrs`` is written and maintained by `Hynek Schlawack `_. -The development is kindly supported by `Variomedia AG `_. +The development is kindly supported by `Variomedia AG `_ and all my amazing `GitHub Sponsors `_. A full list of contributors can be found in `GitHub's overview `_. diff --git a/MANIFEST.in b/MANIFEST.in deleted file mode 100644 index 6dbe985e7..000000000 --- a/MANIFEST.in +++ /dev/null @@ -1,24 +0,0 @@ -include LICENSE *.rst *.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 docs/docutils.conf -recursive-include docs *.png -recursive-include docs *.svg -recursive-include docs *.py -recursive-include docs *.rst -prune docs/_build - -# Just to keep check-manifest happy; on releases those files are gone. -# Last rule wins! -exclude changelog.d/*.rst -include changelog.d/towncrier_template.rst diff --git a/pyproject.toml b/pyproject.toml index d100c75ac..d3c5054dc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,111 @@ +# 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 = "22.2.0.dev0" +authors = [{ name = "Hynek Schlawack", email = "hs@ox.cx" }] +requires-python = ">=3.6" +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.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", +] +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"', + # 5.0 introduced toml; parallel was broken until 5.0.2 + "coverage[toml]>=5.0.2", + "hypothesis", + "pympler", + # 4.3.0 dropped last use of `convert` + "pytest>=4.3.0", + # Since the mypy error messages keep changing, we have to keep updating this + # pin. + 'mypy>=0.971; python_implementation == "CPython"', + 'pytest-mypy-plugins; python_implementation == "CPython"', +] +tests = ["attrs[tests_no_zope]", "zope.interface"] +docs = ["furo", "sphinx", "zope.interface", "sphinx-notfound-page"] +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/x-rst" + +[[tool.hatch.metadata.hooks.fancy-pypi-readme.fragments]] +text = """ +.. image:: https://www.attrs.org/en/stable/_static/attrs_logo.png + :alt: attrs logo + :align: center +""" + +[[tool.hatch.metadata.hooks.fancy-pypi-readme.fragments]] +path = "README.rst" +start-after = ".. teaser-begin" + +[[tool.hatch.metadata.hooks.fancy-pypi-readme.fragments]] +text = """ + + +Release Information +=================== + + +""" + +[[tool.hatch.metadata.hooks.fancy-pypi-readme.fragments]] +path = "CHANGELOG.rst" +pattern = ".. towncrier release notes start\n\n(.*?)\n----\n" + +[[tool.hatch.metadata.hooks.fancy-pypi-readme.fragments]] +text = """ + +`Full changelog `_ + + + +""" + + +[[tool.hatch.metadata.hooks.fancy-pypi-readme.fragments]] +path = "AUTHORS.rst" [tool.coverage.run] @@ -40,32 +145,32 @@ profile = "attrs" [tool.towncrier] - package = "attr" - package_dir = "src" - filename = "CHANGELOG.rst" - template = "changelog.d/towncrier_template.rst" - issue_format = "`#{issue} `_" - directory = "changelog.d" - title_format = "{version} ({project_date})" - underlines = ["-", "^"] - - [[tool.towncrier.section]] - path = "" - - [[tool.towncrier.type]] - directory = "breaking" - name = "Backwards-incompatible Changes" - showcontent = true - - [[tool.towncrier.type]] - directory = "deprecation" - name = "Deprecations" - showcontent = true - - [[tool.towncrier.type]] - directory = "change" - name = "Changes" - showcontent = true +package = "attr" +package_dir = "src" +filename = "CHANGELOG.rst" +template = "changelog.d/towncrier_template.rst" +issue_format = "`#{issue} `_" +directory = "changelog.d" +title_format = "{version} ({project_date})" +underlines = ["-", "^"] + +[[tool.towncrier.section]] +path = "" + +[[tool.towncrier.type]] +directory = "breaking" +name = "Backwards-incompatible Changes" +showcontent = true + +[[tool.towncrier.type]] +directory = "deprecation" +name = "Deprecations" +showcontent = true + +[[tool.towncrier.type]] +directory = "change" +name = "Changes" +showcontent = true [tool.mypy] diff --git a/setup.py b/setup.py deleted file mode 100644 index 874fe757c..000000000 --- a/setup.py +++ /dev/null @@ -1,146 +0,0 @@ -# SPDX-License-Identifier: MIT - -import codecs -import os -import platform -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"] -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", "zope.interface", "sphinx-notfound-page"], - "tests_no_zope": [ - # For regression test to ensure cloudpickle compat doesn't break. - 'cloudpickle; python_implementation == "CPython"', - # 5.0 introduced toml; parallel was broken until 5.0.2 - "coverage[toml]>=5.0.2", - "hypothesis", - "pympler", - "pytest>=4.3.0", # 4.3.0 dropped last use of `convert` - ], -} -if platform.python_implementation() != "PyPy": - EXTRAS_REQUIRE["tests_no_zope"].extend( - ["mypy>=0.900,!=0.940", "pytest-mypy-plugins"] - ) - -EXTRAS_REQUIRE["tests"] = EXTRAS_REQUIRE["tests_no_zope"] + ["zope.interface"] -EXTRAS_REQUIRE["dev"] = ( - EXTRAS_REQUIRE["tests"] + EXTRAS_REQUIRE["docs"] + ["pre-commit"] -) - -############################################################################### - -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.") - - -LOGO = """ -.. image:: https://www.attrs.org/en/stable/_static/attrs_logo.png - :alt: attrs logo - :align: center -""" - -VERSION = find_meta("version") -URL = find_meta("url") -LONG = ( - LOGO - + read("README.rst").split(".. teaser-begin")[1] - + "\n\n" - + "Release Information\n" - + "===================\n\n" - + re.search( - r"(\d+.\d.\d \(.*?\)\r?\n.*?)\r?\n\r?\n\r?\n----\r?\n\r?\n\r?\n", - read("CHANGELOG.rst"), - re.S, - ).group(1) - + "\n\n`Full changelog " - + f"<{URL}en/stable/changelog.html>`_.\n\n" - + read("AUTHORS.rst") -) - - -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/x-rst", - 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, - options={"bdist_wheel": {"universal": "1"}}, - ) diff --git a/src/attr/__init__.py b/src/attr/__init__.py index 92e8920b5..95e81e177 100644 --- a/src/attr/__init__.py +++ b/src/attr/__init__.py @@ -1,6 +1,11 @@ # SPDX-License-Identifier: MIT +""" +Classes Without Boilerplate +""" + from functools import partial +from typing import Callable from . import converters, exceptions, filters, setters, validators from ._cmp import cmp_using @@ -21,18 +26,9 @@ from ._version_info import VersionInfo -__version__ = "22.2.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" @@ -74,3 +70,55 @@ "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 = { + "__version__": "version", + "__version_info__": "version", + "__description__": "summary", + "__uri__": "", + "__url__": "", + "__email__": "", + } + 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 in ("__uri__", "__url__"): + return meta["Project-URL"].split(" ", 1)[-1] + elif name == "__email__": + return meta["Author-email"].split("<", 1)[1].rstrip(">") + elif name == "__version_info__": + return VersionInfo._from_version_string(meta["version"]) + + return meta[dunder_to_metadata[name]] + + return __getattr__ + + +__getattr__ = _make_getattr(__name__) diff --git a/src/attrs/__init__.py b/src/attrs/__init__.py index a704b8b56..10e0605af 100644 --- a/src/attrs/__init__.py +++ b/src/attrs/__init__.py @@ -4,16 +4,7 @@ NOTHING, Attribute, Factory, - __author__, - __copyright__, - __description__, - __doc__, - __email__, - __license__, - __title__, - __url__, - __version__, - __version_info__, + _make_getattr, assoc, cmp_using, define, @@ -68,3 +59,5 @@ "validate", "validators", ] + +__getattr__ = _make_getattr(__name__) diff --git a/conftest.py b/tests/conftest.py similarity index 100% rename from conftest.py rename to tests/conftest.py diff --git a/tests/test_packaging.py b/tests/test_packaging.py new file mode 100644 index 000000000..d2907d59c --- /dev/null +++ b/tests/test_packaging.py @@ -0,0 +1,80 @@ +# 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_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_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_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__