Repository: python-attrs/attrs
Pull request #993 (2022-08-06 04:51:18)

Drop 3.6 & switch to 100% static packaging configuration
Let's try to move into the ✨future✨.

hynek: Looks like the hatch parts will have to wait until we drop 3.6 (currently at almost 8% downloads).
hynek: https://github.com/python-attrs/attrs/pull/993/commits/b7e241d9ec0736c2a74afdc5e533698d2198a214 will probably mean some extra work for @pganssle – sorry!
hynek: oh god, can Python 3.6 do _anything_!?!?!
hynek: To preserve my work, I've made it a draft and will try to keep it around until its time has come…
ofek: > until we drop 3.6 (currently at almost 8% downloads)

What's the threshold to drop EOL versions generally?
hynek: There really isn’t a threshold, but 3.6 is special because it’s the last version without dataclasses. It’s probably just in my head and the result of constant bullying by DC fans, but I have a hard time letting go. I’m thinking to add a deprecation warning for 3.6 and see how many people complain to see how much are active users and how much is just old CIs.
ofek: Okay cool, was just wondering because I saw Black decided to expedite dropping 3.6 by 4 months (in ~10 days) https://github.com/psf/black/issues/3169#issuecomment-1221624251
hynek: yeah, someone may or may not have talked to one of them :) i'm happy to drop 3.6 everywhere – attrs is just special. i've deprecated 3.6 in #1017 – let's see how it goes.
ofek: Needs a rebase btw
hynek: yeah it's annoying…i've screwed up in the beginning I think and have to keep merging main into the branch.

If there's a magic incantation for Git that will take the current diff between main and a branch and make it a single, rebased commit – i'm all ears.
ofek: Merging after the new year?
puremourning: This DeprecationWarning broke my CI: https://github.com/ycm-core/YouCompleteMe/actions/runs/3751229771/

Is that intentional?
auvipy: I don't think dropping support for 3.6 is not drastic as it is EOL already long ago.
puremourning: > I don't think dropping support for 3.6 is not drastic as it is EOL already long ago.

but being forced to because I can no longer make changes because my CI broke, as opposed to a "business" decision? that's the drastic part.
webknjaz: It's not forcing anything, it's just a depreciation warning following the best practices. It's probably turned into an error according to the settings on your side (which is normally considered a best practice too). Handling such cases is your responsibility, it's just an early alert but how you integrated it is on you. If you want more CI stability, pin your test env with pip-tools-generated constraints or something.
webknjaz: > If there's a magic incantation for Git that will take the current diff between main and a branch and make it a single, rebased commit – i'm all ears.

Do you want a single commit or keep individual ones? It shouldn't be hard to do with an interactive rebase.
hynek: > This DeprecationWarning broke my CI: https://github.com/ycm-core/YouCompleteMe/actions/runs/3751229771/
> 
> Is that intentional?
> 
> In terms of Why, well, I guess we could just drop supper for python 3.6 too, but that seems kind of drastic.

I'm sorry for the breakage, but as @webknjaz pointed out it seems like the system is working as intended? Especially given that a warning breaking your CI seems like a conscious decision on your side?

What exactly would you have preferred to happen instead?

hynek: > > If there's a magic incantation for Git that will take the current diff between main and a branch and make it a single, rebased commit – i'm all ears.
> 
> Do you want a single commit or keep individual ones? It shouldn't be hard to do with an interactive rebase.

i just want one big patch, but i don't think that's possible to get there using rebases because there's been force-pushes so there's commits that don't exist.
webknjaz: > i just want one big patch, but i don't think that's possible to get there using rebases

Add `.diff` to the PR URL for this: https://github.com/python-attrs/attrs/pull/993. There's also `.patch` but it shows multiple commits as opposed to that huge diff.
This could be saved into a `.patch` file, branch force-reset to `origin/main` locally and applied with `git am ...`

Another way would be `git rebase --interactive origin/main` locally. It won't list the merge commits by default. You can save the rebase plan as is and see how it goes, or change `pick` to `s` or `f`. It'll probably give you a series of merge conflicts to solve, though, but rerere would make it easier.

