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 @@
-
-
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 = """
+
+
+
+
+"""
+
+[[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 = """
-
-
-
-
-""" # 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