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__