diff --git a/.github/CODE_OF_CONDUCT.md b/.github/CODE_OF_CONDUCT.md new file mode 100644 index 000000000..1d8ad1833 --- /dev/null +++ b/.github/CODE_OF_CONDUCT.md @@ -0,0 +1,133 @@ + +# Contributor Covenant Code of Conduct + +## Our Pledge + +We as members, contributors, and leaders pledge to make participation in our +community a harassment-free experience for everyone, regardless of age, body +size, visible or invisible disability, ethnicity, sex characteristics, gender +identity and expression, level of experience, education, socio-economic status, +nationality, personal appearance, race, caste, color, religion, or sexual +identity and orientation. + +We pledge to act and interact in ways that contribute to an open, welcoming, +diverse, inclusive, and healthy community. + +## Our Standards + +Examples of behavior that contributes to a positive environment for our +community include: + +* Demonstrating empathy and kindness toward other people +* Being respectful of differing opinions, viewpoints, and experiences +* Giving and gracefully accepting constructive feedback +* Accepting responsibility and apologizing to those affected by our mistakes, + and learning from the experience +* Focusing on what is best not just for us as individuals, but for the overall + community + +Examples of unacceptable behavior include: + +* The use of sexualized language or imagery, and sexual attention or advances of + any kind +* Trolling, insulting or derogatory comments, and personal or political attacks +* Public or private harassment +* Publishing others' private information, such as a physical or email address, + without their explicit permission +* Other conduct which could reasonably be considered inappropriate in a + professional setting + +## Enforcement Responsibilities + +Community leaders are responsible for clarifying and enforcing our standards of +acceptable behavior and will take appropriate and fair corrective action in +response to any behavior that they deem inappropriate, threatening, offensive, +or harmful. + +Community leaders have the right and responsibility to remove, edit, or reject +comments, commits, code, wiki edits, issues, and other contributions that are +not aligned to this Code of Conduct, and will communicate reasons for moderation +decisions when appropriate. + +## Scope + +This Code of Conduct applies within all community spaces, and also applies when +an individual is officially representing the community in public spaces. +Examples of representing our community include using an official e-mail address, +posting via an official social media account, or acting as an appointed +representative at an online or offline event. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported to the community leaders responsible for enforcement at +. +All complaints will be reviewed and investigated promptly and fairly. + +All community leaders are obligated to respect the privacy and security of the +reporter of any incident. + +## Enforcement Guidelines + +Community leaders will follow these Community Impact Guidelines in determining +the consequences for any action they deem in violation of this Code of Conduct: + +### 1. Correction + +**Community Impact**: Use of inappropriate language or other behavior deemed +unprofessional or unwelcome in the community. + +**Consequence**: A private, written warning from community leaders, providing +clarity around the nature of the violation and an explanation of why the +behavior was inappropriate. A public apology may be requested. + +### 2. Warning + +**Community Impact**: A violation through a single incident or series of +actions. + +**Consequence**: A warning with consequences for continued behavior. No +interaction with the people involved, including unsolicited interaction with +those enforcing the Code of Conduct, for a specified period of time. This +includes avoiding interactions in community spaces as well as external channels +like social media. Violating these terms may lead to a temporary or permanent +ban. + +### 3. Temporary Ban + +**Community Impact**: A serious violation of community standards, including +sustained inappropriate behavior. + +**Consequence**: A temporary ban from any sort of interaction or public +communication with the community for a specified period of time. No public or +private interaction with the people involved, including unsolicited interaction +with those enforcing the Code of Conduct, is allowed during this period. +Violating these terms may lead to a permanent ban. + +### 4. Permanent Ban + +**Community Impact**: Demonstrating a pattern of violation of community +standards, including sustained inappropriate behavior, harassment of an +individual, or aggression toward or disparagement of classes of individuals. + +**Consequence**: A permanent ban from any sort of public interaction within the +community. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], +version 2.1, available at +[https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1]. + +Community Impact Guidelines were inspired by +[Mozilla's code of conduct enforcement ladder][Mozilla CoC]. + +For answers to common questions about this code of conduct, see the FAQ at +[https://www.contributor-covenant.org/faq][FAQ]. Translations are available at +[https://www.contributor-covenant.org/translations][translations]. + +[homepage]: https://www.contributor-covenant.org +[v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html +[Mozilla CoC]: https://github.com/mozilla/diversity +[FAQ]: https://www.contributor-covenant.org/faq +[translations]: https://www.contributor-covenant.org/translations diff --git a/.github/CODE_OF_CONDUCT.rst b/.github/CODE_OF_CONDUCT.rst deleted file mode 100644 index 56e8914ce..000000000 --- a/.github/CODE_OF_CONDUCT.rst +++ /dev/null @@ -1,55 +0,0 @@ -Contributor Covenant Code of Conduct -==================================== - -Our Pledge ----------- - -In the interest of fostering an open and welcoming environment, we as contributors and maintainers pledge to make participation in our project and our community a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, gender identity and expression, level of experience, nationality, personal appearance, race, religion, or sexual identity and orientation. - -Our Standards -------------- - -Examples of behavior that contributes to creating a positive environment include: - -* Using welcoming and inclusive language -* Being respectful of differing viewpoints and experiences -* Gracefully accepting constructive criticism -* Focusing on what is best for the community -* Showing empathy towards other community members - -Examples of unacceptable behavior by participants include: - -* The use of sexualized language or imagery and unwelcome sexual attention or advances -* Trolling, insulting/derogatory comments, and personal or political attacks -* Public or private harassment -* Publishing others' private information, such as a physical or electronic address, without explicit permission -* Other conduct which could reasonably be considered inappropriate in a professional setting - -Our Responsibilities --------------------- - -Project maintainers are responsible for clarifying the standards of acceptable behavior and are expected to take appropriate and fair corrective action in response to any instances of unacceptable behavior. - -Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, or to ban temporarily or permanently any contributor for other behaviors that they deem inappropriate, threatening, offensive, or harmful. - -Scope ------ - -This Code of Conduct applies both within project spaces and in public spaces when an individual is representing the project or its community. -Examples of representing a project or community include using an official project e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. -Representation of a project may be further defined and clarified by project maintainers. - -Enforcement ------------ - -Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project team at hs@ox.cx. -All complaints will be reviewed and investigated and will result in a response that is deemed necessary and appropriate to the circumstances. -The project team is obligated to maintain confidentiality with regard to the reporter of an incident. -Further details of specific enforcement policies may be posted separately. - -Project maintainers who do not follow or enforce the Code of Conduct in good faith may face temporary or permanent repercussions as determined by other members of the project's leadership. - -Attribution ------------ - -This Code of Conduct is adapted from the `Contributor Covenant `_, version 1.4, available at . diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md new file mode 100644 index 000000000..bbdc20f19 --- /dev/null +++ b/.github/CONTRIBUTING.md @@ -0,0 +1,230 @@ +# How To Contribute + +First off, thank you for considering contributing to `attrs`! +It's people like *you* who make it such a great tool for everyone. + +This document intends to make contribution more accessible by codifying tribal knowledge and expectations. +Don't be afraid to open half-finished PRs, and ask questions if something is unclear! + +Please note that this project is released with a Contributor [Code of Conduct](https://github.com/python-attrs/attrs/blob/main/.github/CODE_OF_CONDUCT.md). +By participating in this project you agree to abide by its terms. +Please report any harm to [Hynek Schlawack] in any way you find appropriate. + + +## Support + +In case you'd like to help out but don't want to deal with GitHub, there's a great opportunity: +help your fellow developers on [Stack Overflow](https://stackoverflow.com/questions/tagged/python-attrs)! + +The official tag is `python-attrs` and helping out in support frees us up to improve `attrs` instead! + + +## Workflow + +- No contribution is too small! + Please submit as many fixes for typos and grammar bloopers as you can! +- Try to limit each pull request to *one* change only. +- Since we squash on merge, it's up to you how you handle updates to the main branch. + Whether you prefer to rebase on main or merge main into your branch, do whatever is more comfortable for you. +- *Always* add tests and docs for your code. + This is a hard rule; patches with missing tests or documentation can't be merged. +- Make sure your changes pass our [CI]. + You won't get any feedback until it's green unless you ask for it. +- For the CI to pass, the coverage must be 100%. + If you have problems to test something, open anyway and ask for advice. + In some situations, we may agree to add an `# pragma: no cover`. +- Once you've addressed review feedback, make sure to bump the pull request with a short note, so we know you're done. +- Don’t break backwards compatibility. + + +## Code + +- Obey [PEP 8](https://www.python.org/dev/peps/pep-0008/) and [PEP 257](https://www.python.org/dev/peps/pep-0257/). + We use the `"""`-on-separate-lines style for docstrings: + + ```python + def func(x): + """ + Do something. + + :param str x: A very important parameter. + + :rtype: str + """ + ``` +- If you add or change public APIs, tag the docstring using `.. versionadded:: 16.0.0 WHAT` or `.. versionchanged:: 16.2.0 WHAT`. +- We use [*isort*](https://github.com/PyCQA/isort) to sort our imports, and we use [*Black*](https://github.com/psf/black) with line length of 79 characters to format our code. + As long as you run our full [*tox*] suite before committing, or install our [*pre-commit*] hooks (ideally you'll do both – see [*Local Development Environment*](#local-development-environment) below), you won't have to spend any time on formatting your code at all. + If you don't, [CI] will catch it for you – but that seems like a waste of your time! + + +## Tests + +- Write your asserts as `expected == actual` to line them up nicely: + + ```python + x = f() + + assert 42 == x.some_attribute + assert "foo" == x._a_private_attribute + ``` + +- To run the test suite, all you need is a recent [*tox*]. + It will ensure the test suite runs with all dependencies against all Python versions just as it will in our [CI]. + If you lack some Python versions, you can can always limit the environments like `tox -e py27,py38`, or make it a non-failure using `tox --skip-missing-interpreters`. + + In that case you should look into [*asdf*](https://asdf-vm.com) or [*pyenv*](https://github.com/pyenv/pyenv), which make it very easy to install many different Python versions in parallel. +- Write [good test docstrings](https://jml.io/pages/test-docstrings.html). +- To ensure new features work well with the rest of the system, they should be also added to our [*Hypothesis*](https://hypothesis.readthedocs.io/) testing strategy, which can be found in `tests/strategies.py`. +- If you've changed or added public APIs, please update our type stubs (files ending in `.pyi`). + + +## Documentation + +- Use [semantic newlines] in [*reStructuredText*] files (files ending in `.rst`): + + ```rst + This is a sentence. + This is another sentence. + ``` + +- If you start a new section, add two blank lines before and one blank line after the header, except if two headers follow immediately after each other: + + ```rst + Last line of previous section. + + + Header of New Top Section + ------------------------- + + Header of New Section + ^^^^^^^^^^^^^^^^^^^^^ + + First line of new section. + ``` + +- If you add a new feature, demonstrate its awesomeness on the [examples page](https://github.com/python-attrs/attrs/blob/main/docs/examples.rst)! + + +### Changelog + +If your change is noteworthy, there needs to be a changelog entry so our users can learn about it! + +To avoid merge conflicts, we use the [*towncrier*](https://pypi.org/project/towncrier) package to manage our changelog. +*towncrier* uses independent files for each pull request – so called *news fragments* – instead of one monolithic changelog file. +On release, those news fragments are compiled into our [`CHANGELOG.rst`](https://github.com/python-attrs/attrs/blob/main/CHANGELOG.rst). + +You don't need to install *towncrier* yourself, you just have to abide by a few simple rules: + +- For each pull request, add a new file into `changelog.d` with a filename adhering to the `pr#.(change|deprecation|breaking).rst` schema: + For example, `changelog.d/42.change.rst` for a non-breaking change that is proposed in pull request #42. +- As with other docs, please use [semantic newlines] within news fragments. +- Wrap symbols like modules, functions, or classes into double backticks so they are rendered in a `monospace font`. +- Wrap arguments into asterisks like in docstrings: + `Added new argument *an_argument*.` +- If you mention functions or other callables, add parentheses at the end of their names: + `attrs.func()` or `attrs.Class.method()`. + This makes the changelog a lot more readable. +- Prefer simple past tense or constructions with "now". + For example: + + + Added `attrs.validators.func()`. + + `attrs.func()` now doesn't crash the Large Hadron Collider anymore when passed the *foobar* argument. +- If you want to reference multiple issues, copy the news fragment to another filename. + *towncrier* will merge all news fragments with identical contents into one entry with multiple links to the respective pull requests. + +Example entries: + + ```rst + Added ``attrs.validators.func()``. + The feature really *is* awesome. + ``` + +or: + + ```rst + ``attrs.func()`` now doesn't crash the Large Hadron Collider anymore when passed the *foobar* argument. + The bug really *was* nasty. + ``` + +--- + +``tox -e changelog`` will render the current changelog to the terminal if you have any doubts. + + +## Local Development Environment + +You can (and should) run our test suite using [*tox*]. +However, you’ll probably want a more traditional environment as well. +We highly recommend to develop using the latest Python release because we try to take advantage of modern features whenever possible. + +First create a [virtual environment](https://virtualenv.pypa.io/) so you don't break your system-wide Python installation. +It’s out of scope for this document to list all the ways to manage virtual environments in Python, but if you don’t already have a pet way, take some time to look at tools like [*direnv*](https://hynek.me/til/python-project-local-venvs/), [*virtualfish*](https://virtualfish.readthedocs.io/), and [*virtualenvwrapper*](https://virtualenvwrapper.readthedocs.io/). + +Next, get an up to date checkout of the `attrs` repository: + +```console +$ git clone git@github.com:python-attrs/attrs.git +``` + +or if you want to use git via `https`: + +```console +$ git clone https://github.com/python-attrs/attrs.git +``` + +Change into the newly created directory and **after activating your virtual environment** install an editable version of `attrs` along with its tests and docs requirements: + +```console +$ cd attrs +$ pip install --upgrade pip setuptools # PLEASE don't skip this step +$ pip install -e '.[dev]' +``` + +At this point, + +```console +$ python -m pytest +``` + +should work and pass, as should: + +```console +$ cd docs +$ make html +``` + +The built documentation can then be found in `docs/_build/html/`. + +To avoid committing code that violates our style guide, we strongly advise you to install [*pre-commit*] [^dev] hooks: + +```console +$ pre-commit install +``` + +You can also run them anytime (as our tox does) using: + +```console +$ pre-commit run --all-files +``` + +[^dev]: *pre-commit* should have been installed into your virtualenv automatically when you ran `pip install -e '.[dev]'` above. + If *pre-commit* is missing, your probably need to run `pip install -e '.[dev]'` again. + + +## Governance + +`attrs` is maintained by [team of volunteers](https://github.com/python-attrs) that is always open to new members that share our vision of a fast, lean, and magic-free library that empowers programmers to write better code with less effort. +If you'd like to join, just get a pull request merged and ask to be added in the very same pull request! + +**The simple rule is that everyone is welcome to review/merge pull requests of others but nobody is allowed to merge their own code.** + +[Hynek Schlawack] acts reluctantly as the [BDFL](https://en.wikipedia.org/wiki/Benevolent_dictator_for_life) and has the final say over design decisions. + + +[CI]: https://github.com/python-attrs/attrs/actions?query=workflow%3ACI +[Hynek Schlawack]: https://hynek.me/about/ +[*pre-commit*]: https://pre-commit.com/ +[*tox*]: https://https://tox.wiki/ +[semantic newlines]: https://rhodesmill.org/brandon/2012/one-sentence-per-line/ +[*reStructuredText*]: https://www.sphinx-doc.org/en/stable/usage/restructuredtext/basics.html diff --git a/.github/CONTRIBUTING.rst b/.github/CONTRIBUTING.rst deleted file mode 100644 index 5bbb94910..000000000 --- a/.github/CONTRIBUTING.rst +++ /dev/null @@ -1,250 +0,0 @@ -How To Contribute -================= - -First off, thank you for considering contributing to ``attrs``! -It's people like *you* who make it such a great tool for everyone. - -This document intends to make contribution more accessible by codifying tribal knowledge and expectations. -Don't be afraid to open half-finished PRs, and ask questions if something is unclear! - - -Support -------- - -In case you'd like to help out but don't want to deal with GitHub, there's a great opportunity: -help your fellow developers on `StackOverflow `_! - -The official tag is ``python-attrs`` and helping out in support frees us up to improve ``attrs`` instead! - - -Workflow --------- - -- No contribution is too small! - Please submit as many fixes for typos and grammar bloopers as you can! -- Try to limit each pull request to *one* change only. -- Since we squash on merge, it's up to you how you handle updates to the main branch. - Whether you prefer to rebase on main or merge main into your branch, do whatever is more comfortable for you. -- *Always* add tests and docs for your code. - This is a hard rule; patches with missing tests or documentation can't be merged. -- Make sure your changes pass our CI_. - You won't get any feedback until it's green unless you ask for it. -- Once you've addressed review feedback, make sure to bump the pull request with a short note, so we know you're done. -- Don’t break `backward compatibility`_. - - -Code ----- - -- Obey `PEP 8`_ and `PEP 257`_. - We use the ``"""``\ -on-separate-lines style for docstrings: - - .. code-block:: python - - def func(x): - """ - Do something. - - :param str x: A very important parameter. - - :rtype: str - """ -- If you add or change public APIs, tag the docstring using ``.. versionadded:: 16.0.0 WHAT`` or ``.. versionchanged:: 16.2.0 WHAT``. -- We use isort_ to sort our imports, and we follow the Black_ code style with a line length of 79 characters. - As long as you run our full tox suite before committing, or install our pre-commit_ hooks (ideally you'll do both -- see below "Local Development Environment"), you won't have to spend any time on formatting your code at all. - If you don't, CI will catch it for you -- but that seems like a waste of your time! - - -Tests ------ - -- Write your asserts as ``expected == actual`` to line them up nicely: - - .. code-block:: python - - x = f() - - assert 42 == x.some_attribute - assert "foo" == x._a_private_attribute - -- To run the test suite, all you need is a recent tox_. - It will ensure the test suite runs with all dependencies against all Python versions just as it will in our CI. - If you lack some Python versions, you can can always limit the environments like ``tox -e py27,py35`` (in that case you may want to look into pyenv_, which makes it very easy to install many different Python versions in parallel). -- Write `good test docstrings`_. -- To ensure new features work well with the rest of the system, they should be also added to our `Hypothesis`_ testing strategy, which is found in ``tests/strategies.py``. -- If you've changed or added public APIs, please update our type stubs (files ending in ``.pyi``). - - -Documentation -------------- - -- Use `semantic newlines`_ in reStructuredText_ files (files ending in ``.rst``): - - .. code-block:: rst - - This is a sentence. - This is another sentence. - -- If you start a new section, add two blank lines before and one blank line after the header, except if two headers follow immediately after each other: - - .. code-block:: rst - - Last line of previous section. - - - Header of New Top Section - ------------------------- - - Header of New Section - ^^^^^^^^^^^^^^^^^^^^^ - - First line of new section. - -- If you add a new feature, demonstrate its awesomeness on the `examples page`_! - - -Changelog -^^^^^^^^^ - -If your change is noteworthy, there needs to be a changelog entry so our users can learn about it! - -To avoid merge conflicts, we use the towncrier_ package to manage our changelog. -``towncrier`` uses independent files for each pull request -- so called *news fragments* -- instead of one monolithic changelog file. -On release, those news fragments are compiled into our ``CHANGELOG.rst``. - -You don't need to install ``towncrier`` yourself, you just have to abide by a few simple rules: - -- For each pull request, add a new file into ``changelog.d`` with a filename adhering to the ``pr#.(change|deprecation|breaking).rst`` schema: - For example, ``changelog.d/42.change.rst`` for a non-breaking change that is proposed in pull request #42. -- As with other docs, please use `semantic newlines`_ within news fragments. -- Wrap symbols like modules, functions, or classes into double backticks so they are rendered in a ``monospace font``. -- Wrap arguments into asterisks like in docstrings: *these* or *attributes*. -- If you mention functions or other callables, add parentheses at the end of their names: ``attr.func()`` or ``attr.Class.method()``. - This makes the changelog a lot more readable. -- Prefer simple past tense or constructions with "now". - For example: - - + Added ``attr.validators.func()``. - + ``attr.func()`` now doesn't crash the Large Hadron Collider anymore when passed the *foobar* argument. -- If you want to reference multiple issues, copy the news fragment to another filename. - ``towncrier`` will merge all news fragments with identical contents into one entry with multiple links to the respective pull requests. - -Example entries: - - .. code-block:: rst - - Added ``attr.validators.func()``. - The feature really *is* awesome. - -or: - - .. code-block:: rst - - ``attr.func()`` now doesn't crash the Large Hadron Collider anymore when passed the *foobar* argument. - The bug really *was* nasty. - ----- - -``tox -e changelog`` will render the current changelog to the terminal if you have any doubts. - - -Local Development Environment ------------------------------ - -You can (and should) run our test suite using tox_. -However, you’ll probably want a more traditional environment as well. -We highly recommend to develop using the latest Python 3 release because ``attrs`` tries to take advantage of modern features whenever possible. - -First create a `virtual environment `_. -It’s out of scope for this document to list all the ways to manage virtual environments in Python, but if you don’t already have a pet way, take some time to look at tools like `pew `_, `virtualfish `_, and `virtualenvwrapper `_. - -Next, get an up to date checkout of the ``attrs`` repository: - -.. code-block:: bash - - $ git clone git@github.com:python-attrs/attrs.git - -or if you want to use git via ``https``: - -.. code-block:: bash - - $ git clone https://github.com/python-attrs/attrs.git - -Change into the newly created directory and **after activating your virtual environment** install an editable version of ``attrs`` along with its tests and docs requirements: - -.. code-block:: bash - - $ cd attrs - $ pip install -e '.[dev]' - -At this point, - -.. code-block:: bash - - $ python -m pytest - -should work and pass, as should: - -.. code-block:: bash - - $ cd docs - $ make html - -The built documentation can then be found in ``docs/_build/html/``. - -To avoid committing code that violates our style guide, we strongly advise you to install pre-commit_ [#f1]_ hooks: - -.. code-block:: bash - - $ pre-commit install - -You can also run them anytime (as our tox does) using: - -.. code-block:: bash - - $ pre-commit run --all-files - - -.. [#f1] pre-commit should have been installed into your virtualenv automatically when you ran ``pip install -e '.[dev]'`` above. If pre-commit is missing, it may be that you need to re-run ``pip install -e '.[dev]'``. - - -Governance ----------- - -``attrs`` is maintained by `team of volunteers`_ that is always open to new members that share our vision of a fast, lean, and magic-free library that empowers programmers to write better code with less effort. -If you'd like to join, just get a pull request merged and ask to be added in the very same pull request! - -**The simple rule is that everyone is welcome to review/merge pull requests of others but nobody is allowed to merge their own code.** - -`Hynek Schlawack`_ acts reluctantly as the BDFL_ and has the final say over design decisions. - - -**** - -Please note that this project is released with a Contributor `Code of Conduct`_. -By participating in this project you agree to abide by its terms. -Please report any harm to `Hynek Schlawack`_ in any way you find appropriate. - -Thank you for considering contributing to ``attrs``! - - -.. _`Hynek Schlawack`: https://hynek.me/about/ -.. _`PEP 8`: https://www.python.org/dev/peps/pep-0008/ -.. _`PEP 257`: https://www.python.org/dev/peps/pep-0257/ -.. _`good test docstrings`: https://jml.io/pages/test-docstrings.html -.. _`Code of Conduct`: https://github.com/python-attrs/attrs/blob/main/.github/CODE_OF_CONDUCT.rst -.. _changelog: https://github.com/python-attrs/attrs/blob/main/CHANGELOG.rst -.. _`backward compatibility`: https://www.attrs.org/en/latest/backward-compatibility.html -.. _tox: https://tox.readthedocs.io/ -.. _pyenv: https://github.com/pyenv/pyenv -.. _reStructuredText: https://www.sphinx-doc.org/en/stable/usage/restructuredtext/basics.html -.. _semantic newlines: https://rhodesmill.org/brandon/2012/one-sentence-per-line/ -.. _examples page: https://github.com/python-attrs/attrs/blob/main/docs/examples.rst -.. _Hypothesis: https://hypothesis.readthedocs.io/ -.. _CI: https://github.com/python-attrs/attrs/actions?query=workflow%3ACI -.. _`team of volunteers`: https://github.com/python-attrs -.. _BDFL: https://en.wikipedia.org/wiki/Benevolent_dictator_for_life -.. _towncrier: https://pypi.org/project/towncrier -.. _black: https://github.com/psf/black -.. _pre-commit: https://pre-commit.com/ -.. _isort: https://github.com/PyCQA/isort diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index a9c069507..88f6415e9 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -1,18 +1,34 @@ +# Summary + + + + # Pull Request Check List -This is just a friendly reminder about the most common mistakes. Please make sure that you tick all boxes. But please read our [contribution guide](https://www.attrs.org/en/latest/contributing.html) at least once, it will save you unnecessary review cycles! + - [ ] Added **tests** for changed code. + Our CI fails if coverage is not 100%. - [ ] New features have been added to our [Hypothesis testing strategy](https://github.com/python-attrs/attrs/blob/main/tests/strategies.py). - [ ] Changes or additions to public APIs are reflected in our type stubs (files ending in ``.pyi``). - [ ] ...and used in the stub test file `tests/typing_example.py`. + - [ ] If they've been added to `attr/__init__.pyi`, they've *also* been re-imported in `attrs/__init__.pyi`. - [ ] Updated **documentation** for changed code. - [ ] New functions/classes have to be added to `docs/api.rst` by hand. - [ ] Changes to the signature of `@attr.s()` have to be added by hand too. - - [ ] Changed/added classes/methods/functions have appropriate `versionadded`, `versionchanged`, or `deprecated` [directives](http://www.sphinx-doc.org/en/stable/markup/para.html#directive-versionadded). Find the appropriate next version in our [``__init__.py``](https://github.com/python-attrs/attrs/blob/main/src/attr/__init__.py) file. + - [ ] Changed/added classes/methods/functions have appropriate `versionadded`, `versionchanged`, or `deprecated` [directives](http://www.sphinx-doc.org/en/stable/markup/para.html#directive-versionadded). + Find the appropriate next version in our [``__init__.py``](https://github.com/python-attrs/attrs/blob/main/src/attr/__init__.py) file. - [ ] Documentation in `.rst` files is written using [semantic newlines](https://rhodesmill.org/brandon/2012/one-sentence-per-line/). - [ ] Changes (and possible deprecations) have news fragments in [`changelog.d`](https://github.com/python-attrs/attrs/blob/main/changelog.d). -If you have *any* questions to *any* of the points above, just **submit and ask**! This checklist is here to *help* you, not to deter you from contributing! + diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index bf4ea0775..f38fd9150 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -8,88 +8,106 @@ on: branches: ["main"] workflow_dispatch: +env: + FORCE_COLOR: "1" # Make tools pretty. + TOX_TESTENV_PASSENV: FORCE_COLOR + PYTHON_LATEST: "3.10" + + jobs: tests: - name: "Python ${{ matrix.python-version }}" - runs-on: "ubuntu-latest" - env: - USING_COVERAGE: "2.7,3.7,3.8" + name: tox on ${{ matrix.python-version }} + runs-on: ubuntu-latest strategy: fail-fast: false matrix: - python-version: ["2.7", "3.5", "3.6", "3.7", "3.8", "3.9", "3.10.0-alpha - 3.10", "pypy2", "pypy3"] + python-version: ["2.7", "3.5", "3.6", "3.7", "3.8", "3.9", "3.10", "pypy-2.7", "pypy-3.7", "pypy-3.8"] steps: - - uses: "actions/checkout@v2" - - uses: "actions/setup-python@v2" + - uses: actions/checkout@v2 + - uses: actions/setup-python@v2 with: - python-version: "${{ matrix.python-version }}" + python-version: ${{ matrix.python-version }} + - name: "Install dependencies" run: | - set -xe python -VV python -m site python -m pip install --upgrade pip setuptools wheel - python -m pip install --upgrade coverage[toml] virtualenv tox tox-gh-actions + python -m pip install --upgrade virtualenv tox tox-gh-actions + + - run: "python -m tox" + + - name: Upload coverage data + uses: "actions/upload-artifact@v2" + with: + name: coverage-data + path: ".coverage.*" + if-no-files-found: ignore + + + coverage: + runs-on: ubuntu-latest + needs: tests - - name: "Run tox targets for ${{ matrix.python-version }}" - run: "python -m tox" + steps: + - uses: actions/checkout@v2 + - uses: actions/setup-python@v2 + with: + # Use latest Python, so it understands all syntax. + python-version: ${{env.PYTHON_LATEST}} - # We always use a modern Python version for combining coverage to prevent - # parsing errors in older versions for modern code. - - uses: "actions/setup-python@v2" + - run: python -m pip install --upgrade coverage[toml] + + - name: Download coverage data + uses: actions/download-artifact@v2 with: - python-version: "3.9" + name: coverage-data - - name: "Combine coverage" + - name: Combine coverage and fail if it's <100%. run: | - set -xe - python -m pip install coverage[toml] python -m coverage combine - python -m coverage xml - if: "contains(env.USING_COVERAGE, matrix.python-version)" - - name: "Upload coverage to Codecov" - if: "contains(env.USING_COVERAGE, matrix.python-version)" - uses: "codecov/codecov-action@v1" + python -m coverage html --skip-covered --skip-empty + python -m coverage report --fail-under=100 + + - name: Upload HTML report if check failed. + uses: actions/upload-artifact@v2 with: - fail_ci_if_error: true + name: html-report + path: htmlcov + if: ${{ failure() }} + package: - name: "Build & verify package" - runs-on: "ubuntu-latest" + name: Build & verify package + runs-on: ubuntu-latest steps: - - uses: "actions/checkout@v2" - - uses: "actions/setup-python@v2" + - uses: actions/checkout@v2 + - uses: actions/setup-python@v2 with: - python-version: "3.9" - - - name: "Install build, check-wheel-content, and twine" - run: "python -m pip install build twine check-wheel-contents" - - name: "Build package" - run: "python -m build --sdist --wheel ." - - name: "List result" - run: "ls -l dist" - - name: "Check wheel contents" - run: "check-wheel-contents dist/*.whl" - - name: "Check long_description" - run: "python -m twine check dist/*" + python-version: ${{env.PYTHON_LATEST}} + + - run: python -m pip install build twine check-wheel-contents + - run: python -m build --sdist --wheel . + - run: ls -l dist + - run: check-wheel-contents dist/*.whl + - name: Check long_description + run: python -m twine check dist/* + install-dev: + name: Verify dev env + runs-on: ${{ matrix.os }} strategy: matrix: - os: ["ubuntu-latest", "windows-latest", "macos-latest"] - - name: "Verify dev env" - runs-on: "${{ matrix.os }}" + os: ["ubuntu-latest", "windows-latest"] steps: - - uses: "actions/checkout@v2" - - uses: "actions/setup-python@v2" + - uses: actions/checkout@v2 + - uses: actions/setup-python@v2 with: - python-version: "3.9" - - name: "Install in dev mode" - run: "python -m pip install -e .[dev]" - - name: "Import package" - run: "python -c 'import attr; print(attr.__version__)'" + python-version: ${{env.PYTHON_LATEST}} + - run: python -m pip install -e .[dev] + - run: python -c 'import attr; print(attr.__version__)' diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index cb59b3a36..a913b068f 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,34 +1,43 @@ --- +ci: + autoupdate_schedule: monthly + repos: - repo: https://github.com/psf/black - rev: 20.8b1 + rev: 21.12b0 hooks: - id: black - language_version: python3.8 + exclude: tests/test_pattern_matching.py + language_version: python3.10 - repo: https://github.com/PyCQA/isort - rev: 5.7.0 + rev: 5.10.1 hooks: - id: isort additional_dependencies: [toml] + files: \.py$ + language_version: python3.10 - - repo: https://gitlab.com/pycqa/flake8 - rev: 3.8.4 + - repo: https://github.com/PyCQA/flake8 + rev: 4.0.1 hooks: - id: flake8 - language_version: python3.8 + language_version: python3.10 - repo: https://github.com/econchick/interrogate - rev: 1.3.2 + rev: 1.5.0 hooks: - id: interrogate + exclude: tests/test_pattern_matching.py args: [tests] - language_version: python3.8 + language_version: python3.10 - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v3.4.0 + rev: v4.1.0 hooks: - id: trailing-whitespace - id: end-of-file-fixer - id: debug-statements + language_version: python3.10 - id: check-toml + - id: check-yaml diff --git a/.readthedocs.yml b/.readthedocs.yml index 511ae165f..d335c40d5 100644 --- a/.readthedocs.yml +++ b/.readthedocs.yml @@ -1,9 +1,14 @@ --- version: 2 -python: - # Keep version in sync with tox.ini (docs and gh-actions). - version: 3.7 +formats: all + +build: + os: ubuntu-20.04 + tools: + # Keep version in sync with tox.ini (docs and gh-actions). + python: "3.10" +python: install: - method: pip path: . diff --git a/CHANGELOG.rst b/CHANGELOG.rst index c7d0ed88c..1d194add2 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -1,11 +1,109 @@ Changelog ========= -Versions follow `CalVer `_ with a strict backwards compatibility policy. -The third digit is only for regressions. +Versions follow `CalVer `_ with a strict backwards-compatibility policy. + +The **first number** of the version is the year. +The **second number** is incremented with each release, starting at 1 for each year. +The **third number** is when we need to start branches for older releases (only for emergencies). + +Put simply, you shouldn't ever be afraid to upgrade ``attrs`` if you're only using its public APIs. +Whenever there is a need to break compatibility, it is announced here in the changelog, and raises a ``DeprecationWarning`` for a year (if possible) before it's finally really broken. + +.. warning:: + + The structure of the `attrs.Attribute` class is exempt from this rule. + It *will* change in the future, but since it should be considered read-only, that shouldn't matter. + + However if you intend to build extensions on top of ``attrs`` you have to anticipate that. .. towncrier release notes start +21.4.0 (2021-12-29) +------------------- + +Changes +^^^^^^^ + +- Fixed the test suite on PyPy3.8 where ``cloudpickle`` does not work. + `#892 `_ +- Fixed ``coverage report`` for projects that use ``attrs`` and don't set a ``--source``. + `#895 `_, + `#896 `_ + + +---- + + +21.3.0 (2021-12-28) +------------------- + +Backward-incompatible Changes +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +- When using ``@define``, converters are now run by default when setting an attribute on an instance -- additionally to validators. + I.e. the new default is ``on_setattr=[attrs.setters.convert, attrs.setters.validate]``. + + This is unfortunately a breaking change, but it was an oversight, impossible to raise a ``DeprecationWarning`` about, and it's better to fix it now while the APIs are very fresh with few users. + `#835 `_, + `#886 `_ +- ``import attrs`` has finally landed! + As of this release, you can finally import ``attrs`` using its proper name. + + Not all names from the ``attr`` namespace have been transferred; most notably ``attr.s`` and ``attr.ib`` are missing. + See ``attrs.define`` and ``attrs.field`` if you haven't seen our next-generation APIs yet. + A more elaborate explanation can be found `On The Core API Names `_ + + This feature is at least for one release **provisional**. + We don't *plan* on changing anything, but such a big change is unlikely to go perfectly on the first strike. + + The API docs have been mostly updated, but it will be an ongoing effort to change everything to the new APIs. + Please note that we have **not** moved -- or even removed -- anything from ``attr``! + + Please do report any bugs or documentation inconsistencies! + `#887 `_ + + +Changes +^^^^^^^ + +- ``attr.asdict(retain_collection_types=False)`` (default) dumps collection-esque keys as tuples. + `#646 `_, + `#888 `_ +- ``__match_args__`` are now generated to support Python 3.10's + `Structural Pattern Matching `_. + This can be controlled by the ``match_args`` argument to the class decorators on Python 3.10 and later. + On older versions, it is never added and the argument is ignored. + `#815 `_ +- If the class-level *on_setattr* is set to ``attrs.setters.validate`` (default in ``@define`` and ``@mutable``) but no field defines a validator, pretend that it's not set. + `#817 `_ +- The generated ``__repr__`` is significantly faster on Pythons with f-strings. + `#819 `_ +- Attributes transformed via ``field_transformer`` are wrapped with ``AttrsClass`` again. + `#824 `_ +- Generated source code is now cached more efficiently for identical classes. + `#828 `_ +- Added ``attrs.converters.to_bool()``. + `#830 `_ +- ``attrs.resolve_types()`` now resolves types of subclasses after the parents are resolved. + `#842 `_ + `#843 `_ +- Added new validators: ``lt(val)`` (< val), ``le(va)`` (≤ val), ``ge(val)`` (≥ val), ``gt(val)`` (> val), and ``maxlen(n)``. + `#845 `_ +- ``attrs`` classes are now fully compatible with `cloudpickle `_ (no need to disable ``repr`` anymore). + `#857 `_ +- Added new context manager ``attrs.validators.disabled()`` and functions ``attrs.validators.(set|get)_disabled()``. + They deprecate ``attrs.(set|get)_run_validators()``. + All functions are interoperable and modify the same internal state. + They are not – and never were – thread-safe, though. + `#859 `_ +- ``attrs.validators.matches_re()`` now accepts pre-compiled regular expressions in addition to pattern strings. + `#877 `_ + + +---- + + 21.2.0 (2021-05-07) ------------------- @@ -54,7 +152,7 @@ Changes - It's now possible to customize the behavior of ``eq`` and ``order`` by passing in a callable. `#435 `_, `#627 `_ -- The instant favorite `next-generation APIs `_ are not provisional anymore! +- The instant favorite next-generation APIs are not provisional anymore! They are also officially supported by Mypy as of their `0.800 release `_. @@ -93,7 +191,7 @@ Changes See the `new docs on comparison `_ for more details. `#787 `_ -- Added **provisional** support for static typing in ``pyright`` via the `dataclass_transforms specification `_. +- Added **provisional** support for static typing in ``pyright`` via the `dataclass_transforms specification `_. Both the ``pyright`` specification and ``attrs`` implementation may change in future versions of both projects. Your constructive feedback is welcome in both `attrs#795 `_ and `pyright#1782 `_. @@ -111,7 +209,7 @@ Backward-incompatible Changes - ``attr.define()``, ``attr.frozen()``, ``attr.mutable()``, and ``attr.field()`` remain **provisional**. - This release does **not** change change anything about them and they are already used widely in production though. + This release does **not** change anything about them and they are already used widely in production though. If you wish to use them together with mypy, you can simply drop `this plugin `_ into your project. @@ -212,7 +310,7 @@ Deprecations Please check out the linked issue for more details. These new APIs have been added *provisionally* as part of #666 so you can try them out today and provide feedback. - Learn more in the `API docs `_. + Learn more in the `API docs `_. `#408 `_ diff --git a/MANIFEST.in b/MANIFEST.in index 398252bb9..3d68bf9c5 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -2,8 +2,8 @@ include LICENSE *.rst *.toml *.yml *.yaml *.ini graft .github # Stubs -include src/attr/py.typed recursive-include src *.pyi +recursive-include src py.typed # Tests include tox.ini conftest.py diff --git a/README.rst b/README.rst index d080f1de8..709bba83d 100644 --- a/README.rst +++ b/README.rst @@ -7,23 +7,20 @@