Oh, and you could (being on the PR branch) just `git reset --soft origin/main` (would reset your branch to that point, keeping the files on disk as is) locally followed by `git add . && git commit -v` (this would create a commit with all the changes that remain on disk after the soft reset).

> because there's been force-pushes so there's commits that don't exist.

I don't know how you drew this connection, but it hugely misinterprets the nature of Git. Git history is a blockchain — each commit has a parent, and it's sha is directly dependent on that parent and the commit contents. Yes, force-pushing replaced a bunch of commits on remote (GH) but they do exist. Also, the old commits may still in your local repo if they weren't garbage-collected. But you don't really need them for this rebase anyway.

Having merges in your feature-branch complicates the rebase, of course, but nothing's impossible. One thing that could've helped you with repeating conflict resolutions would be `rerere`. If you don't have it enabled, do this as soon as possible — you'll thank yourself later: https://git-scm.com/book/en/v2/Git-Tools-Rerere.
TL;DR it memorizes your manual conflict resolution results (usually the last 50, by default) and re-applies them automatically if they are faced again — this tends to happen a lot when you do rebase-driven topic branch updates.
hynek: @ofek I think I've mostly got it, just two issues / qs:

- I'm a bit surprised that coverage is working despite `coverage-enable-subprocess` being for setuptools and despite complaining about "CoverageWarning: Module attrs was never imported. (module-not-imported)" – I believe you've created something similar for Hatch? How would I use it?
- Is it on purpose that the metadata has no `Author` field and just `Author-email` that (unlike with setuptools) doesn't contain the email address but the name + email and I have to split the string to get it out? This looks very clunky.
ofek: > I believe you've created something similar for Hatch? How would I use it?

https://github.com/ofek/hatch-autorun

> Is it on purpose that the metadata has no `Author` field and just `Author-email`

Yes https://github.com/pypa/packaging.python.org/issues/1134#issuecomment-1231564237
hynek: > > I believe you've created something similar for Hatch? How would I use it?
> 
> https://github.com/ofek/hatch-autorun

Cool, does https://github.com/python-attrs/attrs/pull/993/commits/68ab2e0bbbeca9efa38a92c2646429f781a349ea make sense?
ofek: LGTM!
hynek: Awesome! I'll wait a little bit more if I need to make a bugfix release for 22.2.0 (between the years it's unlikely someone would notice) and merge then.
webknjaz: > and I have to split the string to get it out?

I'm pretty sure that any email parsing library would be able to retrieve that — it's one of the standardized email representations. Stdlib should do too.
hynek: Sure it's an email format (still hilarious that packaging metadata is just an email envelope), but I don't think it's gonna be faster? And since I control the input, that's all that counts. 
auvipy: Just want to know will the CI breaking change in 22.2.0 have any bug fix release? or we have to pin an older version?
hynek: I'm waiting for bug reports to arrive, so far there haven't been any except for a missing `.. module` in docs.

This change also shouldn't be breaking unless you have a pip from 2018 or something.
auvipy: OK. Just FWIW https://github.com/encode/django-rest-framework/pull/8828

Patch:
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 433af99..2f75d61 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"
@@ -133,7 +132,7 @@ jobs:
           python-version: "3.10"
 
       - run: python -m pip install --upgrade wheel tox
-      - run: python -m tox -e docs
+      - run: python -m tox -e docs,changelog
 
   pyright:
     name: Check types using pyright
diff --git a/MANIFEST.in b/MANIFEST.in
deleted file mode 100644
index f3a96ec..0000000
--- 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 1c4837a..33371ae 100644
--- a/README.md
+++ b/README.md
@@ -7,8 +7,6 @@
    </a>
 </p>
 
-<!-- logo-end -->
-
 <p align="center">
    <a href="https://www.attrs.org/en/stable/">
        <img src="https://img.shields.io/badge/Docs-RTD-black" alt="Documentation" />
diff --git a/pyproject.toml b/pyproject.toml
index 98fa365..caa9cc6 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,8 +1,126 @@
 # 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 = "MIT"