- Documentation Status + Documentation - - CI Status + + License: MIT - - Test Coverage - - - Code style: black + +

.. teaser-begin -``attrs`` is the Python package that will bring back the **joy** of **writing classes** by relieving you from the drudgery of implementing object protocols (aka `dunder `_ methods). -`Trusted by NASA `_ for Mars missions since 2020! +``attrs`` is the Python package that will bring back the **joy** of **writing classes** by relieving you from the drudgery of implementing object protocols (aka `dunder methods `_). +`Trusted by NASA `_ for Mars missions since 2020! Its main goal is to help you to write **concise** and **correct** software without slowing down your code. @@ -35,12 +32,12 @@ For that, it gives you a class decorator and a way to declaratively define the a .. code-block:: pycon - >>> import attr + >>> from attrs import asdict, define, make_class, Factory - >>> @attr.s - ... class SomeClass(object): - ... a_number = attr.ib(default=42) - ... list_of_numbers = attr.ib(factory=list) + >>> @define + ... class SomeClass: + ... a_number: int = 42 + ... list_of_numbers: list[int] = Factory(list) ... ... def hard_math(self, another_number): ... return self.a_number + sum(self.list_of_numbers) * another_number @@ -57,13 +54,13 @@ For that, it gives you a class decorator and a way to declaratively define the a >>> sc != SomeClass(2, [3, 2, 1]) True - >>> attr.asdict(sc) + >>> asdict(sc) {'a_number': 1, 'list_of_numbers': [1, 2, 3]} >>> SomeClass() SomeClass(a_number=42, list_of_numbers=[]) - >>> C = attr.make_class("C", ["a", "b"]) + >>> C = make_class("C", ["a", "b"]) >>> C("foo", "bar") C(a='foo', b='bar') @@ -72,17 +69,33 @@ After *declaring* your attributes ``attrs`` gives you: - a concise and explicit overview of the class's attributes, - a nice human-readable ``__repr__``, -- a complete set of comparison methods (equality and ordering), +- a equality-checking methods, - an initializer, - and much more, *without* writing dull boilerplate code again and again and *without* runtime performance penalties. -On Python 3.6 and later, you can often even drop the calls to ``attr.ib()`` by using `type annotations `_. +**Hate type annotations**!? +No problem! +Types are entirely **optional** with ``attrs``. +Simply assign ``attrs.field()`` to the attributes instead of annotating them with types. + +---- + +This example uses ``attrs``'s modern APIs that have been introduced in version 20.1.0, and the ``attrs`` package import name that has been added in version 21.3.0. +The classic APIs (``@attr.s``, ``attr.ib``, plus their serious business aliases) and the ``attr`` package import name will remain **indefinitely**. + +Please check out `On The Core API Names `_ for a more in-depth explanation. + + +Data Classes +============ + +On the tin, ``attrs`` might remind you of ``dataclasses`` (and indeed, ``dataclasses`` are a descendant of ``attrs``). +In practice it does a lot more and is more flexible. +For instance it allows you to define `special handling of NumPy arrays for equality checks `_, or allows more ways to `plug into the initialization process `_. -This gives you the power to use actual classes with actual types in your code instead of confusing ``tuple``\ s or `confusingly behaving `_ ``namedtuple``\ s. -Which in turn encourages you to write *small classes* that do `one thing well `_. -Never again violate the `single responsibility principle `_ just because implementing ``__init__`` et al is a painful drag. +For more details, please refer to our `comparison page `_. .. -getting-help- @@ -90,7 +103,7 @@ Never again violate the `single responsibility principle `_ to get help. +Please use the ``python-attrs`` tag on `Stack Overflow `_ to get help. Answering questions of your fellow developers is also a great way to help the project! @@ -109,7 +122,7 @@ It’s rigorously tested on Python 2.7, 3.5+, and PyPy. We collect information on **third-party extensions** in our `wiki `_. Feel free to browse and add your own! -If you'd like to contribute to ``attrs`` you're most welcome and we've written `a little guide `_ to get you started! +If you'd like to contribute to ``attrs`` you're most welcome and we've written `a little guide `_ to get you started! ``attrs`` for Enterprise diff --git a/codecov.yml b/codecov.yml deleted file mode 100644 index 60a1e5c12..000000000 --- a/codecov.yml +++ /dev/null @@ -1,10 +0,0 @@ ---- -comment: false -coverage: - status: - patch: - default: - target: "100" - project: - default: - target: "100" diff --git a/conftest.py b/conftest.py index a2c8d59f2..0d539a115 100644 --- a/conftest.py +++ b/conftest.py @@ -1,9 +1,11 @@ -from __future__ import absolute_import, division, print_function +# SPDX-License-Identifier: MIT -import sys +from __future__ import absolute_import, division, print_function from hypothesis import HealthCheck, settings +from attr._compat import PY36, PY310 + def pytest_configure(config): # HealthCheck.too_slow causes more trouble than good -- especially in CIs. @@ -14,7 +16,7 @@ def pytest_configure(config): collect_ignore = [] -if sys.version_info[:2] < (3, 6): +if not PY36: collect_ignore.extend( [ "tests/test_annotations.py", @@ -23,9 +25,5 @@ def pytest_configure(config): "tests/test_next_gen.py", ] ) -if sys.version_info[:2] >= (3, 10): - collect_ignore.extend( - [ - "tests/test_mypy.yml", - ] - ) +if not PY310: + collect_ignore.extend(["tests/test_pattern_matching.py"]) diff --git a/docs/api.rst b/docs/api.rst index 3df314504..02aed52ad 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -3,32 +3,124 @@ API Reference .. currentmodule:: attr -``attrs`` works by decorating a class using `attr.s` and then optionally defining attributes on the class using `attr.ib`. +``attrs`` works by decorating a class using `attrs.define` or `attr.s` and then optionally defining attributes on the class using `attrs.field`, `attr.ib`, or a type annotation. -.. note:: - - When this documentation speaks about "``attrs`` attributes" it means those attributes that are defined using `attr.ib` in the class body. +If you're confused by the many names, please check out `names` for clarification. What follows is the API explanation, if you'd like a more hands-on introduction, have a look at `examples`. +As of version 21.3.0, ``attrs`` consists of **two** to-level package names: + +- The classic ``attr`` that powered the venerable `attr.s` and `attr.ib` +- The modern ``attrs`` that only contains most modern APIs and relies on `attrs.define` and `attrs.field` to define your classes. + Additionally it offers some ``attr`` APIs with nicer defaults (e.g. `attrs.asdict`). + Using this namespace requires Python 3.6 or later. + +The ``attrs`` namespace is built *on top of* ``attr`` which will *never* go away. Core ---- - .. note:: - ``attrs`` 20.1.0 added a bunch of nicer APIs (sometimes referred to as next generation -- or NG -- APIs) that were intended to become the main way of defining classes in the future. - As of 21.1.0, they are not provisional anymore and are the **recommended** way to use ``attrs``! - The next step will be adding an importable ``attrs`` namespace. - The documentation will be updated successively. + Please note that the ``attrs`` namespace has been added in version 21.3.0. + Most of the objects are simply re-imported from ``attr``. + Therefore if a class, method, or function claims that it has been added in an older version, it is only available in the ``attr`` namespace. + +.. autodata:: attrs.NOTHING + +.. autofunction:: attrs.define + +.. function:: attrs.mutable(same_as_define) + + Alias for `attrs.define`. + + .. versionadded:: 20.1.0 + +.. function:: attrs.frozen(same_as_define) + + Behaves the same as `attrs.define` but sets *frozen=True* and *on_setattr=None*. + + .. versionadded:: 20.1.0 + +.. autofunction:: attrs.field - Please have a look at :ref:`next-gen`! +.. function:: define -.. autodata:: attr.NOTHING + Old import path for `attrs.define`. -.. autofunction:: attr.s(these=None, repr_ns=None, repr=None, cmp=None, hash=None, init=None, slots=False, frozen=False, weakref_slot=True, str=False, auto_attribs=False, kw_only=False, cache_hash=False, auto_exc=False, eq=None, order=None, auto_detect=False, collect_by_mro=False, getstate_setstate=None, on_setattr=None, field_transformer=None) +.. function:: mutable + + Old import path for `attrs.mutable`. + +.. function:: frozen + + Old import path for `attrs.frozen`. + +.. function:: field + + Old import path for `attrs.field`. + +.. autoclass:: attrs.Attribute + :members: evolve + + For example: + + .. doctest:: + + >>> import attr + >>> @attr.s + ... class C(object): + ... x = attr.ib() + >>> attr.fields(C).x + Attribute(name='x', default=NOTHING, validator=None, repr=True, eq=True, eq_key=None, order=True, order_key=None, hash=None, init=True, metadata=mappingproxy({}), type=None, converter=None, kw_only=False, inherited=False, on_setattr=None) + + +.. autofunction:: attrs.make_class + + This is handy if you want to programmatically create classes. + + For example: + + .. doctest:: + + >>> C1 = attr.make_class("C1", ["x", "y"]) + >>> C1(1, 2) + C1(x=1, y=2) + >>> C2 = attr.make_class("C2", {"x": attr.ib(default=42), + ... "y": attr.ib(default=attr.Factory(list))}) + >>> C2() + C2(x=42, y=[]) + + +.. autoclass:: attrs.Factory + + For example: + + .. doctest:: + + >>> @attr.s + ... class C(object): + ... x = attr.ib(default=attr.Factory(list)) + ... y = attr.ib(default=attr.Factory( + ... lambda self: set(self.x), + ... takes_self=True) + ... ) + >>> C() + C(x=[], y=set()) + >>> C([1, 2, 3]) + C(x=[1, 2, 3], y={1, 2, 3}) + + +Classic +~~~~~~~ + +.. data:: attr.NOTHING + + Same as `attrs.NOTHING`. + +.. autofunction:: attr.s(these=None, repr_ns=None, repr=None, cmp=None, hash=None, init=None, slots=False, frozen=False, weakref_slot=True, str=False, auto_attribs=False, kw_only=False, cache_hash=False, auto_exc=False, eq=None, order=None, auto_detect=False, collect_by_mro=False, getstate_setstate=None, on_setattr=None, field_transformer=None, match_args=True) .. note:: @@ -96,67 +188,32 @@ Core ... ValueError: x must be positive -.. autoclass:: attr.Attribute - :members: evolve - - .. doctest:: - - >>> import attr - >>> @attr.s - ... class C(object): - ... x = attr.ib() - >>> attr.fields(C).x - Attribute(name='x', default=NOTHING, validator=None, repr=True, eq=True, eq_key=None, order=True, order_key=None, hash=None, init=True, metadata=mappingproxy({}), type=None, converter=None, kw_only=False, inherited=False, on_setattr=None) - - -.. autofunction:: attr.make_class - - This is handy if you want to programmatically create classes. - - For example: - - .. doctest:: - - >>> C1 = attr.make_class("C1", ["x", "y"]) - >>> C1(1, 2) - C1(x=1, y=2) - >>> C2 = attr.make_class("C2", {"x": attr.ib(default=42), - ... "y": attr.ib(default=attr.Factory(list))}) - >>> C2() - C2(x=42, y=[]) -.. autoclass:: attr.Factory - - For example: - - .. doctest:: +Exceptions +---------- - >>> @attr.s - ... class C(object): - ... x = attr.ib(default=attr.Factory(list)) - ... y = attr.ib(default=attr.Factory( - ... lambda self: set(self.x), - ... takes_self=True) - ... ) - >>> C() - C(x=[], y=set()) - >>> C([1, 2, 3]) - C(x=[1, 2, 3], y={1, 2, 3}) +All exceptions are available from both ``attr.exceptions`` and ``attrs.exceptions`` and are the same thing. +That means that it doesn't matter from from which namespace they've been raised and/or caught: +.. doctest:: -Exceptions ----------- + >>> import attrs, attr + >>> try: + ... raise attrs.exceptions.FrozenError() + ... except attr.exceptions.FrozenError: + ... print("this works!") + this works! -.. autoexception:: attr.exceptions.PythonTooOldError -.. autoexception:: attr.exceptions.FrozenError -.. autoexception:: attr.exceptions.FrozenInstanceError -.. autoexception:: attr.exceptions.FrozenAttributeError -.. autoexception:: attr.exceptions.AttrsAttributeNotFoundError -.. autoexception:: attr.exceptions.NotAnAttrsClassError -.. autoexception:: attr.exceptions.DefaultAlreadySetError -.. autoexception:: attr.exceptions.UnannotatedAttributeError -.. autoexception:: attr.exceptions.NotCallableError +.. autoexception:: attrs.exceptions.PythonTooOldError +.. autoexception:: attrs.exceptions.FrozenError +.. autoexception:: attrs.exceptions.FrozenInstanceError +.. autoexception:: attrs.exceptions.FrozenAttributeError +.. autoexception:: attrs.exceptions.AttrsAttributeNotFoundError +.. autoexception:: attrs.exceptions.NotAnAttrsClassError +.. autoexception:: attrs.exceptions.DefaultAlreadySetError +.. autoexception:: attrs.exceptions.UnannotatedAttributeError +.. autoexception:: attrs.exceptions.NotCallableError For example:: @@ -173,9 +230,12 @@ Helpers ``attrs`` comes with a bunch of helper methods that make working with it easier: -.. autofunction:: attr.cmp_using +.. autofunction:: attrs.cmp_using +.. function:: attr.cmp_using -.. autofunction:: attr.fields + Same as `attrs.cmp_using`. + +.. autofunction:: attrs.fields For example: @@ -185,14 +245,18 @@ Helpers ... class C(object): ... x = attr.ib() ... y = attr.ib() - >>> attr.fields(C) + >>> attrs.fields(C) (Attribute(name='x', default=NOTHING, validator=None, repr=True, eq=True, eq_key=None, order=True, order_key=None, hash=None, init=True, metadata=mappingproxy({}), type=None, converter=None, kw_only=False, inherited=False, on_setattr=None), Attribute(name='y', default=NOTHING, validator=None, repr=True, eq=True, eq_key=None, order=True, order_key=None, hash=None, init=True, metadata=mappingproxy({}), type=None, converter=None, kw_only=False, inherited=False, on_setattr=None)) - >>> attr.fields(C)[1] + >>> attrs.fields(C)[1] Attribute(name='y', default=NOTHING, validator=None, repr=True, eq=True, eq_key=None, order=True, order_key=None, hash=None, init=True, metadata=mappingproxy({}), type=None, converter=None, kw_only=False, inherited=False, on_setattr=None) - >>> attr.fields(C).y is attr.fields(C)[1] + >>> attrs.fields(C).y is attrs.fields(C)[1] True -.. autofunction:: attr.fields_dict +.. function:: attr.fields + + Same as `attrs.fields`. + +.. autofunction:: attrs.fields_dict For example: @@ -202,15 +266,18 @@ Helpers ... class C(object): ... x = attr.ib() ... y = attr.ib() - >>> attr.fields_dict(C) + >>> attrs.fields_dict(C) {'x': Attribute(name='x', default=NOTHING, validator=None, repr=True, eq=True, eq_key=None, order=True, order_key=None, hash=None, init=True, metadata=mappingproxy({}), type=None, converter=None, kw_only=False, inherited=False, on_setattr=None), 'y': Attribute(name='y', default=NOTHING, validator=None, repr=True, eq=True, eq_key=None, order=True, order_key=None, hash=None, init=True, metadata=mappingproxy({}), type=None, converter=None, kw_only=False, inherited=False, on_setattr=None)} >>> attr.fields_dict(C)['y'] Attribute(name='y', default=NOTHING, validator=None, repr=True, eq=True, eq_key=None, order=True, order_key=None, hash=None, init=True, metadata=mappingproxy({}), type=None, converter=None, kw_only=False, inherited=False, on_setattr=None) - >>> attr.fields_dict(C)['y'] is attr.fields(C).y + >>> attrs.fields_dict(C)['y'] is attrs.fields(C).y True +.. function:: attr.fields_dict -.. autofunction:: attr.has + Same as `attrs.fields_dict`. + +.. autofunction:: attrs.has For example: @@ -224,83 +291,106 @@ Helpers >>> attr.has(object) False +.. function:: attr.has + + Same as `attrs.has`. -.. autofunction:: attr.resolve_types +.. autofunction:: attrs.resolve_types For example: .. doctest:: >>> import typing - >>> @attr.s(auto_attribs=True) + >>> @attrs.define ... class A: ... a: typing.List['A'] ... b: 'B' ... - >>> @attr.s(auto_attribs=True) + >>> @attrs.define ... class B: ... a: A ... - >>> attr.fields(A).a.type + >>> attrs.fields(A).a.type typing.List[ForwardRef('A')] - >>> attr.fields(A).b.type + >>> attrs.fields(A).b.type 'B' - >>> attr.resolve_types(A, globals(), locals()) + >>> attrs.resolve_types(A, globals(), locals()) - >>> attr.fields(A).a.type + >>> attrs.fields(A).a.type typing.List[A] - >>> attr.fields(A).b.type + >>> attrs.fields(A).b.type -.. autofunction:: attr.asdict +.. function:: attr.resolve_types + + Same as `attrs.resolve_types`. + +.. autofunction:: attrs.asdict For example: .. doctest:: - >>> @attr.s - ... class C(object): - ... x = attr.ib() - ... y = attr.ib() - >>> attr.asdict(C(1, C(2, 3))) + >>> @attrs.define + ... class C: + ... x: int + ... y: int + >>> attrs.asdict(C(1, C(2, 3))) {'x': 1, 'y': {'x': 2, 'y': 3}} +.. autofunction:: attr.asdict -.. autofunction:: attr.astuple +.. autofunction:: attrs.astuple For example: .. doctest:: - >>> @attr.s - ... class C(object): - ... x = attr.ib() - ... y = attr.ib() - >>> attr.astuple(C(1,2)) + >>> @attrs.define + ... class C: + ... x = attr.field() + ... y = attr.field() + >>> attrs.astuple(C(1,2)) (1, 2) -``attrs`` includes some handy helpers for filtering the attributes in `attr.asdict` and `attr.astuple`: +.. autofunction:: attr.astuple -.. autofunction:: attr.filters.include -.. autofunction:: attr.filters.exclude +``attrs`` includes some handy helpers for filtering the attributes in `attrs.asdict` and `attrs.astuple`: -See :func:`asdict` for examples. +.. autofunction:: attrs.filters.include -.. autofunction:: attr.evolve +.. autofunction:: attrs.filters.exclude + +.. function:: attr.filters.include + + Same as `attrs.filters.include`. + +.. function:: attr.filters.exclude + + Same as `attrs.filters.exclude`. + +See :func:`attrs.asdict` for examples. + +All objects from ``attrs.filters`` are also available from ``attr.filters``. + +---- + +.. autofunction:: attrs.evolve For example: .. doctest:: - >>> @attr.s - ... class C(object): - ... x = attr.ib() - ... y = attr.ib() + >>> @attrs.define + ... class C: + ... x: int + ... y: int >>> i1 = C(1, 2) >>> i1 C(x=1, y=2) - >>> i2 = attr.evolve(i1, y=3) + >>> i2 = attrs.evolve(i1, y=3) >>> i2 C(x=1, y=3) >>> i1 == i2 @@ -313,22 +403,30 @@ See :func:`asdict` for examples. * attributes with ``init=False`` can't be set with ``evolve``. * the usual ``__init__`` validators will validate the new values. -.. autofunction:: validate +.. function:: attr.evolve + + Same as `attrs.evolve`. + +.. autofunction:: attrs.validate For example: .. doctest:: - >>> @attr.s - ... class C(object): - ... x = attr.ib(validator=attr.validators.instance_of(int)) + >>> @attrs.define(on_setattr=attrs.setters.NO_OP) + ... class C: + ... x = attrs.field(validator=attrs.validators.instance_of(int)) >>> i = C(1) >>> i.x = "1" - >>> attr.validate(i) + >>> attrs.validate(i) Traceback (most recent call last): ... TypeError: ("'x' must be (got '1' that is a ).", ...) +.. function:: attr.validate + + Same as `attrs.validate`. + Validators can be globally disabled if you want to run them only in development and tests but not in production because you fear their performance impact: @@ -342,19 +440,99 @@ Validators can be globally disabled if you want to run them only in development Validators ---------- -``attrs`` comes with some common validators in the ``attrs.validators`` module: +``attrs`` comes with some common validators in the ``attrs.validators`` module. +All objects from ``attrs.converters`` are also available from ``attr.converters``. -.. autofunction:: attr.validators.instance_of +.. autofunction:: attrs.validators.lt + For example: + + .. doctest:: + + >>> @attrs.define + ... class C: + ... x = attrs.field(validator=attrs.validators.lt(42)) + >>> C(41) + C(x=41) + >>> C(42) + Traceback (most recent call last): + ... + ValueError: ("'x' must be < 42: 42") + +.. autofunction:: attrs.validators.le For example: .. doctest:: - >>> @attr.s + >>> @attrs.define ... class C(object): - ... x = attr.ib(validator=attr.validators.instance_of(int)) + ... x = attrs.field(validator=attr.validators.le(42)) + >>> C(42) + C(x=42) + >>> C(43) + Traceback (most recent call last): + ... + ValueError: ("'x' must be <= 42: 43") + +.. autofunction:: attrs.validators.ge + + For example: + + .. doctest:: + + >>> @attrs.define + ... class C: + ... x = attrs.field(validator=attrs.validators.ge(42)) + >>> C(42) + C(x=42) + >>> C(41) + Traceback (most recent call last): + ... + ValueError: ("'x' must be => 42: 41") + +.. autofunction:: attrs.validators.gt + + For example: + + .. doctest:: + + >>> @attrs.define + ... class C: + ... x = attr.field(validator=attrs.validators.gt(42)) + >>> C(43) + C(x=43) + >>> C(42) + Traceback (most recent call last): + ... + ValueError: ("'x' must be > 42: 42") + +.. autofunction:: attrs.validators.max_len + + For example: + + .. doctest:: + + >>> @attrs.define + ... class C: + ... x = attrs.field(validator=attrs.validators.max_len(4)) + >>> C("spam") + C(x='spam') + >>> C("bacon") + Traceback (most recent call last): + ... + ValueError: ("Length of 'x' must be <= 4: 5") + +.. autofunction:: attrs.validators.instance_of + + For example: + + .. doctest:: + + >>> @attrs.define + ... class C: + ... x = attrs.field(validator=attrs.validators.instance_of(int)) >>> C(42) C(x=42) >>> C("42") @@ -366,7 +544,7 @@ Validators ... TypeError: ("'x' must be (got None that is a ).", Attribute(name='x', default=NOTHING, validator=>, repr=True, cmp=True, hash=None, init=True, type=None, kw_only=False), , None) -.. autofunction:: attr.validators.in_ +.. autofunction:: attrs.validators.in_ For example: @@ -376,10 +554,10 @@ Validators >>> class State(enum.Enum): ... ON = "on" ... OFF = "off" - >>> @attr.s - ... class C(object): - ... state = attr.ib(validator=attr.validators.in_(State)) - ... val = attr.ib(validator=attr.validators.in_([1, 2, 3])) + >>> @attrs.define + ... class C: + ... state = attrs.field(validator=attrs.validators.in_(State)) + ... val = attrs.field(validator=attrs.validators.in_([1, 2, 3])) >>> C(State.ON, 1) C(state=, val=1) >>> C("on", 1) @@ -391,26 +569,26 @@ Validators ... ValueError: 'val' must be in [1, 2, 3] (got 4) -.. autofunction:: attr.validators.provides +.. autofunction:: attrs.validators.provides -.. autofunction:: attr.validators.and_ +.. autofunction:: attrs.validators.and_ - For convenience, it's also possible to pass a list to `attr.ib`'s validator argument. + For convenience, it's also possible to pass a list to `attrs.field`'s validator argument. Thus the following two statements are equivalent:: - x = attr.ib(validator=attr.validators.and_(v1, v2, v3)) - x = attr.ib(validator=[v1, v2, v3]) + x = attrs.field(validator=attrs.validators.and_(v1, v2, v3)) + x = attrs.field(validator=[v1, v2, v3]) -.. autofunction:: attr.validators.optional +.. autofunction:: attrs.validators.optional For example: .. doctest:: - >>> @attr.s - ... class C(object): - ... x = attr.ib(validator=attr.validators.optional(attr.validators.instance_of(int))) + >>> @attrs.define + ... class C: + ... x = attrs.field(validator=attrs.validators.optional(attr.validators.instance_of(int))) >>> C(42) C(x=42) >>> C("42") @@ -421,15 +599,15 @@ Validators C(x=None) -.. autofunction:: attr.validators.is_callable +.. autofunction:: attrs.validators.is_callable For example: .. doctest:: - >>> @attr.s - ... class C(object): - ... x = attr.ib(validator=attr.validators.is_callable()) + >>> @attrs.define + ... class C: + ... x = attrs.field(validator=attrs.validators.is_callable()) >>> C(isinstance) C(x=) >>> C("not a callable") @@ -438,15 +616,15 @@ Validators attr.exceptions.NotCallableError: 'x' must be callable (got 'not a callable' that is a ). -.. autofunction:: attr.validators.matches_re +.. autofunction:: attrs.validators.matches_re For example: .. doctest:: - >>> @attr.s - ... class User(object): - ... email = attr.ib(validator=attr.validators.matches_re( + >>> @attrs.define + ... class User: + ... email = attrs.field(validator=attrs.validators.matches_re( ... "(^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+$)")) >>> User(email="user@example.com") User(email='user@example.com') @@ -456,17 +634,17 @@ Validators ValueError: ("'email' must match regex '(^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\\\\.[a-zA-Z0-9-.]+$)' ('user@example.com@test.com' doesn't)", Attribute(name='email', default=NOTHING, validator=, repr=True, cmp=True, hash=None, init=True, metadata=mappingproxy({}), type=None, converter=None, kw_only=False), re.compile('(^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\\.[a-zA-Z0-9-.]+$)'), 'user@example.com@test.com') -.. autofunction:: attr.validators.deep_iterable +.. autofunction:: attrs.validators.deep_iterable For example: .. doctest:: - >>> @attr.s - ... class C(object): - ... x = attr.ib(validator=attr.validators.deep_iterable( - ... member_validator=attr.validators.instance_of(int), - ... iterable_validator=attr.validators.instance_of(list) + >>> @attrs.define + ... class C: + ... x = attrs.field(validator=attrs.validators.deep_iterable( + ... member_validator=attrs.validators.instance_of(int), + ... iterable_validator=attrs.validators.instance_of(list) ... )) >>> C(x=[1, 2, 3]) C(x=[1, 2, 3]) @@ -480,18 +658,18 @@ Validators TypeError: ("'x' must be (got '3' that is a ).", Attribute(name='x', default=NOTHING, validator=> iterables of >>, repr=True, cmp=True, hash=None, init=True, metadata=mappingproxy({}), type=None, converter=None, kw_only=False), , '3') -.. autofunction:: attr.validators.deep_mapping +.. autofunction:: attrs.validators.deep_mapping For example: .. doctest:: - >>> @attr.s - ... class C(object): - ... x = attr.ib(validator=attr.validators.deep_mapping( - ... key_validator=attr.validators.instance_of(str), - ... value_validator=attr.validators.instance_of(int), - ... mapping_validator=attr.validators.instance_of(dict) + >>> @attrs.define + ... class C: + ... x = attrs.field(validator=attrs.validators.deep_mapping( + ... key_validator=attrs.validators.instance_of(str), + ... value_validator=attrs.validators.instance_of(int), + ... mapping_validator=attrs.validators.instance_of(dict) ... )) >>> C(x={"a": 1, "b": 2}) C(x={'a': 1, 'b': 2}) @@ -508,11 +686,21 @@ Validators ... TypeError: ("'x' must be (got 7 that is a ).", Attribute(name='x', default=NOTHING, validator=> to >>, repr=True, cmp=True, hash=None, init=True, metadata=mappingproxy({}), type=None, converter=None, kw_only=False), , 7) +Validators can be both globally and locally disabled: + +.. autofunction:: attrs.validators.set_disabled + +.. autofunction:: attrs.validators.get_disabled + +.. autofunction:: attrs.validators.disabled + Converters ---------- -.. autofunction:: attr.converters.pipe +All objects from ``attrs.converters`` are also available from ``attr.converters``. + +.. autofunction:: attrs.converters.pipe For convenience, it's also possible to pass a list to `attr.ib`'s converter argument. @@ -521,7 +709,7 @@ Converters x = attr.ib(converter=attr.converter.pipe(c1, c2, c3)) x = attr.ib(converter=[c1, c2, c3]) -.. autofunction:: attr.converters.optional +.. autofunction:: attrs.converters.optional For example: @@ -536,7 +724,7 @@ Converters C(x=42) -.. autofunction:: attr.converters.default_if_none +.. autofunction:: attrs.converters.default_if_none For example: @@ -551,27 +739,50 @@ Converters C(x='') +.. autofunction:: attrs.converters.to_bool + + For example: + + .. doctest:: + + >>> @attr.s + ... class C(object): + ... x = attr.ib( + ... converter=attr.converters.to_bool + ... ) + >>> C("yes") + C(x=True) + >>> C(0) + C(x=False) + >>> C("foo") + Traceback (most recent call last): + File "", line 1, in + ValueError: Cannot convert value to bool: foo + + + .. _api_setters: Setters ------- -These are helpers that you can use together with `attr.s`'s and `attr.ib`'s ``on_setattr`` arguments. +These are helpers that you can use together with `attrs.define`'s and `attrs.fields`'s ``on_setattr`` arguments. +All setters in ``attrs.setters`` are also available from ``attr.setters``. -.. autofunction:: attr.setters.frozen -.. autofunction:: attr.setters.validate -.. autofunction:: attr.setters.convert -.. autofunction:: attr.setters.pipe -.. autodata:: attr.setters.NO_OP +.. autofunction:: attrs.setters.frozen +.. autofunction:: attrs.setters.validate +.. autofunction:: attrs.setters.convert +.. autofunction:: attrs.setters.pipe +.. autodata:: attrs.setters.NO_OP For example, only ``x`` is frozen here: .. doctest:: - >>> @attr.s(on_setattr=attr.setters.frozen) - ... class C(object): - ... x = attr.ib() - ... y = attr.ib(on_setattr=attr.setters.NO_OP) + >>> @attrs.define(on_setattr=attr.setters.frozen) + ... class C: + ... x = attr.field() + ... y = attr.field(on_setattr=attr.setters.NO_OP) >>> c = C(1, 2) >>> c.y = 3 >>> c.y @@ -579,51 +790,9 @@ These are helpers that you can use together with `attr.s`'s and `attr.ib`'s ``on >>> c.x = 4 Traceback (most recent call last): ... - attr.exceptions.FrozenAttributeError: () - - N.B. Please use `attr.s`'s *frozen* argument to freeze whole classes; it is more efficient. - - -.. _next-gen: - -Next Generation APIs --------------------- - -These are Python 3.6 and later-only, and keyword-only APIs that call `attr.s` with different default values. - -The most notable differences are: - -- automatically detect whether or not *auto_attribs* should be `True` -- *slots=True* (see :term:`slotted classes` for potentially surprising behaviors) -- *auto_exc=True* -- *auto_detect=True* -- *eq=True*, but *order=False* -- Validators run when you set an attribute (*on_setattr=attr.setters.validate*). -- Some options that aren't relevant to Python 3 have been dropped. - -Please note that these are *defaults* and you're free to override them, just like before. - -Since the Python ecosystem has settled on the term ``field`` for defining attributes, we have also added `attr.field` as a substitute for `attr.ib`. - -.. note:: - - `attr.s` and `attr.ib` (and their serious business cousins) aren't going anywhere. - The new APIs build on top of them. - -.. autofunction:: attr.define -.. function:: attr.mutable(same_as_define) - - Alias for `attr.define`. - - .. versionadded:: 20.1.0 - -.. function:: attr.frozen(same_as_define) - - Behaves the same as `attr.define` but sets *frozen=True* and *on_setattr=None*. - - .. versionadded:: 20.1.0 + attrs.exceptions.FrozenAttributeError: () -.. autofunction:: attr.field + N.B. Please use `attrs.define`'s *frozen* argument (or `attrs.frozen`) to freeze whole classes; it is more efficient. Deprecated APIs diff --git a/docs/backward-compatibility.rst b/docs/backward-compatibility.rst deleted file mode 100644 index c1165be14..000000000 --- a/docs/backward-compatibility.rst +++ /dev/null @@ -1,19 +0,0 @@ -Backward Compatibility -====================== - -.. currentmodule:: attr - -``attrs`` has a very strong backward compatibility policy that is inspired by the policy of the `Twisted framework `_. - -Put simply, you shouldn't ever be afraid to upgrade ``attrs`` if you're only using its public APIs. -If there will ever be a need to break compatibility, it will be announced in the `changelog` and raise a ``DeprecationWarning`` for a year (if possible) before it's finally really broken. - - -.. _exemption: - -.. warning:: - - The structure of the `attr.Attribute` class is exempt from this rule. - It *will* change in the future, but since it should be considered read-only, that shouldn't matter. - - However if you intend to build extensions on top of ``attrs`` you have to anticipate that. diff --git a/docs/comparison.rst b/docs/comparison.rst index b2b457bab..760124ca3 100644 --- a/docs/comparison.rst +++ b/docs/comparison.rst @@ -7,6 +7,8 @@ For that, ``attrs`` writes ``__eq__`` and ``__ne__`` methods for you. Additionally, if you pass ``order=True`` (which is the default if you use the `attr.s` decorator), ``attrs`` will also create a full set of ordering methods that are based on the defined fields: ``__le__``, ``__lt__``, ``__ge__``, and ``__gt__``. +.. _custom-comparison: + Customization ------------- @@ -14,11 +16,13 @@ As with other features, you can exclude fields from being involved in comparison .. doctest:: - >>> import attr - >>> @attr.s - ... class C(object): - ... x = attr.ib() - ... y = attr.ib(eq=False) + >>> from attr import define, field + + >>> @define + ... class C: + ... x: int + ... y: int = field(eq=False) + >>> C(1, 2) == C(1, 3) True @@ -27,15 +31,19 @@ It is then used as a key function like you may know from `sorted`: .. doctest:: - >>> import attr - >>> @attr.s - ... class S(object): - ... x = attr.ib(eq=str.lower) + >>> from attr import define, field + + >>> @define + ... class S: + ... x: str = field(eq=str.lower) + >>> S("foo") == S("FOO") True - >>> @attr.s - ... class C(object): - ... x = attr.ib(order=int) + + >>> @define(order=True) + ... class C: + ... x: str = field(order=int) + >>> C("10") > C("2") True @@ -47,12 +55,12 @@ For NumPy arrays it would look like this:: import numpy - @attr.s(order=False) + @define(order=False) class C: - an_array = attr.ib(eq=attr.cmp_using(eq=numpy.array_equal)) + an_array = field(eq=attr.cmp_using(eq=numpy.array_equal)) .. warning:: - Please note that *eq* and *order* are set *independently*, because *order* is `False` by default in `modern APIs `. + Please note that *eq* and *order* are set *independently*, because *order* is `False` by default in `attrs.define` (but not in `attr.s`). You can set both at once by using the *cmp* argument that we've undeprecated just for this use-case. diff --git a/docs/conf.py b/docs/conf.py index ae65fbbc2..0cc80be6a 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -1,38 +1,20 @@ -# -*- coding: utf-8 -*- - -import codecs -import os -import re - - -def read(*parts): - """ - Build an absolute path from *parts* and and return the contents of the - resulting file. Assume UTF-8 encoding. - """ - here = os.path.abspath(os.path.dirname(__file__)) - with codecs.open(os.path.join(here, *parts), "rb", "utf-8") as f: - return f.read() - - -def find_version(*file_paths): - """ - Build a path from *file_paths* and search for a ``__version__`` - string inside. - """ - version_file = read(*file_paths) - version_match = re.search( - r"^__version__ = ['\"]([^'\"]*)['\"]", version_file, re.M - ) - if version_match: - return version_match.group(1) - raise RuntimeError("Unable to find version string.") +# SPDX-License-Identifier: MIT + +from importlib import metadata # -- General configuration ------------------------------------------------ +doctest_global_setup = """ +from attr import define, frozen, field, validators, Factory +""" + linkcheck_ignore = [ + # We run into GitHub's rate limits. r"https://github.com/.*/(issues|pull)/\d+", + # It never finds the anchor even though it's there. + "https://github.com/microsoft/pyright/blob/main/specs/" + "dataclass_transforms.md#attrs", ] # In nitpick mode (-n), still ignore any of the following "broken" references @@ -66,17 +48,18 @@ def find_version(*file_paths): master_doc = "index" # General information about the project. -project = u"attrs" -copyright = u"2015, Hynek Schlawack" +project = "attrs" +author = "Hynek Schlawack" +copyright = f"2015, {author}" # The version info for the project you're documenting, acts as replacement for # |version| and |release|, also used in various other places throughout the # built documents. -# -# The short X.Y version. -release = find_version("../src/attr/__init__.py") -version = release.rsplit(u".", 1)[0] + # The full version, including alpha/beta/rc tags. +release = metadata.version("attrs") +# The short X.Y version. +version = release.rsplit(".", 1)[0] # List of patterns, relative to source directory, that match files and # directories to ignore when looking for source files. @@ -142,9 +125,7 @@ def find_version(*file_paths): # One entry per manual page. List of tuples # (source start file, name, description, authors, manual section). -man_pages = [ - ("index", "attrs", u"attrs Documentation", [u"Hynek Schlawack"], 1) -] +man_pages = [("index", "attrs", "attrs Documentation", ["Hynek Schlawack"], 1)] # -- Options for Texinfo output ------------------------------------------- @@ -156,14 +137,16 @@ def find_version(*file_paths): ( "index", "attrs", - u"attrs Documentation", - u"Hynek Schlawack", + "attrs Documentation", + "Hynek Schlawack", "attrs", - "One line description of project.", + "Python Clases Without Boilerplate", "Miscellaneous", ) ] +epub_description = "Python Clases Without Boilerplate" + intersphinx_mapping = { "https://docs.python.org/3": None, } diff --git a/docs/contributing.rst b/docs/contributing.rst deleted file mode 100644 index acb527b23..000000000 --- a/docs/contributing.rst +++ /dev/null @@ -1,5 +0,0 @@ -.. _contributing: - -.. include:: ../.github/CONTRIBUTING.rst - -.. include:: ../.github/CODE_OF_CONDUCT.rst diff --git a/docs/examples.rst b/docs/examples.rst index 0fac312a0..ba5343d4a 100644 --- a/docs/examples.rst +++ b/docs/examples.rst @@ -9,9 +9,9 @@ The simplest possible usage is: .. doctest:: - >>> import attr - >>> @attr.s - ... class Empty(object): + >>> from attrs import define + >>> @define + ... class Empty: ... pass >>> Empty() Empty() @@ -26,10 +26,10 @@ But you'll usually want some data on your classes, so let's add some: .. doctest:: - >>> @attr.s - ... class Coordinates(object): - ... x = attr.ib() - ... y = attr.ib() + >>> @define + ... class Coordinates: + ... x: int + ... y: int By default, all features are added, so you immediately have a fully functional data class with a nice ``repr`` string and comparison methods. @@ -46,27 +46,13 @@ By default, all features are added, so you immediately have a fully functional d As shown, the generated ``__init__`` method allows for both positional and keyword arguments. -If playful naming turns you off, ``attrs`` comes with serious-business aliases: - -.. doctest:: - - >>> from attr import attrs, attrib - >>> @attrs - ... class SeriousCoordinates(object): - ... x = attrib() - ... y = attrib() - >>> SeriousCoordinates(1, 2) - SeriousCoordinates(x=1, y=2) - >>> attr.fields(Coordinates) == attr.fields(SeriousCoordinates) - True - For private attributes, ``attrs`` will strip the leading underscores for keyword arguments: .. doctest:: - >>> @attr.s - ... class C(object): - ... _x = attr.ib() + >>> @define + ... class C: + ... _x: int >>> C(x=1) C(_x=1) @@ -74,9 +60,9 @@ If you want to initialize your private attributes yourself, you can do that too: .. doctest:: - >>> @attr.s - ... class C(object): - ... _x = attr.ib(init=False, default=42) + >>> @define + ... class C: + ... _x: int = field(init=False, default=42) >>> C() C(_x=42) >>> C(23) @@ -89,12 +75,12 @@ This is useful in times when you want to enhance classes that are not yours (nic .. doctest:: - >>> class SomethingFromSomeoneElse(object): + >>> class SomethingFromSomeoneElse: ... def __init__(self, x): ... self.x = x - >>> SomethingFromSomeoneElse = attr.s( + >>> SomethingFromSomeoneElse = define( ... these={ - ... "x": attr.ib() + ... "x": field() ... }, init=False)(SomethingFromSomeoneElse) >>> SomethingFromSomeoneElse(1) SomethingFromSomeoneElse(x=1) @@ -104,17 +90,17 @@ This is useful in times when you want to enhance classes that are not yours (nic .. doctest:: - >>> @attr.s - ... class A(object): - ... a = attr.ib() + >>> @define(slots=False) + ... class A: + ... a: int ... def get_a(self): ... return self.a - >>> @attr.s - ... class B(object): - ... b = attr.ib() - >>> @attr.s - ... class C(A, B): - ... c = attr.ib() + >>> @define(slots=False) + ... class B: + ... b: int + >>> @define(slots=False) + ... class C(B, A): + ... c: int >>> i = C(1, 2, 3) >>> i C(a=1, b=2, c=3) @@ -123,25 +109,9 @@ This is useful in times when you want to enhance classes that are not yours (nic >>> i.get_a() 1 -The order of the attributes is defined by the `MRO `_. - -In Python 3, classes defined within other classes are `detected `_ and reflected in the ``__repr__``. -In Python 2 though, it's impossible. -Therefore ``@attr.s`` comes with the ``repr_ns`` option to set it manually: - -.. doctest:: - - >>> @attr.s - ... class C(object): - ... @attr.s(repr_ns="C") - ... class D(object): - ... pass - >>> C.D() - C.D() - -``repr_ns`` works on both Python 2 and 3. -On Python 3 it overrides the implicit detection. +:term:`Slotted classes `, which are the default for the new APIs, don't play well with multiple inheritance so we don't use them in the example. +The order of the attributes is defined by the `MRO `_. Keyword-only Attributes ~~~~~~~~~~~~~~~~~~~~~~~ @@ -150,9 +120,9 @@ You can also add `keyword-only >> @attr.s + >>> @define ... class A: - ... a = attr.ib(kw_only=True) + ... a: int = field(kw_only=True) >>> A() Traceback (most recent call last): ... @@ -160,14 +130,14 @@ You can also add `keyword-only >> A(a=1) A(a=1) -``kw_only`` may also be specified at via ``attr.s``, and will apply to all attributes: +``kw_only`` may also be specified at via ``define``, and will apply to all attributes: .. doctest:: - >>> @attr.s(kw_only=True) + >>> @define(kw_only=True) ... class A: - ... a = attr.ib() - ... b = attr.ib() + ... a: int + ... b: int >>> A(1, 2) Traceback (most recent call last): ... @@ -183,12 +153,12 @@ Keyword-only attributes allow subclasses to add attributes without default value .. doctest:: - >>> @attr.s + >>> @define ... class A: - ... a = attr.ib(default=0) - >>> @attr.s + ... a: int = 0 + >>> @define ... class B(A): - ... b = attr.ib(kw_only=True) + ... b: int = field(kw_only=True) >>> B(b=1) B(a=0, b=1) >>> B() @@ -200,15 +170,15 @@ If you don't set ``kw_only=True``, then there's is no valid attribute ordering a .. doctest:: - >>> @attr.s + >>> @define ... class A: - ... a = attr.ib(default=0) - >>> @attr.s + ... a: int = 0 + >>> @define ... class B(A): - ... b = attr.ib() + ... b: int Traceback (most recent call last): ... - ValueError: No mandatory attributes allowed after an attribute with a default value or factory. Attribute in question: Attribute(name='b', default=NOTHING, validator=None, repr=True, cmp=True, hash=None, init=True, converter=None, metadata=mappingproxy({}), type=None, kw_only=False) + ValueError: No mandatory attributes allowed after an attribute with a default value or factory. Attribute in question: Attribute(name='b', default=NOTHING, validator=None, repr=True, cmp=True, hash=None, init=True, converter=None, metadata=mappingproxy({}), type=int, kw_only=False) .. _asdict: @@ -219,46 +189,55 @@ When you have a class with data, it often is very convenient to transform that c .. doctest:: - >>> attr.asdict(Coordinates(x=1, y=2)) + >>> from attrs import asdict + + >>> asdict(Coordinates(x=1, y=2)) {'x': 1, 'y': 2} Some fields cannot or should not be transformed. -For that, `attr.asdict` offers a callback that decides whether an attribute should be included: +For that, `attrs.asdict` offers a callback that decides whether an attribute should be included: .. doctest:: - >>> @attr.s - ... class UserList(object): - ... users = attr.ib() - >>> @attr.s + >>> @define ... class User(object): - ... email = attr.ib() - ... password = attr.ib() - >>> attr.asdict(UserList([User("jane@doe.invalid", "s33kred"), - ... User("joe@doe.invalid", "p4ssw0rd")]), - ... filter=lambda attr, value: attr.name != "password") + ... email: str + ... password: str + + >>> @define + ... class UserList: + ... users: list[User] + + >>> asdict(UserList([User("jane@doe.invalid", "s33kred"), + ... User("joe@doe.invalid", "p4ssw0rd")]), + ... filter=lambda attr, value: attr.name != "password") {'users': [{'email': 'jane@doe.invalid'}, {'email': 'joe@doe.invalid'}]} For the common case where you want to `include ` or `exclude ` certain types or attributes, ``attrs`` ships with a few helpers: .. doctest:: - >>> @attr.s - ... class User(object): - ... login = attr.ib() - ... password = attr.ib() - ... id = attr.ib() - >>> attr.asdict( + >>> from attrs import asdict, filters, fields + + >>> @define + ... class User: + ... login: str + ... password: str + ... id: int + + >>> asdict( ... User("jane", "s33kred", 42), - ... filter=attr.filters.exclude(attr.fields(User).password, int)) + ... filter=filters.exclude(fields(User).password, int)) {'login': 'jane'} - >>> @attr.s - ... class C(object): - ... x = attr.ib() - ... y = attr.ib() - ... z = attr.ib() - >>> attr.asdict(C("foo", "2", 3), - ... filter=attr.filters.include(int, attr.fields(C).x)) + + >>> @define + ... class C: + ... x: str + ... y: str + ... z: int + + >>> asdict(C("foo", "2", 3), + ... filter=filters.include(int, fields(C).x)) {'x': 'foo', 'z': 3} Other times, all you want is a tuple and ``attrs`` won't let you down: @@ -266,45 +245,50 @@ Other times, all you want is a tuple and ``attrs`` won't let you down: .. doctest:: >>> import sqlite3 - >>> import attr - >>> @attr.s + >>> from attrs import astuple + + >>> @define ... class Foo: - ... a = attr.ib() - ... b = attr.ib() + ... a: int + ... b: int + >>> foo = Foo(2, 3) >>> with sqlite3.connect(":memory:") as conn: ... c = conn.cursor() ... c.execute("CREATE TABLE foo (x INTEGER PRIMARY KEY ASC, y)") #doctest: +ELLIPSIS - ... c.execute("INSERT INTO foo VALUES (?, ?)", attr.astuple(foo)) #doctest: +ELLIPSIS + ... c.execute("INSERT INTO foo VALUES (?, ?)", astuple(foo)) #doctest: +ELLIPSIS ... foo2 = Foo(*c.execute("SELECT x, y FROM foo").fetchone()) >>> foo == foo2 True +For more advanced transformations and conversions, we recommend you look at a companion library (such as `cattrs `_). Defaults -------- Sometimes you want to have default values for your initializer. -And sometimes you even want mutable objects as default values (ever used accidentally ``def f(arg=[])``?). +And sometimes you even want mutable objects as default values (ever accidentally used ``def f(arg=[])``?). ``attrs`` has you covered in both cases: .. doctest:: >>> import collections - >>> @attr.s - ... class Connection(object): - ... socket = attr.ib() + + >>> @define + ... class Connection: + ... socket: int ... @classmethod ... def connect(cls, db_string): ... # ... connect somehow to db_string ... ... return cls(socket=42) - >>> @attr.s - ... class ConnectionPool(object): - ... db_string = attr.ib() - ... pool = attr.ib(default=attr.Factory(collections.deque)) - ... debug = attr.ib(default=False) + + >>> @define + ... class ConnectionPool: + ... db_string: str + ... pool: collections.deque = Factory(collections.deque) + ... debug: bool = False ... def get_connection(self): ... try: ... return self.pool.pop() @@ -327,33 +311,24 @@ And sometimes you even want mutable objects as default values (ever used acciden >>> cp ConnectionPool(db_string='postgres://localhost', pool=deque([Connection(socket=42)]), debug=False) -More information on why class methods for constructing objects are awesome can be found in this insightful `blog post `_. +More information on why class methods for constructing objects are awesome can be found in this insightful `blog post `_. -Default factories can also be set using a decorator. +Default factories can also be set using the ``factory`` argument to ``field``, and using a decorator. The method receives the partially initialized instance which enables you to base a default value on other attributes: .. doctest:: - >>> @attr.s - ... class C(object): - ... x = attr.ib(default=1) - ... y = attr.ib() + >>> @define + ... class C: + ... x: int = 1 + ... y: int = field() ... @y.default ... def _any_name_except_a_name_of_an_attribute(self): ... return self.x + 1 + ... z: list = field(factory=list) >>> C() - C(x=1, y=2) - + C(x=1, y=2, z=[]) -And since the case of ``attr.ib(default=attr.Factory(f))`` is so common, ``attrs`` also comes with syntactic sugar for it: - -.. doctest:: - - >>> @attr.s - ... class C(object): - ... x = attr.ib(factory=list) - >>> C() - C(x=[]) .. _examples_validators: @@ -368,9 +343,9 @@ You can use a decorator: .. doctest:: - >>> @attr.s - ... class C(object): - ... x = attr.ib() + >>> @define + ... class C: + ... x: int = field() ... @x.validator ... def check(self, attribute, value): ... if value > 42: @@ -386,14 +361,16 @@ You can use a decorator: .. doctest:: + >>> from attrs import validators + >>> def x_smaller_than_y(instance, attribute, value): ... if value >= instance.y: ... raise ValueError("'x' has to be smaller than 'y'!") - >>> @attr.s - ... class C(object): - ... x = attr.ib(validator=[attr.validators.instance_of(int), - ... x_smaller_than_y]) - ... y = attr.ib() + >>> @define + ... class C: + ... x: int = field(validator=[validators.instance_of(int), + ... x_smaller_than_y]) + ... y: int >>> C(x=3, y=4) C(x=3, y=4) >>> C(x=4, y=3) @@ -405,9 +382,9 @@ You can use a decorator: .. doctest:: - >>> @attr.s - ... class C(object): - ... x = attr.ib(validator=attr.validators.instance_of(int)) + >>> @define + ... class C: + ... x: int = field(validator=validators.instance_of(int)) ... @x.validator ... def fits_byte(self, attribute, value): ... if not 0 <= value < 256: @@ -417,22 +394,22 @@ You can use a decorator: >>> C("128") Traceback (most recent call last): ... - TypeError: ("'x' must be (got '128' that is a ).", Attribute(name='x', default=NOTHING, validator=[>, ], repr=True, cmp=True, hash=True, init=True, metadata=mappingproxy({}), type=None, converter=None, kw_only=False), , '128') + TypeError: ("'x' must be (got '128' that is a ).", Attribute(name='x', default=NOTHING, validator=[>, ], repr=True, cmp=True, hash=True, init=True, metadata=mappingproxy({}), type=int, converter=None, kw_only=False), , '128') >>> C(256) Traceback (most recent call last): ... ValueError: value out of bounds -Please note that the decorator approach only works if -- and only if! -- the attribute in question has an ``attr.ib`` assigned. -Therefore if you use ``@attr.s(auto_attribs=True)``, it is *not* enough to decorate said attribute with a type. +Please note that the decorator approach only works if -- and only if! -- the attribute in question has a ``field`` assigned. +Therefore if you use ``@default``, it is *not* enough to annotate said attribute with a type. ``attrs`` ships with a bunch of validators, make sure to `check them out ` before writing your own: .. doctest:: - >>> @attr.s - ... class C(object): - ... x = attr.ib(validator=attr.validators.instance_of(int)) + >>> @define + ... class C: + ... x: int = field(validator=validators.instance_of(int)) >>> C(42) C(x=42) >>> C("42") @@ -440,6 +417,9 @@ Therefore if you use ``@attr.s(auto_attribs=True)``, it is *not* enough to decor ... TypeError: ("'x' must be (got '42' that is a ).", Attribute(name='x', default=NOTHING, factory=NOTHING, validator=>, type=None, kw_only=False), , '42') +Please note that if you use `attr.s` (and not `attrs.define`) to define your class, validators only run on initialization by default. +This behavior can be changed using the ``on_setattr`` argument. + Check out `validators` for more details. @@ -451,13 +431,15 @@ This can be useful for doing type-conversions on values that you don't want to f .. doctest:: - >>> @attr.s - ... class C(object): - ... x = attr.ib(converter=int) + >>> @define + ... class C: + ... x: int = field(converter=int) >>> o = C("1") >>> o.x 1 +Please note that converters only run on initialization. + Check out `converters` for more details. @@ -470,12 +452,14 @@ All ``attrs`` attributes may include arbitrary metadata in the form of a read-on .. doctest:: - >>> @attr.s - ... class C(object): - ... x = attr.ib(metadata={'my_metadata': 1}) - >>> attr.fields(C).x.metadata + >>> from attrs import fields + + >>> @define + ... class C: + ... x = field(metadata={'my_metadata': 1}) + >>> fields(C).x.metadata mappingproxy({'my_metadata': 1}) - >>> attr.fields(C).x.metadata['my_metadata'] + >>> fields(C).x.metadata['my_metadata'] 1 Metadata is not used by ``attrs``, and is meant to enable rich functionality in third-party libraries. @@ -492,36 +476,42 @@ Types .. doctest:: - >>> @attr.s + >>> from attrs import fields + + >>> @define ... class C: - ... x = attr.ib(type=int) - ... y: int = attr.ib() - >>> attr.fields(C).x.type + ... x: int + >>> fields(C).x.type - >>> attr.fields(C).y.type + + >>> import attr + >>> @attr.s + ... class C(object): + ... x = attr.ib(type=int) + >>> fields(C).x.type -If you don't mind annotating *all* attributes, you can even drop the `attr.ib` and assign default values instead: +If you don't mind annotating *all* attributes, you can even drop the `attrs.field` and assign default values instead: .. doctest:: >>> import typing - >>> @attr.s(auto_attribs=True) + >>> from attrs import fields + + >>> @define ... class AutoC: ... cls_var: typing.ClassVar[int] = 5 # this one is ignored - ... l: typing.List[int] = attr.Factory(list) + ... l: list[int] = Factory(list) ... x: int = 1 - ... foo: str = attr.ib( - ... default="every attrib needs a type if auto_attribs=True" - ... ) + ... foo: str = "every attrib needs a type if auto_attribs=True" ... bar: typing.Any = None - >>> attr.fields(AutoC).l.type - typing.List[int] - >>> attr.fields(AutoC).x.type + >>> fields(AutoC).l.type + list[int] + >>> fields(AutoC).x.type - >>> attr.fields(AutoC).foo.type + >>> fields(AutoC).foo.type - >>> attr.fields(AutoC).bar.type + >>> fields(AutoC).bar.type typing.Any >>> AutoC() AutoC(l=[], x=1, foo='every attrib needs a type if auto_attribs=True', bar=None) @@ -531,32 +521,38 @@ If you don't mind annotating *all* attributes, you can even drop the `attr.ib` a The generated ``__init__`` method will have an attribute called ``__annotations__`` that contains this type information. If your annotations contain strings (e.g. forward references), -you can resolve these after all references have been defined by using :func:`attr.resolve_types`. +you can resolve these after all references have been defined by using :func:`attrs.resolve_types`. This will replace the *type* attribute in the respective fields. .. doctest:: - >>> import typing - >>> @attr.s(auto_attribs=True) + >>> from attrs import fields, resolve_types + + >>> @define ... class A: - ... a: typing.List['A'] + ... a: 'list[A]' ... b: 'B' ... - >>> @attr.s(auto_attribs=True) + >>> @define ... class B: ... a: A ... - >>> attr.fields(A).a.type - typing.List[ForwardRef('A')] - >>> attr.fields(A).b.type + >>> fields(A).a.type + 'list[A]' + >>> fields(A).b.type 'B' - >>> attr.resolve_types(A, globals(), locals()) + >>> resolve_types(A, globals(), locals()) - >>> attr.fields(A).a.type - typing.List[A] - >>> attr.fields(A).b.type + >>> fields(A).a.type + list[A] + >>> fields(A).b.type +.. note:: + + If you find yourself using string type annotations to handle forward references, wrap the entire type annotation in quotes instead of only the type you need a forward reference to (so ``'list[A]'`` instead of ``list['A']``). + This is a limitation of the Python typing system. + .. warning:: ``attrs`` itself doesn't have any features that work on top of type metadata *yet*. @@ -567,14 +563,16 @@ Slots ----- :term:`Slotted classes ` have several advantages on CPython. -Defining ``__slots__`` by hand is tedious, in ``attrs`` it's just a matter of passing ``slots=True``: +Defining ``__slots__`` by hand is tedious, in ``attrs`` it's just a matter of using `attrs.define` or passing ``slots=True`` to `attr.s`: .. doctest:: + >>> import attr + >>> @attr.s(slots=True) - ... class Coordinates(object): - ... x = attr.ib() - ... y = attr.ib() + ... class Coordinates: + ... x: int + ... y: int Immutability @@ -586,9 +584,9 @@ If you'd like to enforce it, ``attrs`` will try to help: .. doctest:: - >>> @attr.s(frozen=True) - ... class C(object): - ... x = attr.ib() + >>> @frozen + ... class C: + ... x: int >>> i = C(1) >>> i.x = 2 Traceback (most recent call last): @@ -605,14 +603,16 @@ In Clojure that function is called `assoc >> @attr.s(frozen=True) - ... class C(object): - ... x = attr.ib() - ... y = attr.ib() + >>> from attrs import evolve + + >>> @frozen + ... class C: + ... x: int + ... y: int >>> i1 = C(1, 2) >>> i1 C(x=1, y=2) - >>> i2 = attr.evolve(i1, y=3) + >>> i2 = evolve(i1, y=3) >>> i2 C(x=1, y=3) >>> i1 == i2 @@ -623,25 +623,28 @@ Other Goodies ------------- Sometimes you may want to create a class programmatically. -``attrs`` won't let you down and gives you `attr.make_class` : +``attrs`` won't let you down and gives you `attrs.make_class` : .. doctest:: - >>> @attr.s - ... class C1(object): - ... x = attr.ib() - ... y = attr.ib() - >>> C2 = attr.make_class("C2", ["x", "y"]) - >>> attr.fields(C1) == attr.fields(C2) + >>> from attrs import fields, make_class + >>> @define + ... class C1: + ... x = field() + ... y = field() + >>> C2 = make_class("C2", ["x", "y"]) + >>> fields(C1) == fields(C2) True -You can still have power over the attributes if you pass a dictionary of name: ``attr.ib`` mappings and can pass arguments to ``@attr.s``: +You can still have power over the attributes if you pass a dictionary of name: ``field`` mappings and can pass arguments to ``@attr.s``: .. doctest:: - >>> C = attr.make_class("C", {"x": attr.ib(default=42), - ... "y": attr.ib(default=attr.Factory(list))}, - ... repr=False) + >>> from attrs import make_class + + >>> C = make_class("C", {"x": field(default=42), + ... "y": field(default=Factory(list))}, + ... repr=False) >>> i = C() >>> i # no repr added! <__main__.C object at ...> @@ -650,30 +653,32 @@ You can still have power over the attributes if you pass a dictionary of name: ` >>> i.y [] -If you need to dynamically make a class with `attr.make_class` and it needs to be a subclass of something else than ``object``, use the ``bases`` argument: +If you need to dynamically make a class with `attrs.make_class` and it needs to be a subclass of something else than ``object``, use the ``bases`` argument: .. doctest:: - >>> class D(object): - ... def __eq__(self, other): - ... return True # arbitrary example - >>> C = attr.make_class("C", {}, bases=(D,), cmp=False) - >>> isinstance(C(), D) - True + >>> from attrs import make_class + + >>> class D: + ... def __eq__(self, other): + ... return True # arbitrary example + >>> C = make_class("C", {}, bases=(D,), cmp=False) + >>> isinstance(C(), D) + True Sometimes, you want to have your class's ``__init__`` method do more than just the initialization, validation, etc. that gets done for you automatically when -using ``@attr.s``. +using ``@define``. To do this, just define a ``__attrs_post_init__`` method in your class. It will get called at the end of the generated ``__init__`` method. .. doctest:: - >>> @attr.s - ... class C(object): - ... x = attr.ib() - ... y = attr.ib() - ... z = attr.ib(init=False) + >>> @define + ... class C: + ... x: int + ... y: int + ... z: int = field(init=False) ... ... def __attrs_post_init__(self): ... self.z = self.x + self.y @@ -685,10 +690,10 @@ You can exclude single attributes from certain methods: .. doctest:: - >>> @attr.s - ... class C(object): - ... user = attr.ib() - ... password = attr.ib(repr=False) + >>> @define + ... class C: + ... user: str + ... password: str = field(repr=False) >>> C("me", "s3kr3t") C(user='me') @@ -696,9 +701,9 @@ Alternatively, to influence how the generated ``__repr__()`` method formats a sp .. doctest:: - >>> @attr.s - ... class C(object): - ... user = attr.ib() - ... password = attr.ib(repr=lambda value: '***') + >>> @define + ... class C: + ... user: str + ... password: str = field(repr=lambda value: '***') >>> C("me", "s3kr3t") C(user='me', password=***) diff --git a/docs/extending.rst b/docs/extending.rst index 8994940f0..faf71afd9 100644 --- a/docs/extending.rst +++ b/docs/extending.rst @@ -2,26 +2,26 @@ Extending ========= Each ``attrs``-decorated class has a ``__attrs_attrs__`` class attribute. -It is a tuple of `attr.Attribute` carrying meta-data about each attribute. +It's a tuple of `attrs.Attribute` carrying metadata about each attribute. So it is fairly simple to build your own decorators on top of ``attrs``: .. doctest:: - >>> import attr + >>> from attr import define >>> def print_attrs(cls): ... print(cls.__attrs_attrs__) ... return cls >>> @print_attrs - ... @attr.s - ... class C(object): - ... a = attr.ib() - (Attribute(name='a', default=NOTHING, validator=None, repr=True, eq=True, eq_key=None, order=True, order_key=None, hash=None, init=True, metadata=mappingproxy({}), type=None, converter=None, kw_only=False, inherited=False, on_setattr=None),) + ... @define + ... class C: + ... a: int + (Attribute(name='a', default=NOTHING, validator=None, repr=True, eq=True, eq_key=None, order=True, order_key=None, hash=None, init=True, metadata=mappingproxy({}), type=, converter=None, kw_only=False, inherited=False, on_setattr=None),) .. warning:: - The `attr.s` decorator **must** be applied first because it puts ``__attrs_attrs__`` in place! + The `attrs.define`/`attr.s` decorator **must** be applied first because it puts ``__attrs_attrs__`` in place! That means that is has to come *after* your decorator because:: @a @@ -131,14 +131,14 @@ This information is available to you: .. doctest:: - >>> import attr - >>> @attr.s - ... class C(object): - ... x: int = attr.ib() - ... y = attr.ib(type=str) - >>> attr.fields(C).x.type + >>> from attr import attrib, define, field, fields + >>> @define + ... class C: + ... x: int = field() + ... y = attrib(type=str) + >>> fields(C).x.type - >>> attr.fields(C).y.type + >>> fields(C).y.type Currently, ``attrs`` doesn't do anything with this information but it's very useful if you'd like to write your own validators or serializers! @@ -160,36 +160,37 @@ Here are some tips for effective use of metadata: from mylib import MY_METADATA_KEY - @attr.s - class C(object): - x = attr.ib(metadata={MY_METADATA_KEY: 1}) + @define + class C: + x = field(metadata={MY_METADATA_KEY: 1}) Metadata should be composable, so consider supporting this approach even if you decide implementing your metadata in one of the following ways. -- Expose ``attr.ib`` wrappers for your specific metadata. +- Expose ``field`` wrappers for your specific metadata. This is a more graceful approach if your users don't require metadata from other libraries. .. doctest:: + >>> from attr import fields, NOTHING >>> MY_TYPE_METADATA = '__my_type_metadata' >>> >>> def typed( - ... cls, default=attr.NOTHING, validator=None, repr=True, + ... cls, default=NOTHING, validator=None, repr=True, ... eq=True, order=None, hash=None, init=True, metadata={}, - ... type=None, converter=None + ... converter=None ... ): ... metadata = dict() if not metadata else metadata ... metadata[MY_TYPE_METADATA] = cls - ... return attr.ib( + ... return field( ... default=default, validator=validator, repr=repr, ... eq=eq, order=order, hash=hash, init=init, - ... metadata=metadata, type=type, converter=converter + ... metadata=metadata, converter=converter ... ) >>> - >>> @attr.s - ... class C(object): - ... x = typed(int, default=1, init=False) - >>> attr.fields(C).x.metadata[MY_TYPE_METADATA] + >>> @define + ... class C: + ... x: int = typed(int, default=1, init=False) + >>> fields(C).x.metadata[MY_TYPE_METADATA] @@ -204,13 +205,13 @@ Its main purpose is to automatically add converters to attributes based on their This hook must have the following signature: -.. function:: your_hook(cls: type, fields: List[attr.Attribute]) -> List[attr.Attribute] +.. function:: your_hook(cls: type, fields: list[attrs.Attribute]) -> list[attrs.Attribute] :noindex: - *cls* is your class right *before* it is being converted into an attrs class. This means it does not yet have the ``__attrs_attrs__`` attribute. -- *fields* is a list of all :class:`attr.Attribute` instances that will later be set to ``__attrs_attrs__``. +- *fields* is a list of all `attrs.Attribute` instances that will later be set to ``__attrs_attrs__``. You can modify these attributes any way you want: You can add converters, change types, and even remove attributes completely or create new ones! @@ -221,7 +222,7 @@ For example, let's assume that you really don't like floats: >>> def drop_floats(cls, fields): ... return [f for f in fields if f.type not in {float, 'float'}] ... - >>> @attr.frozen(field_transformer=drop_floats) + >>> @frozen(field_transformer=drop_floats) ... class Data: ... a: int ... b: float @@ -249,7 +250,7 @@ A more realistic example would be to automatically convert data that you, e.g., ... results.append(field.evolve(converter=converter)) ... return results ... - >>> @attr.frozen(field_transformer=auto_convert) + >>> @frozen(field_transformer=auto_convert) ... class Data: ... a: int ... b: str @@ -263,19 +264,20 @@ A more realistic example would be to automatically convert data that you, e.g., Customize Value Serialization in ``asdict()`` --------------------------------------------- -``attrs`` allows you to serialize instances of ``attrs`` classes to dicts using the `attr.asdict` function. +``attrs`` allows you to serialize instances of ``attrs`` classes to dicts using the `attrs.asdict` function. However, the result can not always be serialized since most data types will remain as they are: .. doctest:: >>> import json >>> import datetime + >>> from attrs import asdict >>> - >>> @attr.frozen + >>> @frozen ... class Data: ... dt: datetime.datetime ... - >>> data = attr.asdict(Data(datetime.datetime(2020, 5, 4, 13, 37))) + >>> data = asdict(Data(datetime.datetime(2020, 5, 4, 13, 37))) >>> data {'dt': datetime.datetime(2020, 5, 4, 13, 37)} >>> json.dumps(data) @@ -286,17 +288,18 @@ However, the result can not always be serialized since most data types will rema To help you with this, `attr.asdict` allows you to pass a *value_serializer* hook. It has the signature -.. function:: your_hook(inst: type, field: attr.Attribute, value: typing.Any) -> typing.Any +.. function:: your_hook(inst: type, field: attrs.Attribute, value: typing.Any) -> typing.Any :noindex: .. doctest:: + >>> from attr import asdict >>> def serialize(inst, field, value): ... if isinstance(value, datetime.datetime): ... return value.isoformat() ... return value ... - >>> data = attr.asdict( + >>> data = asdict( ... Data(datetime.datetime(2020, 5, 4, 13, 37)), ... value_serializer=serialize, ... ) diff --git a/docs/glossary.rst b/docs/glossary.rst index 8bd53556b..5fd01f4fb 100644 --- a/docs/glossary.rst +++ b/docs/glossary.rst @@ -3,6 +3,15 @@ Glossary .. glossary:: + dunder methods + "Dunder" is a contraction of "double underscore". + + It's methods like ``__init__`` or ``__eq__`` that are sometimes also called *magic methods* or it's said that they implement an *object protocol*. + + In spoken form, you'd call ``__init__`` just "dunder init". + + Its first documented use is a `mailing list posting `_ by Mark Jackson from 2002. + dict classes A regular class whose attributes are stored in the `object.__dict__` attribute of every single instance. This is quite wasteful especially for objects with very few data attributes and the space consumption can become significant when creating large numbers of instances. @@ -22,11 +31,11 @@ Glossary .. doctest:: - >>> import attr - >>> @attr.s(slots=True) - ... class Coordinates(object): - ... x = attr.ib() - ... y = attr.ib() + >>> from attr import define + >>> @define + ... class Coordinates: + ... x: int + ... y: int ... >>> c = Coordinates(x=1, y=2) >>> c.z = 3 @@ -47,9 +56,9 @@ Glossary .. doctest:: >>> import attr, unittest.mock - >>> @attr.s(slots=True) - ... class Slotted(object): - ... x = attr.ib() + >>> @define + ... class Slotted: + ... x: int ... ... def method(self): ... return self.x @@ -61,7 +70,7 @@ Glossary Traceback (most recent call last): ... AttributeError: 'Slotted' object attribute 'method' is read-only - >>> @attr.s # implies 'slots=False' + >>> @define(slots=False) ... class Dicted(Slotted): ... pass >>> d = Dicted(42) diff --git a/docs/how-does-it-work.rst b/docs/how-does-it-work.rst index 8519c8119..f89974054 100644 --- a/docs/how-does-it-work.rst +++ b/docs/how-does-it-work.rst @@ -10,24 +10,26 @@ Boilerplate ``attrs`` certainly isn't the first library that aims to simplify class definition in Python. But its **declarative** approach combined with **no runtime overhead** lets it stand out. -Once you apply the ``@attr.s`` decorator to a class, ``attrs`` searches the class object for instances of ``attr.ib``\ s. +Once you apply the ``@attrs.define`` (or ``@attr.s``) decorator to a class, ``attrs`` searches the class object for instances of ``attr.ib``\ s. Internally they're a representation of the data passed into ``attr.ib`` along with a counter to preserve the order of the attributes. +Alternatively, it's possible to define them using :doc:`types`. In order to ensure that subclassing works as you'd expect it to work, ``attrs`` also walks the class hierarchy and collects the attributes of all base classes. Please note that ``attrs`` does *not* call ``super()`` *ever*. -It will write dunder methods to work on *all* of those attributes which also has performance benefits due to fewer function calls. +It will write :term:`dunder methods` to work on *all* of those attributes which also has performance benefits due to fewer function calls. -Once ``attrs`` knows what attributes it has to work on, it writes the requested dunder methods and -- depending on whether you wish to have a :term:`dict ` or :term:`slotted ` class -- creates a new class for you (``slots=True``) or attaches them to the original class (``slots=False``). +Once ``attrs`` knows what attributes it has to work on, it writes the requested :term:`dunder methods` and -- depending on whether you wish to have a :term:`dict ` or :term:`slotted ` class -- creates a new class for you (``slots=True``) or attaches them to the original class (``slots=False``). While creating new classes is more elegant, we've run into several edge cases surrounding metaclasses that make it impossible to go this route unconditionally. To be very clear: if you define a class with a single attribute without a default value, the generated ``__init__`` will look *exactly* how you'd expect: .. doctest:: - >>> import attr, inspect - >>> @attr.s - ... class C(object): - ... x = attr.ib() + >>> import inspect + >>> from attr import define + >>> @define + ... class C: + ... x: int >>> print(inspect.getsource(C.__init__)) def __init__(self, x): self.x = x @@ -40,7 +42,7 @@ No magic, no meta programming, no expensive introspection at runtime. Everything until this point happens exactly *once* when the class is defined. As soon as a class is done, it's done. And it's just a regular Python class like any other, except for a single ``__attrs_attrs__`` attribute that ``attrs`` uses internally. -Much of the information is accessible via `attr.fields` and other functions which can be used for introspection or for writing your own tools and decorators on top of ``attrs`` (like `attr.asdict`). +Much of the information is accessible via `attrs.fields` and other functions which can be used for introspection or for writing your own tools and decorators on top of ``attrs`` (like `attrs.asdict`). And once you start instantiating your classes, ``attrs`` is out of your way completely. @@ -52,11 +54,11 @@ This **static** approach was very much a design goal of ``attrs`` and what I str Immutability ------------ -In order to give you immutability, ``attrs`` will attach a ``__setattr__`` method to your class that raises an `attr.exceptions.FrozenInstanceError` whenever anyone tries to set an attribute. +In order to give you immutability, ``attrs`` will attach a ``__setattr__`` method to your class that raises an `attrs.exceptions.FrozenInstanceError` whenever anyone tries to set an attribute. -The same is true if you choose to freeze individual attributes using the `attr.setters.frozen` *on_setattr* hook -- except that the exception becomes `attr.exceptions.FrozenAttributeError`. +The same is true if you choose to freeze individual attributes using the `attrs.setters.frozen` *on_setattr* hook -- except that the exception becomes `attrs.exceptions.FrozenAttributeError`. -Both errors subclass `attr.exceptions.FrozenError`. +Both errors subclass `attrs.exceptions.FrozenError`. ----- @@ -102,6 +104,6 @@ Pick what's more important to you. Summary +++++++ -You should avoid instantiating lots of frozen slotted classes (i.e. ``@attr.s(slots=True, frozen=True)``) in performance-critical code. +You should avoid instantiating lots of frozen slotted classes (i.e. ``@frozen``) in performance-critical code. Frozen dict classes have barely a performance impact, unfrozen slotted classes are even *faster* than unfrozen dict classes (i.e. regular classes). diff --git a/docs/index.rst b/docs/index.rst index 2700045ef..ff65a6738 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -1,3 +1,6 @@ +.. module:: attr +.. module:: attrs + ====================================== ``attrs``: Classes Without Boilerplate ====================================== @@ -22,22 +25,23 @@ The recommended installation method is `pip `_-i The next three steps should bring you up and running in no time: - `overview` will show you a simple example of ``attrs`` in action and introduce you to its philosophy. - Afterwards, you can start writing your own classes, understand what drives ``attrs``'s design, and know what ``@attr.s`` and ``attr.ib()`` stand for. + Afterwards, you can start writing your own classes and understand what drives ``attrs``'s design. - `examples` will give you a comprehensive tour of ``attrs``'s features. After reading, you will know about our advanced features and how to use them. +- If you're confused by all the ``attr.s``, ``attr.ib``, ``attrs``, ``attrib``, ``define``, ``frozen``, and ``field``, head over to `names` for a very short explanation, and optionally a quick history lesson. - Finally `why` gives you a rundown of potential alternatives and why we think ``attrs`` is superior. Yes, we've heard about ``namedtuple``\ s and Data Classes! - If at any point you get confused by some terminology, please check out our `glossary`. -If you need any help while getting started, feel free to use the ``python-attrs`` tag on `StackOverflow `_ and someone will surely help you out! +If you need any help while getting started, feel free to use the ``python-attrs`` tag on `Stack Overflow `_ and someone will surely help you out! Day-to-Day Usage ================ - `types` help you to write *correct* and *self-documenting* code. - ``attrs`` has first class support for them and even allows you to drop the calls to `attr.ib` on modern Python versions! + ``attrs`` has first class support for them, yet keeps them optional if you’re not convinced! - Instance initialization is one of ``attrs`` key feature areas. Our goal is to relieve you from writing as much code as possible. `init` gives you an overview what ``attrs`` has to offer and explains some related philosophies we believe in. @@ -74,6 +78,7 @@ Full Table of Contents api extending how-does-it-work + names glossary @@ -84,9 +89,7 @@ Full Table of Contents :maxdepth: 1 license - backward-compatibility python-2 - contributing changelog diff --git a/docs/init.rst b/docs/init.rst index fdbf0e126..fb276ded8 100644 --- a/docs/init.rst +++ b/docs/init.rst @@ -17,10 +17,10 @@ So assuming you use an ORM and want to extract 2D points from a row object, do n Instead, write a `classmethod` that will extract it for you:: - @attr.s - class Point(object): - x = attr.ib() - y = attr.ib() + @define + class Point: + x: float + y: float @classmethod def from_row(cls, row): @@ -51,21 +51,22 @@ One thing people tend to find confusing is the treatment of private attributes t .. doctest:: - >>> import inspect, attr - >>> @attr.s - ... class C(object): - ... _x = attr.ib() + >>> import inspect, attr, attrs + >>> from attr import define + >>> @define + ... class C: + ... _x: int >>> inspect.signature(C.__init__) - None> + None> There really isn't a right or wrong, it's a matter of taste. But it's important to be aware of it because it can lead to surprising syntax errors: .. doctest:: - >>> @attr.s - ... class C(object): - ... _1 = attr.ib() + >>> @define + ... class C: + ... _1: int Traceback (most recent call last): ... SyntaxError: invalid syntax @@ -83,13 +84,14 @@ This is when default values come into play: .. doctest:: - >>> import attr - >>> @attr.s - ... class C(object): - ... a = attr.ib(default=42) - ... b = attr.ib(default=attr.Factory(list)) - ... c = attr.ib(factory=list) # syntactic sugar for above - ... d = attr.ib() + >>> from attr import define, field, Factory + + >>> @define + ... class C: + ... a: int = 42 + ... b: list = field(factory=list) + ... c: list = Factory(list) # syntactic sugar for above + ... d: dict = field() ... @d.default ... def _any_name_except_a_name_of_an_attribute(self): ... return {} @@ -97,15 +99,14 @@ This is when default values come into play: C(a=42, b=[], c=[], d={}) It's important that the decorated method -- or any other method or property! -- doesn't have the same name as the attribute, otherwise it would overwrite the attribute definition. -You also cannot use type annotations to elide the `attr.ib` call for ``d`` as explained in `types`. Please note that as with function and method signatures, ``default=[]`` will *not* do what you may think it might do: .. doctest:: - >>> @attr.s - ... class C(object): - ... x = attr.ib(default=[]) + >>> @define + ... class C: + ... x = [] >>> i = C() >>> k = C() >>> i.x.append(42) @@ -147,9 +148,9 @@ The method has to accept three arguments: If the value does not pass the validator's standards, it just raises an appropriate exception. - >>> @attr.s - ... class C(object): - ... x = attr.ib() + >>> @define + ... class C: + ... x: int = field() ... @x.validator ... def _check_x(self, attribute, value): ... if value > 42: @@ -161,28 +162,28 @@ If the value does not pass the validator's standards, it just raises an appropri ... ValueError: x must be smaller or equal to 42 -Again, it's important that the decorated method doesn't have the same name as the attribute and that you can't elide the call to `attr.ib`. +Again, it's important that the decorated method doesn't have the same name as the attribute and that the `attrs.field()` helper is used. Callables ~~~~~~~~~ -If you want to re-use your validators, you should have a look at the ``validator`` argument to `attr.ib`. +If you want to re-use your validators, you should have a look at the ``validator`` argument to `attrs.field`. It takes either a callable or a list of callables (usually functions) and treats them as validators that receive the same arguments as with the decorator approach. -Since the validators runs *after* the instance is initialized, you can refer to other attributes while validating: +Since the validators run *after* the instance is initialized, you can refer to other attributes while validating: .. doctest:: >>> def x_smaller_than_y(instance, attribute, value): ... if value >= instance.y: ... raise ValueError("'x' has to be smaller than 'y'!") - >>> @attr.s - ... class C(object): - ... x = attr.ib(validator=[attr.validators.instance_of(int), - ... x_smaller_than_y]) - ... y = attr.ib() + >>> @define + ... class C: + ... x = field(validator=[attrs.validators.instance_of(int), + ... x_smaller_than_y]) + ... y = field() >>> C(x=3, y=4) C(x=3, y=4) >>> C(x=4, y=3) @@ -190,15 +191,15 @@ Since the validators runs *after* the instance is initialized, you can refer to ... ValueError: 'x' has to be smaller than 'y'! -This example also shows of some syntactic sugar for using the `attr.validators.and_` validator: if you pass a list, all validators have to pass. +This example also shows of some syntactic sugar for using the `attrs.validators.and_` validator: if you pass a list, all validators have to pass. -``attrs`` won't intercept your changes to those attributes but you can always call `attr.validate` on any instance to verify that it's still valid: +``attrs`` won't intercept your changes to those attributes but you can always call `attrs.validate` on any instance to verify that it's still valid: +When using `attrs.define` or `attrs.frozen`, ``attrs`` will run the validators even when setting the attribute. .. doctest:: >>> i = C(4, 5) - >>> i.x = 5 # works, no magic here - >>> attr.validate(i) + >>> i.x = 5 Traceback (most recent call last): ... ValueError: 'x' has to be smaller than 'y'! @@ -207,9 +208,9 @@ This example also shows of some syntactic sugar for using the `attr.validators.a .. doctest:: - >>> @attr.s - ... class C(object): - ... x = attr.ib(validator=attr.validators.instance_of(int)) + >>> @define + ... class C: + ... x = field(validator=attrs.validators.instance_of(int)) >>> C(42) C(x=42) >>> C("42") @@ -222,9 +223,9 @@ If you define validators both ways for an attribute, they are both ran: .. doctest:: - >>> @attr.s - ... class C(object): - ... x = attr.ib(validator=attr.validators.instance_of(int)) + >>> @define + ... class C: + ... x = field(validator=attrs.validators.instance_of(int)) ... @x.validator ... def fits_byte(self, attribute, value): ... if not 0 <= value < 256: @@ -242,10 +243,20 @@ If you define validators both ways for an attribute, they are both ran: And finally you can disable validators globally: - >>> attr.set_run_validators(False) + >>> attrs.validators.set_disabled(True) + >>> C("128") + C(x='128') + >>> attrs.validators.set_disabled(False) >>> C("128") + Traceback (most recent call last): + ... + TypeError: ("'x' must be (got '128' that is a ).", Attribute(name='x', default=NOTHING, validator=[>, ], repr=True, cmp=True, hash=True, init=True, metadata=mappingproxy({}), type=None, converter=None), , '128') + +You can achieve the same by using the context manager: + + >>> with attrs.validators.disabled(): + ... C("128") C(x='128') - >>> attr.set_run_validators(True) >>> C("128") Traceback (most recent call last): ... @@ -265,9 +276,9 @@ This can be useful for doing type-conversions on values that you don't want to f .. doctest:: - >>> @attr.s - ... class C(object): - ... x = attr.ib(converter=int) + >>> @define + ... class C: + ... x = field(converter=int) >>> o = C("1") >>> o.x 1 @@ -279,9 +290,9 @@ Converters are run *before* validators, so you can use validators to check the f >>> def validate_x(instance, attribute, value): ... if value < 0: ... raise ValueError("x must be at least 0.") - >>> @attr.s - ... class C(object): - ... x = attr.ib(converter=int, validator=validate_x) + >>> @define + ... class C: + ... x = field(converter=int, validator=validate_x) >>> o = C("0") >>> o.x 0 @@ -308,9 +319,9 @@ A converter will override an explicit type annotation or ``type`` argument. >>> def str2int(x: str) -> int: ... return int(x) - >>> @attr.s - ... class C(object): - ... x = attr.ib(converter=str2int) + >>> @define + ... class C: + ... x = field(converter=str2int) >>> C.__init__.__annotations__ {'return': None, 'x': } @@ -338,9 +349,9 @@ The sole reason for the existance of ``__attrs_pre_init__`` is to give users the .. doctest:: - >>> @attr.s - ... class C(object): - ... x = attr.ib() + >>> @define + ... class C: + ... x: int ... def __attrs_pre_init__(self): ... super().__init__() >>> C(42) @@ -359,9 +370,10 @@ Here's an example of a manual default value: .. doctest:: >>> from typing import Optional - >>> @attr.s(auto_detect=True) # or init=False - ... class C(object): - ... x = attr.ib() + + >>> @define + ... class C: + ... x: int ... ... def __init__(self, x: int = 42): ... self.__attrs_init__(x) @@ -374,10 +386,10 @@ Post Init .. doctest:: - >>> @attr.s - ... class C(object): - ... x = attr.ib() - ... y = attr.ib(init=False) + >>> @define + ... class C: + ... x: int + ... y: int = field(init=False) ... def __attrs_post_init__(self): ... self.y = self.x + 1 >>> C(1) @@ -387,25 +399,25 @@ Please note that you can't directly set attributes on frozen classes: .. doctest:: - >>> @attr.s(frozen=True) - ... class FrozenBroken(object): - ... x = attr.ib() - ... y = attr.ib(init=False) + >>> @frozen + ... class FrozenBroken: + ... x: int + ... y: int = field(init=False) ... def __attrs_post_init__(self): ... self.y = self.x + 1 >>> FrozenBroken(1) Traceback (most recent call last): ... - attr.exceptions.FrozenInstanceError: can't set attribute + attrs.exceptions.FrozenInstanceError: can't set attribute If you need to set attributes on a frozen class, you'll have to resort to the `same trick ` as ``attrs`` and use :meth:`object.__setattr__`: .. doctest:: - >>> @attr.s(frozen=True) - ... class Frozen(object): - ... x = attr.ib() - ... y = attr.ib(init=False) + >>> @define + ... class Frozen: + ... x: int + ... y: int = field(init=False) ... def __attrs_post_init__(self): ... object.__setattr__(self, "y", self.x + 1) >>> Frozen(1) @@ -431,6 +443,47 @@ If present, the hooks are executed in the following order: Notably this means, that you can access all attributes from within your validators, but your converters have to deal with invalid values and have to return a valid value. +Derived Attributes +------------------ + +One of the most common ``attrs`` questions on *Stack Overflow* is how to have attributes that depend on other attributes. +For example if you have an API token and want to instantiate a web client that uses it for authentication. +Based on the previous sections, there's two approaches. + +The simpler one is using ``__attrs_post_init__``:: + + @define + class APIClient: + token: str + client: WebClient = field(init=False) + + def __attrs_post_init__(self): + self.client = WebClient(self.token) + +The second one is using a decorator-based default:: + + @define + class APIClient: + token: str + client: WebClient = field() # needed! attr.ib works too + + @client.default + def _client_factory(self): + return WebClient(self.token) + +That said, and as pointed out in the beginning of the chapter, a better approach would be to have a factory class method:: + + @define + class APIClient: + client: WebClient + + @classmethod + def from_token(cls, token: str) -> SomeClass: + return cls(client=WebClient(token)) + +This makes the class more testable. + + .. _`Wiki page`: https://github.com/python-attrs/attrs/wiki/Extensions-to-attrs .. _`get confused`: https://github.com/python-attrs/attrs/issues/289 .. _`there is no such thing as a private argument`: https://github.com/hynek/characteristic/issues/6 diff --git a/docs/names.rst b/docs/names.rst new file mode 100644 index 000000000..0fe953e6a --- /dev/null +++ b/docs/names.rst @@ -0,0 +1,122 @@ +On The Core API Names +===================== + +You may be surprised seeing ``attrs`` classes being created using `attrs.define` and with type annotated fields, instead of `attr.s` and `attr.ib()`. + +Or, you wonder why the web and talks are full of this weird `attr.s` and `attr.ib` -- including people having strong opinions about it and using ``attr.attrs`` and ``attr.attrib`` instead. + +And what even is ``attr.dataclass`` that's not documented but commonly used!? + + +TL;DR +----- + +We recommend our modern APIs for new code: + +- `attrs.define()` to define a new class, +- `attrs.mutable()` is an alias for `attrs.define()`, +- `attrs.frozen()` is an alias for ``define(frozen=True)`` +- and `attrs.field()` to define an attribute. + +They have been added in ``attrs`` 20.1.0, they are expressive, and they have modern defaults like slots and type annotation awareness switched on by default. +They are only available in Python 3.6 and later. +Sometimes they're referred to as *next-generation* or *NG* APIs. +As of ``attrs`` 21.3.0 you can also import them from the ``attrs`` package namespace. + +The traditional APIs `attr.s` / `attr.ib`, their serious business aliases ``attr.attrs`` / ``attr.attrib``, and the never-documented, but popular ``attr.dataclass`` easter egg will stay **forever**. + +``attrs`` will **never** force you to use type annotations. + + +A Short History Lesson +---------------------- + +At this point, ``attrs`` is an old project. +It had its first release in April 2015 -- back when most Python code was on Python 2.7 and Python 3.4 was the first Python 3 release that showed promise. +``attrs`` was always Python 3-first, but `type annotations `_ came only into Python 3.5 that was released in September 2015 and were largely ignored until years later. + +At this time, if you didn't want to implement all the :term:`dunder methods`, the most common way to create a class with some attributes on it was to subclass a `collections.namedtuple`, or one of the many hacks that allowed you to access dictionary keys using attribute lookup. + +But ``attrs`` history goes even a bit further back, to the now-forgotten `characteristic `_ that came out in May 2014 and already used a class decorator, but was overall too unergonomic. + +In the wake of all of that, `glyph `_ and `Hynek `_ came together on IRC and brainstormed how to take the good ideas of ``characteristic``, but make them easier to use and read. +At this point the plan was not to make ``attrs`` what it is now -- a flexible class building kit. +All we wanted was an ergonomic little library to succinctly define classes with attributes. + +Under the impression of of the unwieldy ``characteristic`` name, we went to the other side and decided to make the package name part of the API, and keep the API functions very short. +This led to the infamous `attr.s` and `attr.ib` which some found confusing and pronounced it as "attr dot s" or used a singular ``@s`` as the decorator. +But it was really just a way to say ``attrs`` and ``attrib``\ [#attr]_. + +Some people hated this cutey API from day one, which is why we added aliases for them that we called *serious business*: ``@attr.attrs`` and ``attr.attrib()``. +Fans of them usually imported the names and didn't use the package name in the first place. +Unfortunately, the ``attr`` package name started creaking the moment we added ``attr.Factory``, since it couldn’t be morphed into something meaningful in any way. +A problem that grew worse over time, as more APIs and even modules were added. + +But overall, ``attrs`` in this shape was a **huge** success -- especially after glyph's blog post `The One Python Library Everyone Needs `_ in August 2016 and `pytest `_ adopting it. + +Being able to just write:: + + @attr.s + class Point(object): + x = attr.ib() + y = attr.ib() + +was a big step for those who wanted to write small, focused classes. + + +Dataclasses Enter The Arena +^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +A big change happened in May 2017 when Hynek sat down with `Guido van Rossum `_ and `Eric V. Smith `_ at PyCon US 2017. + +Type annotations for class attributes have `just landed `_ in Python 3.6 and Guido felt like it would be a good mechanic to introduce something similar to ``attrs`` to the Python standard library. +The result, of course, was `PEP 557 `_\ [#stdlib]_ which eventually became the `dataclasses` module in Python 3.7. + +``attrs`` at this point was lucky to have several people on board who were also very excited about type annotations and helped implementing it; including a `Mypy plugin `_. +And so it happened that ``attrs`` `shipped `_ the new method of defining classes more than half a year before Python 3.7 -- and thus `dataclasses` -- were released. + +----- + +Due to backward-compatibility concerns, this feature is off by default in the `attr.s` decorator and has to be activated using ``@attr.s(auto_attribs=True)``, though. +As a little easter egg and to save ourselves some typing, we've also `added `_ an alias called ``attr.dataclasses`` that just set ``auto_attribs=True``. +It was never documented, but people found it and used it and loved it. + +Over the next months and years it became clear that type annotations have become the popular way to define classes and their attributes. +However, it has also become clear that some people viscerally hate type annotations. +We're determined to serve both. + + +``attrs`` TNG +^^^^^^^^^^^^^ + +Over its existence, ``attrs`` never stood still. +But since we also greatly care about backward compatibility and not breaking our users's code, many features and niceties have to be manually activated. + +That is not only annoying, it also leads to the problem that many of ``attrs``'s users don't even know what it can do for them. +We've spent years alone explaining that defining attributes using type annotations is in no way unique to `dataclasses`. + +Finally we've decided to take the `Go route `_: +instead of fiddling with the old APIs -- whose names felt anachronistic anyway -- we'd define new ones, with better defaults. +So in July 2018, we `looked for better names `_ and came up with `attr.define`, `attr.field`, and friends. +Then in January 2019, we `started looking for inconvenient defaults `_ that we now could fix without any repercussions. + +These APIs proved to be very popular, so we've finally changed the documentation to them in November of 2021. + +All of this took way too long, of course. +One reason is the COVID-19 pandemic, but also our fear to fumble this historic chance to fix our APIs. + +Finally, in December 2021, we've added the ``attrs`` package namespace. + +We hope you like the result:: + + from attrs import define + + @define + class Point: + x: int + y: int + + +.. [#attr] We considered calling the PyPI package just ``attr`` too, but the name was already taken by an *ostensibly* inactive `package on PyPI `_. +.. [#stdlib] The highly readable PEP also explains why ``attrs`` wasn't just added to the standard library. + Don't believe the myths and rumors. diff --git a/docs/overview.rst b/docs/overview.rst index 3b2ce6f60..b35f66f2d 100644 --- a/docs/overview.rst +++ b/docs/overview.rst @@ -26,7 +26,7 @@ Philosophy An ``attrs`` class in runtime is indistinguishable from a regular class: because it *is* a regular class with a few boilerplate-y methods attached. **Be light on API impact.** - As convenient as it seems at first, ``attrs`` will *not* tack on any methods to your classes save the dunder ones. + As convenient as it seems at first, ``attrs`` will *not* tack on any methods to your classes except for the :term:`dunder ones `. Hence all the useful `tools ` that come with ``attrs`` live in functions that operate on top of instances. Since they take an ``attrs`` instance as their first argument, you can attach them to your classes with one line of code. @@ -50,38 +50,9 @@ What ``attrs`` Is Not All ``attrs`` does is: 1. take your declaration, -2. write dunder methods based on that information, +2. write :term:`dunder methods` based on that information, 3. and attach them to your class. It does *nothing* dynamic at runtime, hence zero runtime overhead. It's still *your* class. Do with it as you please. - - -On the ``attr.s`` and ``attr.ib`` Names -======================================= - -The ``attr.s`` decorator and the ``attr.ib`` function aren't any obscure abbreviations. -They are a *concise* and highly *readable* way to write ``attrs`` and ``attrib`` with an *explicit namespace*. - -At first, some people have a negative gut reaction to that; resembling the reactions to Python's significant whitespace. -And as with that, once one gets used to it, the readability and explicitness of that API prevails and delights. - -For those who can't swallow that API at all, ``attrs`` comes with serious business aliases: ``attr.attrs`` and ``attr.attrib``. - -Therefore, the following class definition is identical to the previous one: - -.. doctest:: - - >>> from attr import attrs, attrib, Factory - >>> @attrs - ... class SomeClass(object): - ... a_number = attrib(default=42) - ... list_of_numbers = attrib(default=Factory(list)) - ... - ... def hard_math(self, another_number): - ... return self.a_number + sum(self.list_of_numbers) * another_number - >>> SomeClass(1, [1, 2, 3]) - SomeClass(a_number=1, list_of_numbers=[1, 2, 3]) - -Use whichever variant fits your taste better. diff --git a/docs/python-2.rst b/docs/python-2.rst index 4fdf2c9b2..7ec9e5112 100644 --- a/docs/python-2.rst +++ b/docs/python-2.rst @@ -1,7 +1,7 @@ Python 2 Statement ================== -While ``attrs`` has always been a Python 3-first package, we the maintainers are aware that Python 2 will not magically disappear in 2020. +While ``attrs`` has always been a Python 3-first package, we the maintainers are aware that Python 2 has not magically disappeared in 2020. We are also aware that ``attrs`` is an important building block in many people's systems and livelihoods. As such, we do **not** have any immediate plans to drop Python 2 support in ``attrs``. diff --git a/docs/types.rst b/docs/types.rst index f2dffca7d..fbb90a7e9 100644 --- a/docs/types.rst +++ b/docs/types.rst @@ -3,31 +3,30 @@ Type Annotations ``attrs`` comes with first class support for type annotations for both Python 3.6 (:pep:`526`) and legacy syntax. -On Python 3.6 and later, you can even drop the `attr.ib`\ s if you're willing to annotate *all* attributes. -That means that on modern Python versions, the declaration part of the example from the README can be simplified to: - +However they will forever remain *optional*, therefore the example from the README could also be written as: .. doctest:: - >>> import attr - >>> import typing + >>> from attrs import define, field - >>> @attr.s(auto_attribs=True) + >>> @define ... class SomeClass: - ... a_number: int = 42 - ... list_of_numbers: typing.List[int] = attr.Factory(list) + ... a_number = field(default=42) + ... list_of_numbers = field(factory=list) >>> sc = SomeClass(1, [1, 2, 3]) >>> sc SomeClass(a_number=1, list_of_numbers=[1, 2, 3]) - >>> attr.fields(SomeClass).a_number.type - -You will still need `attr.ib` for advanced features, but not for the common cases. +You can choose freely between the approaches, but please remember that if you choose to use type annotations, you **must** annotate **all** attributes! + +---- + +Even when going all-in an type annotations, you will need `attr.field` for some advanced features though. One of those features are the decorator-based features like defaults. It's important to remember that ``attrs`` doesn't do any magic behind your back. -All the decorators are implemented using an object that is returned by the call to `attr.ib`. +All the decorators are implemented using an object that is returned by the call to `attrs.field`. Attributes that only carry a class annotation do not have that object so trying to call a method on it will inevitably fail. @@ -36,10 +35,10 @@ Attributes that only carry a class annotation do not have that object so trying Please note that types -- however added -- are *only metadata* that can be queried from the class and they aren't used for anything out of the box! Because Python does not allow references to a class object before the class is defined, -types may be defined as string literals, so-called *forward references*. -Also, starting in Python 3.10 (:pep:`526`) **all** annotations will be string literals. -When this happens, ``attrs`` will simply put these string literals into the ``type`` attributes. -If you need to resolve these to real types, you can call `attr.resolve_types` which will update the attribute in place. +types may be defined as string literals, so-called *forward references* (:pep:`526`). +You can enable this automatically for a whole module by using ``from __future__ import annotations`` (:pep:`563`) as of Python 3.7. +In this case ``attrs`` simply puts these string literals into the ``type`` attributes. +If you need to resolve these to real types, you can call `attrs.resolve_types` which will update the attribute in place. In practice though, types show their biggest usefulness in combination with tools like mypy_, pytype_, or pyright_ that have dedicated support for ``attrs`` classes. @@ -71,7 +70,7 @@ To mypy, this code is equivalent to the one above: @attr.s class SomeClass(object): a_number = attr.ib(default=42) # type: int - list_of_numbers = attr.ib(factory=list, type=typing.List[int]) + list_of_numbers = attr.ib(factory=list, type=list[int]) pyright @@ -79,20 +78,17 @@ pyright ``attrs`` provides support for pyright_ though the dataclass_transform_ specification. This provides static type inference for a subset of ``attrs`` equivalent to standard-library ``dataclasses``, -and requires explicit type annotations using the :ref:`next-gen` or ``@attr.s(auto_attribs=True)`` API. +and requires explicit type annotations using the `attrs.define` or ``@attr.s(auto_attribs=True)`` API. Given the following definition, ``pyright`` will generate static type signatures for ``SomeClass`` attribute access, ``__init__``, ``__eq__``, and comparison methods:: @attr.define class SomeClass: a_number: int = 42 - list_of_numbers: typing.List[int] = attr.field(factory=list) + list_of_numbers: list[int] = attr.field(factory=list) .. warning:: - ``dataclass_transform``-based types are supported provisionally as of ``pyright`` 1.1.135 and ``attrs`` 21.1. - Both the ``pyright`` dataclass_transform_ specification and ``attrs`` implementation may changed in future versions. - The ``pyright`` inferred types are a subset of those supported by ``mypy``, including: - The generated ``__init__`` signature only includes the attribute type annotations. @@ -100,10 +96,13 @@ Given the following definition, ``pyright`` will generate static type signatures - The ``attr.frozen`` decorator is not typed with frozen attributes, which are properly typed via ``attr.define(frozen=True)``. + A `full list `_ of limitations and incompatibilities can be found in pyright's repository. + Your constructive feedback is welcome in both `attrs#795 `_ and `pyright#1782 `_. + Generally speaking, the decision on improving ``attrs`` support in pyright is entirely Microsoft's prerogative though. .. _mypy: http://mypy-lang.org .. _pytype: https://google.github.io/pytype/ .. _pyright: https://github.com/microsoft/pyright -.. _dataclass_transform: https://github.com/microsoft/pyright/blob/master/specs/dataclass_transforms.md +.. _dataclass_transform: https://github.com/microsoft/pyright/blob/main/specs/dataclass_transforms.md diff --git a/docs/why.rst b/docs/why.rst index 3c5e5e6d2..2c0ca4cd6 100644 --- a/docs/why.rst +++ b/docs/why.rst @@ -5,51 +5,48 @@ Why not… If you'd like third party's account why ``attrs`` is great, have a look at Glyph's `The One Python Library Everyone Needs `_! -…tuples? --------- - - -Readability -^^^^^^^^^^^ - -What makes more sense while debugging:: - - Point(x=1, y=2) - -or:: - - (1, 2) - -? - -Let's add even more ambiguity:: +…Data Classes? +-------------- - Customer(id=42, reseller=23, first_name="Jane", last_name="John") +:pep:`557` added Data Classes to `Python 3.7 `_ that resemble ``attrs`` in many ways. -or:: +They are the result of the Python community's `wish `_ to have an easier way to write classes in the standard library that doesn't carry the problems of ``namedtuple``\ s. +To that end, ``attrs`` and its developers were involved in the PEP process and while we may disagree with some minor decisions that have been made, it's a fine library and if it stops you from abusing ``namedtuple``\ s, they are a huge win. - (42, 23, "Jane", "John") +Nevertheless, there are still reasons to prefer ``attrs`` over Data Classes. +Whether they're relevant to *you* depends on your circumstances: -? +- Data Classes are *intentionally* less powerful than ``attrs``. + There is a long list of features that were sacrificed for the sake of simplicity and while the most obvious ones are validators, converters, :ref:`equality customization `, or :doc:`extensibility ` in general, it permeates throughout all APIs. -Why would you want to write ``customer[2]`` instead of ``customer.first_name``? + On the other hand, Data Classes currently do not offer any significant feature that ``attrs`` doesn't already have. +- ``attrs`` supports all mainstream Python versions, including CPython 2.7 and PyPy. +- ``attrs`` doesn't force type annotations on you if you don't like them. +- But since it **also** supports typing, it's the best way to embrace type hints *gradually*, too. +- While Data Classes are implementing features from ``attrs`` every now and then, their presence is dependent on the Python version, not the package version. + For example, support for ``__slots__`` has only been added in Python 3.10. + That is especially painful for PyPI packages that support multiple Python versions. + This includes possible implementation bugs. +- ``attrs`` can and will move faster. + We are not bound to any release schedules and we have a clear deprecation policy. -Don't get me started when you add nesting. -If you've never run into mysterious tuples you had no idea what the hell they meant while debugging, you're much smarter than yours truly. + One of the `reasons `_ to not vendor ``attrs`` in the standard library was to not impede ``attrs``'s future development. -Using proper classes with names and types makes program code much more readable and comprehensible_. -Especially when trying to grok a new piece of software or returning to old code after several months. +One way to think about ``attrs`` vs Data Classes is that ``attrs`` is a fully-fledged toolkit to write powerful classes while Data Classes are an easy way to get a class with some attributes. +Basically what ``attrs`` was in 2015. -.. _comprehensible: https://arxiv.org/pdf/1304.5257.pdf +…pydantic? +---------- -Extendability -^^^^^^^^^^^^^ +*pydantic* is first an foremost a *data validation library*. +As such, it is a capable complement to class building libraries like ``attrs`` (or Data Classes!) for parsing and validating untrusted data. -Imagine you have a function that takes or returns a tuple. -Especially if you use tuple unpacking (eg. ``x, y = get_point()``), adding additional data means that you have to change the invocation of that function *everywhere*. +However, as convenient as it might be, using it for your business or data layer `is problematic in several ways `_: +Is it really necessary to re-validate all your objects while reading them from a trusted database? +In the parlance of `Form, Command, and Model Validation `_, *pydantic* is the right tool for *Commands*. -Adding an attribute to a class concerns only those who actually care about that attribute. +`Separation of concerns `_ feels tedious at times, but it's one of those things that you get to appreciate once you've shot your own foot often enough. …namedtuples? @@ -57,7 +54,7 @@ Adding an attribute to a class concerns only those who actually care about that `collections.namedtuple`\ s are tuples with names, not classes. [#history]_ Since writing classes is tiresome in Python, every now and then someone discovers all the typing they could save and gets really excited. -However that convenience comes at a price. +However, that convenience comes at a price. The most obvious difference between ``namedtuple``\ s and ``attrs``-based classes is that the latter are type-sensitive: @@ -133,26 +130,50 @@ With ``attrs`` your users won't notice a difference because it creates regular, .. _behaving like a tuple: https://docs.python.org/3/tutorial/datastructures.html#tuples-and-sequences -…Data Classes? --------------- +…tuples? +-------- -:pep:`557` added Data Classes to `Python 3.7 `_ that resemble ``attrs`` in many ways. +Readability +^^^^^^^^^^^ -They are the result of the Python community's `wish `_ to have an easier way to write classes in the standard library that doesn't carry the problems of ``namedtuple``\ s. -To that end, ``attrs`` and its developers were involved in the PEP process and while we may disagree with some minor decisions that have been made, it's a fine library and if it stops you from abusing ``namedtuple``\ s, they are a huge win. +What makes more sense while debugging:: -Nevertheless, there are still reasons to prefer ``attrs`` over Data Classes whose relevancy depends on your circumstances: + Point(x=1, y=2) -- ``attrs`` supports all mainstream Python versions, including CPython 2.7 and PyPy. -- Data Classes are intentionally less powerful than ``attrs``. - There is a long list of features that were sacrificed for the sake of simplicity and while the most obvious ones are validators, converters, and ``__slots__``, it permeates throughout all APIs. +or:: - On the other hand, Data Classes currently do not offer any significant feature that ``attrs`` doesn't already have. -- ``attrs`` can and will move faster. - We are not bound to any release schedules and we have a clear deprecation policy. + (1, 2) - One of the `reasons `_ to not vendor ``attrs`` in the standard library was to not impede ``attrs``'s future development. +? + +Let's add even more ambiguity:: + + Customer(id=42, reseller=23, first_name="Jane", last_name="John") + +or:: + (42, 23, "Jane", "John") + +? + +Why would you want to write ``customer[2]`` instead of ``customer.first_name``? + +Don't get me started when you add nesting. +If you've never run into mysterious tuples you had no idea what the hell they meant while debugging, you're much smarter than yours truly. + +Using proper classes with names and types makes program code much more readable and comprehensible_. +Especially when trying to grok a new piece of software or returning to old code after several months. + +.. _comprehensible: https://arxiv.org/pdf/1304.5257.pdf + + +Extendability +^^^^^^^^^^^^^ + +Imagine you have a function that takes or returns a tuple. +Especially if you use tuple unpacking (eg. ``x, y = get_point()``), adding additional data means that you have to change the invocation of that function *everywhere*. + +Adding an attribute to a class concerns only those who actually care about that attribute. …dicts? diff --git a/pyproject.toml b/pyproject.toml index 14f65a366..52c0e49ec 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "setuptools.build_meta" [tool.coverage.run] parallel = true branch = true -source = ["attr"] +source = ["attr", "attrs"] [tool.coverage.paths] source = ["src", ".tox/*/site-packages"] @@ -22,6 +22,10 @@ exclude_lines = [ [tool.black] line-length = 79 +extend-exclude = ''' +# Exclude pattern matching test till black gains Python 3.10 support +.*test_pattern_matching.* +''' [tool.interrogate] @@ -30,6 +34,10 @@ fail-under = 100 whitelist-regex = ["test_.*"] +[tool.check-wheel-contents] +toplevel = ["attr", "attrs"] + + [tool.isort] profile = "attrs" diff --git a/setup.py b/setup.py index 79e45bfda..00e7b012a 100644 --- a/setup.py +++ b/setup.py @@ -1,3 +1,5 @@ +# SPDX-License-Identifier: MIT + import codecs import os import platform @@ -47,6 +49,8 @@ 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", @@ -95,12 +99,16 @@ def find_meta(meta): raise RuntimeError("Unable to find __{meta}__ string.".format(meta=meta)) +LOGO = """ +.. image:: https://www.attrs.org/en/stable/_static/attrs_logo.png + :alt: attrs logo + :align: center +""" # noqa + VERSION = find_meta("version") URL = find_meta("url") LONG = ( - "======================================\n" - "``attrs``: Classes Without Boilerplate\n" - "======================================\n" + LOGO + read("README.rst").split(".. teaser-begin")[1] + "\n\n" + "Release Information\n" diff --git a/src/attr/__init__.py b/src/attr/__init__.py index b1ce7fe24..f95c96dd5 100644 --- a/src/attr/__init__.py +++ b/src/attr/__init__.py @@ -1,3 +1,5 @@ +# SPDX-License-Identifier: MIT + from __future__ import absolute_import, division, print_function import sys @@ -22,7 +24,7 @@ from ._version_info import VersionInfo -__version__ = "21.2.0" +__version__ = "21.4.0" __version_info__ = VersionInfo._from_version_string(__version__) __title__ = "attrs" @@ -73,6 +75,6 @@ ] if sys.version_info[:2] >= (3, 6): - from ._next_gen import define, field, frozen, mutable + from ._next_gen import define, field, frozen, mutable # noqa: F401 - __all__.extend((define, field, frozen, mutable)) + __all__.extend(("define", "field", "frozen", "mutable")) diff --git a/src/attr/__init__.pyi b/src/attr/__init__.pyi index 3503b073b..c0a212650 100644 --- a/src/attr/__init__.pyi +++ b/src/attr/__init__.pyi @@ -24,7 +24,6 @@ from . import setters as setters from . import validators as validators from ._version_info import VersionInfo - __version__: str __version_info__: VersionInfo __title__: str @@ -49,7 +48,10 @@ _OnSetAttrType = Callable[[Any, Attribute[Any], Any], Any] _OnSetAttrArgType = Union[ _OnSetAttrType, List[_OnSetAttrType], setters._NoOpType ] -_FieldTransformer = Callable[[type, List[Attribute[Any]]], List[Attribute[Any]]] +_FieldTransformer = Callable[ + [type, List[Attribute[Any]]], List[Attribute[Any]] +] +_CompareWithType = Callable[[Any, Any], bool] # FIXME: in reality, if multiple validators are passed they must be in a list # or tuple, but those are invariant and so would prevent subtypes of # _ValidatorType from working when passed in a list or tuple. @@ -64,7 +66,6 @@ NOTHING: object # Work around mypy issue #4554 in the common case by using an overload. if sys.version_info >= (3, 8): from typing import Literal - @overload def Factory(factory: Callable[[], _T]) -> _T: ... @overload @@ -77,6 +78,7 @@ if sys.version_info >= (3, 8): factory: Callable[[], _T], takes_self: Literal[False], ) -> _T: ... + else: @overload def Factory(factory: Callable[[], _T]) -> _T: ... @@ -117,7 +119,6 @@ class Attribute(Generic[_T]): type: Optional[Type[_T]] kw_only: bool on_setattr: _OnSetAttrType - def evolve(self, **changes: Any) -> "Attribute[Any]": ... # NOTE: We had several choices for the annotation to use for type arg: @@ -315,6 +316,7 @@ def attrs( getstate_setstate: Optional[bool] = ..., on_setattr: Optional[_OnSetAttrArgType] = ..., field_transformer: Optional[_FieldTransformer] = ..., + match_args: bool = ..., ) -> _C: ... @overload @__dataclass_transform__(order_default=True, field_descriptors=(attrib, field)) @@ -341,6 +343,7 @@ def attrs( getstate_setstate: Optional[bool] = ..., on_setattr: Optional[_OnSetAttrArgType] = ..., field_transformer: Optional[_FieldTransformer] = ..., + match_args: bool = ..., ) -> Callable[[_C], _C]: ... @overload @__dataclass_transform__(field_descriptors=(attrib, field)) @@ -365,6 +368,7 @@ def define( getstate_setstate: Optional[bool] = ..., on_setattr: Optional[_OnSetAttrArgType] = ..., field_transformer: Optional[_FieldTransformer] = ..., + match_args: bool = ..., ) -> _C: ... @overload @__dataclass_transform__(field_descriptors=(attrib, field)) @@ -389,6 +393,7 @@ def define( getstate_setstate: Optional[bool] = ..., on_setattr: Optional[_OnSetAttrArgType] = ..., field_transformer: Optional[_FieldTransformer] = ..., + match_args: bool = ..., ) -> Callable[[_C], _C]: ... mutable = define @@ -442,13 +447,17 @@ def make_class( # these: # https://github.com/python/mypy/issues/4236 # https://github.com/python/typing/issues/253 +# XXX: remember to fix attrs.asdict/astuple too! def asdict( inst: Any, recurse: bool = ..., filter: Optional[_FilterType[Any]] = ..., dict_factory: Type[Mapping[Any, Any]] = ..., retain_collection_types: bool = ..., - value_serializer: Optional[Callable[[type, Attribute[Any], Any], Any]] = ..., + value_serializer: Optional[ + Callable[[type, Attribute[Any], Any], Any] + ] = ..., + tuple_keys: Optional[bool] = ..., ) -> Dict[str, Any]: ... # TODO: add support for returning NamedTuple from the mypy plugin diff --git a/src/attr/_cmp.py b/src/attr/_cmp.py index b747b603f..6cffa4dba 100644 --- a/src/attr/_cmp.py +++ b/src/attr/_cmp.py @@ -1,3 +1,5 @@ +# SPDX-License-Identifier: MIT + from __future__ import absolute_import, division, print_function import functools diff --git a/src/attr/_cmp.pyi b/src/attr/_cmp.pyi index 7093550f0..e71aaff7a 100644 --- a/src/attr/_cmp.pyi +++ b/src/attr/_cmp.pyi @@ -2,7 +2,6 @@ from typing import Type from . import _CompareWithType - def cmp_using( eq: Optional[_CompareWithType], lt: Optional[_CompareWithType], diff --git a/src/attr/_compat.py b/src/attr/_compat.py index 6939f338d..dc0cb02b6 100644 --- a/src/attr/_compat.py +++ b/src/attr/_compat.py @@ -1,16 +1,22 @@ +# SPDX-License-Identifier: MIT + from __future__ import absolute_import, division, print_function import platform import sys +import threading import types import warnings PY2 = sys.version_info[0] == 2 PYPY = platform.python_implementation() == "PyPy" +PY36 = sys.version_info[:2] >= (3, 6) +HAS_F_STRINGS = PY36 +PY310 = sys.version_info[:2] >= (3, 10) -if PYPY or sys.version_info[:2] >= (3, 6): +if PYPY or PY36: ordered_dict = dict else: from collections import OrderedDict @@ -106,7 +112,6 @@ def just_warn(*args, **kw): # pragma: no cover consequences of not setting the cell on Python 2. """ - else: # Python 3 and later. from collections.abc import Mapping, Sequence # noqa @@ -240,3 +245,17 @@ def func(): set_closure_cell = make_set_closure_cell() + +# Thread-local global to track attrs instances which are already being repr'd. +# This is needed because there is no other (thread-safe) way to pass info +# about the instances that are already being repr'd through the call stack +# in order to ensure we don't perform infinite recursion. +# +# For instance, if an instance contains a dict which contains that instance, +# we need to know that we're already repr'ing the outside instance from within +# the dict's repr() call. +# +# This lives here rather than in _make.py so that the functions in _make.py +# don't have a direct reference to the thread-local in their globals dict. +# If they have such a reference, it breaks cloudpickle. +repr_context = threading.local() diff --git a/src/attr/_config.py b/src/attr/_config.py index 8ec920962..fc9be29d0 100644 --- a/src/attr/_config.py +++ b/src/attr/_config.py @@ -1,3 +1,5 @@ +# SPDX-License-Identifier: MIT + from __future__ import absolute_import, division, print_function @@ -9,6 +11,10 @@ def set_run_validators(run): """ Set whether or not validators are run. By default, they are run. + + .. deprecated:: 21.3.0 It will not be removed, but it also will not be + moved to new ``attrs`` namespace. Use `attrs.validators.set_disabled()` + instead. """ if not isinstance(run, bool): raise TypeError("'run' must be bool.") @@ -19,5 +25,9 @@ def set_run_validators(run): def get_run_validators(): """ Return whether or not validators are run. + + .. deprecated:: 21.3.0 It will not be removed, but it also will not be + moved to new ``attrs`` namespace. Use `attrs.validators.get_disabled()` + instead. """ return _run_validators diff --git a/src/attr/_funcs.py b/src/attr/_funcs.py index fda508c5c..4c90085a4 100644 --- a/src/attr/_funcs.py +++ b/src/attr/_funcs.py @@ -1,3 +1,5 @@ +# SPDX-License-Identifier: MIT + from __future__ import absolute_import, division, print_function import copy @@ -25,7 +27,7 @@ def asdict( ``attrs``-decorated. :param callable filter: A callable whose return code determines whether an attribute or element is included (``True``) or dropped (``False``). Is - called with the `attr.Attribute` as the first argument and the + called with the `attrs.Attribute` as the first argument and the value as the second argument. :param callable dict_factory: A callable to produce dictionaries from. For example, to produce ordered dictionaries instead of normal Python @@ -46,6 +48,8 @@ def asdict( .. versionadded:: 16.0.0 *dict_factory* .. versionadded:: 16.1.0 *retain_collection_types* .. versionadded:: 20.3.0 *value_serializer* + .. versionadded:: 21.3.0 If a dict has a collection for a key, it is + serialized as a tuple. """ attrs = fields(inst.__class__) rv = dict_factory() @@ -61,11 +65,11 @@ def asdict( if has(v.__class__): rv[a.name] = asdict( v, - True, - filter, - dict_factory, - retain_collection_types, - value_serializer, + recurse=True, + filter=filter, + dict_factory=dict_factory, + retain_collection_types=retain_collection_types, + value_serializer=value_serializer, ) elif isinstance(v, (tuple, list, set, frozenset)): cf = v.__class__ if retain_collection_types is True else list @@ -73,10 +77,11 @@ def asdict( [ _asdict_anything( i, - filter, - dict_factory, - retain_collection_types, - value_serializer, + is_key=False, + filter=filter, + dict_factory=dict_factory, + retain_collection_types=retain_collection_types, + value_serializer=value_serializer, ) for i in v ] @@ -87,17 +92,19 @@ def asdict( ( _asdict_anything( kk, - filter, - df, - retain_collection_types, - value_serializer, + is_key=True, + filter=filter, + dict_factory=df, + retain_collection_types=retain_collection_types, + value_serializer=value_serializer, ), _asdict_anything( vv, - filter, - df, - retain_collection_types, - value_serializer, + is_key=False, + filter=filter, + dict_factory=df, + retain_collection_types=retain_collection_types, + value_serializer=value_serializer, ), ) for kk, vv in iteritems(v) @@ -111,6 +118,7 @@ def asdict( def _asdict_anything( val, + is_key, filter, dict_factory, retain_collection_types, @@ -123,22 +131,29 @@ def _asdict_anything( # Attrs class. rv = asdict( val, - True, - filter, - dict_factory, - retain_collection_types, - value_serializer, + recurse=True, + filter=filter, + dict_factory=dict_factory, + retain_collection_types=retain_collection_types, + value_serializer=value_serializer, ) elif isinstance(val, (tuple, list, set, frozenset)): - cf = val.__class__ if retain_collection_types is True else list + if retain_collection_types is True: + cf = val.__class__ + elif is_key: + cf = tuple + else: + cf = list + rv = cf( [ _asdict_anything( i, - filter, - dict_factory, - retain_collection_types, - value_serializer, + is_key=False, + filter=filter, + dict_factory=dict_factory, + retain_collection_types=retain_collection_types, + value_serializer=value_serializer, ) for i in val ] @@ -148,10 +163,20 @@ def _asdict_anything( rv = df( ( _asdict_anything( - kk, filter, df, retain_collection_types, value_serializer + kk, + is_key=True, + filter=filter, + dict_factory=df, + retain_collection_types=retain_collection_types, + value_serializer=value_serializer, ), _asdict_anything( - vv, filter, df, retain_collection_types, value_serializer + vv, + is_key=False, + filter=filter, + dict_factory=df, + retain_collection_types=retain_collection_types, + value_serializer=value_serializer, ), ) for kk, vv in iteritems(val) @@ -181,7 +206,7 @@ def astuple( ``attrs``-decorated. :param callable filter: A callable whose return code determines whether an attribute or element is included (``True``) or dropped (``False``). Is - called with the `attr.Attribute` as the first argument and the + called with the `attrs.Attribute` as the first argument and the value as the second argument. :param callable tuple_factory: A callable to produce tuples from. For example, to produce lists instead of tuples. @@ -291,7 +316,9 @@ def assoc(inst, **changes): class. .. deprecated:: 17.1.0 - Use `evolve` instead. + Use `attrs.evolve` instead if you can. + This function will not be removed du to the slightly different approach + compared to `attrs.evolve`. """ import warnings @@ -370,18 +397,16 @@ class and you didn't pass any attribs. :raise NameError: If types cannot be resolved because of missing variables. :returns: *cls* so you can use this function also as a class decorator. - Please note that you have to apply it **after** `attr.s`. That means - the decorator has to come in the line **before** `attr.s`. + Please note that you have to apply it **after** `attrs.define`. That + means the decorator has to come in the line **before** `attrs.define`. .. versionadded:: 20.1.0 .. versionadded:: 21.1.0 *attribs* """ - try: - # Since calling get_type_hints is expensive we cache whether we've - # done it already. - cls.__attrs_types_resolved__ - except AttributeError: + # Since calling get_type_hints is expensive we cache whether we've + # done it already. + if getattr(cls, "__attrs_types_resolved__", None) != cls: import typing hints = typing.get_type_hints(cls, globalns=globalns, localns=localns) @@ -389,7 +414,9 @@ class and you didn't pass any attribs. if field.name in hints: # Since fields have been frozen we must work around it. _obj_setattr(field, "type", hints[field.name]) - cls.__attrs_types_resolved__ = True + # We store the class we resolved so that subclasses know they haven't + # been resolved. + cls.__attrs_types_resolved__ = cls # Return the class so you can use it as a decorator too. return cls diff --git a/src/attr/_make.py b/src/attr/_make.py index a1912b123..d46f8a3e7 100644 --- a/src/attr/_make.py +++ b/src/attr/_make.py @@ -1,18 +1,22 @@ +# SPDX-License-Identifier: MIT + from __future__ import absolute_import, division, print_function import copy import inspect import linecache import sys -import threading -import uuid import warnings from operator import itemgetter -from . import _config, setters +# We need to import _compat itself in addition to the _compat members to avoid +# having the thread-local in the globals here. +from . import _compat, _config, setters from ._compat import ( + HAS_F_STRINGS, PY2, + PY310, PYPY, isclass, iteritems, @@ -57,6 +61,8 @@ # Unique object for unequivocal getattr() defaults. _sentinel = object() +_ng_default_on_setattr = setters.pipe(setters.convert, setters.validate) + class _Nothing(object): """ @@ -143,11 +149,11 @@ def attrib( is used and no value is passed while instantiating or the attribute is excluded using ``init=False``. - If the value is an instance of `Factory`, its callable will be + If the value is an instance of `attrs.Factory`, its callable will be used to construct a new value (useful for mutable data types like lists or dicts). - If a default is not set (or set manually to `attr.NOTHING`), a value + If a default is not set (or set manually to `attrs.NOTHING`), a value *must* be supplied when instantiating; otherwise a `TypeError` will be raised. @@ -160,7 +166,7 @@ def attrib( :param validator: `callable` that is called by ``attrs``-generated ``__init__`` methods after the instance has been initialized. They - receive the initialized instance, the `Attribute`, and the + receive the initialized instance, the :func:`~attrs.Attribute`, and the passed value. The return value is *not* inspected so the validator has to throw an @@ -233,10 +239,10 @@ def attrib( parameter is ignored). :param on_setattr: Allows to overwrite the *on_setattr* setting from `attr.s`. If left `None`, the *on_setattr* value from `attr.s` is used. - Set to `attr.setters.NO_OP` to run **no** `setattr` hooks for this + Set to `attrs.setters.NO_OP` to run **no** `setattr` hooks for this attribute -- regardless of the setting in `attr.s`. :type on_setattr: `callable`, or a list of callables, or `None`, or - `attr.setters.NO_OP` + `attrs.setters.NO_OP` .. versionadded:: 15.2.0 *convert* .. versionadded:: 16.3.0 *metadata* @@ -327,16 +333,25 @@ def _make_method(name, script, filename, globs=None): if globs is None: globs = {} - _compile_and_eval(script, globs, locs, filename) - # In order of debuggers like PDB being able to step through the code, # we add a fake linecache entry. - linecache.cache[filename] = ( - len(script), - None, - script.splitlines(True), - filename, - ) + count = 1 + base_filename = filename + while True: + linecache_tuple = ( + len(script), + None, + script.splitlines(True), + filename, + ) + old_val = linecache.cache.setdefault(filename, linecache_tuple) + if old_val == linecache_tuple: + break + else: + filename = "{}-{}>".format(base_filename[:-1], count) + count += 1 + + _compile_and_eval(script, globs, locs, filename) return locs[name] @@ -571,15 +586,11 @@ def _transform_attrs( cls, {a.name for a in own_attrs} ) - attr_names = [a.name for a in base_attrs + own_attrs] - - AttrsClass = _make_attr_tuple_class(cls.__name__, attr_names) - if kw_only: own_attrs = [a.evolve(kw_only=True) for a in own_attrs] base_attrs = [a.evolve(kw_only=True) for a in base_attrs] - attrs = AttrsClass(base_attrs + own_attrs) + attrs = base_attrs + own_attrs # Mandatory vs non-mandatory attr order only matters when they are part of # the __init__ signature and when they aren't kw_only (which are moved to @@ -598,7 +609,13 @@ def _transform_attrs( if field_transformer is not None: attrs = field_transformer(cls, attrs) - return _Attributes((attrs, base_attrs, base_attr_map)) + + # Create AttrsClass *after* applying the field_transformer since it may + # add or remove attributes! + attr_names = [a.name for a in attrs] + AttrsClass = _make_attr_tuple_class(cls.__name__, attr_names) + + return _Attributes((AttrsClass(attrs), base_attrs, base_attr_map)) if PYPY: @@ -616,7 +633,6 @@ def _frozen_setattrs(self, name, value): raise FrozenInstanceError() - else: def _frozen_setattrs(self, name, value): @@ -654,7 +670,7 @@ class _ClassBuilder(object): "_on_setattr", "_slots", "_weakref_slot", - "_has_own_setattr", + "_wrote_own_setattr", "_has_custom_setattr", ) @@ -701,7 +717,7 @@ def __init__( self._on_setattr = on_setattr self._has_custom_setattr = has_custom_setattr - self._has_own_setattr = False + self._wrote_own_setattr = False self._cls_dict["__attrs_attrs__"] = self._attrs @@ -709,7 +725,33 @@ def __init__( self._cls_dict["__setattr__"] = _frozen_setattrs self._cls_dict["__delattr__"] = _frozen_delattrs - self._has_own_setattr = True + self._wrote_own_setattr = True + elif on_setattr in ( + _ng_default_on_setattr, + setters.validate, + setters.convert, + ): + has_validator = has_converter = False + for a in attrs: + if a.validator is not None: + has_validator = True + if a.converter is not None: + has_converter = True + + if has_validator and has_converter: + break + if ( + ( + on_setattr == _ng_default_on_setattr + and not (has_validator or has_converter) + ) + or (on_setattr == setters.validate and not has_validator) + or (on_setattr == setters.convert and not has_converter) + ): + # If class-level on_setattr is set to convert + validate, but + # there's no field to convert or validate, pretend like there's + # no on_setattr. + self._on_setattr = None if getstate_setstate: ( @@ -759,7 +801,7 @@ def _patch_original_class(self): # If we've inherited an attrs __setattr__ and don't write our own, # reset it to object's. - if not self._has_own_setattr and getattr( + if not self._wrote_own_setattr and getattr( cls, "__attrs_own_setattr__", False ): cls.__attrs_own_setattr__ = False @@ -787,7 +829,7 @@ def _create_slots_class(self): # XXX: a non-attrs class and subclass the resulting class with an attrs # XXX: class. See `test_slotted_confused` for details. For now that's # XXX: OK with us. - if not self._has_own_setattr: + if not self._wrote_own_setattr: cd["__attrs_own_setattr__"] = False if not self._has_custom_setattr: @@ -847,7 +889,7 @@ def _create_slots_class(self): cls = type(self._cls)(self._cls.__name__, self._cls.__bases__, cd) # The following is a fix for - # https://github.com/python-attrs/attrs/issues/102. On Python 3, + # . On Python 3, # if a method mentions `__class__` or uses the no-arg super(), the # compiler will bake a reference to the class in the method itself # as `method.__closure__`. Since we replace the class with a @@ -879,7 +921,7 @@ def _create_slots_class(self): def add_repr(self, ns): self._cls_dict["__repr__"] = self._add_method_dunders( - _make_repr(self._attrs, ns=ns) + _make_repr(self._attrs, ns, self._cls) ) return self @@ -958,14 +1000,20 @@ def add_init(self): self._cache_hash, self._base_attr_map, self._is_exc, - self._on_setattr is not None - and self._on_setattr is not setters.NO_OP, + self._on_setattr, attrs_init=False, ) ) return self + def add_match_args(self): + self._cls_dict["__match_args__"] = tuple( + field.name + for field in self._attrs + if field.init and not field.kw_only + ) + def add_attrs_init(self): self._cls_dict["__attrs_init__"] = self._add_method_dunders( _make_init( @@ -978,8 +1026,7 @@ def add_attrs_init(self): self._cache_hash, self._base_attr_map, self._is_exc, - self._on_setattr is not None - and self._on_setattr is not setters.NO_OP, + self._on_setattr, attrs_init=True, ) ) @@ -1038,7 +1085,7 @@ def __setattr__(self, name, val): self._cls_dict["__attrs_own_setattr__"] = True self._cls_dict["__setattr__"] = self._add_method_dunders(__setattr__) - self._has_own_setattr = True + self._wrote_own_setattr = True return self @@ -1192,6 +1239,7 @@ def attrs( getstate_setstate=None, on_setattr=None, field_transformer=None, + match_args=True, ): r""" A class decorator that adds `dunder @@ -1240,7 +1288,7 @@ def attrs( *cmp*, or *hash* overrides whatever *auto_detect* would determine. *auto_detect* requires Python 3. Setting it ``True`` on Python 2 raises - a `PythonTooOldError`. + an `attrs.exceptions.PythonTooOldError`. :param bool repr: Create a ``__repr__`` method with a human readable representation of ``attrs`` attributes.. @@ -1327,7 +1375,7 @@ def attrs( If you assign a value to those attributes (e.g. ``x: int = 42``), that value becomes the default value like if it were passed using - ``attr.ib(default=42)``. Passing an instance of `Factory` also + ``attr.ib(default=42)``. Passing an instance of `attrs.Factory` also works as expected in most cases (see warning below). Attributes annotated as `typing.ClassVar`, and attributes that are @@ -1369,7 +1417,7 @@ def attrs( :param bool collect_by_mro: Setting this to `True` fixes the way ``attrs`` collects attributes from base classes. The default behavior is incorrect in certain cases of multiple inheritance. It should be on by - default but is kept off for backward-compatability. + default but is kept off for backward-compatibility. See issue `#428 `_ for more details. @@ -1399,7 +1447,7 @@ def attrs( the callable. If a list of callables is passed, they're automatically wrapped in an - `attr.setters.pipe`. + `attrs.setters.pipe`. :param Optional[callable] field_transformer: A function that is called with the original class object and all @@ -1407,6 +1455,13 @@ def attrs( this, e.g., to automatically add converters or validators to fields based on their types. See `transform-fields` for more details. + :param bool match_args: + If `True` (default), set ``__match_args__`` on the class to support + `PEP 634 `_ (Structural + Pattern Matching). It is a tuple of all positional-only ``__init__`` + parameter names on Python 3.10 and later. Ignored on older Python + versions. + .. versionadded:: 16.0.0 *slots* .. versionadded:: 16.1.0 *frozen* .. versionadded:: 16.3.0 *str* @@ -1440,6 +1495,7 @@ def attrs( ``init=False`` injects ``__attrs_init__`` .. versionchanged:: 21.1.0 Support for ``__attrs_pre_init__`` .. versionchanged:: 21.1.0 *cmp* undeprecated + .. versionadded:: 21.3.0 *match_args* """ if auto_detect and PY2: raise PythonTooOldError( @@ -1556,6 +1612,13 @@ def wrap(cls): " init must be True." ) + if ( + PY310 + and match_args + and not _has_own_attribute(cls, "__match_args__") + ): + builder.add_match_args() + return builder.build_class() # maybe_cls's type depends on the usage of the decorator. It's a class @@ -1586,7 +1649,6 @@ def _has_frozen_base_class(cls): and cls.__setattr__.__name__ == _frozen_setattrs.__name__ ) - else: def _has_frozen_base_class(cls): @@ -1601,30 +1663,12 @@ def _generate_unique_filename(cls, func_name): """ Create a "filename" suitable for a function being generated. """ - unique_id = uuid.uuid4() - extra = "" - count = 1 - - while True: - unique_filename = "".format( - func_name, - cls.__module__, - getattr(cls, "__qualname__", cls.__name__), - extra, - ) - # To handle concurrency we essentially "reserve" our spot in - # the linecache with a dummy line. The caller can then - # set this value correctly. - cache_line = (1, None, (str(unique_id),), unique_filename) - if ( - linecache.cache.setdefault(unique_filename, cache_line) - == cache_line - ): - return unique_filename - - # Looks like this spot is taken. Try again. - count += 1 - extra = "-{0}".format(count) + unique_filename = "".format( + func_name, + cls.__module__, + getattr(cls, "__qualname__", cls.__name__), + ) + return unique_filename def _make_hash(cls, attrs, frozen, cache_hash): @@ -1841,66 +1885,134 @@ def _add_eq(cls, attrs=None): return cls -_already_repring = threading.local() +if HAS_F_STRINGS: + def _make_repr(attrs, ns, cls): + unique_filename = _generate_unique_filename(cls, "repr") + # Figure out which attributes to include, and which function to use to + # format them. The a.repr value can be either bool or a custom + # callable. + attr_names_with_reprs = tuple( + (a.name, (repr if a.repr is True else a.repr), a.init) + for a in attrs + if a.repr is not False + ) + globs = { + name + "_repr": r + for name, r, _ in attr_names_with_reprs + if r != repr + } + globs["_compat"] = _compat + globs["AttributeError"] = AttributeError + globs["NOTHING"] = NOTHING + attribute_fragments = [] + for name, r, i in attr_names_with_reprs: + accessor = ( + "self." + name + if i + else 'getattr(self, "' + name + '", NOTHING)' + ) + fragment = ( + "%s={%s!r}" % (name, accessor) + if r == repr + else "%s={%s_repr(%s)}" % (name, name, accessor) + ) + attribute_fragments.append(fragment) + repr_fragment = ", ".join(attribute_fragments) -def _make_repr(attrs, ns): - """ - Make a repr method that includes relevant *attrs*, adding *ns* to the full - name. - """ + if ns is None: + cls_name_fragment = ( + '{self.__class__.__qualname__.rsplit(">.", 1)[-1]}' + ) + else: + cls_name_fragment = ns + ".{self.__class__.__name__}" + + lines = [ + "def __repr__(self):", + " try:", + " already_repring = _compat.repr_context.already_repring", + " except AttributeError:", + " already_repring = {id(self),}", + " _compat.repr_context.already_repring = already_repring", + " else:", + " if id(self) in already_repring:", + " return '...'", + " else:", + " already_repring.add(id(self))", + " try:", + " return f'%s(%s)'" % (cls_name_fragment, repr_fragment), + " finally:", + " already_repring.remove(id(self))", + ] + + return _make_method( + "__repr__", "\n".join(lines), unique_filename, globs=globs + ) - # Figure out which attributes to include, and which function to use to - # format them. The a.repr value can be either bool or a custom callable. - attr_names_with_reprs = tuple( - (a.name, repr if a.repr is True else a.repr) - for a in attrs - if a.repr is not False - ) +else: - def __repr__(self): + def _make_repr(attrs, ns, _): """ - Automatically created by attrs. + Make a repr method that includes relevant *attrs*, adding *ns* to the + full name. """ - try: - working_set = _already_repring.working_set - except AttributeError: - working_set = set() - _already_repring.working_set = working_set - if id(self) in working_set: - return "..." - real_cls = self.__class__ - if ns is None: - qualname = getattr(real_cls, "__qualname__", None) - if qualname is not None: - class_name = qualname.rsplit(">.", 1)[-1] - else: - class_name = real_cls.__name__ - else: - class_name = ns + "." + real_cls.__name__ + # Figure out which attributes to include, and which function to use to + # format them. The a.repr value can be either bool or a custom + # callable. + attr_names_with_reprs = tuple( + (a.name, repr if a.repr is True else a.repr) + for a in attrs + if a.repr is not False + ) - # Since 'self' remains on the stack (i.e.: strongly referenced) for the - # duration of this call, it's safe to depend on id(...) stability, and - # not need to track the instance and therefore worry about properties - # like weakref- or hash-ability. - working_set.add(id(self)) - try: - result = [class_name, "("] - first = True - for name, attr_repr in attr_names_with_reprs: - if first: - first = False + def __repr__(self): + """ + Automatically created by attrs. + """ + try: + already_repring = _compat.repr_context.already_repring + except AttributeError: + already_repring = set() + _compat.repr_context.already_repring = already_repring + + if id(self) in already_repring: + return "..." + real_cls = self.__class__ + if ns is None: + qualname = getattr(real_cls, "__qualname__", None) + if qualname is not None: # pragma: no cover + # This case only happens on Python 3.5 and 3.6. We exclude + # it from coverage, because we don't want to slow down our + # test suite by running them under coverage too for this + # one line. + class_name = qualname.rsplit(">.", 1)[-1] else: - result.append(", ") - result.extend( - (name, "=", attr_repr(getattr(self, name, NOTHING))) - ) - return "".join(result) + ")" - finally: - working_set.remove(id(self)) + class_name = real_cls.__name__ + else: + class_name = ns + "." + real_cls.__name__ - return __repr__ + # Since 'self' remains on the stack (i.e.: strongly referenced) + # for the duration of this call, it's safe to depend on id(...) + # stability, and not need to track the instance and therefore + # worry about properties like weakref- or hash-ability. + already_repring.add(id(self)) + try: + result = [class_name, "("] + first = True + for name, attr_repr in attr_names_with_reprs: + if first: + first = False + else: + result.append(", ") + result.extend( + (name, "=", attr_repr(getattr(self, name, NOTHING))) + ) + return "".join(result) + ")" + finally: + already_repring.remove(id(self)) + + return __repr__ def _add_repr(cls, ns=None, attrs=None): @@ -1910,7 +2022,7 @@ def _add_repr(cls, ns=None, attrs=None): if attrs is None: attrs = cls.__attrs_attrs__ - cls.__repr__ = _make_repr(attrs, ns) + cls.__repr__ = _make_repr(attrs, ns, cls) return cls @@ -1927,7 +2039,7 @@ def fields(cls): :raise attr.exceptions.NotAnAttrsClassError: If *cls* is not an ``attrs`` class. - :rtype: tuple (with name accessors) of `attr.Attribute` + :rtype: tuple (with name accessors) of `attrs.Attribute` .. versionchanged:: 16.2.0 Returned tuple allows accessing the fields by name. @@ -1954,7 +2066,7 @@ def fields_dict(cls): class. :rtype: an ordered dict where keys are attribute names and values are - `attr.Attribute`\\ s. This will be a `dict` if it's + `attrs.Attribute`\\ s. This will be a `dict` if it's naturally ordered like on Python 3.6+ or an :class:`~collections.OrderedDict` otherwise. @@ -2008,10 +2120,14 @@ def _make_init( cache_hash, base_attr_map, is_exc, - has_global_on_setattr, + cls_on_setattr, attrs_init, ): - if frozen and has_global_on_setattr: + has_cls_on_setattr = ( + cls_on_setattr is not None and cls_on_setattr is not setters.NO_OP + ) + + if frozen and has_cls_on_setattr: raise ValueError("Frozen classes can't use on_setattr.") needs_cached_setattr = cache_hash or frozen @@ -2029,9 +2145,7 @@ def _make_init( raise ValueError("Frozen classes can't use on_setattr.") needs_cached_setattr = True - elif ( - has_global_on_setattr and a.on_setattr is not setters.NO_OP - ) or _is_slot_attr(a.name, base_attr_map): + elif has_cls_on_setattr and a.on_setattr is not setters.NO_OP: needs_cached_setattr = True unique_filename = _generate_unique_filename(cls, "init") @@ -2046,7 +2160,7 @@ def _make_init( base_attr_map, is_exc, needs_cached_setattr, - has_global_on_setattr, + has_cls_on_setattr, attrs_init, ) if cls.__module__ in sys.modules: @@ -2183,7 +2297,7 @@ def _attrs_to_init_script( base_attr_map, is_exc, needs_cached_setattr, - has_global_on_setattr, + has_cls_on_setattr, attrs_init, ): """ @@ -2257,7 +2371,7 @@ def fmt_setter_with_converter( attr_name = a.name has_on_setattr = a.on_setattr is not None or ( - a.on_setattr is not setters.NO_OP and has_global_on_setattr + a.on_setattr is not setters.NO_OP and has_cls_on_setattr ) arg_name = a.name.lstrip("_") @@ -2474,19 +2588,26 @@ class Attribute(object): """ *Read-only* representation of an attribute. + The class has *all* arguments of `attr.ib` (except for ``factory`` + which is only syntactic sugar for ``default=Factory(...)`` plus the + following: + + - ``name`` (`str`): The name of the attribute. + - ``inherited`` (`bool`): Whether or not that attribute has been inherited + from a base class. + - ``eq_key`` and ``order_key`` (`typing.Callable` or `None`): The callables + that are used for comparing and ordering objects by this attribute, + respectively. These are set by passing a callable to `attr.ib`'s ``eq``, + ``order``, or ``cmp`` arguments. See also :ref:`comparison customization + `. + Instances of this class are frequently used for introspection purposes like: - `fields` returns a tuple of them. - Validators get them passed as the first argument. - - The *field transformer* hook receives a list of them. - - :attribute name: The name of the attribute. - :attribute inherited: Whether or not that attribute has been inherited from - a base class. - - Plus *all* arguments of `attr.ib` (except for ``factory`` - which is only syntactic sugar for ``default=Factory(...)``. + - The :ref:`field transformer ` hook receives a list of + them. .. versionadded:: 20.1.0 *inherited* .. versionadded:: 20.1.0 *on_setattr* @@ -2832,7 +2953,7 @@ class Factory(object): """ Stores a factory callable. - If passed as the default value to `attr.ib`, the factory is used to + If passed as the default value to `attrs.field`, the factory is used to generate a new value. :param callable factory: A callable that takes either none or exactly one diff --git a/src/attr/_next_gen.py b/src/attr/_next_gen.py index fab0af966..068253688 100644 --- a/src/attr/_next_gen.py +++ b/src/attr/_next_gen.py @@ -1,14 +1,24 @@ +# SPDX-License-Identifier: MIT + """ These are Python 3.6+-only and keyword-only APIs that call `attr.s` and `attr.ib` with different default values. """ -from functools import partial -from attr.exceptions import UnannotatedAttributeError +from functools import partial from . import setters -from ._make import NOTHING, _frozen_setattrs, attrib, attrs +from ._funcs import asdict as _asdict +from ._funcs import astuple as _astuple +from ._make import ( + NOTHING, + _frozen_setattrs, + _ng_default_on_setattr, + attrib, + attrs, +) +from .exceptions import UnannotatedAttributeError def define( @@ -32,22 +42,41 @@ def define( getstate_setstate=None, on_setattr=None, field_transformer=None, + match_args=True, ): r""" - The only behavioral differences are the handling of the *auto_attribs* - option: + Define an ``attrs`` class. + + Differences to the classic `attr.s` that it uses underneath: + + - Automatically detect whether or not *auto_attribs* should be `True` + (c.f. *auto_attribs* parameter). + - If *frozen* is `False`, run converters and validators when setting an + attribute by default. + - *slots=True* (see :term:`slotted classes` for potentially surprising + behaviors) + - *auto_exc=True* + - *auto_detect=True* + - *order=False* + - *match_args=True* + - Some options that were only relevant on Python 2 or were kept around for + backwards-compatibility have been removed. + + Please note that these are all defaults and you can change them as you + wish. :param Optional[bool] auto_attribs: If set to `True` or `False`, it behaves exactly like `attr.s`. If left `None`, `attr.s` will try to guess: - 1. If any attributes are annotated and no unannotated `attr.ib`\ s + 1. If any attributes are annotated and no unannotated `attrs.fields`\ s are found, it assumes *auto_attribs=True*. 2. Otherwise it assumes *auto_attribs=False* and tries to collect - `attr.ib`\ s. + `attrs.fields`\ s. - and that mutable classes (``frozen=False``) validate on ``__setattr__``. + For now, please refer to `attr.s` for the rest of the parameters. .. versionadded:: 20.1.0 + .. versionchanged:: 21.3.0 Converters are also run ``on_setattr``. """ def do_it(cls, auto_attribs): @@ -72,6 +101,7 @@ def do_it(cls, auto_attribs): getstate_setstate=getstate_setstate, on_setattr=on_setattr, field_transformer=field_transformer, + match_args=match_args, ) def wrap(cls): @@ -84,9 +114,9 @@ def wrap(cls): had_on_setattr = on_setattr not in (None, setters.NO_OP) - # By default, mutable classes validate on setattr. + # By default, mutable classes convert & validate on setattr. if frozen is False and on_setattr is None: - on_setattr = setters.validate + on_setattr = _ng_default_on_setattr # However, if we subclass a frozen class, we inherit the immutability # and disable on_setattr. @@ -156,3 +186,31 @@ def field( order=order, on_setattr=on_setattr, ) + + +def asdict(inst, *, recurse=True, filter=None, value_serializer=None): + """ + Same as `attr.asdict`, except that collections types are always retained + and dict is always used as *dict_factory*. + + .. versionadded:: 21.3.0 + """ + return _asdict( + inst=inst, + recurse=recurse, + filter=filter, + value_serializer=value_serializer, + retain_collection_types=True, + ) + + +def astuple(inst, *, recurse=True, filter=None): + """ + Same as `attr.astuple`, except that collections types are always retained + and `tuple` is always used as the *tuple_factory*. + + .. versionadded:: 21.3.0 + """ + return _astuple( + inst=inst, recurse=recurse, filter=filter, retain_collection_types=True + ) diff --git a/src/attr/_version_info.py b/src/attr/_version_info.py index 014e78a1b..cdaeec37a 100644 --- a/src/attr/_version_info.py +++ b/src/attr/_version_info.py @@ -1,3 +1,5 @@ +# SPDX-License-Identifier: MIT + from __future__ import absolute_import, division, print_function from functools import total_ordering diff --git a/src/attr/converters.py b/src/attr/converters.py index 2777db6d0..1fb6c05d7 100644 --- a/src/attr/converters.py +++ b/src/attr/converters.py @@ -1,3 +1,5 @@ +# SPDX-License-Identifier: MIT + """ Commonly useful converters. """ @@ -14,9 +16,10 @@ __all__ = [ - "pipe", - "optional", "default_if_none", + "optional", + "pipe", + "to_bool", ] @@ -65,14 +68,14 @@ def default_if_none(default=NOTHING, factory=None): result of *factory*. :param default: Value to be used if ``None`` is passed. Passing an instance - of `attr.Factory` is supported, however the ``takes_self`` option + of `attrs.Factory` is supported, however the ``takes_self`` option is *not*. :param callable factory: A callable that takes no parameters whose result is used if ``None`` is passed. :raises TypeError: If **neither** *default* or *factory* is passed. :raises TypeError: If **both** *default* and *factory* are passed. - :raises ValueError: If an instance of `attr.Factory` is passed with + :raises ValueError: If an instance of `attrs.Factory` is passed with ``takes_self=True``. .. versionadded:: 18.2.0 @@ -109,3 +112,44 @@ def default_if_none_converter(val): return default return default_if_none_converter + + +def to_bool(val): + """ + Convert "boolean" strings (e.g., from env. vars.) to real booleans. + + Values mapping to :code:`True`: + + - :code:`True` + - :code:`"true"` / :code:`"t"` + - :code:`"yes"` / :code:`"y"` + - :code:`"on"` + - :code:`"1"` + - :code:`1` + + Values mapping to :code:`False`: + + - :code:`False` + - :code:`"false"` / :code:`"f"` + - :code:`"no"` / :code:`"n"` + - :code:`"off"` + - :code:`"0"` + - :code:`0` + + :raises ValueError: for any other value. + + .. versionadded:: 21.3.0 + """ + if isinstance(val, str): + val = val.lower() + truthy = {True, "true", "t", "yes", "y", "on", "1", 1} + falsy = {False, "false", "f", "no", "n", "off", "0", 0} + try: + if val in truthy: + return True + if val in falsy: + return False + except TypeError: + # Raised when "val" is not hashable (e.g., lists) + pass + raise ValueError("Cannot convert value to bool: {}".format(val)) diff --git a/src/attr/converters.pyi b/src/attr/converters.pyi index 84a57590b..0f58088a3 100644 --- a/src/attr/converters.pyi +++ b/src/attr/converters.pyi @@ -2,7 +2,6 @@ from typing import Callable, Optional, TypeVar, overload from . import _ConverterType - _T = TypeVar("_T") def pipe(*validators: _ConverterType) -> _ConverterType: ... @@ -11,3 +10,4 @@ def optional(converter: _ConverterType) -> _ConverterType: ... def default_if_none(default: _T) -> _ConverterType: ... @overload def default_if_none(*, factory: Callable[[], _T]) -> _ConverterType: ... +def to_bool(val: str) -> bool: ... diff --git a/src/attr/exceptions.py b/src/attr/exceptions.py index f6f9861be..b2f1edc32 100644 --- a/src/attr/exceptions.py +++ b/src/attr/exceptions.py @@ -1,3 +1,5 @@ +# SPDX-License-Identifier: MIT + from __future__ import absolute_import, division, print_function diff --git a/src/attr/exceptions.pyi b/src/attr/exceptions.pyi index a800fb26b..f2680118b 100644 --- a/src/attr/exceptions.pyi +++ b/src/attr/exceptions.pyi @@ -1,6 +1,5 @@ from typing import Any - class FrozenError(AttributeError): msg: str = ... diff --git a/src/attr/filters.py b/src/attr/filters.py index dc47e8fa3..a1978a877 100644 --- a/src/attr/filters.py +++ b/src/attr/filters.py @@ -1,3 +1,5 @@ +# SPDX-License-Identifier: MIT + """ Commonly useful filters for `attr.asdict`. """ @@ -20,10 +22,10 @@ def _split_what(what): def include(*what): """ - Whitelist *what*. + Include *what*. - :param what: What to whitelist. - :type what: `list` of `type` or `attr.Attribute`\\ s + :param what: What to include. + :type what: `list` of `type` or `attrs.Attribute`\\ s :rtype: `callable` """ @@ -37,10 +39,10 @@ def include_(attribute, value): def exclude(*what): """ - Blacklist *what*. + Exclude *what*. - :param what: What to blacklist. - :type what: `list` of classes or `attr.Attribute`\\ s. + :param what: What to exclude. + :type what: `list` of classes or `attrs.Attribute`\\ s. :rtype: `callable` """ diff --git a/src/attr/filters.pyi b/src/attr/filters.pyi index f7b63f1bb..993866865 100644 --- a/src/attr/filters.pyi +++ b/src/attr/filters.pyi @@ -2,6 +2,5 @@ from typing import Any, Union from . import Attribute, _FilterType - def include(*what: Union[type, Attribute[Any]]) -> _FilterType[Any]: ... def exclude(*what: Union[type, Attribute[Any]]) -> _FilterType[Any]: ... diff --git a/src/attr/setters.py b/src/attr/setters.py index 240014b3c..b1cbb5d83 100644 --- a/src/attr/setters.py +++ b/src/attr/setters.py @@ -1,3 +1,5 @@ +# SPDX-License-Identifier: MIT + """ Commonly used hooks for on_setattr. """ diff --git a/src/attr/setters.pyi b/src/attr/setters.pyi index a921e07de..3f5603c2b 100644 --- a/src/attr/setters.pyi +++ b/src/attr/setters.pyi @@ -2,7 +2,6 @@ from typing import Any, NewType, NoReturn, TypeVar, cast from . import Attribute, _OnSetAttrType - _T = TypeVar("_T") def frozen( diff --git a/src/attr/validators.py b/src/attr/validators.py index b9a73054e..0b0c8342f 100644 --- a/src/attr/validators.py +++ b/src/attr/validators.py @@ -1,28 +1,96 @@ +# SPDX-License-Identifier: MIT + """ Commonly useful validators. """ from __future__ import absolute_import, division, print_function +import operator import re +from contextlib import contextmanager + +from ._config import get_run_validators, set_run_validators from ._make import _AndValidator, and_, attrib, attrs 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", "deep_mapping", + "disabled", + "ge", + "get_disabled", + "gt", "in_", "instance_of", "is_callable", + "le", + "lt", "matches_re", + "max_len", "optional", "provides", + "set_disabled", ] +def set_disabled(disabled): + """ + Globally disable or enable running validators. + + By default, they are run. + + :param disabled: If ``True``, disable running all validators. + :type disabled: bool + + .. warning:: + + This function is not thread-safe! + + .. versionadded:: 21.3.0 + """ + set_run_validators(not disabled) + + +def get_disabled(): + """ + Return a bool indicating whether validators are currently disabled or not. + + :return: ``True`` if validators are currently disabled. + :rtype: bool + + .. versionadded:: 21.3.0 + """ + return not get_run_validators() + + +@contextmanager +def disabled(): + """ + Context manager that disables running validators within its context. + + .. warning:: + + This context manager is not thread-safe! + + .. versionadded:: 21.3.0 + """ + set_run_validators(False) + try: + yield + finally: + set_run_validators(True) + + @attrs(repr=False, slots=True, hash=True) class _InstanceOfValidator(object): type = attrib() @@ -61,7 +129,7 @@ def instance_of(type): :type type: type or tuple of types :raises TypeError: With a human readable error message, the attribute - (of type `attr.Attribute`), the expected type, and the value it + (of type `attrs.Attribute`), the expected type, and the value it got. """ return _InstanceOfValidator(type) @@ -69,8 +137,7 @@ def instance_of(type): @attrs(repr=False, frozen=True, slots=True) class _MatchesReValidator(object): - regex = attrib() - flags = attrib() + pattern = attrib() match_func = attrib() def __call__(self, inst, attr, value): @@ -79,18 +146,18 @@ def __call__(self, inst, attr, value): """ if not self.match_func(value): raise ValueError( - "'{name}' must match regex {regex!r}" + "'{name}' must match regex {pattern!r}" " ({value!r} doesn't)".format( - name=attr.name, regex=self.regex.pattern, value=value + name=attr.name, pattern=self.pattern.pattern, value=value ), attr, - self.regex, + self.pattern, value, ) def __repr__(self): - return "".format( - regex=self.regex + return "".format( + pattern=self.pattern ) @@ -99,7 +166,7 @@ def matches_re(regex, flags=0, func=None): A validator that raises `ValueError` if the initializer is called with a string that doesn't match *regex*. - :param str regex: a regex string to match against + :param regex: a regex string or precompiled pattern to match against :param int flags: flags that will be passed to the underlying re function (default 0) :param callable func: which underlying `re` function to call (options @@ -109,34 +176,44 @@ def matches_re(regex, flags=0, func=None): but on a pre-`re.compile`\ ed pattern. .. versionadded:: 19.2.0 + .. versionchanged:: 21.3.0 *regex* can be a pre-compiled pattern. """ fullmatch = getattr(re, "fullmatch", None) valid_funcs = (fullmatch, None, re.search, re.match) if func not in valid_funcs: raise ValueError( - "'func' must be one of %s." - % ( + "'func' must be one of {}.".format( ", ".join( sorted( e and e.__name__ or "None" for e in set(valid_funcs) ) - ), + ) ) ) - pattern = re.compile(regex, flags) + if isinstance(regex, Pattern): + if flags: + raise TypeError( + "'flags' can only be used with a string pattern; " + "pass flags to re.compile() instead" + ) + pattern = regex + else: + pattern = re.compile(regex, flags) + if func is re.match: match_func = pattern.match elif func is re.search: match_func = pattern.search - else: - if fullmatch: - match_func = pattern.fullmatch - else: - pattern = re.compile(r"(?:{})\Z".format(regex), flags) - match_func = pattern.match + elif fullmatch: + match_func = pattern.fullmatch + else: # Python 2 fullmatch emulation (https://bugs.python.org/issue16203) + pattern = re.compile( + r"(?:{})\Z".format(pattern.pattern), pattern.flags + ) + match_func = pattern.match - return _MatchesReValidator(pattern, flags, match_func) + return _MatchesReValidator(pattern, match_func) @attrs(repr=False, slots=True, hash=True) @@ -175,7 +252,7 @@ def provides(interface): :type interface: ``zope.interface.Interface`` :raises TypeError: With a human readable error message, the attribute - (of type `attr.Attribute`), the expected interface, and the + (of type `attrs.Attribute`), the expected interface, and the value it got. """ return _ProvidesValidator(interface) @@ -248,7 +325,7 @@ def in_(options): :type options: list, tuple, `enum.Enum`, ... :raises ValueError: With a human readable error message, the attribute (of - type `attr.Attribute`), the expected options, and the value it + type `attrs.Attribute`), the expected options, and the value it got. .. versionadded:: 17.1.0 @@ -287,7 +364,7 @@ def is_callable(): .. versionadded:: 19.1.0 :raises `attr.exceptions.NotCallableError`: With a human readable error - message containing the attribute (`attr.Attribute`) name, + message containing the attribute (`attrs.Attribute`) name, and the value it got. """ return _IsCallableValidator() @@ -377,3 +454,108 @@ def deep_mapping(key_validator, value_validator, mapping_validator=None): :raises TypeError: if any sub-validators fail """ return _DeepMapping(key_validator, value_validator, mapping_validator) + + +@attrs(repr=False, frozen=True, slots=True) +class _NumberValidator(object): + bound = attrib() + compare_op = attrib() + compare_func = attrib() + + def __call__(self, inst, attr, value): + """ + We use a callable class to be able to change the ``__repr__``. + """ + if not self.compare_func(value, self.bound): + raise ValueError( + "'{name}' must be {op} {bound}: {value}".format( + name=attr.name, + op=self.compare_op, + bound=self.bound, + value=value, + ) + ) + + def __repr__(self): + return "".format( + op=self.compare_op, bound=self.bound + ) + + +def lt(val): + """ + A validator that raises `ValueError` if the initializer is called + with a number larger or equal to *val*. + + :param val: Exclusive upper bound for values + + .. versionadded:: 21.3.0 + """ + return _NumberValidator(val, "<", operator.lt) + + +def le(val): + """ + A validator that raises `ValueError` if the initializer is called + with a number greater than *val*. + + :param val: Inclusive upper bound for values + + .. versionadded:: 21.3.0 + """ + return _NumberValidator(val, "<=", operator.le) + + +def ge(val): + """ + A validator that raises `ValueError` if the initializer is called + with a number smaller than *val*. + + :param val: Inclusive lower bound for values + + .. versionadded:: 21.3.0 + """ + return _NumberValidator(val, ">=", operator.ge) + + +def gt(val): + """ + A validator that raises `ValueError` if the initializer is called + with a number smaller or equal to *val*. + + :param val: Exclusive lower bound for values + + .. versionadded:: 21.3.0 + """ + return _NumberValidator(val, ">", operator.gt) + + +@attrs(repr=False, frozen=True, slots=True) +class _MaxLengthValidator(object): + max_length = attrib() + + def __call__(self, inst, attr, value): + """ + We use a callable class to be able to change the ``__repr__``. + """ + if len(value) > self.max_length: + raise ValueError( + "Length of '{name}' must be <= {max}: {len}".format( + name=attr.name, max=self.max_length, len=len(value) + ) + ) + + def __repr__(self): + return "".format(max=self.max_length) + + +def max_len(length): + """ + A validator that raises `ValueError` if the initializer is called + with a string or iterable that is longer than *length*. + + :param int length: Maximum length of the string or iterable + + .. versionadded:: 21.3.0 + """ + return _MaxLengthValidator(length) diff --git a/src/attr/validators.pyi b/src/attr/validators.pyi index fe92aac42..5e00b8543 100644 --- a/src/attr/validators.pyi +++ b/src/attr/validators.pyi @@ -3,11 +3,13 @@ from typing import ( AnyStr, Callable, Container, + ContextManager, Iterable, List, Mapping, Match, Optional, + Pattern, Tuple, Type, TypeVar, @@ -17,7 +19,6 @@ from typing import ( from . import _ValidatorType - _T = TypeVar("_T") _T1 = TypeVar("_T1") _T2 = TypeVar("_T2") @@ -27,6 +28,10 @@ _K = TypeVar("_K") _V = TypeVar("_V") _M = TypeVar("_M", bound=Mapping) +def set_disabled(run: bool) -> None: ... +def get_disabled() -> bool: ... +def disabled() -> ContextManager[None]: ... + # To be more precise on instance_of use some overloads. # If there are more than 3 items in the tuple then we fall back to Any @overload @@ -50,7 +55,7 @@ def optional( def in_(options: Container[_T]) -> _ValidatorType[_T]: ... def and_(*validators: _ValidatorType[_T]) -> _ValidatorType[_T]: ... def matches_re( - regex: AnyStr, + regex: Union[Pattern[AnyStr], AnyStr], flags: int = ..., func: Optional[ Callable[[AnyStr, AnyStr, int], Optional[Match[AnyStr]]] @@ -66,3 +71,8 @@ def deep_mapping( mapping_validator: Optional[_ValidatorType[_M]] = ..., ) -> _ValidatorType[_M]: ... def is_callable() -> _ValidatorType[_T]: ... +def lt(val: _T) -> _ValidatorType[_T]: ... +def le(val: _T) -> _ValidatorType[_T]: ... +def ge(val: _T) -> _ValidatorType[_T]: ... +def gt(val: _T) -> _ValidatorType[_T]: ... +def max_len(length: int) -> _ValidatorType[_T]: ... diff --git a/src/attrs/__init__.py b/src/attrs/__init__.py new file mode 100644 index 000000000..a704b8b56 --- /dev/null +++ b/src/attrs/__init__.py @@ -0,0 +1,70 @@ +# SPDX-License-Identifier: MIT + +from attr import ( + NOTHING, + Attribute, + Factory, + __author__, + __copyright__, + __description__, + __doc__, + __email__, + __license__, + __title__, + __url__, + __version__, + __version_info__, + assoc, + cmp_using, + define, + evolve, + field, + fields, + fields_dict, + frozen, + has, + make_class, + mutable, + resolve_types, + validate, +) +from attr._next_gen import asdict, astuple + +from . import converters, exceptions, filters, setters, validators + + +__all__ = [ + "__author__", + "__copyright__", + "__description__", + "__doc__", + "__email__", + "__license__", + "__title__", + "__url__", + "__version__", + "__version_info__", + "asdict", + "assoc", + "astuple", + "Attribute", + "cmp_using", + "converters", + "define", + "evolve", + "exceptions", + "Factory", + "field", + "fields_dict", + "fields", + "filters", + "frozen", + "has", + "make_class", + "mutable", + "NOTHING", + "resolve_types", + "setters", + "validate", + "validators", +] diff --git a/src/attrs/__init__.pyi b/src/attrs/__init__.pyi new file mode 100644 index 000000000..7426fa5dd --- /dev/null +++ b/src/attrs/__init__.pyi @@ -0,0 +1,63 @@ +from typing import ( + Any, + Callable, + Dict, + Mapping, + Optional, + Sequence, + Tuple, + Type, +) + +# Because we need to type our own stuff, we have to make everything from +# attr explicitly public too. +from attr import __author__ as __author__ +from attr import __copyright__ as __copyright__ +from attr import __description__ as __description__ +from attr import __email__ as __email__ +from attr import __license__ as __license__ +from attr import __title__ as __title__ +from attr import __url__ as __url__ +from attr import __version__ as __version__ +from attr import __version_info__ as __version_info__ +from attr import _FilterType +from attr import assoc as assoc +from attr import Attribute as Attribute +from attr import define as define +from attr import evolve as evolve +from attr import Factory as Factory +from attr import exceptions as exceptions +from attr import field as field +from attr import fields as fields +from attr import fields_dict as fields_dict +from attr import frozen as frozen +from attr import has as has +from attr import make_class as make_class +from attr import mutable as mutable +from attr import NOTHING as NOTHING +from attr import resolve_types as resolve_types +from attr import setters as setters +from attr import validate as validate +from attr import validators as validators + +# TODO: see definition of attr.asdict/astuple +def asdict( + inst: Any, + recurse: bool = ..., + filter: Optional[_FilterType[Any]] = ..., + dict_factory: Type[Mapping[Any, Any]] = ..., + retain_collection_types: bool = ..., + value_serializer: Optional[ + Callable[[type, Attribute[Any], Any], Any] + ] = ..., + tuple_keys: bool = ..., +) -> Dict[str, Any]: ... + +# TODO: add support for returning NamedTuple from the mypy plugin +def astuple( + inst: Any, + recurse: bool = ..., + filter: Optional[_FilterType[Any]] = ..., + tuple_factory: Type[Sequence[Any]] = ..., + retain_collection_types: bool = ..., +) -> Tuple[Any, ...]: ... diff --git a/src/attrs/converters.py b/src/attrs/converters.py new file mode 100644 index 000000000..edfa8d3c1 --- /dev/null +++ b/src/attrs/converters.py @@ -0,0 +1,3 @@ +# SPDX-License-Identifier: MIT + +from attr.converters import * # noqa diff --git a/src/attrs/exceptions.py b/src/attrs/exceptions.py new file mode 100644 index 000000000..bd9efed20 --- /dev/null +++ b/src/attrs/exceptions.py @@ -0,0 +1,3 @@ +# SPDX-License-Identifier: MIT + +from attr.exceptions import * # noqa diff --git a/src/attrs/filters.py b/src/attrs/filters.py new file mode 100644 index 000000000..52959005b --- /dev/null +++ b/src/attrs/filters.py @@ -0,0 +1,3 @@ +# SPDX-License-Identifier: MIT + +from attr.filters import * # noqa diff --git a/src/attrs/py.typed b/src/attrs/py.typed new file mode 100644 index 000000000..e69de29bb diff --git a/src/attrs/setters.py b/src/attrs/setters.py new file mode 100644 index 000000000..9b5077080 --- /dev/null +++ b/src/attrs/setters.py @@ -0,0 +1,3 @@ +# SPDX-License-Identifier: MIT + +from attr.setters import * # noqa diff --git a/src/attrs/validators.py b/src/attrs/validators.py new file mode 100644 index 000000000..ab2c9b302 --- /dev/null +++ b/src/attrs/validators.py @@ -0,0 +1,3 @@ +# SPDX-License-Identifier: MIT + +from attr.validators import * # noqa diff --git a/tests/__init__.py b/tests/__init__.py index e69de29bb..548d2d447 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -0,0 +1 @@ +# SPDX-License-Identifier: MIT diff --git a/tests/attr_import_star.py b/tests/attr_import_star.py new file mode 100644 index 000000000..eaec321ba --- /dev/null +++ b/tests/attr_import_star.py @@ -0,0 +1,10 @@ +# SPDX-License-Identifier: MIT + +from __future__ import absolute_import + +from attr import * # noqa: F401,F403 + + +# This is imported by test_import::test_from_attr_import_star; this must +# be done indirectly because importing * is only allowed on module level, +# so can't be done inside a test. diff --git a/tests/dataclass_transform_example.py b/tests/dataclass_transform_example.py index f2e949d92..49e09061a 100644 --- a/tests/dataclass_transform_example.py +++ b/tests/dataclass_transform_example.py @@ -1,3 +1,5 @@ +# SPDX-License-Identifier: MIT + import attr diff --git a/tests/strategies.py b/tests/strategies.py index 70d424af4..99f9f4853 100644 --- a/tests/strategies.py +++ b/tests/strategies.py @@ -1,3 +1,5 @@ +# SPDX-License-Identifier: MIT + """ Testing strategies for Hypothesis-based tests. """ diff --git a/tests/test_3rd_party.py b/tests/test_3rd_party.py new file mode 100644 index 000000000..8866d7f6e --- /dev/null +++ b/tests/test_3rd_party.py @@ -0,0 +1,31 @@ +# SPDX-License-Identifier: MIT + +""" +Tests for compatibility against other Python modules. +""" + +import pytest + +from hypothesis import given + +from .strategies import simple_classes + + +cloudpickle = pytest.importorskip("cloudpickle") + + +class TestCloudpickleCompat(object): + """ + Tests for compatibility with ``cloudpickle``. + """ + + @given(simple_classes()) + def test_repr(self, cls): + """ + attrs instances can be pickled and un-pickled with cloudpickle. + """ + inst = cls() + # Exact values aren't a concern so long as neither direction + # raises an exception. + pkl = cloudpickle.dumps(inst) + cloudpickle.loads(pkl) diff --git a/tests/test_annotations.py b/tests/test_annotations.py index 2df6311b2..a201ebf7f 100644 --- a/tests/test_annotations.py +++ b/tests/test_annotations.py @@ -1,3 +1,5 @@ +# SPDX-License-Identifier: MIT + """ Tests for PEP-526 type annotations. @@ -618,6 +620,40 @@ class C: with pytest.raises(NameError): typing.get_type_hints(C.__init__) + def test_inheritance(self): + """ + Subclasses can be resolved after the parent is resolved. + """ + + @attr.define() + class A: + n: "int" + + @attr.define() + class B(A): + pass + + attr.resolve_types(A) + attr.resolve_types(B) + + assert int == attr.fields(A).n.type + assert int == attr.fields(B).n.type + + def test_resolve_twice(self): + """ + You can call resolve_types as many times as you like. + This test is here mostly for coverage. + """ + + @attr.define() + class A: + n: "int" + + attr.resolve_types(A) + assert int == attr.fields(A).n.type + attr.resolve_types(A) + assert int == attr.fields(A).n.type + @pytest.mark.parametrize( "annot", diff --git a/tests/test_cmp.py b/tests/test_cmp.py index fa6b31821..ec2c68748 100644 --- a/tests/test_cmp.py +++ b/tests/test_cmp.py @@ -1,3 +1,5 @@ +# SPDX-License-Identifier: MIT + """ Tests for methods from `attrib._cmp`. """ diff --git a/tests/test_compat.py b/tests/test_compat.py new file mode 100644 index 000000000..464b492f0 --- /dev/null +++ b/tests/test_compat.py @@ -0,0 +1,52 @@ +# SPDX-License-Identifier: MIT + +import pytest + +from attr._compat import metadata_proxy + + +@pytest.fixture(name="mp") +def _mp(): + return metadata_proxy({"x": 42, "y": "foo"}) + + +class TestMetadataProxy: + """ + Ensure properties of metadata_proxy independently of hypothesis strategies. + """ + + def test_repr(self, mp): + """ + repr makes sense and is consistent across Python versions. + """ + assert any( + [ + "mappingproxy({'x': 42, 'y': 'foo'})" == repr(mp), + "mappingproxy({'y': 'foo', 'x': 42})" == repr(mp), + ] + ) + + def test_immutable(self, mp): + """ + All mutating methods raise errors. + """ + with pytest.raises(TypeError, match="not support item assignment"): + mp["z"] = 23 + + with pytest.raises(TypeError, match="not support item deletion"): + del mp["x"] + + with pytest.raises(AttributeError, match="no attribute 'update'"): + mp.update({}) + + with pytest.raises(AttributeError, match="no attribute 'clear'"): + mp.clear() + + with pytest.raises(AttributeError, match="no attribute 'pop'"): + mp.pop("x") + + with pytest.raises(AttributeError, match="no attribute 'popitem'"): + mp.popitem() + + with pytest.raises(AttributeError, match="no attribute 'setdefault'"): + mp.setdefault("x") diff --git a/tests/test_config.py b/tests/test_config.py index 287be03a5..bbf675640 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -1,3 +1,5 @@ +# SPDX-License-Identifier: MIT + """ Tests for `attr._config`. """ diff --git a/tests/test_converters.py b/tests/test_converters.py index f86e07e29..d0fc723eb 100644 --- a/tests/test_converters.py +++ b/tests/test_converters.py @@ -1,17 +1,17 @@ +# SPDX-License-Identifier: MIT + """ Tests for `attr.converters`. """ from __future__ import absolute_import -from distutils.util import strtobool - import pytest import attr from attr import Factory, attrib -from attr.converters import default_if_none, optional, pipe +from attr.converters import default_if_none, optional, pipe, to_bool class TestOptional(object): @@ -106,7 +106,7 @@ def test_success(self): """ Succeeds if all wrapped converters succeed. """ - c = pipe(str, strtobool, bool) + c = pipe(str, to_bool, bool) assert True is c("True") is c(True) @@ -114,7 +114,7 @@ def test_fail(self): """ Fails if any wrapped converter fails. """ - c = pipe(str, strtobool) + c = pipe(str, to_bool) # First wrapped converter fails: with pytest.raises(ValueError): @@ -131,8 +131,33 @@ def test_sugar(self): @attr.s class C(object): - a1 = attrib(default="True", converter=pipe(str, strtobool, bool)) - a2 = attrib(default=True, converter=[str, strtobool, bool]) + a1 = attrib(default="True", converter=pipe(str, to_bool, bool)) + a2 = attrib(default=True, converter=[str, to_bool, bool]) c = C() assert True is c.a1 is c.a2 + + +class TestToBool(object): + def test_unhashable(self): + """ + Fails if value is unhashable. + """ + with pytest.raises(ValueError, match="Cannot convert value to bool"): + to_bool([]) + + def test_truthy(self): + """ + Fails if truthy values are incorrectly converted. + """ + assert to_bool("t") + assert to_bool("yes") + assert to_bool("on") + + def test_falsy(self): + """ + Fails if falsy values are incorrectly converted. + """ + assert not to_bool("f") + assert not to_bool("no") + assert not to_bool("off") diff --git a/tests/test_dunders.py b/tests/test_dunders.py index 70f26d044..186762eb0 100644 --- a/tests/test_dunders.py +++ b/tests/test_dunders.py @@ -1,3 +1,5 @@ +# SPDX-License-Identifier: MIT + """ Tests for dunder methods from `attrib._make`. """ @@ -94,7 +96,7 @@ def _add_init(cls, frozen): cache_hash=False, base_attr_map={}, is_exc=False, - has_global_on_setattr=False, + cls_on_setattr=None, attrs_init=False, ) return cls @@ -367,6 +369,23 @@ class Cycle(object): cycle.cycle = cycle assert "Cycle(value=7, cycle=...)" == repr(cycle) + def test_infinite_recursion_long_cycle(self): + """ + A cyclic graph can pass through other non-attrs objects, and repr will + still emit an ellipsis and not raise an exception. + """ + + @attr.s + class LongCycle(object): + value = attr.ib(default=14) + cycle = attr.ib(default=None) + + cycle = LongCycle() + # Ensure that the reference cycle passes through a non-attrs object. + # This demonstrates the need for a thread-local "global" ID tracker. + cycle.cycle = {"cycle": [cycle]} + assert "LongCycle(value=14, cycle={'cycle': [...]})" == repr(cycle) + def test_underscores(self): """ repr does not strip underscores. @@ -936,6 +955,16 @@ class C(object): pass +CopyC = C + + +@attr.s(hash=True, order=True) +class C(object): + """A different class, to generate different methods.""" + + a = attr.ib() + + class TestFilenames(object): def test_filenames(self): """ @@ -953,15 +982,27 @@ def test_filenames(self): OriginalC.__hash__.__code__.co_filename == "" ) + assert ( + CopyC.__init__.__code__.co_filename + == "" + ) + assert ( + CopyC.__eq__.__code__.co_filename + == "" + ) + assert ( + CopyC.__hash__.__code__.co_filename + == "" + ) assert ( C.__init__.__code__.co_filename - == "" + == "" ) assert ( C.__eq__.__code__.co_filename - == "" + == "" ) assert ( C.__hash__.__code__.co_filename - == "" + == "" ) diff --git a/tests/test_filters.py b/tests/test_filters.py index 7a1a41895..d1ec24dc6 100644 --- a/tests/test_filters.py +++ b/tests/test_filters.py @@ -1,3 +1,5 @@ +# SPDX-License-Identifier: MIT + """ Tests for `attr.filters`. """ @@ -49,7 +51,7 @@ class TestInclude(object): ) def test_allow(self, incl, value): """ - Return True if a class or attribute is whitelisted. + Return True if a class or attribute is included. """ i = include(*incl) assert i(fields(C).a, value) is True @@ -65,7 +67,7 @@ def test_allow(self, incl, value): ) def test_drop_class(self, incl, value): """ - Return False on non-whitelisted classes and attributes. + Return False on non-included classes and attributes. """ i = include(*incl) assert i(fields(C).a, value) is False @@ -87,7 +89,7 @@ class TestExclude(object): ) def test_allow(self, excl, value): """ - Return True if class or attribute is not blacklisted. + Return True if class or attribute is not excluded. """ e = exclude(*excl) assert e(fields(C).a, value) is True @@ -103,7 +105,7 @@ def test_allow(self, excl, value): ) def test_drop_class(self, excl, value): """ - Return True on non-blacklisted classes and attributes. + Return True on non-excluded classes and attributes. """ e = exclude(*excl) assert e(fields(C).a, value) is False diff --git a/tests/test_funcs.py b/tests/test_funcs.py index e957d0e38..4490ed815 100644 --- a/tests/test_funcs.py +++ b/tests/test_funcs.py @@ -1,3 +1,5 @@ +# SPDX-License-Identifier: MIT + """ Tests for `attr._funcs`. """ @@ -26,7 +28,7 @@ @pytest.fixture(scope="session", name="C") -def fixture_C(): +def _C(): """ Return a simple but fully featured attrs class with an x and a y attribute. """ @@ -199,6 +201,37 @@ def test_asdict_preserve_order(self, cls): assert [a.name for a in fields(cls)] == list(dict_instance.keys()) + def test_retain_keys_are_tuples(self): + """ + retain_collect_types also retains keys. + """ + + @attr.s + class A(object): + a = attr.ib() + + instance = A({(1,): 1}) + + assert {"a": {(1,): 1}} == attr.asdict( + instance, retain_collection_types=True + ) + + def test_tuple_keys(self): + """ + If a key is collection type, retain_collection_types is False, + the key is serialized as a tuple. + + See #646 + """ + + @attr.s + class A(object): + a = attr.ib() + + instance = A({(1,): 1}) + + assert {"a": {(1,): 1}} == attr.asdict(instance) + class TestAsTuple(object): """ diff --git a/tests/test_functional.py b/tests/test_functional.py index a17023ffc..9b6a27e2f 100644 --- a/tests/test_functional.py +++ b/tests/test_functional.py @@ -1,9 +1,12 @@ +# SPDX-License-Identifier: MIT + """ End-to-end tests. """ from __future__ import absolute_import, division, print_function +import inspect import pickle from copy import deepcopy @@ -16,7 +19,7 @@ import attr -from attr._compat import PY2, TYPE +from attr._compat import PY2, PY36, TYPE from attr._make import NOTHING, Attribute from attr.exceptions import FrozenInstanceError @@ -687,3 +690,101 @@ class C(object): "2021-06-01. Please use `eq` and `order` instead." == w.message.args[0] ) + + @pytest.mark.parametrize("slots", [True, False]) + def test_no_setattr_if_validate_without_validators(self, slots): + """ + If a class has on_setattr=attr.setters.validate (former default in NG + APIs) but sets no validators, don't use the (slower) setattr in + __init__. + + Regression test for #816. + """ + + @attr.s(on_setattr=attr.setters.validate) + class C(object): + x = attr.ib() + + @attr.s(on_setattr=attr.setters.validate) + class D(C): + y = attr.ib() + + src = inspect.getsource(D.__init__) + + assert "setattr" not in src + assert "self.x = x" in src + assert "self.y = y" in src + assert object.__setattr__ == D.__setattr__ + + @pytest.mark.parametrize("slots", [True, False]) + def test_no_setattr_if_convert_without_converters(self, slots): + """ + If a class has on_setattr=attr.setters.convert but sets no validators, + don't use the (slower) setattr in __init__. + """ + + @attr.s(on_setattr=attr.setters.convert) + class C(object): + x = attr.ib() + + @attr.s(on_setattr=attr.setters.convert) + class D(C): + y = attr.ib() + + src = inspect.getsource(D.__init__) + + assert "setattr" not in src + assert "self.x = x" in src + assert "self.y = y" in src + assert object.__setattr__ == D.__setattr__ + + @pytest.mark.skipif(not PY36, reason="NG APIs are 3.6+") + @pytest.mark.parametrize("slots", [True, False]) + def test_no_setattr_with_ng_defaults(self, slots): + """ + If a class has the NG default on_setattr=[convert, validate] but sets + no validators or converters, don't use the (slower) setattr in + __init__. + """ + + @attr.define + class C(object): + x = attr.ib() + + src = inspect.getsource(C.__init__) + + assert "setattr" not in src + assert "self.x = x" in src + assert object.__setattr__ == C.__setattr__ + + @attr.define + class D(C): + y = attr.ib() + + src = inspect.getsource(D.__init__) + + assert "setattr" not in src + assert "self.x = x" in src + assert "self.y = y" in src + assert object.__setattr__ == D.__setattr__ + + def test_on_setattr_detect_inherited_validators(self): + """ + _make_init detects the presence of a validator even if the field is + inherited. + """ + + @attr.s(on_setattr=attr.setters.validate) + class C(object): + x = attr.ib(validator=42) + + @attr.s(on_setattr=attr.setters.validate) + class D(C): + y = attr.ib() + + src = inspect.getsource(D.__init__) + + assert "_setattr = _cached_setattr" in src + assert "_setattr('x', x)" in src + assert "_setattr('y', y)" in src + assert object.__setattr__ != D.__setattr__ diff --git a/tests/test_hooks.py b/tests/test_hooks.py index 5fcb10047..92fc2dcaa 100644 --- a/tests/test_hooks.py +++ b/tests/test_hooks.py @@ -1,3 +1,5 @@ +# SPDX-License-Identifier: MIT + from datetime import datetime from typing import Dict, List @@ -117,6 +119,22 @@ class Sub(Base): assert attr.asdict(Sub(2)) == {"y": 2} + def test_attrs_attrclass(self): + """ + The list of attrs returned by a field_transformer is converted to + "AttrsClass" again. + + Regression test for #821. + """ + + @attr.s(auto_attribs=True, field_transformer=lambda c, a: list(a)) + class C: + x: int + + fields_type = type(attr.fields(C)) + assert fields_type.__name__ == "CAttributes" + assert issubclass(fields_type, tuple) + class TestAsDictHook: def test_asdict(self): diff --git a/tests/test_import.py b/tests/test_import.py new file mode 100644 index 000000000..423124319 --- /dev/null +++ b/tests/test_import.py @@ -0,0 +1,11 @@ +# SPDX-License-Identifier: MIT + + +class TestImportStar(object): + def test_from_attr_import_star(self): + """ + import * from attr + """ + # attr_import_star contains `from attr import *`, which cannot + # be done here because *-imports are only allowed on module level. + from . import attr_import_star # noqa: F401 diff --git a/tests/test_init_subclass.py b/tests/test_init_subclass.py index 2748655a0..863e79437 100644 --- a/tests/test_init_subclass.py +++ b/tests/test_init_subclass.py @@ -1,3 +1,5 @@ +# SPDX-License-Identifier: MIT + """ Tests for `__init_subclass__` related tests. diff --git a/tests/test_make.py b/tests/test_make.py index e54b0bb05..729d3a71f 100644 --- a/tests/test_make.py +++ b/tests/test_make.py @@ -1,3 +1,5 @@ +# SPDX-License-Identifier: MIT + """ Tests for `attr._make`. """ @@ -21,7 +23,7 @@ import attr from attr import _config -from attr._compat import PY2, ordered_dict +from attr._compat import PY2, PY310, ordered_dict from attr._make import ( Attribute, Factory, @@ -402,7 +404,7 @@ def test_mro(self): See #428 """ - @attr.s + @attr.s(collect_by_mro=True) class A(object): x = attr.ib(10) @@ -410,11 +412,11 @@ class A(object): def xx(self): return 10 - @attr.s + @attr.s(collect_by_mro=True) class B(A): y = attr.ib(20) - @attr.s + @attr.s(collect_by_mro=True) class C(A): x = attr.ib(50) @@ -2327,3 +2329,134 @@ def __setstate__(self, state): assert True is i.called assert None is getattr(C(), "__getstate__", None) + + @pytest.mark.skipif(PY310, reason="Pre-3.10 only.") + def test_match_args_pre_310(self): + """ + __match_args__ is not created on Python versions older than 3.10. + """ + + @attr.s + class C(object): + a = attr.ib() + + assert None is getattr(C, "__match_args__", None) + + +@pytest.mark.skipif(not PY310, reason="Structural pattern matching is 3.10+") +class TestMatchArgs(object): + """ + Tests for match_args and __match_args__ generation. + """ + + def test_match_args(self): + """ + __match_args__ is created by default on Python 3.10. + """ + + @attr.define + class C: + a = attr.field() + + assert ("a",) == C.__match_args__ + + def test_explicit_match_args(self): + """ + A custom __match_args__ set is not overwritten. + """ + + ma = () + + @attr.define + class C: + a = attr.field() + __match_args__ = ma + + assert C(42).__match_args__ is ma + + @pytest.mark.parametrize("match_args", [True, False]) + def test_match_args_attr_set(self, match_args): + """ + __match_args__ is set depending on match_args. + """ + + @attr.define(match_args=match_args) + class C: + a = attr.field() + + if match_args: + assert hasattr(C, "__match_args__") + else: + assert not hasattr(C, "__match_args__") + + def test_match_args_kw_only(self): + """ + kw_only classes don't generate __match_args__. + kw_only fields are not included in __match_args__. + """ + + @attr.define + class C: + a = attr.field(kw_only=True) + b = attr.field() + + assert C.__match_args__ == ("b",) + + @attr.define(kw_only=True) + class C: + a = attr.field() + b = attr.field() + + assert C.__match_args__ == () + + def test_match_args_argument(self): + """ + match_args being False with inheritance. + """ + + @attr.define(match_args=False) + class X: + a = attr.field() + + assert "__match_args__" not in X.__dict__ + + @attr.define(match_args=False) + class Y: + a = attr.field() + __match_args__ = ("b",) + + assert Y.__match_args__ == ("b",) + + @attr.define(match_args=False) + class Z(Y): + z = attr.field() + + assert Z.__match_args__ == ("b",) + + @attr.define + class A: + a = attr.field() + z = attr.field() + + @attr.define(match_args=False) + class B(A): + b = attr.field() + + assert B.__match_args__ == ("a", "z") + + def test_make_class(self): + """ + match_args generation with make_class. + """ + + C1 = make_class("C1", ["a", "b"]) + assert ("a", "b") == C1.__match_args__ + + C1 = make_class("C1", ["a", "b"], match_args=False) + assert not hasattr(C1, "__match_args__") + + C1 = make_class("C1", ["a", "b"], kw_only=True) + assert () == C1.__match_args__ + + C1 = make_class("C1", {"a": attr.ib(kw_only=True), "b": attr.ib()}) + assert ("b",) == C1.__match_args__ diff --git a/tests/test_mypy.yml b/tests/test_mypy.yml index 1dd0f9a7f..ca17b0a66 100644 --- a/tests/test_mypy.yml +++ b/tests/test_mypy.yml @@ -35,7 +35,7 @@ def foo(self): return self.a - reveal_type(A) # N: Revealed type is 'def (a: Any, b: Any, c: Any =, d: Any =) -> main.A' + reveal_type(A) # N: Revealed type is "def (a: Any, b: Any, c: Any =, d: Any =) -> main.A" A(1, [2]) A(1, [2], '3', 4) A(1, 2, 3, 4) @@ -53,7 +53,7 @@ _d: int = attr.ib(validator=None, default=18) E = 7 F: ClassVar[int] = 22 - reveal_type(A) # N: Revealed type is 'def (a: builtins.int, b: builtins.list[builtins.int], c: builtins.str =, d: builtins.int =) -> main.A' + reveal_type(A) # N: Revealed type is "def (a: builtins.int, b: builtins.list[builtins.int], c: builtins.str =, d: builtins.int =) -> main.A" A(1, [2]) A(1, [2], '3', 4) A(1, 2, 3, 4) # E: Argument 2 to "A" has incompatible type "int"; expected "List[int]" # E: Argument 3 to "A" has incompatible type "int"; expected "str" @@ -71,7 +71,7 @@ _d = attr.ib(validator=None, default=18) # type: int E = 7 F: ClassVar[int] = 22 - reveal_type(A) # N: Revealed type is 'def (a: builtins.int, b: builtins.list[builtins.int], c: builtins.str =, d: builtins.int =) -> main.A' + reveal_type(A) # N: Revealed type is "def (a: builtins.int, b: builtins.list[builtins.int], c: builtins.str =, d: builtins.int =) -> main.A" A(1, [2]) A(1, [2], '3', 4) A(1, 2, 3, 4) # E: Argument 2 to "A" has incompatible type "int"; expected "List[int]" # E: Argument 3 to "A" has incompatible type "int"; expected "str" @@ -89,7 +89,7 @@ _d: int = attr.ib(validator=None, default=18) E = 7 F: ClassVar[int] = 22 - reveal_type(A) # N: Revealed type is 'def (a: builtins.int, b: builtins.list[builtins.int], c: builtins.str =, d: builtins.int =) -> main.A' + reveal_type(A) # N: Revealed type is "def (a: builtins.int, b: builtins.list[builtins.int], c: builtins.str =, d: builtins.int =) -> main.A" A(1, [2]) A(1, [2], '3', 4) A(1, 2, 3, 4) # E: Argument 2 to "A" has incompatible type "int"; expected "List[int]" # E: Argument 3 to "A" has incompatible type "int"; expected "str" @@ -102,10 +102,10 @@ import attr @attr.s class A: - a = attr.ib() # E: Need type annotation for 'a' - _b = attr.ib() # E: Need type annotation for '_b' - c = attr.ib(18) # E: Need type annotation for 'c' - _d = attr.ib(validator=None, default=18) # E: Need type annotation for '_d' + a = attr.ib() # E: Need type annotation for "a" + _b = attr.ib() # E: Need type annotation for "_b" + c = attr.ib(18) # E: Need type annotation for "c" + _d = attr.ib(validator=None, default=18) # E: Need type annotation for "_d" E = 18 - case: testAttrsWrongReturnValue @@ -143,7 +143,7 @@ c = attrib(18) _d = attrib(validator=None, default=18) CLASS_VAR = 18 - reveal_type(A) # N: Revealed type is 'def (a: Any, b: builtins.list[builtins.int], c: Any =, d: Any =) -> main.A' + reveal_type(A) # N: Revealed type is "def (a: Any, b: builtins.list[builtins.int], c: Any =, d: Any =) -> main.A" A(1, [2]) A(1, [2], '3', 4) A(1, 2, 3, 4) # E: Argument 2 to "A" has incompatible type "int"; expected "List[int]" @@ -190,7 +190,7 @@ _b: int c: int = 18 _d: int = attrib(validator=None, default=18) - reveal_type(A) # N: Revealed type is 'def () -> main.A' + reveal_type(A) # N: Revealed type is "def () -> main.A" A() A(1, [2]) # E: Too many arguments for "A" A(1, [2], '3', 4) # E: Too many arguments for "A" @@ -202,7 +202,7 @@ class A: a = attrib(init=False) b = attrib() - reveal_type(A) # N: Revealed type is 'def (b: Any) -> main.A' + reveal_type(A) # N: Revealed type is "def (b: Any) -> main.A" - case: testAttrsCmpTrue main: | @@ -210,11 +210,11 @@ @attrs(auto_attribs=True) class A: a: int - reveal_type(A) # N: Revealed type is 'def (a: builtins.int) -> main.A' - reveal_type(A.__lt__) # N: Revealed type is 'def [_AT] (self: _AT`-1, other: _AT`-1) -> builtins.bool' - reveal_type(A.__le__) # N: Revealed type is 'def [_AT] (self: _AT`-1, other: _AT`-1) -> builtins.bool' - reveal_type(A.__gt__) # N: Revealed type is 'def [_AT] (self: _AT`-1, other: _AT`-1) -> builtins.bool' - reveal_type(A.__ge__) # N: Revealed type is 'def [_AT] (self: _AT`-1, other: _AT`-1) -> builtins.bool' + reveal_type(A) # N: Revealed type is "def (a: builtins.int) -> main.A" + reveal_type(A.__lt__) # N: Revealed type is "def [_AT] (self: _AT`-1, other: _AT`-1) -> builtins.bool" + reveal_type(A.__le__) # N: Revealed type is "def [_AT] (self: _AT`-1, other: _AT`-1) -> builtins.bool" + reveal_type(A.__gt__) # N: Revealed type is "def [_AT] (self: _AT`-1, other: _AT`-1) -> builtins.bool" + reveal_type(A.__ge__) # N: Revealed type is "def [_AT] (self: _AT`-1, other: _AT`-1) -> builtins.bool" A(1) < A(2) A(1) <= A(2) @@ -243,9 +243,9 @@ @attrs(auto_attribs=True, eq=False) class A: a: int - reveal_type(A) # N: Revealed type is 'def (a: builtins.int) -> main.A' - reveal_type(A.__eq__) # N: Revealed type is 'def (builtins.object, builtins.object) -> builtins.bool' - reveal_type(A.__ne__) # N: Revealed type is 'def (builtins.object, builtins.object) -> builtins.bool' + reveal_type(A) # N: Revealed type is "def (a: builtins.int) -> main.A" + reveal_type(A.__eq__) # N: Revealed type is "def (builtins.object, builtins.object) -> builtins.bool" + reveal_type(A.__ne__) # N: Revealed type is "def (builtins.object, builtins.object) -> builtins.bool" A(1) < A(2) # E: Unsupported left operand type for < ("A") A(1) <= A(2) # E: Unsupported left operand type for <= ("A") @@ -274,7 +274,7 @@ @attrs(auto_attribs=True, order=False) class A: a: int - reveal_type(A) # N: Revealed type is 'def (a: builtins.int) -> main.A' + reveal_type(A) # N: Revealed type is "def (a: builtins.int) -> main.A" A(1) < A(2) # E: Unsupported left operand type for < ("A") A(1) <= A(2) # E: Unsupported left operand type for <= ("A") @@ -308,7 +308,7 @@ class DeprecatedFalse: ... - @attrs(cmp=False, eq=True) # E: Don't mix `cmp` with `eq' and `order` + @attrs(cmp=False, eq=True) # E: Don't mix "cmp" with "eq" and "order" class Mixed: ... @@ -329,7 +329,7 @@ @attr.s class C(A, B): c: bool = attr.ib() - reveal_type(C) # N: Revealed type is 'def (a: builtins.int, b: builtins.str, c: builtins.bool) -> main.C' + reveal_type(C) # N: Revealed type is "def (a: builtins.int, b: builtins.str, c: builtins.bool) -> main.C" - case: testAttrsNestedInClasses main: | @@ -340,8 +340,8 @@ @attr.s class D: x: int = attr.ib() - reveal_type(C) # N: Revealed type is 'def (y: Any) -> main.C' - reveal_type(C.D) # N: Revealed type is 'def (x: builtins.int) -> main.C.D' + reveal_type(C) # N: Revealed type is "def (y: Any) -> main.C" + reveal_type(C.D) # N: Revealed type is "def (x: builtins.int) -> main.C.D" - case: testAttrsInheritanceOverride main: | @@ -362,9 +362,9 @@ c: bool = attr.ib() # No error here because the x below overwrites the x above. x: int = attr.ib() - reveal_type(A) # N: Revealed type is 'def (a: builtins.int, x: builtins.int) -> main.A' - reveal_type(B) # N: Revealed type is 'def (a: builtins.int, b: builtins.str, x: builtins.int =) -> main.B' - reveal_type(C) # N: Revealed type is 'def (a: builtins.int, b: builtins.str, c: builtins.bool, x: builtins.int) -> main.C' + reveal_type(A) # N: Revealed type is "def (a: builtins.int, x: builtins.int) -> main.A" + reveal_type(B) # N: Revealed type is "def (a: builtins.int, b: builtins.str, x: builtins.int =) -> main.B" + reveal_type(C) # N: Revealed type is "def (a: builtins.int, b: builtins.str, c: builtins.bool, x: builtins.int) -> main.C" - case: testAttrsTypeEquals main: | @@ -374,7 +374,7 @@ class A: a = attr.ib(type=int) b = attr.ib(18, type=int) - reveal_type(A) # N: Revealed type is 'def (a: builtins.int, b: builtins.int =) -> main.A' + reveal_type(A) # N: Revealed type is "def (a: builtins.int, b: builtins.int =) -> main.A" - case: testAttrsFrozen main: | @@ -420,10 +420,10 @@ b = field() # TODO: Next Gen hasn't shipped with mypy yet so the following are wrong - reveal_type(A) # N: Revealed type is 'def (a: Any) -> main.A' - reveal_type(B) # N: Revealed type is 'def (a: builtins.int) -> main.B' - reveal_type(C) # N: Revealed type is 'def (a: builtins.int, b: Any) -> main.C' - reveal_type(D) # N: Revealed type is 'def (b: Any) -> main.D' + reveal_type(A) # N: Revealed type is "def (a: Any) -> main.A" + reveal_type(B) # N: Revealed type is "def (a: builtins.int) -> main.B" + reveal_type(C) # N: Revealed type is "def (a: builtins.int, b: Any) -> main.C" + reveal_type(D) # N: Revealed type is "def (b: Any) -> main.D" - case: testAttrsDataClass main: | @@ -437,7 +437,7 @@ _d: int = attr.ib(validator=None, default=18) E = 7 F: ClassVar[int] = 22 - reveal_type(A) # N: Revealed type is 'def (a: builtins.int, b: builtins.list[builtins.str], c: builtins.str =, d: builtins.int =) -> main.A' + reveal_type(A) # N: Revealed type is "def (a: builtins.int, b: builtins.list[builtins.str], c: builtins.str =, d: builtins.int =) -> main.A" A(1, ['2']) - case: testAttrsTypeAlias @@ -450,7 +450,7 @@ Alias2 = List[str] x: Alias y: Alias2 = attr.ib() - reveal_type(A) # N: Revealed type is 'def (x: builtins.list[builtins.int], y: builtins.list[builtins.str]) -> main.A' + reveal_type(A) # N: Revealed type is "def (x: builtins.list[builtins.int], y: builtins.list[builtins.str]) -> main.A" - case: testAttrsGeneric main: | @@ -467,11 +467,11 @@ return self.x[0] def problem(self) -> T: return self.x # E: Incompatible return value type (got "List[T]", expected "T") - reveal_type(A) # N: Revealed type is 'def [T] (x: builtins.list[T`1], y: T`1) -> main.A[T`1]' + reveal_type(A) # N: Revealed type is "def [T] (x: builtins.list[T`1], y: T`1) -> main.A[T`1]" a = A([1], 2) - reveal_type(a) # N: Revealed type is 'main.A[builtins.int*]' - reveal_type(a.x) # N: Revealed type is 'builtins.list[builtins.int*]' - reveal_type(a.y) # N: Revealed type is 'builtins.int*' + reveal_type(a) # N: Revealed type is "main.A[builtins.int*]" + reveal_type(a.x) # N: Revealed type is "builtins.list[builtins.int*]" + reveal_type(a.y) # N: Revealed type is "builtins.int*" A(['str'], 7) # E: Cannot infer type argument 1 of "A" A([1], '2') # E: Cannot infer type argument 1 of "A" @@ -493,8 +493,8 @@ pass sub = Sub(attr=1) - reveal_type(sub) # N: Revealed type is 'main.Sub' - reveal_type(sub.attr) # N: Revealed type is 'Any' + reveal_type(sub) # N: Revealed type is "main.Sub" + reveal_type(sub.attr) # N: Revealed type is "Any" skip: True # Need to investigate why this is broken - case: testAttrsGenericInheritance @@ -514,12 +514,12 @@ pass sub_int = Sub[int](attr=1) - reveal_type(sub_int) # N: Revealed type is 'main.Sub[builtins.int*]' - reveal_type(sub_int.attr) # N: Revealed type is 'builtins.int*' + reveal_type(sub_int) # N: Revealed type is "main.Sub[builtins.int*]" + reveal_type(sub_int.attr) # N: Revealed type is "builtins.int*" sub_str = Sub[str](attr='ok') - reveal_type(sub_str) # N: Revealed type is 'main.Sub[builtins.str*]' - reveal_type(sub_str.attr) # N: Revealed type is 'builtins.str*' + reveal_type(sub_str) # N: Revealed type is "main.Sub[builtins.str*]" + reveal_type(sub_str.attr) # N: Revealed type is "builtins.str*" - case: testAttrsGenericInheritance2 main: | @@ -541,10 +541,10 @@ pass sub = Sub(one=1, two='ok', three=3.14) - reveal_type(sub) # N: Revealed type is 'main.Sub' - reveal_type(sub.one) # N: Revealed type is 'builtins.int*' - reveal_type(sub.two) # N: Revealed type is 'builtins.str*' - reveal_type(sub.three) # N: Revealed type is 'builtins.float*' + reveal_type(sub) # N: Revealed type is "main.Sub" + reveal_type(sub.one) # N: Revealed type is "builtins.int*" + reveal_type(sub.two) # N: Revealed type is "builtins.str*" + reveal_type(sub.three) # N: Revealed type is "builtins.float*" skip: True # Need to investigate why this is broken - case: testAttrsMultiGenericInheritance @@ -571,9 +571,9 @@ reveal_type(Sub.__init__) sub = Sub(base_attr=1, middle_attr='ok') - reveal_type(sub) # N: Revealed type is 'main.Sub' - reveal_type(sub.base_attr) # N: Revealed type is 'builtins.int*' - reveal_type(sub.middle_attr) # N: Revealed type is 'builtins.str*' + reveal_type(sub) # N: Revealed type is "main.Sub" + reveal_type(sub.base_attr) # N: Revealed type is "builtins.int*" + reveal_type(sub.middle_attr) # N: Revealed type is "builtins.str*" skip: True # Need to investigate why this is broken - case: testAttrsGenericClassmethod @@ -586,7 +586,7 @@ x: Optional[T] @classmethod def clsmeth(cls) -> None: - reveal_type(cls) # N: Revealed type is 'Type[main.A[T`1]]' + reveal_type(cls) # N: Revealed type is "Type[main.A[T`1]]" - case: testAttrsForwardReference main: | @@ -600,8 +600,8 @@ class B: parent: Optional[A] - reveal_type(A) # N: Revealed type is 'def (parent: main.B) -> main.A' - reveal_type(B) # N: Revealed type is 'def (parent: Union[main.A, None]) -> main.B' + reveal_type(A) # N: Revealed type is "def (parent: main.B) -> main.A" + reveal_type(B) # N: Revealed type is "def (parent: Union[main.A, None]) -> main.B" A(B(None)) - case: testAttrsForwardReferenceInClass @@ -616,14 +616,14 @@ class B: parent: Optional[A] - reveal_type(A) # N: Revealed type is 'def (parent: main.A.B) -> main.A' - reveal_type(A.B) # N: Revealed type is 'def (parent: Union[main.A, None]) -> main.A.B' + reveal_type(A) # N: Revealed type is "def (parent: main.A.B) -> main.A" + reveal_type(A.B) # N: Revealed type is "def (parent: Union[main.A, None]) -> main.A.B" A(A.B(None)) - case: testAttrsImporting main: | from helper import A - reveal_type(A) # N: Revealed type is 'def (a: builtins.int, b: builtins.str) -> helper.A' + reveal_type(A) # N: Revealed type is "def (a: builtins.int, b: builtins.str) -> helper.A" files: - path: helper.py content: | @@ -642,16 +642,16 @@ b: str = attr.ib() @classmethod def new(cls) -> A: - reveal_type(cls) # N: Revealed type is 'Type[main.A]' + reveal_type(cls) # N: Revealed type is "Type[main.A]" return cls(6, 'hello') @classmethod def bad(cls) -> A: return cls(17) # E: Missing positional argument "b" in call to "A" def foo(self) -> int: return self.a - reveal_type(A) # N: Revealed type is 'def (a: builtins.int, b: builtins.str) -> main.A' + reveal_type(A) # N: Revealed type is "def (a: builtins.int, b: builtins.str) -> main.A" a = A.new() - reveal_type(a.foo) # N: Revealed type is 'def () -> builtins.int' + reveal_type(a.foo) # N: Revealed type is "def () -> builtins.int" - case: testAttrsOtherOverloads main: | @@ -677,12 +677,12 @@ @classmethod def foo(cls, x: Union[int, str]) -> Union[int, str]: - reveal_type(cls) # N: Revealed type is 'Type[main.A]' - reveal_type(cls.other()) # N: Revealed type is 'builtins.str' + reveal_type(cls) # N: Revealed type is "Type[main.A]" + reveal_type(cls.other()) # N: Revealed type is "builtins.str" return x - reveal_type(A.foo(3)) # N: Revealed type is 'builtins.int' - reveal_type(A.foo("foo")) # N: Revealed type is 'builtins.str' + reveal_type(A.foo(3)) # N: Revealed type is "builtins.int" + reveal_type(A.foo("foo")) # N: Revealed type is "builtins.str" - case: testAttrsDefaultDecorator main: | @@ -736,8 +736,8 @@ AOrB = Union[A, B] - reveal_type(A) # N: Revealed type is 'def (frob: builtins.list[Union[main.A, main.B]]) -> main.A' - reveal_type(B) # N: Revealed type is 'def () -> main.B' + reveal_type(A) # N: Revealed type is "def (frob: builtins.list[Union[main.A, main.B]]) -> main.A" + reveal_type(B) # N: Revealed type is "def () -> main.B" A([B()]) @@ -755,8 +755,8 @@ y: str = attr.ib(converter=converter2) # Because of the converter the __init__ takes an int, but the variable is a str. - reveal_type(C) # N: Revealed type is 'def (x: builtins.int, y: builtins.int) -> main.C' - reveal_type(C(15, 16).x) # N: Revealed type is 'builtins.str' + reveal_type(C) # N: Revealed type is "def (x: builtins.int, y: builtins.int) -> main.C" + reveal_type(C(15, 16).x) # N: Revealed type is "builtins.str" files: - path: helper.py content: | @@ -789,7 +789,7 @@ main:15: error: Argument "converter" has incompatible type "Callable[[], str]"; expected "Callable[[Any], Any]" main:16: error: Cannot determine __init__ type from converter main:16: error: Argument "converter" has incompatible type overloaded function; expected "Callable[[Any], Any]" - main:17: note: Revealed type is 'def (bad: Any, bad_overloaded: Any) -> main.A' + main:17: note: Revealed type is "def (bad: Any, bad_overloaded: Any) -> main.A" - case: testAttrsUsingBadConverterReprocess mypy_config: @@ -818,7 +818,7 @@ main:16: error: Argument "converter" has incompatible type "Callable[[], str]"; expected "Callable[[Any], Any]" main:17: error: Cannot determine __init__ type from converter main:17: error: Argument "converter" has incompatible type overloaded function; expected "Callable[[Any], Any]" - main:18: note: Revealed type is 'def (bad: Any, bad_overloaded: Any) -> main.A' + main:18: note: Revealed type is "def (bad: Any, bad_overloaded: Any) -> main.A" - case: testAttrsUsingUnsupportedConverter main: | @@ -834,7 +834,7 @@ x: str = attr.ib(converter=thing.do_it) # E: Unsupported converter, only named functions and types are currently supported y: str = attr.ib(converter=lambda x: x) # E: Unsupported converter, only named functions and types are currently supported z: str = attr.ib(converter=factory(8)) # E: Unsupported converter, only named functions and types are currently supported - reveal_type(C) # N: Revealed type is 'def (x: Any, y: Any, z: Any) -> main.C' + reveal_type(C) # N: Revealed type is "def (x: Any, y: Any, z: Any) -> main.C" - case: testAttrsUsingConverterAndSubclass main: | @@ -852,8 +852,8 @@ pass # Because of the convert the __init__ takes an int, but the variable is a str. - reveal_type(A) # N: Revealed type is 'def (x: builtins.int) -> main.A' - reveal_type(A(15).x) # N: Revealed type is 'builtins.str' + reveal_type(A) # N: Revealed type is "def (x: builtins.int) -> main.A" + reveal_type(A(15).x) # N: Revealed type is "builtins.str" - case: testAttrsUsingConverterWithTypes main: | @@ -885,10 +885,10 @@ @attr.s class D(A): pass - reveal_type(A.__lt__) # N: Revealed type is 'def [_AT] (self: _AT`-1, other: _AT`-1) -> builtins.bool' - reveal_type(B.__lt__) # N: Revealed type is 'def [_AT] (self: _AT`-1, other: _AT`-1) -> builtins.bool' - reveal_type(C.__lt__) # N: Revealed type is 'def [_AT] (self: _AT`-1, other: _AT`-1) -> builtins.bool' - reveal_type(D.__lt__) # N: Revealed type is 'def [_AT] (self: _AT`-1, other: _AT`-1) -> builtins.bool' + reveal_type(A.__lt__) # N: Revealed type is "def [_AT] (self: _AT`-1, other: _AT`-1) -> builtins.bool" + reveal_type(B.__lt__) # N: Revealed type is "def [_AT] (self: _AT`-1, other: _AT`-1) -> builtins.bool" + reveal_type(C.__lt__) # N: Revealed type is "def [_AT] (self: _AT`-1, other: _AT`-1) -> builtins.bool" + reveal_type(D.__lt__) # N: Revealed type is "def [_AT] (self: _AT`-1, other: _AT`-1) -> builtins.bool" A() < A() B() < B() @@ -922,8 +922,8 @@ @attr.s class A(C): z: int = attr.ib(default=18) - reveal_type(C) # N: Revealed type is 'def (x: builtins.int =, y: builtins.int =) -> main.C' - reveal_type(A) # N: Revealed type is 'def (x: builtins.int =, y: builtins.int =, z: builtins.int =) -> main.A' + reveal_type(C) # N: Revealed type is "def (x: builtins.int =, y: builtins.int =) -> main.C" + reveal_type(A) # N: Revealed type is "def (x: builtins.int =, y: builtins.int =, z: builtins.int =) -> main.A" - case: testAttrsMultiAssign main: | @@ -931,7 +931,7 @@ @attr.s class A: x, y, z = attr.ib(), attr.ib(type=int), attr.ib(default=17) - reveal_type(A) # N: Revealed type is 'def (x: Any, y: builtins.int, z: Any =) -> main.A' + reveal_type(A) # N: Revealed type is "def (x: Any, y: builtins.int, z: Any =) -> main.A" - case: testAttrsMultiAssign2 main: | @@ -957,9 +957,9 @@ a: int b = 17 # The following forms are not allowed with auto_attribs=True - c = attr.ib() # E: Need type annotation for 'c' - d, e = attr.ib(), attr.ib() # E: Need type annotation for 'd' # E: Need type annotation for 'e' - f = g = attr.ib() # E: Need type annotation for 'f' # E: Need type annotation for 'g' + c = attr.ib() # E: Need type annotation for "c" + d, e = attr.ib(), attr.ib() # E: Need type annotation for "d" # E: Need type annotation for "e" + f = g = attr.ib() # E: Need type annotation for "f" # E: Need type annotation for "g" - case: testAttrsRepeatedName main: | @@ -969,19 +969,19 @@ a = attr.ib(default=8) b = attr.ib() a = attr.ib() - reveal_type(A) # N: Revealed type is 'def (b: Any, a: Any) -> main.A' + reveal_type(A) # N: Revealed type is "def (b: Any, a: Any) -> main.A" @attr.s class B: a: int = attr.ib(default=8) b: int = attr.ib() - a: int = attr.ib() # E: Name 'a' already defined on line 10 - reveal_type(B) # N: Revealed type is 'def (b: builtins.int, a: builtins.int) -> main.B' + a: int = attr.ib() # E: Name "a" already defined on line 10 + reveal_type(B) # N: Revealed type is "def (b: builtins.int, a: builtins.int) -> main.B" @attr.s(auto_attribs=True) class C: a: int = 8 b: int - a: int = attr.ib() # E: Name 'a' already defined on line 16 - reveal_type(C) # N: Revealed type is 'def (a: builtins.int, b: builtins.int) -> main.C' + a: int = attr.ib() # E: Name "a" already defined on line 16 + reveal_type(C) # N: Revealed type is "def (a: builtins.int, b: builtins.int) -> main.C" - case: testAttrsNewStyleClassPy2 mypy_config: @@ -1088,7 +1088,7 @@ import attr @attr.s class A: - x: int = attr.ib(factory=int, default=7) # E: Can't pass both `default` and `factory`. + x: int = attr.ib(factory=int, default=7) # E: Can't pass both "default" and "factory". - case: testAttrsFactoryBadReturn main: | @@ -1244,7 +1244,7 @@ class C(List[C]): pass - reveal_type(B) # N: Revealed type is 'def (x: main.C) -> main.B' + reveal_type(B) # N: Revealed type is "def (x: main.C) -> main.B" - case: testDisallowUntypedWorksForwardBad mypy_config: @@ -1254,9 +1254,9 @@ @attr.s class B: - x = attr.ib() # E: Need type annotation for 'x' + x = attr.ib() # E: Need type annotation for "x" - reveal_type(B) # N: Revealed type is 'def (x: Any) -> main.B' + reveal_type(B) # N: Revealed type is "def (x: Any) -> main.B" - case: testAttrsDefaultDecoratorDeferred main: | @@ -1296,7 +1296,7 @@ @attr.s class C: - total = attr.ib(type=Bad) # E: Name 'Bad' is not defined + total = attr.ib(type=Bad) # E: Name "Bad" is not defined - case: testTypeInAttrForwardInRuntime main: | @@ -1306,7 +1306,7 @@ class C: total = attr.ib(type=Forward) - reveal_type(C.total) # N: Revealed type is 'main.Forward' + reveal_type(C.total) # N: Revealed type is "main.Forward" C('no') # E: Argument 1 to "C" has incompatible type "str"; expected "Forward" class Forward: ... @@ -1330,7 +1330,7 @@ @attr.s(frozen=True) class C: - total = attr.ib(type=Bad) # E: Name 'Bad' is not defined + total = attr.ib(type=Bad) # E: Name "Bad" is not defined C(0).total = 1 # E: Property "total" defined in "C" is read-only @@ -1392,4 +1392,4 @@ class B(A): foo = x - reveal_type(B) # N: Revealed type is 'def (foo: builtins.int) -> main.B' + reveal_type(B) # N: Revealed type is "def (foo: builtins.int) -> main.B" diff --git a/tests/test_next_gen.py b/tests/test_next_gen.py index fce01ad45..8395f9c02 100644 --- a/tests/test_next_gen.py +++ b/tests/test_next_gen.py @@ -1,3 +1,5 @@ +# SPDX-License-Identifier: MIT + """ Python 3-only integration tests for provisional next generation APIs. """ @@ -8,10 +10,11 @@ import pytest -import attr +import attr as _attr # don't use it by accident +import attrs -@attr.define +@attrs.define class C: x: str y: int @@ -29,7 +32,7 @@ def test_no_slots(self): slots can be deactivated. """ - @attr.define(slots=False) + @attrs.define(slots=False) class NoSlots: x: int @@ -42,9 +45,9 @@ def test_validates(self): Validators at __init__ and __setattr__ work. """ - @attr.define + @attrs.define class Validated: - x: int = attr.field(validator=attr.validators.instance_of(int)) + x: int = attrs.field(validator=attrs.validators.instance_of(int)) v = Validated(1) @@ -61,7 +64,7 @@ def test_no_order(self): with pytest.raises(TypeError): C("1", 2) < C("2", 3) - @attr.define(order=True) + @attrs.define(order=True) class Ordered: x: int @@ -71,23 +74,23 @@ def test_override_auto_attribs_true(self): """ Don't guess if auto_attrib is set explicitly. - Having an unannotated attr.ib/attr.field fails. + Having an unannotated attrs.ib/attrs.field fails. """ - with pytest.raises(attr.exceptions.UnannotatedAttributeError): + with pytest.raises(attrs.exceptions.UnannotatedAttributeError): - @attr.define(auto_attribs=True) + @attrs.define(auto_attribs=True) class ThisFails: - x = attr.field() + x = attrs.field() y: int def test_override_auto_attribs_false(self): """ Don't guess if auto_attrib is set explicitly. - Annotated fields that don't carry an attr.ib are ignored. + Annotated fields that don't carry an attrs.ib are ignored. """ - @attr.define(auto_attribs=False) + @attrs.define(auto_attribs=False) class NoFields: x: int y: int @@ -99,16 +102,16 @@ def test_auto_attribs_detect(self): define correctly detects if a class lacks type annotations. """ - @attr.define + @attrs.define class OldSchool: - x = attr.field() + x = attrs.field() assert OldSchool(1) == OldSchool(1) # Test with maybe_cls = None - @attr.define() + @attrs.define() class OldSchool2: - x = attr.field() + x = attrs.field() assert OldSchool2(1) == OldSchool2(1) @@ -117,10 +120,10 @@ def test_auto_attribs_detect_fields_and_annotations(self): define infers auto_attribs=True if fields have type annotations """ - @attr.define + @attrs.define class NewSchool: x: int - y: list = attr.field() + y: list = attrs.field() @y.validator def _validate_y(self, attribute, value): @@ -130,14 +133,14 @@ def _validate_y(self, attribute, value): assert NewSchool(1, 1) == NewSchool(1, 1) with pytest.raises(ValueError): NewSchool(1, -1) - assert list(attr.fields_dict(NewSchool).keys()) == ["x", "y"] + assert list(attrs.fields_dict(NewSchool).keys()) == ["x", "y"] def test_auto_attribs_partially_annotated(self): """ define infers auto_attribs=True if any type annotations are found """ - @attr.define + @attrs.define class NewSchool: x: int y: list @@ -145,7 +148,7 @@ class NewSchool: # fields are defined for any annotated attributes assert NewSchool(1, []) == NewSchool(1, []) - assert list(attr.fields_dict(NewSchool).keys()) == ["x", "y"] + assert list(attrs.fields_dict(NewSchool).keys()) == ["x", "y"] # while the unannotated attributes are left as class vars assert NewSchool.z == 10 @@ -156,14 +159,14 @@ def test_auto_attribs_detect_annotations(self): define correctly detects if a class has type annotations. """ - @attr.define + @attrs.define class NewSchool: x: int assert NewSchool(1) == NewSchool(1) # Test with maybe_cls = None - @attr.define() + @attrs.define() class NewSchool2: x: int @@ -174,7 +177,7 @@ def test_exception(self): Exceptions are detected and correctly handled. """ - @attr.define + @attrs.define class E(Exception): msg: str other: int @@ -190,16 +193,16 @@ class E(Exception): def test_frozen(self): """ - attr.frozen freezes classes. + attrs.frozen freezes classes. """ - @attr.frozen + @attrs.frozen class F: x: str f = F(1) - with pytest.raises(attr.exceptions.FrozenInstanceError): + with pytest.raises(attrs.exceptions.FrozenInstanceError): f.x = 2 def test_auto_detect_eq(self): @@ -209,7 +212,7 @@ def test_auto_detect_eq(self): Regression test for #670. """ - @attr.define + @attrs.define class C: def __eq__(self, o): raise ValueError() @@ -219,35 +222,35 @@ def __eq__(self, o): def test_subclass_frozen(self): """ - It's possible to subclass an `attr.frozen` class and the frozen-ness is - inherited. + It's possible to subclass an `attrs.frozen` class and the frozen-ness + is inherited. """ - @attr.frozen + @attrs.frozen class A: a: int - @attr.frozen + @attrs.frozen class B(A): b: int - @attr.define(on_setattr=attr.setters.NO_OP) + @attrs.define(on_setattr=attrs.setters.NO_OP) class C(B): c: int assert B(1, 2) == B(1, 2) assert C(1, 2, 3) == C(1, 2, 3) - with pytest.raises(attr.exceptions.FrozenInstanceError): + with pytest.raises(attrs.exceptions.FrozenInstanceError): A(1).a = 1 - with pytest.raises(attr.exceptions.FrozenInstanceError): + with pytest.raises(attrs.exceptions.FrozenInstanceError): B(1, 2).a = 1 - with pytest.raises(attr.exceptions.FrozenInstanceError): + with pytest.raises(attrs.exceptions.FrozenInstanceError): B(1, 2).b = 2 - with pytest.raises(attr.exceptions.FrozenInstanceError): + with pytest.raises(attrs.exceptions.FrozenInstanceError): C(1, 2, 3).c = 3 def test_catches_frozen_on_setattr(self): @@ -256,7 +259,7 @@ def test_catches_frozen_on_setattr(self): immutability is inherited. """ - @attr.define(frozen=True) + @attrs.define(frozen=True) class A: pass @@ -264,7 +267,7 @@ class A: ValueError, match="Frozen classes can't use on_setattr." ): - @attr.define(frozen=True, on_setattr=attr.setters.validate) + @attrs.define(frozen=True, on_setattr=attrs.setters.validate) class B: pass @@ -276,17 +279,17 @@ class B: ), ): - @attr.define(on_setattr=attr.setters.validate) + @attrs.define(on_setattr=attrs.setters.validate) class C(A): pass @pytest.mark.parametrize( "decorator", [ - partial(attr.s, frozen=True, slots=True, auto_exc=True), - attr.frozen, - attr.define, - attr.mutable, + partial(_attr.s, frozen=True, slots=True, auto_exc=True), + attrs.frozen, + attrs.define, + attrs.mutable, ], ) def test_discard_context(self, decorator): @@ -298,7 +301,7 @@ def test_discard_context(self, decorator): @decorator class MyException(Exception): - x: str = attr.ib() + x: str = attrs.field() with pytest.raises(MyException) as ei: try: @@ -308,3 +311,130 @@ class MyException(Exception): assert "foo" == ei.value.x assert ei.value.__cause__ is None + + def test_converts_and_validates_by_default(self): + """ + If no on_setattr is set, assume setters.convert, setters.validate. + """ + + @attrs.define + class C: + x: int = attrs.field(converter=int) + + @x.validator + def _v(self, _, value): + if value < 10: + raise ValueError("must be >=10") + + inst = C(10) + + # Converts + inst.x = "11" + + assert 11 == inst.x + + # Validates + with pytest.raises(ValueError, match="must be >=10"): + inst.x = "9" + + def test_mro_ng(self): + """ + Attributes and methods are looked up the same way in NG by default. + + See #428 + """ + + @attrs.define + class A: + + x: int = 10 + + def xx(self): + return 10 + + @attrs.define + class B(A): + y: int = 20 + + @attrs.define + class C(A): + x: int = 50 + + def xx(self): + return 50 + + @attrs.define + class D(B, C): + pass + + d = D() + + assert d.x == d.xx() + + +class TestAsTuple: + def test_smoke(self): + """ + `attrs.astuple` only changes defaults, so we just call it and compare. + """ + inst = C("foo", 42) + + assert attrs.astuple(inst) == _attr.astuple(inst) + + +class TestAsDict: + def test_smoke(self): + """ + `attrs.asdict` only changes defaults, so we just call it and compare. + """ + inst = C("foo", {(1,): 42}) + + assert attrs.asdict(inst) == _attr.asdict( + inst, retain_collection_types=True + ) + + +class TestImports: + """ + Verify our re-imports and mirroring works. + """ + + def test_converters(self): + """ + Importing from attrs.converters works. + """ + from attrs.converters import optional + + assert optional is _attr.converters.optional + + def test_exceptions(self): + """ + Importing from attrs.exceptions works. + """ + from attrs.exceptions import FrozenError + + assert FrozenError is _attr.exceptions.FrozenError + + def test_filters(self): + """ + Importing from attrs.filters works. + """ + from attrs.filters import include + + assert include is _attr.filters.include + + def test_setters(self): + """ + Importing from attrs.setters works. + """ + from attrs.setters import pipe + + assert pipe is _attr.setters.pipe + + def test_validators(self): + """ + Importing from attrs.validators works. + """ + from attrs.validators import and_ + + assert and_ is _attr.validators.and_ diff --git a/tests/test_pattern_matching.py b/tests/test_pattern_matching.py new file mode 100644 index 000000000..590804a8a --- /dev/null +++ b/tests/test_pattern_matching.py @@ -0,0 +1,101 @@ +# SPDX-License-Identifier: MIT + +# Keep this file SHORT, until Black can handle it. +import pytest + +import attr + + +class TestPatternMatching: + """ + Pattern matching syntax test cases. + """ + + @pytest.mark.parametrize("dec", [attr.s, attr.define, attr.frozen]) + def test_simple_match_case(self, dec): + """ + Simple match case statement works as expected with all class + decorators. + """ + + @dec + class C(object): + a = attr.ib() + + assert ("a",) == C.__match_args__ + + matched = False + c = C(a=1) + match c: + case C(a): + matched = True + + assert matched + assert 1 == a + + def test_explicit_match_args(self): + """ + Does not overwrite a manually set empty __match_args__. + """ + + ma = () + + @attr.define + class C: + a = attr.field() + __match_args__ = ma + + c = C(a=1) + + msg = r"C\(\) accepts 0 positional sub-patterns \(1 given\)" + with pytest.raises(TypeError, match=msg): + match c: + case C(_): + pass + + def test_match_args_kw_only(self): + """ + kw_only classes don't generate __match_args__. + kw_only fields are not included in __match_args__. + """ + + @attr.define + class C: + a = attr.field(kw_only=True) + b = attr.field() + + assert ("b",) == C.__match_args__ + + c = C(a=1, b=1) + msg = r"C\(\) accepts 1 positional sub-pattern \(2 given\)" + with pytest.raises(TypeError, match=msg): + match c: + case C(a, b): + pass + + found = False + match c: + case C(b, a=a): + found = True + + assert found + + @attr.define(kw_only=True) + class C: + a = attr.field() + b = attr.field() + + c = C(a=1, b=1) + msg = r"C\(\) accepts 0 positional sub-patterns \(2 given\)" + with pytest.raises(TypeError, match=msg): + match c: + case C(a, b): + pass + + found = False + match c: + case C(a=a, b=b): + found = True + + assert found + assert (1, 1) == (a, b) diff --git a/tests/test_pyright.py b/tests/test_pyright.py index 60aabe780..c30dcc5cb 100644 --- a/tests/test_pyright.py +++ b/tests/test_pyright.py @@ -1,3 +1,5 @@ +# SPDX-License-Identifier: MIT + import json import os.path import shutil diff --git a/tests/test_setattr.py b/tests/test_setattr.py index 8e55da2d1..aaedde574 100644 --- a/tests/test_setattr.py +++ b/tests/test_setattr.py @@ -1,3 +1,5 @@ +# SPDX-License-Identifier: MIT + from __future__ import absolute_import, division, print_function import pickle diff --git a/tests/test_slots.py b/tests/test_slots.py index 47abee238..baf9a40dd 100644 --- a/tests/test_slots.py +++ b/tests/test_slots.py @@ -1,3 +1,5 @@ +# SPDX-License-Identifier: MIT + """ Unit tests for slots-related functionality. """ diff --git a/tests/test_validators.py b/tests/test_validators.py index 4aeec9990..d7c6de8ba 100644 --- a/tests/test_validators.py +++ b/tests/test_validators.py @@ -1,3 +1,5 @@ +# SPDX-License-Identifier: MIT + """ Tests for `attr.validators`. """ @@ -10,17 +12,22 @@ import attr -from attr import has +from attr import _config, fields, has from attr import validators as validator_module from attr._compat import PY2, TYPE from attr.validators import ( and_, deep_iterable, deep_mapping, + ge, + gt, in_, instance_of, is_callable, + le, + lt, matches_re, + max_len, optional, provides, ) @@ -41,6 +48,66 @@ def zope_interface(): return zope.interface +class TestDisableValidators(object): + @pytest.fixture(autouse=True) + def reset_default(self): + """ + Make sure validators are always enabled after a test. + """ + yield + _config._run_validators = True + + def test_default(self): + """ + Run validators by default. + """ + assert _config._run_validators is True + + @pytest.mark.parametrize("value, expected", [(True, False), (False, True)]) + def test_set_validators_diabled(self, value, expected): + """ + Sets `_run_validators`. + """ + validator_module.set_disabled(value) + + assert _config._run_validators is expected + + @pytest.mark.parametrize("value, expected", [(True, False), (False, True)]) + def test_disabled(self, value, expected): + """ + Returns `_run_validators`. + """ + _config._run_validators = value + + assert validator_module.get_disabled() is expected + + def test_disabled_ctx(self): + """ + The `disabled` context manager disables running validators, + but only within its context. + """ + assert _config._run_validators is True + + with validator_module.disabled(): + assert _config._run_validators is False + + assert _config._run_validators is True + + def test_disabled_ctx_with_errors(self): + """ + Running validators is re-enabled even if an error is raised. + """ + assert _config._run_validators is True + + with pytest.raises(ValueError): + with validator_module.disabled(): + assert _config._run_validators is False + + raise ValueError("haha!") + + assert _config._run_validators is True + + class TestInstanceOf(object): """ Tests for `instance_of`. @@ -111,9 +178,9 @@ def test_match(self): @attr.s class ReTester(object): - str_match = attr.ib(validator=matches_re("a")) + str_match = attr.ib(validator=matches_re("a|ab")) - ReTester("a") # shouldn't raise exceptions + ReTester("ab") # shouldn't raise exceptions with pytest.raises(TypeError): ReTester(1) with pytest.raises(ValueError): @@ -132,6 +199,29 @@ class MatchTester(object): MatchTester("A1") # test flags and using re.match + def test_precompiled_pattern(self): + """ + Pre-compiled patterns are accepted. + """ + pattern = re.compile("a") + + @attr.s + class RePatternTester(object): + val = attr.ib(validator=matches_re(pattern)) + + RePatternTester("a") + + def test_precompiled_pattern_no_flags(self): + """ + A pre-compiled pattern cannot be combined with a 'flags' argument. + """ + pattern = re.compile("") + + with pytest.raises( + TypeError, match="can only be used with a string pattern" + ): + matches_re(pattern, flags=re.IGNORECASE) + def test_different_func(self): """ Changing the match functions works. @@ -709,3 +799,154 @@ def test_hashability(): hash_func = getattr(obj, "__hash__", None) assert hash_func is not None assert hash_func is not object.__hash__ + + +class TestLtLeGeGt: + """ + Tests for `max_len`. + """ + + BOUND = 4 + + def test_in_all(self): + """ + validator is in ``__all__``. + """ + assert all( + f.__name__ in validator_module.__all__ for f in [lt, le, ge, gt] + ) + + @pytest.mark.parametrize("v", [lt, le, ge, gt]) + def test_retrieve_bound(self, v): + """ + The configured bound for the comparison can be extracted from the + Attribute. + """ + + @attr.s + class Tester(object): + value = attr.ib(validator=v(self.BOUND)) + + assert fields(Tester).value.validator.bound == self.BOUND + + @pytest.mark.parametrize( + "v, value", + [ + (lt, 3), + (le, 3), + (le, 4), + (ge, 4), + (ge, 5), + (gt, 5), + ], + ) + def test_check_valid(self, v, value): + """Silent if value {op} bound.""" + + @attr.s + class Tester(object): + value = attr.ib(validator=v(self.BOUND)) + + Tester(value) # shouldn't raise exceptions + + @pytest.mark.parametrize( + "v, value", + [ + (lt, 4), + (le, 5), + (ge, 3), + (gt, 4), + ], + ) + def test_check_invalid(self, v, value): + """Raise ValueError if value {op} bound.""" + + @attr.s + class Tester(object): + value = attr.ib(validator=v(self.BOUND)) + + with pytest.raises(ValueError): + Tester(value) + + @pytest.mark.parametrize("v", [lt, le, ge, gt]) + def test_repr(self, v): + """ + __repr__ is meaningful. + """ + nv = v(23) + assert repr(nv) == "".format( + op=nv.compare_op, bound=23 + ) + + +class TestMaxLen: + """ + Tests for `max_len`. + """ + + MAX_LENGTH = 4 + + def test_in_all(self): + """ + validator is in ``__all__``. + """ + assert max_len.__name__ in validator_module.__all__ + + def test_retrieve_max_len(self): + """ + The configured max. length can be extracted from the Attribute + """ + + @attr.s + class Tester(object): + value = attr.ib(validator=max_len(self.MAX_LENGTH)) + + assert fields(Tester).value.validator.max_length == self.MAX_LENGTH + + @pytest.mark.parametrize( + "value", + [ + "", + "foo", + "spam", + [], + list(range(MAX_LENGTH)), + {"spam": 3, "eggs": 4}, + ], + ) + def test_check_valid(self, value): + """ + Silent if len(value) <= max_len. + Values can be strings and other iterables. + """ + + @attr.s + class Tester(object): + value = attr.ib(validator=max_len(self.MAX_LENGTH)) + + Tester(value) # shouldn't raise exceptions + + @pytest.mark.parametrize( + "value", + [ + "bacon", + list(range(6)), + ], + ) + def test_check_invalid(self, value): + """ + Raise ValueError if len(value) > max_len. + """ + + @attr.s + class Tester(object): + value = attr.ib(validator=max_len(self.MAX_LENGTH)) + + with pytest.raises(ValueError): + Tester(value) + + def test_repr(self): + """ + __repr__ is meaningful. + """ + assert repr(max_len(23)) == "" diff --git a/tests/test_version_info.py b/tests/test_version_info.py index db4053f94..41f75f47a 100644 --- a/tests/test_version_info.py +++ b/tests/test_version_info.py @@ -1,3 +1,5 @@ +# SPDX-License-Identifier: MIT + from __future__ import absolute_import, division, print_function import pytest diff --git a/tests/typing_example.py b/tests/typing_example.py index 13b5638db..a85c768c1 100644 --- a/tests/typing_example.py +++ b/tests/typing_example.py @@ -1,8 +1,11 @@ +# SPDX-License-Identifier: MIT + import re from typing import Any, Dict, List, Tuple, Union import attr +import attrs # Typing via "type" Argument --- @@ -59,6 +62,14 @@ class FF: z: Any = attr.ib() +@attrs.define +class FFF: + z: int + + +FFF(1) + + # Inheritance -- @@ -96,6 +107,19 @@ class Error(Exception): str(e) +@attrs.define +class Error2(Exception): + x: int + + +try: + raise Error2(1) +except Error as e: + e.x + e.args + str(e) + + # Converters # XXX: Currently converters can only be functions so none of this works # although the stubs should be correct. @@ -118,6 +142,20 @@ class Error(Exception): # ConvCDefaultIfNone(None) +# @attr.s +# class ConvCToBool: +# x: int = attr.ib(converter=attr.converters.to_bool) + + +# ConvCToBool(1) +# ConvCToBool(True) +# ConvCToBool("on") +# ConvCToBool("yes") +# ConvCToBool(0) +# ConvCToBool(False) +# ConvCToBool("n") + + # Validators @attr.s class Validated: @@ -153,7 +191,7 @@ class Validated: attr.validators.instance_of(C), attr.validators.instance_of(D) ), ) - e: str = attr.ib(validator=attr.validators.matches_re(r"foo")) + e: str = attr.ib(validator=attr.validators.matches_re(re.compile(r"foo"))) f: str = attr.ib( validator=attr.validators.matches_re(r"foo", flags=42, func=re.search) ) @@ -165,10 +203,33 @@ class Validated: validator=attr.validators.instance_of((int, str)) ) k: Union[int, str, C] = attr.ib( - validator=attr.validators.instance_of((int, C, str)) + validator=attrs.validators.instance_of((int, C, str)) ) +@attr.define +class Validated2: + num: int = attr.field(validator=attr.validators.ge(0)) + + +@attrs.define +class Validated3: + num: int = attr.field(validator=attr.validators.ge(0)) + + +with attr.validators.disabled(): + Validated2(num=-1) + +with attrs.validators.disabled(): + Validated3(num=-1) + +try: + attr.validators.set_disabled(True) + Validated2(num=-1) +finally: + attr.validators.set_disabled(False) + + # Custom repr() @attr.s class WithCustomRepr: @@ -178,6 +239,14 @@ class WithCustomRepr: d: bool = attr.ib(repr=str) +@attrs.define +class WithCustomRepr2: + a: int = attrs.field(repr=True) + b: str = attrs.field(repr=False) + c: str = attrs.field(repr=lambda value: "c is for cookie") + d: bool = attrs.field(repr=str) + + # Check some of our own types @attr.s(eq=True, order=False) class OrderFlags: @@ -199,16 +268,43 @@ class ValidatedSetter: ) +@attrs.define(on_setattr=attr.setters.validate) +class ValidatedSetter2: + a: int + b: str = attrs.field(on_setattr=attrs.setters.NO_OP) + c: bool = attrs.field(on_setattr=attrs.setters.frozen) + d: int = attrs.field( + on_setattr=[attrs.setters.convert, attrs.setters.validate] + ) + e: bool = attrs.field( + on_setattr=attrs.setters.pipe( + attrs.setters.convert, attrs.setters.validate + ) + ) + + # field_transformer def ft_hook(cls: type, attribs: List[attr.Attribute]) -> List[attr.Attribute]: return attribs +# field_transformer +def ft_hook2( + cls: type, attribs: List[attrs.Attribute] +) -> List[attrs.Attribute]: + return attribs + + @attr.s(field_transformer=ft_hook) class TransformedAttrs: x: int +@attrs.define(field_transformer=ft_hook2) +class TransformedAttrs2: + x: int + + # Auto-detect @attr.s(auto_detect=True) class AutoDetect: @@ -247,6 +343,11 @@ class NGFrozen: a.evolve(repr=False) +attrs.fields(NGFrozen).x.evolve(eq=False) +a = attrs.fields(NGFrozen).x +a.evolve(repr=False) + + @attr.s(collect_by_mro=True) class MRO: pass @@ -257,3 +358,63 @@ class FactoryTest: a: List[int] = attr.ib(default=attr.Factory(list)) b: List[Any] = attr.ib(default=attr.Factory(list, False)) c: List[int] = attr.ib(default=attr.Factory((lambda s: s.a), True)) + + +@attrs.define +class FactoryTest2: + a: List[int] = attrs.field(default=attrs.Factory(list)) + b: List[Any] = attrs.field(default=attrs.Factory(list, False)) + c: List[int] = attrs.field(default=attrs.Factory((lambda s: s.a), True)) + + +attrs.asdict(FactoryTest2()) +attr.asdict(FactoryTest(), tuple_keys=True) + + +# Check match_args stub +@attr.s(match_args=False) +class MatchArgs: + a: int = attr.ib() + b: int = attr.ib() + + +attr.asdict(FactoryTest()) +attr.asdict(FactoryTest(), retain_collection_types=False) + + +# Check match_args stub +@attrs.define(match_args=False) +class MatchArgs2: + a: int + b: int + + +# NG versions of asdict/astuple +attrs.asdict(MatchArgs2(1, 2)) +attrs.astuple(MatchArgs2(1, 2)) + + +def importing_from_attr() -> None: + """ + Use a function to keep the ns clean. + """ + from attr.converters import optional + from attr.exceptions import FrozenError + from attr.filters import include + from attr.setters import frozen + from attr.validators import and_ + + assert optional and FrozenError and include and frozen and and_ + + +def importing_from_attrs() -> None: + """ + Use a function to keep the ns clean. + """ + from attrs.converters import optional + from attrs.exceptions import FrozenError + from attrs.filters import include + from attrs.setters import frozen + from attrs.validators import and_ + + assert optional and FrozenError and include and frozen and and_ diff --git a/tests/utils.py b/tests/utils.py index ad3fb578a..a2fefbd60 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -1,3 +1,5 @@ +# SPDX-License-Identifier: MIT + """ Common helper functions for tests. """ diff --git a/tox.ini b/tox.ini index a9f67b296..ddcbc4dbb 100644 --- a/tox.ini +++ b/tox.ini @@ -13,65 +13,67 @@ python = 2.7: py27 3.5: py35 3.6: py36 - 3.7: py37, docs - 3.8: py38, lint, manifest, typing, changelog + 3.7: py37 + 3.8: py38, changelog 3.9: py39, pyright - 3.10: py310 - pypy2: pypy2 - pypy3: pypy3 + 3.10: py310, manifest, typing, docs + pypy-2: pypy + pypy-3: pypy3 [tox] -envlist = typing,lint,py27,py35,py36,py37,py38,py39,py310,pypy,pypy3,pyright,manifest,docs,pypi-description,changelog,coverage-report +envlist = typing,pre-commit,py27,py35,py36,py37,py38,py39,py310,pypy,pypy3,pyright,manifest,docs,pypi-description,changelog,coverage-report isolated_build = True +[testenv:docs] +# Keep basepython in sync with gh-actions and .readthedocs.yml. +basepython = python3.10 +extras = docs +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 + python -m doctest README.rst + + [testenv] -# Prevent random setuptools/pip breakages like -# https://github.com/pypa/setuptools/issues/1042 from breaking our builds. -setenv = - VIRTUALENV_NO_DOWNLOAD=1 -extras = {env:TOX_AP_TEST_EXTRAS:tests} +extras = tests commands = python -m pytest {posargs} [testenv:py27] -extras = {env:TOX_AP_TEST_EXTRAS:tests} +extras = tests commands = coverage run -m pytest {posargs} [testenv:py37] -# 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 = pip install --no-compile {opts} {packages} -setenv = - PYTHONWARNINGS=d -extras = {env:TOX_AP_TEST_EXTRAS:tests} +extras = tests commands = coverage run -m pytest {posargs} -[testenv:py38] +[testenv:py310] # 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. -basepython = python3.8 +basepython = python3.10 install_command = pip install --no-compile {opts} {packages} setenv = PYTHONWARNINGS=d -extras = {env:TOX_AP_TEST_EXTRAS:tests} +extras = tests commands = coverage run -m pytest {posargs} [testenv:coverage-report] -basepython = python3.7 +basepython = python3.10 +depends = py27,py37,py310 skip_install = true -deps = coverage[toml]>=5.0.2 +deps = coverage[toml]>=5.4 commands = coverage combine coverage report -[testenv:lint] -basepython = python3.8 +[testenv:pre-commit] +basepython = python3.10 skip_install = true deps = pre-commit @@ -80,18 +82,8 @@ commands = pre-commit run --all-files -[testenv:docs] -# Keep basepython in sync with gh-actions and .readthedocs.yml. -basepython = python3.7 -extras = docs -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 - python -m doctest README.rst - - [testenv:manifest] -basepython = python3.8 +basepython = python3.10 deps = check-manifest skip_install = true commands = check-manifest @@ -116,8 +108,8 @@ commands = towncrier --draft [testenv:typing] -basepython = python3.8 -deps = mypy>=0.800 +basepython = python3.10 +deps = mypy>=0.902 commands = mypy src/attr/__init__.pyi src/attr/_version_info.pyi src/attr/converters.pyi src/attr/exceptions.pyi src/attr/filters.pyi src/attr/setters.pyi src/attr/validators.pyi mypy tests/typing_example.py