+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]",
+    # 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 <picture> tag.
+[[tool.hatch.metadata.hooks.fancy-pypi-readme.fragments]]
+text = """<p align="center">
+  <a href="https://www.attrs.org/">
+    <img src="https://raw.githubusercontent.com/python-attrs/attrs/main/docs/_static/attrs_logo.svg" width="35%" alt="attrs" />
+  </a>
+</p>
+"""
+
+[[tool.hatch.metadata.hooks.fancy-pypi-readme.fragments]]
+path = "README.md"
+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.md"
+pattern = "\n(###.+?\n)## "
+
+[[tool.hatch.metadata.hooks.fancy-pypi-readme.fragments]]
+text = """
+
+---
+
+[Full changelog](https://www.attrs.org/en/stable/changelog.html)
+"""
+
+
+# Make coverage play nicely with pytest-xdist.
+[tool.hatch.build.targets.wheel.hooks.autorun]
+dependencies = ["hatch-autorun"]
+code = """
+import coverage
+coverage.process_startup()
+"""
+enable-by-default = false
 
 
 [tool.pytest.ini_options]
diff --git a/setup.py b/setup.py
deleted file mode 100644
index 0278215..0000000
--- 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 <picture> tag.
-LOGO = """<p align="center">
-  <a href="https://www.attrs.org/">
-    <img src="https://raw.githubusercontent.com/python-attrs/attrs/main/docs/_static/attrs_logo.svg" width="35%" alt="attrs" />
-  </a>
-</p>
-"""  # noqa
-
-LONG = (
-    LOGO
-    + read("README.md").split("<!-- logo-end -->", 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 0eea7a7..7cfa792 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 ._next_gen import define, field, frozen, mutable
 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: <https://github.com/python-attrs/attrs/pull/993>",
-        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 @@ __all__ = [
     "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 8c8ae0b..e8bd3ba 100644
--- a/src/attr/validators.py
+++ b/src/attr/validators.py
@@ -9,6 +9,7 @@ import operator
 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 .converters import default_if_none
 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 81dd6b2..0c24815 100644
--- a/src/attrs/__init__.py
+++ b/src/attrs/__init__.py
@@ -5,16 +5,7 @@ from attr import (
     Attribute,
     AttrsInstance,
     Factory,
-    __author__,
-    __copyright__,
-    __description__,
-    __doc__,
-    __email__,
-    __license__,
-    __title__,
-    __url__,
-    __version__,
-    __version_info__,
+    _make_getattr,
     assoc,
     cmp_using,
     define,
@@ -70,3 +61,5 @@ __all__ = [
     "validate",
     "validators",
 ]
+
+__getattr__ = _make_getattr(__name__)
diff --git a/tox.ini b/tox.ini
index 0a31e9c..44a5551 100644
--- a/tox.ini
+++ b/tox.ini
@@ -1,9 +1,8 @@
 # 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.8: py38
     3.9: py39
     3.10: py310, mypy
     3.11: py311
@@ -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,40 +23,27 @@ 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]
-extras = cov
-setenv = COVERAGE_PROCESS_START={toxinidir}/pyproject.toml
-commands = coverage run -m pytest {posargs:-n auto}
-
-
-[testenv:py310]
-extras = cov
-setenv =
-    PYTHONWARNINGS=d
-    {[testenv:py36]setenv}
-commands = {[testenv:py36]commands}
-
-
-[testenv:py31{1,2}]
+[testenv:py3{7,10,11}]
 extras = cov
 # Python 3.6+ has a number of compile-time warnings on invalid string escapes.
 # PYTHONWARNINGS=d and --no-compile below make them visible during the Tox run.
 install_command = python -m pip install --no-compile {opts} {packages}
 setenv =
+    HATCH_BUILD_HOOK_ENABLE_AUTORUN=1
+    COVERAGE_PROCESS_START={toxinidir}/pyproject.toml
     PYTHONWARNINGS=d
-    {[testenv:py36]setenv}
-# xdist is currently broken on 3.11rc2
-commands = coverage run -m pytest {posargs}
+commands = coverage run -m pytest {posargs:-n auto}
 
 
 [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,26 +58,7 @@ 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
 skip_install = true
 commands = towncrier build --version main --draft


Test patch:
diff --git a/tests/test_packaging.py b/tests/test_packaging.py
new file mode 100644
index 0000000..c197bcc
--- /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