├── .git_archival.txt ├── .gitattributes ├── .github ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── FUNDING.yml ├── PULL_REQUEST_TEMPLATE.md ├── SECURITY.md ├── dependabot.yml └── workflows │ ├── build-docset.yml │ ├── ci.yml │ ├── codeql-analysis.yml │ ├── codspeed.yml │ ├── pinact.yml │ ├── pypi-package.yml │ └── zizmor.yml ├── .gitignore ├── .pre-commit-config.yaml ├── .python-version-default ├── .readthedocs.yaml ├── CHANGELOG.md ├── CITATION.cff ├── LICENSE ├── README.md ├── bench └── test_benchmarks.py ├── changelog.d └── towncrier_template.md.jinja ├── conftest.py ├── docs ├── Makefile ├── _static │ ├── attrs_logo.png │ ├── attrs_logo.svg │ ├── attrs_logo_white.png │ ├── attrs_logo_white.svg │ ├── custom.css │ ├── docset-icon.png │ ├── docset-icon@2x.png │ ├── social card.afdesign │ ├── social card.png │ └── sponsors │ │ ├── FilePreviews.svg │ │ ├── Klaviyo.svg │ │ ├── Polar.svg │ │ ├── Privacy-Solutions.svg │ │ ├── Tidelift.svg │ │ ├── Variomedia.svg │ │ └── emsys-renewables.svg ├── api-attr.rst ├── api.rst ├── changelog.md ├── comparison.md ├── conf.py ├── examples.md ├── extending.md ├── glossary.md ├── hashing.md ├── how-does-it-work.md ├── index.md ├── init.md ├── license.md ├── names.md ├── overview.md ├── types.md └── why.md ├── pyproject.toml ├── src ├── attr │ ├── __init__.py │ ├── __init__.pyi │ ├── _cmp.py │ ├── _cmp.pyi │ ├── _compat.py │ ├── _config.py │ ├── _funcs.py │ ├── _make.py │ ├── _next_gen.py │ ├── _typing_compat.pyi │ ├── _version_info.py │ ├── _version_info.pyi │ ├── converters.py │ ├── converters.pyi │ ├── exceptions.py │ ├── exceptions.pyi │ ├── filters.py │ ├── filters.pyi │ ├── py.typed │ ├── setters.py │ ├── setters.pyi │ ├── validators.py │ └── validators.pyi └── attrs │ ├── __init__.py │ ├── __init__.pyi │ ├── converters.py │ ├── exceptions.py │ ├── filters.py │ ├── py.typed │ ├── setters.py │ └── validators.py ├── tests ├── __init__.py ├── attr_import_star.py ├── dataclass_transform_example.py ├── strategies.py ├── test_3rd_party.py ├── test_abc.py ├── test_annotations.py ├── test_cmp.py ├── test_compat.py ├── test_config.py ├── test_converters.py ├── test_dunders.py ├── test_filters.py ├── test_funcs.py ├── test_functional.py ├── test_hooks.py ├── test_import.py ├── test_init_subclass.py ├── test_make.py ├── test_mypy.yml ├── test_next_gen.py ├── test_packaging.py ├── test_pattern_matching.py ├── test_pyright.py ├── test_setattr.py ├── test_slots.py ├── test_utils.py ├── test_validators.py ├── test_version_info.py ├── typing_example.py └── utils.py ├── tox.ini └── uv.lock /.git_archival.txt: -------------------------------------------------------------------------------- 1 | node: a6ae894aad9bc09edc7cdad8c416898784ceec9b 2 | node-date: 2025-06-02T09:09:48+02:00 3 | describe-name: 25.3.0-14-ga6ae894aa 4 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # Force LF line endings for text files 2 | * text=auto eol=lf 3 | 4 | # Needed for setuptools-scm-git-archive 5 | .git_archival.txt export-subst 6 | -------------------------------------------------------------------------------- /.github/CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | 2 | # Contributor Covenant Code of Conduct 3 | 4 | ## Our Pledge 5 | 6 | We as members, contributors, and leaders pledge to make participation in our 7 | community a harassment-free experience for everyone, regardless of age, body 8 | size, visible or invisible disability, ethnicity, sex characteristics, gender 9 | identity and expression, level of experience, education, socioeconomic status, 10 | nationality, personal appearance, race, caste, color, religion, or sexual 11 | identity and orientation. 12 | 13 | We pledge to act and interact in ways that contribute to an open, welcoming, 14 | diverse, inclusive, and healthy community. 15 | 16 | ## Our Standards 17 | 18 | Examples of behavior that contributes to a positive environment for our 19 | community include: 20 | 21 | * Demonstrating empathy and kindness toward other people 22 | * Being respectful of differing opinions, viewpoints, and experiences 23 | * Giving and gracefully accepting constructive feedback 24 | * Accepting responsibility and apologizing to those affected by our mistakes, 25 | and learning from the experience 26 | * Focusing on what is best not just for us as individuals, but for the overall 27 | community 28 | 29 | Examples of unacceptable behavior include: 30 | 31 | * The use of sexualized language or imagery, and sexual attention or advances of 32 | any kind 33 | * Trolling, insulting or derogatory comments, and personal or political attacks 34 | * Public or private harassment 35 | * Publishing others' private information, such as a physical or email address, 36 | without their explicit permission 37 | * Other conduct which could reasonably be considered inappropriate in a 38 | professional setting 39 | 40 | ## Enforcement Responsibilities 41 | 42 | Community leaders are responsible for clarifying and enforcing our standards of 43 | acceptable behavior and will take appropriate and fair corrective action in 44 | response to any behavior that they deem inappropriate, threatening, offensive, 45 | or harmful. 46 | 47 | Community leaders have the right and responsibility to remove, edit, or reject 48 | comments, commits, code, wiki edits, issues, and other contributions that are 49 | not aligned to this Code of Conduct, and will communicate reasons for moderation 50 | decisions when appropriate. 51 | 52 | ## Scope 53 | 54 | This Code of Conduct applies within all community spaces, and also applies when 55 | an individual is officially representing the community in public spaces. 56 | Examples of representing our community include using an official e-mail address, 57 | posting via an official social media account, or acting as an appointed 58 | representative at an online or offline event. 59 | 60 | ## Enforcement 61 | 62 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 63 | reported to the community leaders responsible for enforcement at 64 | . 65 | All complaints will be reviewed and investigated promptly and fairly. 66 | 67 | All community leaders are obligated to respect the privacy and security of the 68 | reporter of any incident. 69 | 70 | ## Enforcement Guidelines 71 | 72 | Community leaders will follow these Community Impact Guidelines in determining 73 | the consequences for any action they deem in violation of this Code of Conduct: 74 | 75 | ### 1. Correction 76 | 77 | **Community Impact**: Use of inappropriate language or other behavior deemed 78 | unprofessional or unwelcome in the community. 79 | 80 | **Consequence**: A private, written warning from community leaders, providing 81 | clarity around the nature of the violation and an explanation of why the 82 | behavior was inappropriate. A public apology may be requested. 83 | 84 | ### 2. Warning 85 | 86 | **Community Impact**: A violation through a single incident or series of 87 | actions. 88 | 89 | **Consequence**: A warning with consequences for continued behavior. No 90 | interaction with the people involved, including unsolicited interaction with 91 | those enforcing the Code of Conduct, for a specified period of time. This 92 | includes avoiding interactions in community spaces as well as external channels 93 | like social media. Violating these terms may lead to a temporary or permanent 94 | ban. 95 | 96 | ### 3. Temporary Ban 97 | 98 | **Community Impact**: A serious violation of community standards, including 99 | sustained inappropriate behavior. 100 | 101 | **Consequence**: A temporary ban from any sort of interaction or public 102 | communication with the community for a specified period of time. No public or 103 | private interaction with the people involved, including unsolicited interaction 104 | with those enforcing the Code of Conduct, is allowed during this period. 105 | Violating these terms may lead to a permanent ban. 106 | 107 | ### 4. Permanent Ban 108 | 109 | **Community Impact**: Demonstrating a pattern of violation of community 110 | standards, including sustained inappropriate behavior, harassment of an 111 | individual, or aggression toward or disparagement of classes of individuals. 112 | 113 | **Consequence**: A permanent ban from any sort of public interaction within the 114 | community. 115 | 116 | ## Attribution 117 | 118 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 119 | version 2.1, available at 120 | [https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1]. 121 | 122 | Community Impact Guidelines were inspired by 123 | [Mozilla's code of conduct enforcement ladder][Mozilla CoC]. 124 | 125 | For answers to common questions about this code of conduct, see the FAQ at 126 | [https://www.contributor-covenant.org/faq][FAQ]. Translations are available at 127 | [https://www.contributor-covenant.org/translations][translations]. 128 | 129 | [homepage]: https://www.contributor-covenant.org 130 | [v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html 131 | [Mozilla CoC]: https://github.com/mozilla/diversity 132 | [FAQ]: https://www.contributor-covenant.org/faq 133 | [translations]: https://www.contributor-covenant.org/translations 134 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | --- 2 | github: hynek 3 | tidelift: "pypi/attrs" 4 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | # Summary 2 | 3 | 4 | 5 | 6 | # Pull Request Check List 7 | 8 | 16 | 17 | - [ ] Do **not** open pull requests from your `main` branch – **use a separate branch**! 18 | - There's a ton of footguns waiting if you don't heed this warning. You can still go back to your project, create a branch from your main branch, push it, and open the pull request from the new branch. 19 | - This is not a pre-requisite for your pull request to be accepted, but **you have been warned**. 20 | - [ ] Added **tests** for changed code. 21 | Our CI fails if coverage is not 100%. 22 | - [ ] New features have been added to our [Hypothesis testing strategy](https://github.com/python-attrs/attrs/blob/main/tests/strategies.py). 23 | - [ ] Changes or additions to public APIs are reflected in our type stubs (files ending in ``.pyi``). 24 | - [ ] ...and used in the stub test file `tests/typing_example.py`. 25 | - [ ] If they've been added to `attr/__init__.pyi`, they've *also* been re-imported in `attrs/__init__.pyi`. 26 | - [ ] Updated **documentation** for changed code. 27 | - [ ] New functions/classes have to be added to `docs/api.rst` by hand. 28 | - [ ] Changes to the signatures of `@attr.s()` and `@attrs.define()` have to be added by hand too. 29 | - [ ] Changed/added classes/methods/functions have appropriate `versionadded`, `versionchanged`, or `deprecated` [directives](http://www.sphinx-doc.org/en/stable/markup/para.html#directive-versionadded). 30 | The next version is the second number in the current release + 1. 31 | The first number represents the current year. 32 | So if the current version on PyPI is 22.2.0, the next version is gonna be 22.3.0. 33 | If the next version is the first in the new year, it'll be 23.1.0. 34 | - [ ] If something changed that affects both `attrs.define()` and `attr.s()`, you have to add version directives to both. 35 | - [ ] Documentation in `.rst` and `.md` files is written using [semantic newlines](https://rhodesmill.org/brandon/2012/one-sentence-per-line/). 36 | - [ ] Changes (and possible deprecations) have news fragments in [`changelog.d`](https://github.com/python-attrs/attrs/blob/main/changelog.d). 37 | - [ ] Consider granting [push permissions to the PR branch](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/working-with-forks/allowing-changes-to-a-pull-request-branch-created-from-a-fork), so maintainers can fix minor issues themselves without pestering you. 38 | 39 | 43 | -------------------------------------------------------------------------------- /.github/SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | ## Supported Versions 4 | 5 | We are following [Calendar Versioning](https://calver.org) with generous backwards-compatibility guarantees. 6 | Therefore we only support the latest version. 7 | 8 | Put simply, you shouldn't ever be afraid to upgrade as long as you're only using our public APIs. 9 | Whenever there is a need to break compatibility, it is announced in the changelog, and raises a `DeprecationWarning` for a year (if possible) before it's finally really broken. 10 | 11 | > [!WARNING] 12 | > The structure of the `attrs.Attribute` class is exempt from this rule. 13 | > It *will* change in the future, but since it should be considered read-only, that shouldn't matter. 14 | > 15 | > However if you intend to build extensions on top of *attrs* you have to anticipate that. 16 | 17 | 18 | ## Reporting a Vulnerability 19 | 20 | To report a security vulnerability, please use the [Tidelift security contact](https://tidelift.com/security). 21 | Tidelift will coordinate the fix and disclosure. 22 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | --- 2 | version: 2 3 | updates: 4 | - package-ecosystem: github-actions 5 | directory: / 6 | schedule: 7 | interval: monthly 8 | -------------------------------------------------------------------------------- /.github/workflows/build-docset.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Build docset 3 | 4 | on: 5 | push: 6 | tags: ["*"] 7 | workflow_dispatch: 8 | 9 | env: 10 | PIP_DISABLE_PIP_VERSION_CHECK: "1" 11 | PIP_NO_PYTHON_VERSION_WARNING: "1" 12 | 13 | permissions: {} 14 | 15 | 16 | jobs: 17 | docset: 18 | runs-on: ubuntu-latest 19 | steps: 20 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 21 | with: 22 | fetch-depth: 0 23 | persist-credentials: false 24 | - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 25 | with: 26 | python-version: "3.x" 27 | - uses: hynek/setup-cached-uv@757bedc3f972eb7227a1aa657651f15a8527c817 # v2.3.0 28 | 29 | - run: uvx --with=tox-uv tox run -e docset 30 | 31 | - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 32 | with: 33 | name: docset 34 | path: attrs.tgz 35 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: CI 3 | 4 | on: 5 | merge_group: 6 | push: 7 | branches: [main] 8 | tags: ["*"] 9 | pull_request: 10 | workflow_dispatch: 11 | 12 | env: 13 | FORCE_COLOR: "1" 14 | PIP_DISABLE_PIP_VERSION_CHECK: "1" 15 | PIP_NO_PYTHON_VERSION_WARNING: "1" 16 | 17 | permissions: {} 18 | 19 | 20 | jobs: 21 | build-package: 22 | name: Build & verify package 23 | runs-on: ubuntu-latest 24 | 25 | steps: 26 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 27 | with: 28 | fetch-depth: 0 29 | persist-credentials: false 30 | 31 | - uses: hynek/build-and-inspect-python-package@b5076c307dc91924a82ad150cdd1533b444d3310 # v2.12.0 32 | id: baipp 33 | 34 | outputs: 35 | # Used to define the matrix for tests below. The value is based on 36 | # packaging metadata (trove classifiers). 37 | supported-python-versions: ${{ steps.baipp.outputs.supported_python_classifiers_json_array }} 38 | 39 | tests: 40 | name: Tests & Mypy on ${{ matrix.python-version }} 41 | runs-on: ubuntu-latest 42 | needs: build-package 43 | 44 | strategy: 45 | fail-fast: false 46 | matrix: 47 | # Created by the build-and-inspect-python-package action above. 48 | python-version: ${{ fromJson(needs.build-package.outputs.supported-python-versions) }} 49 | 50 | steps: 51 | - name: Download pre-built packages 52 | uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 53 | with: 54 | name: Packages 55 | path: dist 56 | - run: tar xf dist/*.tar.gz --strip-components=1 57 | - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 58 | with: 59 | python-version: ${{ matrix.python-version }} 60 | allow-prereleases: true 61 | - uses: hynek/setup-cached-uv@757bedc3f972eb7227a1aa657651f15a8527c817 # v2.3.0 62 | 63 | - name: Prepare tox 64 | env: 65 | V: ${{ matrix.python-version }} 66 | run: | 67 | DO_MYPY=1 68 | 69 | if [[ "$V" == "3.9" ]]; then 70 | DO_MYPY=0 71 | fi 72 | 73 | echo DO_MYPY=$DO_MYPY >>$GITHUB_ENV 74 | echo TOX_PYTHON=py$(echo $V | tr -d .) >>$GITHUB_ENV 75 | 76 | - run: > 77 | uvx --with=tox-uv 78 | tox run 79 | -e $TOX_PYTHON-mypy 80 | if: env.DO_MYPY == '1' 81 | 82 | - name: Remove src to ensure tests run against wheel 83 | run: rm -rf src 84 | 85 | - run: > 86 | uvx --with=tox-uv 87 | tox run 88 | --installpkg dist/*.whl 89 | -e $TOX_PYTHON-tests 90 | 91 | - name: Upload coverage data 92 | uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 93 | with: 94 | name: coverage-data-${{ matrix.python-version }} 95 | path: .coverage.* 96 | include-hidden-files: true 97 | if-no-files-found: ignore 98 | 99 | tests-pypy: 100 | name: Tests on ${{ matrix.python-version }} 101 | runs-on: ubuntu-latest 102 | needs: build-package 103 | 104 | strategy: 105 | fail-fast: false 106 | matrix: 107 | python-version: 108 | - pypy-3.10 109 | 110 | steps: 111 | - name: Download pre-built packages 112 | uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 113 | with: 114 | name: Packages 115 | path: dist 116 | - run: | 117 | tar xf dist/*.tar.gz --strip-components=1 118 | rm -rf src # ensure tests run against wheel 119 | - uses: hynek/setup-cached-uv@757bedc3f972eb7227a1aa657651f15a8527c817 # v2.3.0 120 | 121 | - run: > 122 | uvx --with=tox-uv 123 | tox run 124 | --installpkg dist/*.whl 125 | -e pypy3-tests 126 | 127 | coverage: 128 | name: Combine & check coverage 129 | runs-on: ubuntu-latest 130 | needs: tests 131 | 132 | steps: 133 | - name: Download pre-built packages 134 | uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 135 | with: 136 | name: Packages 137 | path: dist 138 | - run: tar xf dist/*.tar.gz --strip-components=1 139 | - uses: hynek/setup-cached-uv@757bedc3f972eb7227a1aa657651f15a8527c817 # v2.3.0 140 | 141 | - name: Download coverage data 142 | uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 143 | with: 144 | pattern: coverage-data-* 145 | merge-multiple: true 146 | 147 | - name: Combine coverage & fail if it's <100%. 148 | run: | 149 | uv tool install --python $(cat .python-version-default) coverage 150 | 151 | coverage combine 152 | coverage html --skip-covered --skip-empty 153 | 154 | # Report and write to summary. 155 | coverage report --format=markdown >> $GITHUB_STEP_SUMMARY 156 | 157 | # Report again and fail if under 100%. 158 | coverage report --fail-under=100 159 | 160 | - name: Upload HTML report if check failed. 161 | uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 162 | with: 163 | name: html-report 164 | path: htmlcov 165 | if: ${{ failure() }} 166 | 167 | docs: 168 | name: Run doctests & render changelog 169 | runs-on: ubuntu-latest 170 | needs: build-package 171 | steps: 172 | - name: Download pre-built packages 173 | uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 174 | with: 175 | name: Packages 176 | path: dist 177 | - run: tar xf dist/*.tar.gz --strip-components=1 178 | - uses: hynek/setup-cached-uv@757bedc3f972eb7227a1aa657651f15a8527c817 # v2.3.0 179 | 180 | - run: uvx --with=tox-uv tox run -e docs-doctests,changelog 181 | 182 | pyright: 183 | name: Check types using pyright 184 | runs-on: ubuntu-latest 185 | steps: 186 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 187 | with: 188 | persist-credentials: false 189 | - uses: hynek/setup-cached-uv@757bedc3f972eb7227a1aa657651f15a8527c817 # v2.3.0 190 | 191 | - run: > 192 | uvx --with=tox-uv 193 | --python $(cat .python-version-default) 194 | tox run -e pyright 195 | 196 | install-dev: 197 | name: Verify dev env 198 | runs-on: ubuntu-latest 199 | 200 | steps: 201 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 202 | with: 203 | persist-credentials: false 204 | - uses: hynek/setup-cached-uv@757bedc3f972eb7227a1aa657651f15a8527c817 # v2.3.0 205 | 206 | - run: uv venv --python $(cat .python-version-default) 207 | - run: uv pip install -e .[dev] 208 | 209 | - name: Ensure we can import attr and attrs packages 210 | run: | 211 | source .venv/bin/activate 212 | 213 | python -Ic 'import attr; print(attr.__version__)' 214 | python -Ic 'import attrs; print(attrs.__version__)' 215 | 216 | # Ensure everything required is passing for branch protection. 217 | required-checks-pass: 218 | if: always() 219 | 220 | needs: 221 | - coverage 222 | - tests-pypy 223 | - docs 224 | - install-dev 225 | - pyright 226 | 227 | runs-on: ubuntu-latest 228 | 229 | steps: 230 | - name: Decide whether the needed jobs succeeded or failed 231 | uses: re-actors/alls-green@05ac9388f0aebcb5727afa17fcccfecd6f8ec5fe # v1.2.2 232 | with: 233 | jobs: ${{ toJSON(needs) }} 234 | -------------------------------------------------------------------------------- /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: CodeQL 3 | 4 | on: 5 | schedule: 6 | - cron: "30 22 * * 4" 7 | 8 | permissions: 9 | contents: read 10 | 11 | jobs: 12 | analyze: 13 | name: Analyze 14 | runs-on: ubuntu-latest 15 | permissions: 16 | actions: read 17 | contents: read 18 | security-events: write 19 | 20 | strategy: 21 | fail-fast: false 22 | matrix: 23 | language: [python] 24 | 25 | steps: 26 | - name: Checkout repository 27 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 28 | with: 29 | persist-credentials: false 30 | 31 | - name: Initialize CodeQL 32 | uses: github/codeql-action/init@ff0a06e83cb2de871e5a09832bc6a81e7276941f # v3.28.18 33 | with: 34 | languages: ${{ matrix.language }} 35 | 36 | - name: Autobuild 37 | uses: github/codeql-action/autobuild@ff0a06e83cb2de871e5a09832bc6a81e7276941f # v3.28.18 38 | 39 | - name: Perform CodeQL Analysis 40 | uses: github/codeql-action/analyze@ff0a06e83cb2de871e5a09832bc6a81e7276941f # v3.28.18 41 | -------------------------------------------------------------------------------- /.github/workflows/codspeed.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: CodSpeed Benchmarks 3 | 4 | on: 5 | push: 6 | branches: [main] 7 | tags: ["*"] 8 | paths: 9 | - src/**.py 10 | - bench/** 11 | - .github/workflows/codspeed.yml 12 | pull_request: 13 | paths: 14 | - src/**.py 15 | - bench/** 16 | - .github/workflows/codspeed.yml 17 | workflow_dispatch: 18 | 19 | 20 | env: 21 | FORCE_COLOR: "1" 22 | PIP_DISABLE_PIP_VERSION_CHECK: "1" 23 | PIP_NO_PYTHON_VERSION_WARNING: "1" 24 | 25 | permissions: {} 26 | 27 | jobs: 28 | codspeed: 29 | name: Run CodSpeed benchmarks 30 | runs-on: ubuntu-latest 31 | 32 | steps: 33 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 34 | with: 35 | persist-credentials: false 36 | - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 37 | with: 38 | python-version-file: .python-version-default 39 | - uses: hynek/setup-cached-uv@757bedc3f972eb7227a1aa657651f15a8527c817 # v2.3.0 40 | 41 | - name: Run CodSpeed benchmarks 42 | uses: CodSpeedHQ/action@0010eb0ca6e89b80c88e8edaaa07cfe5f3e6664d # v3.5.0 43 | with: 44 | token: ${{ secrets.CODSPEED_TOKEN }} 45 | run: uvx --with tox-uv tox run -e codspeed 46 | -------------------------------------------------------------------------------- /.github/workflows/pinact.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Pinact 3 | 4 | on: 5 | schedule: 6 | - cron: "30 22 * * 4" 7 | workflow_dispatch: 8 | 9 | permissions: {} 10 | 11 | jobs: 12 | pinact: 13 | runs-on: ubuntu-latest 14 | permissions: 15 | contents: write 16 | 17 | steps: 18 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 19 | with: 20 | persist-credentials: false 21 | 22 | - name: Pin actions 23 | uses: suzuki-shunsuke/pinact-action@49cbd6acd0dbab6a6be2585d1dbdaa43b4410133 # v1.0.0 24 | -------------------------------------------------------------------------------- /.github/workflows/pypi-package.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Build & upload PyPI package 3 | 4 | on: 5 | push: 6 | branches: [main] 7 | tags: ["*"] 8 | release: 9 | types: 10 | - published 11 | workflow_dispatch: 12 | 13 | 14 | jobs: 15 | # Always build & lint package. 16 | build-package: 17 | name: Build & verify package 18 | runs-on: ubuntu-latest 19 | permissions: 20 | attestations: write 21 | id-token: write 22 | 23 | steps: 24 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 25 | with: 26 | fetch-depth: 0 27 | persist-credentials: false 28 | 29 | - uses: hynek/build-and-inspect-python-package@b5076c307dc91924a82ad150cdd1533b444d3310 # v2.12.0 30 | with: 31 | attest-build-provenance-github: 'true' 32 | 33 | 34 | # Upload to Test PyPI on every commit on main. 35 | release-test-pypi: 36 | name: Publish in-dev package to test.pypi.org 37 | environment: release-test-pypi 38 | if: github.repository_owner == 'python-attrs' && github.event_name == 'push' && github.ref == 'refs/heads/main' 39 | runs-on: ubuntu-latest 40 | needs: build-package 41 | 42 | permissions: 43 | id-token: write 44 | 45 | steps: 46 | - name: Download packages built by build-and-inspect-python-package 47 | uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 48 | with: 49 | name: Packages 50 | path: dist 51 | 52 | - name: Upload package to Test PyPI 53 | uses: pypa/gh-action-pypi-publish@76f52bc884231f62b9a034ebfe128415bbaabdfc # v1.12.4 54 | with: 55 | attestations: true 56 | repository-url: https://test.pypi.org/legacy/ 57 | 58 | 59 | # Upload to real PyPI on GitHub Releases. 60 | release-pypi: 61 | name: Publish released package to pypi.org 62 | environment: release-pypi 63 | if: github.repository_owner == 'python-attrs' && github.event.action == 'published' 64 | runs-on: ubuntu-latest 65 | needs: build-package 66 | 67 | permissions: 68 | id-token: write 69 | 70 | steps: 71 | - name: Download packages built by build-and-inspect-python-package 72 | uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 73 | with: 74 | name: Packages 75 | path: dist 76 | 77 | - name: Upload package to PyPI 78 | uses: pypa/gh-action-pypi-publish@76f52bc884231f62b9a034ebfe128415bbaabdfc # v1.12.4 79 | with: 80 | attestations: true 81 | -------------------------------------------------------------------------------- /.github/workflows/zizmor.yml: -------------------------------------------------------------------------------- 1 | # https://github.com/woodruffw/zizmor 2 | name: Zizmor 3 | 4 | on: 5 | push: 6 | branches: ["main"] 7 | pull_request: 8 | branches: ["*"] 9 | 10 | permissions: 11 | contents: read 12 | 13 | 14 | jobs: 15 | zizmor: 16 | name: Zizmor latest via PyPI 17 | runs-on: ubuntu-latest 18 | permissions: 19 | security-events: write 20 | steps: 21 | - name: Checkout repository 22 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 23 | with: 24 | persist-credentials: false 25 | - uses: hynek/setup-cached-uv@757bedc3f972eb7227a1aa657651f15a8527c817 # v2.3.0 26 | 27 | - name: Run zizmor 🌈 28 | run: uvx zizmor --format sarif . > results.sarif 29 | env: 30 | GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} 31 | 32 | - name: Upload SARIF file 33 | uses: github/codeql-action/upload-sarif@ff0a06e83cb2de871e5a09832bc6a81e7276941f # v3.28.18 34 | with: 35 | # Path to SARIF file relative to the root of the repository 36 | sarif_file: results.sarif 37 | # Optional category for the results 38 | # Used to differentiate multiple results for one commit 39 | category: zizmor 40 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.egg-info 2 | *.pyc 3 | .DS_Store 4 | .cache 5 | .coverage* 6 | .direnv 7 | .envrc 8 | .hypothesis 9 | .mypy_cache 10 | .pytest_cache 11 | .tox 12 | .vscode 13 | .venv* 14 | build 15 | dist 16 | docs/_build 17 | htmlcov 18 | tmp* 19 | attrs.docset 20 | attrs.tgz 21 | Justfile 22 | t.py 23 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | ci: 3 | autoupdate_schedule: monthly 4 | 5 | repos: 6 | - repo: https://github.com/astral-sh/ruff-pre-commit 7 | rev: v0.11.12 8 | hooks: 9 | - id: ruff-check 10 | args: [--fix, --exit-non-zero-on-fix] 11 | - id: ruff-format 12 | 13 | - repo: https://github.com/econchick/interrogate 14 | rev: 1.7.0 15 | hooks: 16 | - id: interrogate 17 | args: [tests] 18 | 19 | - repo: https://github.com/codespell-project/codespell 20 | rev: v2.4.1 21 | hooks: 22 | - id: codespell 23 | args: [--exclude-file=tests/test_mypy.yml] 24 | 25 | - repo: https://github.com/pre-commit/pre-commit-hooks 26 | rev: v5.0.0 27 | hooks: 28 | - id: trailing-whitespace 29 | - id: end-of-file-fixer 30 | - id: check-toml 31 | - id: check-yaml 32 | -------------------------------------------------------------------------------- /.python-version-default: -------------------------------------------------------------------------------- 1 | 3.13 2 | -------------------------------------------------------------------------------- /.readthedocs.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | version: 2 3 | 4 | build: 5 | os: ubuntu-lts-latest 6 | tools: 7 | # Keep version in sync with tox.ini/docs. 8 | python: "3.13" 9 | jobs: 10 | create_environment: 11 | # Need the tags to calculate the version (sometimes). 12 | - git fetch --tags 13 | 14 | - asdf plugin add uv 15 | - asdf install uv latest 16 | - asdf global uv latest 17 | 18 | build: 19 | html: 20 | - uvx --with tox-uv tox run -e docs-sponsors 21 | - uvx --with tox-uv tox run -e docs-build -- $READTHEDOCS_OUTPUT 22 | -------------------------------------------------------------------------------- /CITATION.cff: -------------------------------------------------------------------------------- 1 | cff-version: 1.2.0 2 | message: If you use this software, please cite it as below. 3 | title: attrs 4 | type: software 5 | authors: 6 | - given-names: Hynek 7 | family-names: Schlawack 8 | email: hs@ox.cx 9 | doi: 10.5281/zenodo.6925130 10 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 Hynek Schlawack and the attrs contributors 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | 3 | 4 | 5 | attrs 6 | 7 | 8 |

9 | 10 |

11 | Documentation 12 | 13 | 14 | Downloads per month 15 | DOI 16 |

17 | 18 | 19 | 20 | *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](https://www.attrs.org/en/latest/glossary.html#term-dunder-methods)). 21 | [Trusted by NASA](https://docs.github.com/en/account-and-profile/setting-up-and-managing-your-github-profile/customizing-your-profile/personalizing-your-profile#list-of-qualifying-repositories-for-mars-2020-helicopter-contributor-achievement) for Mars missions since 2020! 22 | 23 | Its main goal is to help you to write **concise** and **correct** software without slowing down your code. 24 | 25 | 26 | ## Sponsors 27 | 28 | *attrs* would not be possible without our [amazing sponsors](https://github.com/sponsors/hynek). 29 | Especially those generously supporting us at the *The Organization* tier and higher: 30 | 31 | 32 | 33 |

34 | 35 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 |

51 | 52 | 53 | 54 |

55 | Please consider joining them to help make attrs’s maintenance more sustainable! 56 |

57 | 58 | 59 | 60 | ## Example 61 | 62 | *attrs* gives you a class decorator and a way to declaratively define the attributes on that class: 63 | 64 | 65 | 66 | ```pycon 67 | >>> from attrs import asdict, define, make_class, Factory 68 | 69 | >>> @define 70 | ... class SomeClass: 71 | ... a_number: int = 42 72 | ... list_of_numbers: list[int] = Factory(list) 73 | ... 74 | ... def hard_math(self, another_number): 75 | ... return self.a_number + sum(self.list_of_numbers) * another_number 76 | 77 | 78 | >>> sc = SomeClass(1, [1, 2, 3]) 79 | >>> sc 80 | SomeClass(a_number=1, list_of_numbers=[1, 2, 3]) 81 | 82 | >>> sc.hard_math(3) 83 | 19 84 | >>> sc == SomeClass(1, [1, 2, 3]) 85 | True 86 | >>> sc != SomeClass(2, [3, 2, 1]) 87 | True 88 | 89 | >>> asdict(sc) 90 | {'a_number': 1, 'list_of_numbers': [1, 2, 3]} 91 | 92 | >>> SomeClass() 93 | SomeClass(a_number=42, list_of_numbers=[]) 94 | 95 | >>> C = make_class("C", ["a", "b"]) 96 | >>> C("foo", "bar") 97 | C(a='foo', b='bar') 98 | ``` 99 | 100 | After *declaring* your attributes, *attrs* gives you: 101 | 102 | - a concise and explicit overview of the class's attributes, 103 | - a nice human-readable `__repr__`, 104 | - equality-checking methods, 105 | - an initializer, 106 | - and much more, 107 | 108 | *without* writing dull boilerplate code again and again and *without* runtime performance penalties. 109 | 110 | --- 111 | 112 | 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. 113 | The classic APIs (`@attr.s`, `attr.ib`, plus their serious-business aliases) and the `attr` package import name will remain **indefinitely**. 114 | 115 | Check out [*On The Core API Names*](https://www.attrs.org/en/latest/names.html) for an in-depth explanation! 116 | 117 | 118 | ### Hate Type Annotations!? 119 | 120 | No problem! 121 | Types are entirely **optional** with *attrs*. 122 | Simply assign `attrs.field()` to the attributes instead of annotating them with types: 123 | 124 | ```python 125 | from attrs import define, field 126 | 127 | @define 128 | class SomeClass: 129 | a_number = field(default=42) 130 | list_of_numbers = field(factory=list) 131 | ``` 132 | 133 | 134 | ## Data Classes 135 | 136 | On the tin, *attrs* might remind you of `dataclasses` (and indeed, `dataclasses` [are a descendant](https://hynek.me/articles/import-attrs/) of *attrs*). 137 | In practice it does a lot more and is more flexible. 138 | For instance, it allows you to define [special handling of NumPy arrays for equality checks](https://www.attrs.org/en/stable/comparison.html#customization), allows more ways to [plug into the initialization process](https://www.attrs.org/en/stable/init.html#hooking-yourself-into-initialization), has a replacement for `__init_subclass__`, and allows for stepping through the generated methods using a debugger. 139 | 140 | For more details, please refer to our [comparison page](https://www.attrs.org/en/stable/why.html#data-classes), but generally speaking, we are more likely to commit crimes against nature to make things work that one would expect to work, but that are quite complicated in practice. 141 | 142 | 143 | ## Project Information 144 | 145 | - [**Changelog**](https://www.attrs.org/en/stable/changelog.html) 146 | - [**Documentation**](https://www.attrs.org/) 147 | - [**PyPI**](https://pypi.org/project/attrs/) 148 | - [**Source Code**](https://github.com/python-attrs/attrs) 149 | - [**Contributing**](https://github.com/python-attrs/attrs/blob/main/.github/CONTRIBUTING.md) 150 | - [**Third-party Extensions**](https://github.com/python-attrs/attrs/wiki/Extensions-to-attrs) 151 | - **Get Help**: use the `python-attrs` tag on [Stack Overflow](https://stackoverflow.com/questions/tagged/python-attrs) 152 | 153 | 154 | ### *attrs* for Enterprise 155 | 156 | Available as part of the [Tidelift Subscription](https://tidelift.com/?utm_source=lifter&utm_medium=referral&utm_campaign=hynek). 157 | 158 | The maintainers of *attrs* and thousands of other packages are working with Tidelift to deliver commercial support and maintenance for the open source packages you use to build your applications. 159 | Save time, reduce risk, and improve code health, while paying the maintainers of the exact packages you use. 160 | -------------------------------------------------------------------------------- /bench/test_benchmarks.py: -------------------------------------------------------------------------------- 1 | """ 2 | Benchmark attrs using CodSpeed. 3 | """ 4 | 5 | from __future__ import annotations 6 | 7 | import pytest 8 | 9 | import attrs 10 | 11 | 12 | pytestmark = pytest.mark.benchmark() 13 | 14 | ROUNDS = 1_000 15 | 16 | 17 | def test_create_simple_class(): 18 | """ 19 | Benchmark creating a simple class without any extras. 20 | """ 21 | for _ in range(ROUNDS): 22 | 23 | @attrs.define 24 | class LocalC: 25 | x: int 26 | y: str 27 | z: dict[str, int] 28 | 29 | 30 | def test_create_frozen_class(): 31 | """ 32 | Benchmark creating a frozen class without any extras. 33 | """ 34 | for _ in range(ROUNDS): 35 | 36 | @attrs.frozen 37 | class LocalC: 38 | x: int 39 | y: str 40 | z: dict[str, int] 41 | 42 | LocalC(1, "2", {}) 43 | 44 | 45 | def test_create_simple_class_make_class(): 46 | """ 47 | Benchmark creating a simple class using attrs.make_class(). 48 | """ 49 | for i in range(ROUNDS): 50 | LocalC = attrs.make_class( 51 | f"LocalC{i}", 52 | { 53 | "x": attrs.field(type=int), 54 | "y": attrs.field(type=str), 55 | "z": attrs.field(type=dict[str, int]), 56 | }, 57 | ) 58 | 59 | LocalC(1, "2", {}) 60 | 61 | 62 | @attrs.define 63 | class C: 64 | x: int = 0 65 | y: str = "foo" 66 | z: dict[str, int] = attrs.Factory(dict) 67 | 68 | 69 | def test_instantiate_no_defaults(): 70 | """ 71 | Benchmark instantiating a class without using any defaults. 72 | """ 73 | for _ in range(ROUNDS): 74 | C(1, "2", {}) 75 | 76 | 77 | def test_instantiate_with_defaults(): 78 | """ 79 | Benchmark instantiating a class relying on defaults. 80 | """ 81 | for _ in range(ROUNDS): 82 | C() 83 | 84 | 85 | def test_eq_equal(): 86 | """ 87 | Benchmark comparing two equal instances for equality. 88 | """ 89 | c1 = C() 90 | c2 = C() 91 | 92 | for _ in range(ROUNDS): 93 | c1 == c2 94 | 95 | 96 | def test_eq_unequal(): 97 | """ 98 | Benchmark comparing two unequal instances for equality. 99 | """ 100 | c1 = C() 101 | c2 = C(1, "bar", {"baz": 42}) 102 | 103 | for _ in range(ROUNDS): 104 | c1 == c2 105 | 106 | 107 | @attrs.frozen 108 | class HashableC: 109 | x: int = 0 110 | y: str = "foo" 111 | z: tuple[str] = ("bar",) 112 | 113 | 114 | def test_hash(): 115 | """ 116 | Benchmark hashing an instance. 117 | """ 118 | c = HashableC() 119 | 120 | for _ in range(ROUNDS): 121 | hash(c) 122 | -------------------------------------------------------------------------------- /changelog.d/towncrier_template.md.jinja: -------------------------------------------------------------------------------- 1 | {%- if versiondata["version"] == "main" -%} 2 | ## Changes for the Upcoming Release 3 | 4 | :::{warning} 5 | These changes reflect the current [development progress](https://github.com/python-attrs/attrs/tree/main) and have **not** been part of a PyPI release yet. 6 | ::: 7 | {% else -%} 8 | ## [{{ versiondata["version"] }}](https://github.com/python-attrs/attrs/tree/{{ versiondata["version"] }}) - {{ versiondata["date"] }} 9 | {%- endif %} 10 | 11 | {% for section, _ in sections.items() %} 12 | {% if sections[section] %} 13 | {% for category, val in definitions.items() if category in sections[section] %} 14 | 15 | ### {{ definitions[category]['name'] }} 16 | 17 | {% for text, values in sections[section][category].items() %} 18 | - {{ text }} 19 | {{ values|join(',\n ') }} 20 | {% endfor %} 21 | 22 | {% endfor %} 23 | {% else %} 24 | No significant changes. 25 | 26 | 27 | {% endif %} 28 | 29 | {% endfor %} 30 | -------------------------------------------------------------------------------- /conftest.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: MIT 2 | 3 | from datetime import timedelta 4 | 5 | import pytest 6 | 7 | from hypothesis import HealthCheck, settings 8 | 9 | from attr._compat import PY_3_10_PLUS 10 | 11 | 12 | @pytest.fixture(name="slots", params=(True, False)) 13 | def _slots(request): 14 | return request.param 15 | 16 | 17 | @pytest.fixture(name="frozen", params=(True, False)) 18 | def _frozen(request): 19 | return request.param 20 | 21 | 22 | def pytest_configure(config): 23 | # HealthCheck.too_slow causes more trouble than good -- especially in CIs. 24 | settings.register_profile( 25 | "patience", 26 | settings( 27 | suppress_health_check=[HealthCheck.too_slow], 28 | deadline=timedelta(milliseconds=400), 29 | ), 30 | ) 31 | settings.load_profile("patience") 32 | 33 | 34 | collect_ignore = [] 35 | if not PY_3_10_PLUS: 36 | collect_ignore.extend(["tests/test_pattern_matching.py"]) 37 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = sphinx-build 7 | PAPER = 8 | BUILDDIR = _build 9 | 10 | # User-friendly check for sphinx-build 11 | ifeq ($(shell which $(SPHINXBUILD) >/dev/null 2>&1; echo $$?), 1) 12 | $(error The '$(SPHINXBUILD)' command was not found. Make sure you have Sphinx installed, then set the SPHINXBUILD environment variable to point to the full path of the '$(SPHINXBUILD)' executable. Alternatively you can add the directory with the executable to your PATH. If you don't have Sphinx installed, grab it from http://sphinx-doc.org/) 13 | endif 14 | 15 | # Internal variables. 16 | PAPEROPT_a4 = -D latex_paper_size=a4 17 | PAPEROPT_letter = -D latex_paper_size=letter 18 | ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 19 | # the i18n builder cannot share the environment and doctrees with the others 20 | I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 21 | 22 | .PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest gettext 23 | 24 | help: 25 | @echo "Please use \`make ' where is one of" 26 | @echo " html to make standalone HTML files" 27 | @echo " dirhtml to make HTML files named index.html in directories" 28 | @echo " singlehtml to make a single large HTML file" 29 | @echo " pickle to make pickle files" 30 | @echo " json to make JSON files" 31 | @echo " htmlhelp to make HTML files and a HTML help project" 32 | @echo " qthelp to make HTML files and a qthelp project" 33 | @echo " devhelp to make HTML files and a Devhelp project" 34 | @echo " epub to make an epub" 35 | @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" 36 | @echo " latexpdf to make LaTeX files and run them through pdflatex" 37 | @echo " latexpdfja to make LaTeX files and run them through platex/dvipdfmx" 38 | @echo " text to make text files" 39 | @echo " man to make manual pages" 40 | @echo " texinfo to make Texinfo files" 41 | @echo " info to make Texinfo files and run them through makeinfo" 42 | @echo " gettext to make PO message catalogs" 43 | @echo " changes to make an overview of all changed/added/deprecated items" 44 | @echo " xml to make Docutils-native XML files" 45 | @echo " pseudoxml to make pseudoxml-XML files for display purposes" 46 | @echo " linkcheck to check all external links for integrity" 47 | @echo " doctest to run all doctests embedded in the documentation (if enabled)" 48 | 49 | clean: 50 | rm -rf $(BUILDDIR)/* 51 | 52 | html: 53 | $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html 54 | @echo 55 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." 56 | 57 | dirhtml: 58 | $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml 59 | @echo 60 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." 61 | 62 | singlehtml: 63 | $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml 64 | @echo 65 | @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." 66 | 67 | pickle: 68 | $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle 69 | @echo 70 | @echo "Build finished; now you can process the pickle files." 71 | 72 | json: 73 | $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json 74 | @echo 75 | @echo "Build finished; now you can process the JSON files." 76 | 77 | htmlhelp: 78 | $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp 79 | @echo 80 | @echo "Build finished; now you can run HTML Help Workshop with the" \ 81 | ".hhp project file in $(BUILDDIR)/htmlhelp." 82 | 83 | qthelp: 84 | $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp 85 | @echo 86 | @echo "Build finished; now you can run "qcollectiongenerator" with the" \ 87 | ".qhcp project file in $(BUILDDIR)/qthelp, like this:" 88 | @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/attrs.qhcp" 89 | @echo "To view the help file:" 90 | @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/attrs.qhc" 91 | 92 | devhelp: 93 | $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp 94 | @echo 95 | @echo "Build finished." 96 | @echo "To view the help file:" 97 | @echo "# mkdir -p $$HOME/.local/share/devhelp/attrs" 98 | @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/attrs" 99 | @echo "# devhelp" 100 | 101 | epub: 102 | $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub 103 | @echo 104 | @echo "Build finished. The epub file is in $(BUILDDIR)/epub." 105 | 106 | latex: 107 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 108 | @echo 109 | @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." 110 | @echo "Run \`make' in that directory to run these through (pdf)latex" \ 111 | "(use \`make latexpdf' here to do that automatically)." 112 | 113 | latexpdf: 114 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 115 | @echo "Running LaTeX files through pdflatex..." 116 | $(MAKE) -C $(BUILDDIR)/latex all-pdf 117 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 118 | 119 | latexpdfja: 120 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 121 | @echo "Running LaTeX files through platex and dvipdfmx..." 122 | $(MAKE) -C $(BUILDDIR)/latex all-pdf-ja 123 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 124 | 125 | text: 126 | $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text 127 | @echo 128 | @echo "Build finished. The text files are in $(BUILDDIR)/text." 129 | 130 | man: 131 | $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man 132 | @echo 133 | @echo "Build finished. The manual pages are in $(BUILDDIR)/man." 134 | 135 | texinfo: 136 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 137 | @echo 138 | @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo." 139 | @echo "Run \`make' in that directory to run these through makeinfo" \ 140 | "(use \`make info' here to do that automatically)." 141 | 142 | info: 143 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 144 | @echo "Running Texinfo files through makeinfo..." 145 | make -C $(BUILDDIR)/texinfo info 146 | @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo." 147 | 148 | gettext: 149 | $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale 150 | @echo 151 | @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale." 152 | 153 | changes: 154 | $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes 155 | @echo 156 | @echo "The overview file is in $(BUILDDIR)/changes." 157 | 158 | linkcheck: 159 | $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck 160 | @echo 161 | @echo "Link check complete; look for any errors in the above output " \ 162 | "or in $(BUILDDIR)/linkcheck/output.txt." 163 | 164 | doctest: 165 | $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest 166 | @echo "Testing of doctests in the sources finished, look at the " \ 167 | "results in $(BUILDDIR)/doctest/output.txt." 168 | 169 | xml: 170 | $(SPHINXBUILD) -b xml $(ALLSPHINXOPTS) $(BUILDDIR)/xml 171 | @echo 172 | @echo "Build finished. The XML files are in $(BUILDDIR)/xml." 173 | 174 | pseudoxml: 175 | $(SPHINXBUILD) -b pseudoxml $(ALLSPHINXOPTS) $(BUILDDIR)/pseudoxml 176 | @echo 177 | @echo "Build finished. The pseudo-XML files are in $(BUILDDIR)/pseudoxml." 178 | -------------------------------------------------------------------------------- /docs/_static/attrs_logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/python-attrs/attrs/a6ae894aad9bc09edc7cdad8c416898784ceec9b/docs/_static/attrs_logo.png -------------------------------------------------------------------------------- /docs/_static/attrs_logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /docs/_static/attrs_logo_white.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/python-attrs/attrs/a6ae894aad9bc09edc7cdad8c416898784ceec9b/docs/_static/attrs_logo_white.png -------------------------------------------------------------------------------- /docs/_static/attrs_logo_white.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /docs/_static/custom.css: -------------------------------------------------------------------------------- 1 | @import url('https://rsms.me/inter/inter.css'); 2 | @import url('https://assets.hynek.me/css/bm.css'); 3 | 4 | 5 | :root { 6 | font-feature-settings: 'liga' 1, 'calt' 1; /* fix for Chrome */ 7 | } 8 | @supports (font-variation-settings: normal) { 9 | :root { font-family: InterVariable, sans-serif; } 10 | } 11 | -------------------------------------------------------------------------------- /docs/_static/docset-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/python-attrs/attrs/a6ae894aad9bc09edc7cdad8c416898784ceec9b/docs/_static/docset-icon.png -------------------------------------------------------------------------------- /docs/_static/docset-icon@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/python-attrs/attrs/a6ae894aad9bc09edc7cdad8c416898784ceec9b/docs/_static/docset-icon@2x.png -------------------------------------------------------------------------------- /docs/_static/social card.afdesign: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/python-attrs/attrs/a6ae894aad9bc09edc7cdad8c416898784ceec9b/docs/_static/social card.afdesign -------------------------------------------------------------------------------- /docs/_static/social card.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/python-attrs/attrs/a6ae894aad9bc09edc7cdad8c416898784ceec9b/docs/_static/social card.png -------------------------------------------------------------------------------- /docs/_static/sponsors/FilePreviews.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /docs/_static/sponsors/Klaviyo.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /docs/_static/sponsors/Polar.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /docs/_static/sponsors/Tidelift.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /docs/_static/sponsors/Variomedia.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /docs/api-attr.rst: -------------------------------------------------------------------------------- 1 | API Reference for the ``attr`` Namespace 2 | ======================================== 3 | 4 | .. note:: 5 | 6 | These are the traditional APIs whose creation predates type annotations. 7 | They are **not** deprecated, but we suggest using the :mod:`attrs` namespace for new code, because they look nicer and have better defaults. 8 | 9 | See also :doc:`names`. 10 | 11 | .. module:: attr 12 | 13 | 14 | Core 15 | ---- 16 | 17 | .. 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, unsafe_hash=None) 18 | 19 | For example: 20 | 21 | .. doctest:: 22 | 23 | >>> import attr 24 | >>> @attr.s 25 | ... class C: 26 | ... _private = attr.ib() 27 | >>> C(private=42) 28 | C(_private=42) 29 | >>> class D: 30 | ... def __init__(self, x): 31 | ... self.x = x 32 | >>> D(1) 33 | 34 | >>> D = attr.s(these={"x": attr.ib()}, init=False)(D) 35 | >>> D(1) 36 | D(x=1) 37 | >>> @attr.s(auto_exc=True) 38 | ... class Error(Exception): 39 | ... x = attr.ib() 40 | ... y = attr.ib(default=42, init=False) 41 | >>> Error("foo") 42 | Error(x='foo', y=42) 43 | >>> raise Error("foo") 44 | Traceback (most recent call last): 45 | ... 46 | Error: ('foo', 42) 47 | >>> raise ValueError("foo", 42) # for comparison 48 | Traceback (most recent call last): 49 | ... 50 | ValueError: ('foo', 42) 51 | 52 | 53 | .. autofunction:: attr.ib 54 | 55 | .. note:: 56 | 57 | *attrs* also comes with a serious-business alias ``attr.attrib``. 58 | 59 | The object returned by `attr.ib` also allows for setting the default and the validator using decorators: 60 | 61 | .. doctest:: 62 | 63 | >>> @attr.s 64 | ... class C: 65 | ... x = attr.ib() 66 | ... y = attr.ib() 67 | ... @x.validator 68 | ... def _any_name_except_a_name_of_an_attribute(self, attribute, value): 69 | ... if value < 0: 70 | ... raise ValueError("x must be positive") 71 | ... @y.default 72 | ... def _any_name_except_a_name_of_an_attribute(self): 73 | ... return self.x + 1 74 | >>> C(1) 75 | C(x=1, y=2) 76 | >>> C(-1) 77 | Traceback (most recent call last): 78 | ... 79 | ValueError: x must be positive 80 | 81 | .. function:: attrs 82 | 83 | Serious business alias for `attr.s`. 84 | 85 | .. function:: define 86 | 87 | Same as `attrs.define`. 88 | 89 | .. function:: mutable 90 | 91 | Same as `attrs.mutable`. 92 | 93 | .. function:: frozen 94 | 95 | Same as `attrs.frozen`. 96 | 97 | .. function:: field 98 | 99 | Same as `attrs.field`. 100 | 101 | .. class:: Attribute 102 | 103 | Same as `attrs.Attribute`. 104 | 105 | .. function:: make_class 106 | 107 | Same as `attrs.make_class`. 108 | 109 | .. autoclass:: Factory 110 | :noindex: 111 | 112 | Same as `attrs.Factory`. 113 | 114 | 115 | .. data:: NOTHING 116 | 117 | Same as `attrs.NOTHING`. 118 | 119 | 120 | Exceptions 121 | ---------- 122 | 123 | .. module:: attr.exceptions 124 | 125 | All exceptions are available from both ``attr.exceptions`` and `attrs.exceptions` (it's the same module in a different namespace). 126 | 127 | Please refer to `attrs.exceptions` for details. 128 | 129 | 130 | Helpers 131 | ------- 132 | 133 | .. currentmodule:: attr 134 | 135 | .. function:: cmp_using 136 | 137 | Same as `attrs.cmp_using`. 138 | 139 | .. function:: fields 140 | 141 | Same as `attrs.fields`. 142 | 143 | .. function:: fields_dict 144 | 145 | Same as `attrs.fields_dict`. 146 | 147 | .. function:: has 148 | 149 | Same as `attrs.has`. 150 | 151 | .. function:: resolve_types 152 | 153 | Same as `attrs.resolve_types`. 154 | 155 | .. autofunction:: asdict 156 | .. autofunction:: astuple 157 | 158 | .. module:: attr.filters 159 | 160 | .. function:: include 161 | 162 | Same as `attrs.filters.include`. 163 | 164 | .. function:: exclude 165 | 166 | Same as `attrs.filters.exclude`. 167 | 168 | See :func:`attrs.asdict` for examples. 169 | 170 | All objects from `attrs.filters` are also available in ``attr.filters``. 171 | 172 | ---- 173 | 174 | .. currentmodule:: attr 175 | 176 | .. function:: evolve 177 | 178 | Same as `attrs.evolve`. 179 | 180 | .. function:: validate 181 | 182 | Same as `attrs.validate`. 183 | 184 | 185 | Validators 186 | ---------- 187 | 188 | .. module:: attr.validators 189 | 190 | All objects from `attrs.validators` are also available in ``attr.validators``. 191 | Please refer to the former for details. 192 | 193 | 194 | Converters 195 | ---------- 196 | 197 | .. module:: attr.converters 198 | 199 | All objects from `attrs.converters` are also available from ``attr.converters``. 200 | Please refer to the former for details. 201 | 202 | 203 | Setters 204 | ------- 205 | 206 | .. module:: attr.setters 207 | 208 | All objects from `attrs.setters` are also available in ``attr.setters``. 209 | Please refer to the former for details. 210 | 211 | 212 | Deprecated APIs 213 | --------------- 214 | 215 | .. currentmodule:: attr 216 | 217 | To help you write backward compatible code that doesn't throw warnings on modern releases, the ``attr`` module has an ``__version_info__`` attribute as of version 19.2.0. 218 | It behaves similarly to `sys.version_info` and is an instance of `attr.VersionInfo`: 219 | 220 | .. autoclass:: VersionInfo 221 | 222 | With its help you can write code like this: 223 | 224 | >>> if getattr(attr, "__version_info__", (0,)) >= (19, 2): 225 | ... cmp_off = {"eq": False} 226 | ... else: 227 | ... cmp_off = {"cmp": False} 228 | >>> cmp_off == {"eq": False} 229 | True 230 | >>> @attr.s(**cmp_off) 231 | ... class C: 232 | ... pass 233 | 234 | 235 | ---- 236 | 237 | .. autofunction:: assoc 238 | 239 | Before *attrs* got `attrs.validators.set_disabled` and `attrs.validators.set_disabled`, it had the following APIs to globally enable and disable validators. 240 | They won't be removed, but are discouraged to use: 241 | 242 | .. autofunction:: set_run_validators 243 | .. autofunction:: get_run_validators 244 | 245 | ---- 246 | 247 | The serious-business aliases used to be called ``attr.attributes`` and ``attr.attr``. 248 | There are no plans to remove them but they shouldn't be used in new code. 249 | -------------------------------------------------------------------------------- /docs/changelog.md: -------------------------------------------------------------------------------- 1 | ```{include} ../CHANGELOG.md 2 | :end-before: Changes for the upcoming release can be found 3 | ``` 4 | 5 | ```{towncrier-draft-entries} 6 | main 7 | ``` 8 | 9 | ```{include} ../CHANGELOG.md 10 | :start-after: towncrier release notes start --> 11 | ``` 12 | -------------------------------------------------------------------------------- /docs/comparison.md: -------------------------------------------------------------------------------- 1 | # Comparison 2 | 3 | By default, two instances of *attrs* classes are equal if they have the same type and all their fields are equal. 4 | For that, *attrs* writes `__eq__` and `__ne__` methods for you. 5 | 6 | Additionally, if you pass `order=True`, *attrs* will also create a complete set of ordering methods: `__le__`, `__lt__`, `__ge__`, and `__gt__`. 7 | 8 | For equality, *attrs* will generate a statement comparing the types of both instances, 9 | and then comparing each attribute in turn using `==`. 10 | 11 | For order, *attrs* will: 12 | 13 | - Check if the types of the instances you're comparing are equal, 14 | - if so, create a tuple of all field values for each instance, 15 | - and finally perform the desired comparison operation on those tuples. 16 | 17 | (custom-comparison)= 18 | 19 | ## Customization 20 | 21 | As with other features, you can exclude fields from being involved in comparison operations: 22 | 23 | ```{doctest} 24 | >>> from attrs import define, field 25 | >>> @define 26 | ... class C: 27 | ... x: int 28 | ... y: int = field(eq=False) 29 | 30 | >>> C(1, 2) == C(1, 3) 31 | True 32 | ``` 33 | 34 | Additionally you can also pass a *callable* instead of a bool to both *eq* and *order*. 35 | It is then used as a key function like you may know from {func}`sorted`: 36 | 37 | ```{doctest} 38 | >>> @define 39 | ... class S: 40 | ... x: str = field(eq=str.lower) 41 | 42 | >>> S("foo") == S("FOO") 43 | True 44 | 45 | >>> @define(order=True) 46 | ... class C: 47 | ... x: str = field(order=int) 48 | 49 | >>> C("10") > C("2") 50 | True 51 | ``` 52 | 53 | This is especially useful when you have fields with objects that have atypical comparison properties. 54 | Common examples of such objects are [NumPy arrays](https://github.com/python-attrs/attrs/issues/435). 55 | 56 | To save you unnecessary boilerplate, *attrs* comes with the {func}`attrs.cmp_using` helper to create such functions. 57 | For NumPy arrays it would look like this: 58 | 59 | ```python 60 | import numpy 61 | 62 | @define 63 | class C: 64 | an_array = field(eq=attrs.cmp_using(eq=numpy.array_equal)) 65 | ``` 66 | 67 | :::{warning} 68 | Please note that *eq* and *order* are set *independently*, because *order* is `False` by default in {func}`~attrs.define` (but not in {func}`attr.s`). 69 | You can set both at once by using the *cmp* argument that we've undeprecated just for this use-case. 70 | ::: 71 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: MIT 2 | 3 | import os 4 | 5 | from importlib import metadata 6 | from pathlib import Path 7 | 8 | 9 | # Set canonical URL from the Read the Docs Domain 10 | html_baseurl = os.environ.get("READTHEDOCS_CANONICAL_URL", "") 11 | 12 | # Tell Jinja2 templates the build is running on Read the Docs 13 | if os.environ.get("READTHEDOCS", "") == "True": 14 | html_context = {"READTHEDOCS": True} 15 | 16 | 17 | # -- Path setup ----------------------------------------------------------- 18 | 19 | PROJECT_ROOT_DIR = Path(__file__).parents[1].resolve() 20 | 21 | 22 | # -- General configuration ------------------------------------------------ 23 | 24 | doctest_global_setup = """ 25 | from attr import define, frozen, field, validators, Factory 26 | """ 27 | 28 | linkcheck_ignore = [ 29 | # Fastly blocks this. 30 | "https://pypi.org/project/attr/#history", 31 | # We run into GitHub's rate limits. 32 | r"https://github.com/.*/(issues|pull)/\d+", 33 | # Rate limits and the latest tag is missing anyways on release. 34 | "https://github.com/python-attrs/attrs/tree/.*", 35 | ] 36 | 37 | linkcheck_anchors_ignore_for_url = [ 38 | r"^https?://(www\.)?github\.com/.*", 39 | ] 40 | 41 | # In nitpick mode (-n), still ignore any of the following "broken" references 42 | # to non-types. 43 | nitpick_ignore = [ 44 | ("py:class", "Any value"), 45 | ("py:class", "callable"), 46 | ("py:class", "callables"), 47 | ("py:class", "tuple of types"), 48 | ] 49 | 50 | # Add any Sphinx extension module names here, as strings. They can be 51 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 52 | # ones. 53 | extensions = [ 54 | "myst_parser", 55 | "sphinx.ext.napoleon", 56 | "sphinx.ext.autodoc", 57 | "sphinx.ext.doctest", 58 | "sphinx.ext.intersphinx", 59 | "sphinx.ext.todo", 60 | "notfound.extension", 61 | "sphinxcontrib.towncrier", 62 | ] 63 | 64 | myst_enable_extensions = [ 65 | "colon_fence", 66 | "smartquotes", 67 | "deflist", 68 | ] 69 | 70 | # Add any paths that contain templates here, relative to this directory. 71 | templates_path = ["_templates"] 72 | 73 | # The suffix of source filenames. 74 | source_suffix = ".rst" 75 | 76 | # The master toctree document. 77 | master_doc = "index" 78 | 79 | # General information about the project. 80 | project = "attrs" 81 | author = "Hynek Schlawack" 82 | copyright = f"2015, {author}" 83 | 84 | # The version info for the project you're documenting, acts as replacement for 85 | # |version| and |release|, also used in various other places throughout the 86 | # built documents. 87 | 88 | # The full version, including alpha/beta/rc tags. 89 | release = metadata.version("attrs") 90 | if "dev" in release: 91 | release = version = "UNRELEASED" 92 | else: 93 | # The short X.Y version. 94 | version = release.rsplit(".", 1)[0] 95 | 96 | # List of patterns, relative to source directory, that match files and 97 | # directories to ignore when looking for source files. 98 | exclude_patterns = ["_build"] 99 | 100 | # The reST default role (used for this markup: `text`) to use for all 101 | # documents. 102 | default_role = "any" 103 | 104 | # If true, '()' will be appended to :func: etc. cross-reference text. 105 | add_function_parentheses = True 106 | 107 | # -- Options for HTML output ---------------------------------------------- 108 | 109 | # The theme to use for HTML and HTML Help pages. See the documentation for 110 | # a list of builtin themes. 111 | 112 | html_theme = "furo" 113 | html_theme_options = { 114 | "sidebar_hide_name": True, 115 | "light_logo": "attrs_logo.svg", 116 | "dark_logo": "attrs_logo_white.svg", 117 | "top_of_page_buttons": [], 118 | "light_css_variables": { 119 | "font-stack": "Inter,sans-serif", 120 | "font-stack--monospace": "BerkeleyMono, MonoLisa, ui-monospace, " 121 | "SFMono-Regular, Menlo, Consolas, Liberation Mono, monospace", 122 | }, 123 | } 124 | html_css_files = ["custom.css"] 125 | 126 | 127 | # The name of an image file (within the static path) to use as favicon of the 128 | # docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 129 | # pixels large. 130 | # html_favicon = None 131 | 132 | # Add any paths that contain custom static files (such as style sheets) here, 133 | # relative to this directory. They are copied after the builtin static files, 134 | # so a file named "default.css" will overwrite the builtin "default.css". 135 | html_static_path = ["_static"] 136 | 137 | # If false, no module index is generated. 138 | html_domain_indices = True 139 | 140 | # If false, no index is generated. 141 | html_use_index = True 142 | 143 | # If true, the index is split into individual pages for each letter. 144 | html_split_index = False 145 | 146 | # If true, links to the reST sources are added to the pages. 147 | html_show_sourcelink = False 148 | 149 | # If true, "Created using Sphinx" is shown in the HTML footer. Default is True. 150 | html_show_sphinx = True 151 | 152 | # If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. 153 | html_show_copyright = True 154 | 155 | # If true, an OpenSearch description file will be output, and all pages will 156 | # contain a tag referring to it. The value of this option must be the 157 | # base URL from which the finished HTML is served. 158 | # html_use_opensearch = '' 159 | 160 | # Output file base name for HTML help builder. 161 | htmlhelp_basename = "attrsdoc" 162 | 163 | # -- Options for manual page output --------------------------------------- 164 | 165 | # One entry per manual page. List of tuples 166 | # (source start file, name, description, authors, manual section). 167 | man_pages = [("index", "attrs", "attrs Documentation", ["Hynek Schlawack"], 1)] 168 | 169 | 170 | # -- Options for Texinfo output ------------------------------------------- 171 | 172 | # Grouping the document tree into Texinfo files. List of tuples 173 | # (source start file, target name, title, author, 174 | # dir menu entry, description, category) 175 | texinfo_documents = [ 176 | ( 177 | "index", 178 | "attrs", 179 | "attrs Documentation", 180 | "Hynek Schlawack", 181 | "attrs", 182 | "Python Classes Without Boilerplate", 183 | "Miscellaneous", 184 | ) 185 | ] 186 | 187 | epub_description = "Python Classes Without Boilerplate" 188 | 189 | intersphinx_mapping = {"python": ("https://docs.python.org/3", None)} 190 | 191 | # Allow non-local URIs so we can have images in CHANGELOG etc. 192 | suppress_warnings = ["image.nonlocal_uri"] 193 | 194 | 195 | # -- Options for sphinxcontrib.towncrier extension ------------------------ 196 | 197 | towncrier_draft_autoversion_mode = "draft" 198 | towncrier_draft_include_empty = True 199 | towncrier_draft_working_directory = PROJECT_ROOT_DIR 200 | towncrier_draft_config_path = "pyproject.toml" 201 | -------------------------------------------------------------------------------- /docs/glossary.md: -------------------------------------------------------------------------------- 1 | # Glossary 2 | 3 | :::{glossary} 4 | dunder methods 5 | "Dunder" is a contraction of "double underscore". 6 | 7 | It's methods like `__init__` or `__eq__` that are sometimes also called *magic methods* or it's said that they implement an *object protocol*. 8 | 9 | In spoken form, you'd call `__init__` just "dunder init". 10 | 11 | Its first documented use is a [mailing list posting](https://mail.python.org/pipermail/python-list/2002-September/155836.html) by Mark Jackson from 2002. 12 | 13 | 14 | dict classes 15 | A regular class whose attributes are stored in the {attr}`object.__dict__` attribute of every single instance. 16 | 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. 17 | 18 | This is the type of class you get by default both with and without *attrs* (except with the next APIs {func}`attrs.define()`, [`attrs.mutable()`](attrs.mutable), and [`attrs.frozen()`](attrs.frozen)). 19 | 20 | 21 | slotted classes 22 | A class whose instances have no {attr}`object.__dict__` attribute and [define](https://docs.python.org/3/reference/datamodel.html#slots) their attributes in a `object.__slots__` attribute instead. 23 | In *attrs*, they are created by passing `slots=True` to `@attr.s` (and are on by default in {func}`attrs.define()`, [`attrs.mutable()`](attrs.mutable), and [`attrs.frozen()`](attrs.frozen)). 24 | 25 | Their main advantage is that they use less memory on CPython[^pypy] and are slightly faster. 26 | 27 | However, they also come with several possibly surprising gotchas: 28 | 29 | - Slotted classes don't allow for any other attribute to be set except for those defined in one of the class' hierarchies `__slots__`: 30 | 31 | ```{doctest} 32 | >>> from attr import define 33 | >>> @define 34 | ... class Coordinates: 35 | ... x: int 36 | ... y: int 37 | ... 38 | >>> c = Coordinates(x=1, y=2) 39 | >>> c.z = 3 40 | Traceback (most recent call last): 41 | ... 42 | AttributeError: 'Coordinates' object has no attribute 'z' 43 | ``` 44 | 45 | - Slotted classes can inherit from other classes just like non-slotted classes, but some of the benefits of slotted classes are lost if you do that. 46 | If you must inherit from other classes, try to inherit only from other slotted classes. 47 | 48 | - However, [it's not possible](https://docs.python.org/3/reference/datamodel.html#slots) to inherit from more than one class that has attributes in `__slots__` (you will get an `TypeError: multiple bases have instance lay-out conflict`). 49 | 50 | - It's not possible to monkeypatch methods on slotted classes. 51 | This can feel limiting in test code, however the need to monkeypatch your own classes is usually a design smell. 52 | 53 | If you really need to monkeypatch an instance in your tests, but don't want to give up on the advantages of slotted classes in production code, you can always subclass a slotted class as a dict class with no further changes and all the limitations go away: 54 | 55 | ```{doctest} 56 | >>> import unittest.mock 57 | >>> @define 58 | ... class Slotted: 59 | ... x: int 60 | ... 61 | ... def method(self): 62 | ... return self.x 63 | >>> s = Slotted(42) 64 | >>> s.method() 65 | 42 66 | >>> with unittest.mock.patch.object(s, "method", return_value=23): 67 | ... pass 68 | Traceback (most recent call last): 69 | ... 70 | AttributeError: 'Slotted' object attribute 'method' is read-only 71 | >>> @define(slots=False) 72 | ... class Dicted(Slotted): 73 | ... pass 74 | >>> d = Dicted(42) 75 | >>> d.method() 76 | 42 77 | >>> with unittest.mock.patch.object(d, "method", return_value=23): 78 | ... assert 23 == d.method() 79 | ``` 80 | 81 | - Slotted classes must implement {meth}`__getstate__ ` and {meth}`__setstate__ ` to be serializable with {mod}`pickle` protocol 0 and 1. 82 | Therefore, *attrs* creates these methods automatically for slotted classes. 83 | 84 | :::{note} 85 | When decorating with `@attr.s(slots=True)` and the class already implements the {meth}`__getstate__ ` and {meth}`__setstate__ ` methods, they will be *overwritten* by *attrs* autogenerated implementation by default. 86 | 87 | This can be avoided by setting `@attr.s(getstate_setstate=False)` or by setting `@attr.s(auto_detect=True)`. 88 | 89 | {func}`~attrs.define` sets `auto_detect=True` by default. 90 | ::: 91 | 92 | Also, [think twice](https://www.youtube.com/watch?v=7KnfGDajDQw) before using {mod}`pickle`. 93 | 94 | - Slotted classes are weak-referenceable by default. 95 | This can be disabled in CPython by passing `weakref_slot=False` to `@attr.s` [^pypyweakref]. 96 | 97 | - Since it's currently impossible to make a class slotted after it's been created, *attrs* has to replace your class with a new one. 98 | While it tries to do that as graciously as possible, certain metaclass features like {meth}`object.__init_subclass__` do not work with slotted classes. 99 | 100 | - The {attr}`type.__subclasses__` attribute needs a garbage collection run (which can be manually triggered using {func}`gc.collect`), for the original class to be removed. 101 | See issue [#407](https://github.com/python-attrs/attrs/issues/407) for more details. 102 | 103 | - Pickling of slotted classes will fail if you define a class with missing attributes. 104 | 105 | This situation can occur if you define an `attrs.field(init=False)` and don't set the attribute by hand before pickling. 106 | 107 | 108 | field 109 | As the project name suggests, *attrs* is all about attributes. 110 | We especially tried to emphasize that we only care about attributes and not about the classes themselves -- because we believe the class belongs to the user. 111 | 112 | This explains why the traditional API uses an {func}`attr.ib` (or ``attrib``) function to define attributes and we still use the term throughout the documentation. 113 | 114 | However, with the emergence of {mod}`dataclasses`, [Pydantic](https://docs.pydantic.dev/latest/concepts/fields/), and other libraries, the term "field" has become a common term for a predefined attribute on a class in the Python ecosystem. 115 | 116 | So with our new APIs, we've embraced it too by calling the function to create them {func}`attrs.field`, and use the term "field" throughout the documentation interchangeably. 117 | 118 | See also {doc}`names`. 119 | 120 | attribute 121 | See {term}`field`. 122 | ::: 123 | 124 | [^pypy]: On PyPy, there is no memory advantage in using slotted classes. 125 | 126 | [^pypyweakref]: On PyPy, slotted classes are naturally weak-referenceable so `weakref_slot=False` has no effect. 127 | -------------------------------------------------------------------------------- /docs/hashing.md: -------------------------------------------------------------------------------- 1 | # Hashing 2 | 3 | ## Hash Method Generation 4 | 5 | :::{warning} 6 | The overarching theme is to never set the `@attrs.define(unsafe_hash=X)` parameter yourself. 7 | Leave it at `None` which means that *attrs* will do the right thing for you, depending on the other parameters: 8 | 9 | - If you want to make objects hashable by value: use `@define(frozen=True)`. 10 | - If you want hashing and equality by object identity: use `@define(eq=False)` 11 | 12 | Setting `unsafe_hash` yourself can have unexpected consequences so we recommend to tinker with it only if you know exactly what you're doing. 13 | ::: 14 | 15 | Under certain circumstances, it's necessary for objects to be *hashable*. 16 | For example if you want to put them into a {class}`set` or if you want to use them as keys in a {class}`dict`. 17 | 18 | The *hash* of an object is an integer that represents the contents of an object. 19 | It can be obtained by calling {func}`hash` on an object and is implemented by writing a `__hash__` method for your class. 20 | 21 | *attrs* will happily write a `__hash__` method for you [^fn1], however it will *not* do so by default. 22 | Because according to the [definition](https://docs.python.org/3/glossary.html#term-hashable) from the official Python docs, the returned hash has to fulfill certain constraints: 23 | 24 | [^fn1]: The hash is computed by hashing a tuple that consists of a unique id for the class plus all attribute values. 25 | 26 | 1. Two objects that are equal, **must** have the same hash. 27 | This means that if `x == y`, it *must* follow that `hash(x) == hash(y)`. 28 | 29 | By default, Python classes are compared *and* hashed by their `id`. 30 | That means that every instance of a class has a different hash, no matter what attributes it carries. 31 | 32 | It follows that the moment you (or *attrs*) change the way equality is handled by implementing `__eq__` which is based on attribute values, this constraint is broken. 33 | For that reason Python 3 will make a class that has customized equality unhashable. 34 | Python 2 on the other hand will happily let you shoot your foot off. 35 | Unfortunately, *attrs* still mimics (otherwise unsupported) Python 2's behavior for backward-compatibility reasons if you set `unsafe_hash=False`. 36 | 37 | The *correct way* to achieve hashing by id is to set `@define(eq=False)`. 38 | Setting `@define(unsafe_hash=False)` (which implies `eq=True`) is almost certainly a *bug*. 39 | 40 | :::{warning} 41 | Be careful when subclassing! 42 | Setting `eq=False` on a class whose base class has a non-default `__hash__` method will *not* make *attrs* remove that `__hash__` for you. 43 | 44 | It is part of *attrs*'s philosophy to only *add* to classes so you have the freedom to customize your classes as you wish. 45 | So if you want to *get rid* of methods, you'll have to do it by hand. 46 | 47 | The easiest way to reset `__hash__` on a class is adding `__hash__ = object.__hash__` in the class body. 48 | ::: 49 | 50 | 2. If two objects are not equal, their hash **should** be different. 51 | 52 | While this isn't a requirement from a standpoint of correctness, sets and dicts become less effective if there are a lot of identical hashes. 53 | The worst case is when all objects have the same hash which turns a set into a list. 54 | 55 | 3. The hash of an object **must not** change. 56 | 57 | If you create a class with `@define(frozen=True)` this is fulfilled by definition, therefore *attrs* will write a `__hash__` function for you automatically. 58 | You can also force it to write one with `unsafe_hash=True` but then it's *your* responsibility to make sure that the object is not mutated. 59 | 60 | This point is the reason why mutable structures like lists, dictionaries, or sets aren't hashable while immutable ones like tuples or `frozenset`s are: 61 | point 1 and 2 require that the hash changes with the contents but point 3 forbids it. 62 | 63 | For a more thorough explanation of this topic, please refer to this blog post: [*Python Hashes and Equality*](https://hynek.me/articles/hashes-and-equality/). 64 | 65 | :::{note} 66 | Please note that the `unsafe_hash` argument's original name was `hash` but was changed to conform with {pep}`681` in 22.2.0. 67 | The old argument name is still around and will **not** be removed -- but setting `unsafe_hash` takes precedence over `hash`. 68 | The field-level argument is still called `hash` and will remain so. 69 | ::: 70 | 71 | 72 | ## Hashing and Mutability 73 | 74 | Changing any field involved in hash code computation after the first call to `__hash__` (typically this would be after its insertion into a hash-based collection) can result in silent bugs. 75 | Therefore, it is strongly recommended that hashable classes be `frozen`. 76 | Beware, however, that this is not a complete guarantee of safety: 77 | if a field points to an object and that object is mutated, the hash code may change, but `frozen` will not protect you. 78 | 79 | 80 | ## Hash Code Caching 81 | 82 | Some objects have hash codes which are expensive to compute. 83 | If such objects are to be stored in hash-based collections, it can be useful to compute the hash codes only once and then store the result on the object to make future hash code requests fast. 84 | To enable caching of hash codes, pass `@define(cache_hash=True)`. 85 | This may only be done if *attrs* is already generating a hash function for the object. 86 | -------------------------------------------------------------------------------- /docs/how-does-it-work.md: -------------------------------------------------------------------------------- 1 | (how)= 2 | 3 | # How Does It Work? 4 | 5 | ## Boilerplate 6 | 7 | *attrs* isn't the first library that aims to simplify class definition in Python. 8 | But its **declarative** approach combined with **no runtime overhead** lets it stand out. 9 | 10 | Once you apply the `@attrs.define` (or `@attr.s`) decorator to a class, *attrs* searches the class object for instances of `attr.ib`s. 11 | Internally they're a representation of the data passed into `attr.ib` along with a counter to preserve the order of the attributes. 12 | Alternatively, it's possible to define them using {doc}`types`. 13 | 14 | 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. 15 | Please note that *attrs* does *not* call `super()` *ever*. 16 | It will write {term}`dunder methods` to work on *all* of those attributes which also has performance benefits due to fewer function calls. 17 | 18 | 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`). 19 | 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. 20 | 21 | 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: 22 | 23 | ```{doctest} 24 | >>> import inspect 25 | >>> from attrs import define 26 | >>> @define 27 | ... class C: 28 | ... x: int 29 | >>> print(inspect.getsource(C.__init__)) 30 | def __init__(self, x): 31 | self.x = x 32 | 33 | ``` 34 | 35 | No magic, no meta programming, no expensive introspection at runtime. 36 | 37 | --- 38 | 39 | Everything until this point happens exactly *once* when the class is defined. 40 | As soon as a class is done, it's done. 41 | And it's just a regular Python class like any other, except for a single `__attrs_attrs__` attribute that *attrs* uses internally. 42 | Much of the information is accessible via {func}`attrs.fields` and other functions which can be used for introspection or for writing your own tools and decorators on top of *attrs* (like {func}`attrs.asdict`). 43 | 44 | And once you start instantiating your classes, *attrs* is out of your way completely. 45 | 46 | This **static** approach was very much a design goal of *attrs* and what I strongly believe makes it distinct. 47 | 48 | (how-frozen)= 49 | 50 | ## Immutability 51 | 52 | In order to give you immutability, *attrs* will attach a `__setattr__` method to your class that raises an {class}`attrs.exceptions.FrozenInstanceError` whenever anyone tries to set an attribute. 53 | 54 | The same is true if you choose to freeze individual attributes using the {obj}`attrs.setters.frozen` *on_setattr* hook -- except that the exception becomes {class}`attrs.exceptions.FrozenAttributeError`. 55 | 56 | Both exceptions subclass {class}`attrs.exceptions.FrozenError`. 57 | 58 | --- 59 | 60 | Depending on whether a class is a dict class or a slotted class, *attrs* uses a different technique to circumvent that limitation in the `__init__` method. 61 | 62 | Once constructed, frozen instances don't differ in any way from regular ones except that you cannot change its attributes. 63 | 64 | 65 | ### Dict Classes 66 | 67 | Dict classes -- that is: regular classes -- simply assign the value directly into the class' eponymous `__dict__` (and there's nothing we can do to stop the user to do the same). 68 | 69 | The performance impact is negligible. 70 | 71 | 72 | ### Slotted Classes 73 | 74 | Slotted classes are more complicated. 75 | Here it uses (an aggressively cached) {meth}`object.__setattr__` to set your attributes. 76 | This is (still) slower than a plain assignment: 77 | 78 | ```none 79 | $ pyperf timeit --rigorous \ 80 | -s "import attr; C = attr.make_class('C', ['x', 'y', 'z'], slots=True)" \ 81 | "C(1, 2, 3)" 82 | ......................................... 83 | Mean +- std dev: 228 ns +- 18 ns 84 | 85 | $ pyperf timeit --rigorous \ 86 | -s "import attr; C = attr.make_class('C', ['x', 'y', 'z'], slots=True, frozen=True)" \ 87 | "C(1, 2, 3)" 88 | ......................................... 89 | Mean +- std dev: 425 ns +- 16 ns 90 | ``` 91 | 92 | So on a laptop computer the difference is about 200 nanoseconds (1 second is 1,000,000,000 nanoseconds). 93 | It's certainly something you'll feel in a hot loop but shouldn't matter in normal code. 94 | Pick what's more important to you. 95 | 96 | ### Summary 97 | 98 | You should avoid instantiating lots of frozen slotted classes (meaning: `@frozen`) in performance-critical code. 99 | 100 | Frozen dict classes have barely a performance impact, unfrozen slotted classes are even *faster* than unfrozen dict classes (meaning: regular classes). 101 | 102 | 103 | (how-slotted-cached_property)= 104 | 105 | ## Cached Properties on Slotted Classes 106 | 107 | By default, the standard library {func}`functools.cached_property` decorator does not work on slotted classes, because it requires a `__dict__` to store the cached value. 108 | This could be surprising when using *attrs*, as slotted classes are the default. 109 | Therefore, *attrs* converts `cached_property`-decorated methods when constructing slotted classes. 110 | 111 | Getting this working is achieved by: 112 | 113 | * Adding names to `__slots__` for the wrapped methods. 114 | * Adding a `__getattr__` method to set values on the wrapped methods. 115 | 116 | For most users, this should mean that it works transparently. 117 | 118 | :::{note} 119 | The implementation does not guarantee that the wrapped method is called only once in multi-threaded usage. 120 | This matches the implementation of `cached_property` in Python 3.12. 121 | ::: 122 | -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | # *attrs*: Classes Without Boilerplate 2 | 3 | Release **{sub-ref}`release`** ([What's new?](changelog.md)) 4 | 5 | ```{include} ../README.md 6 | :start-after: 'teaser-begin -->' 7 | :end-before: ' 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | ```{include} ../README.md 28 | :start-after: 'sponsor-break-end -->' 29 | :end-before: '' 7 | :end-before: '### Hate Type Annotations!?' 8 | ``` 9 | 10 | 11 | ## Philosophy 12 | 13 | **It's about regular classes.** 14 | 15 | : *attrs* is for creating well-behaved classes with a type, attributes, methods, and everything that comes with a class. 16 | It can be used for data-only containers like `namedtuple`s or `types.SimpleNamespace` but they're just a sub-genre of what *attrs* is good for. 17 | 18 | 19 | **The class belongs to the users.** 20 | 21 | : You define a class and *attrs* adds static methods to that class based on the attributes you declare. 22 | The end. 23 | It doesn't add metaclasses. 24 | It doesn't add classes you've never heard of to your inheritance tree. 25 | An *attrs* class in runtime is indistinguishable from a regular class: because it *is* a regular class with a few boilerplate-y methods attached. 26 | 27 | 28 | **Be light on API impact.** 29 | 30 | : As convenient as it seems at first, *attrs* will *not* tack on any methods to your classes except for the {term}`dunder ones `. 31 | Hence all the useful [tools](helpers) that come with *attrs* live in functions that operate on top of instances. 32 | Since they take an *attrs* instance as their first argument, you can attach them to your classes with one line of code. 33 | 34 | 35 | **Performance matters.** 36 | 37 | : *attrs* runtime impact is very close to zero because all the work is done when the class is defined. 38 | Once you're instantiating it, *attrs* is out of the picture completely. 39 | 40 | 41 | **No surprises.** 42 | 43 | : *attrs* creates classes that arguably work the way a Python beginner would reasonably expect them to work. 44 | It doesn't try to guess what you mean because explicit is better than implicit. 45 | It doesn't try to be clever because software shouldn't be clever. 46 | 47 | Check out {doc}`how-does-it-work` if you'd like to know how it achieves all of the above. 48 | 49 | 50 | ## What *attrs* Is Not 51 | 52 | *attrs* does *not* invent some kind of magic system that pulls classes out of its hat using meta classes, runtime introspection, and shaky interdependencies. 53 | 54 | All *attrs* does is: 55 | 56 | 1. Take your declaration, 57 | 2. write {term}`dunder methods` based on that information, 58 | 3. and attach them to your class. 59 | 60 | It does *nothing* dynamic at runtime, hence zero runtime overhead. 61 | It's still *your* class. 62 | Do with it as you please. 63 | 64 | --- 65 | 66 | *attrs* also is *not* a fully-fledged serialization library. 67 | While it comes with features like converters and validators, it is meant to be a kit for building classes that you would write yourself – but with less boilerplate. 68 | If you look for powerful-yet-unintrusive serialization and validation for your *attrs* classes, have a look at our sibling project [*cattrs*](https://catt.rs/) or our [third-party extensions](https://github.com/python-attrs/attrs/wiki/Extensions-to-attrs). 69 | 70 | This separation of creating classes and serializing them is a conscious design decision. 71 | We don't think that your business model and your serialization format should be coupled. 72 | -------------------------------------------------------------------------------- /docs/types.md: -------------------------------------------------------------------------------- 1 | # Type Annotations 2 | 3 | *attrs* comes with first-class support for type annotations for both {pep}`526` and legacy syntax. 4 | 5 | However, they will remain *optional* forever, therefore the example from the README could also be written as: 6 | 7 | ```{doctest} 8 | >>> from attrs import define, field 9 | 10 | >>> @define 11 | ... class SomeClass: 12 | ... a_number = field(default=42) 13 | ... list_of_numbers = field(factory=list) 14 | 15 | >>> sc = SomeClass(1, [1, 2, 3]) 16 | >>> sc 17 | SomeClass(a_number=1, list_of_numbers=[1, 2, 3]) 18 | ``` 19 | 20 | You can choose freely between the approaches, but please remember that if you choose to use type annotations, you **must** annotate **all** attributes! 21 | 22 | :::{caution} 23 | If you define a class with a {func}`attrs.field` that **lacks** a type annotation, *attrs* will **ignore** other fields that have a type annotation, but are not defined using {func}`attrs.field`: 24 | 25 | ```{doctest} 26 | >>> @define 27 | ... class SomeClass: 28 | ... a_number = field(default=42) 29 | ... another_number: int = 23 30 | >>> SomeClass() 31 | SomeClass(a_number=42) 32 | ``` 33 | ::: 34 | 35 | Even when going all-in on type annotations, you will need {func}`attrs.field` for some advanced features, though. 36 | 37 | One of those features are the decorator-based features like defaults. 38 | It's important to remember that *attrs* doesn't do any magic behind your back. 39 | All the decorators are implemented using an object that is returned by the call to {func}`attrs.field`. 40 | 41 | Attributes that only carry a class annotation do not have that object so trying to call a method on it will inevitably fail. 42 | 43 | --- 44 | 45 | Please note that types -- regardless how added -- are *only metadata* that can be queried from the class and they aren't used for anything out of the box! 46 | 47 | Because Python does not allow references to a class object before the class is defined, 48 | types may be defined as string literals, so-called *forward references* ({pep}`526`). 49 | You can enable this automatically for a whole module by using `from __future__ import annotations` ({pep}`563`). 50 | In this case *attrs* simply puts these string literals into the `type` attributes. 51 | If you need to resolve these to real types, you can call {func}`attrs.resolve_types` which will update the attribute in place. 52 | 53 | In practice though, types show their biggest usefulness in combination with tools like [Mypy], [*pytype*], or [Pyright] that have dedicated support for *attrs* classes. 54 | 55 | The addition of static types is certainly one of the most exciting features in the Python ecosystem and helps you write *correct* and *verified self-documenting* code. 56 | 57 | 58 | ## Mypy 59 | 60 | While having a nice syntax for type metadata is great, it's even greater that [Mypy] ships with a dedicated *attrs* plugin which allows you to statically check your code. 61 | 62 | Imagine you add another line that tries to instantiate the defined class using `SomeClass("23")`. 63 | Mypy will catch that error for you: 64 | 65 | ```console 66 | $ mypy t.py 67 | t.py:12: error: Argument 1 to "SomeClass" has incompatible type "str"; expected "int" 68 | ``` 69 | 70 | This happens *without* running your code! 71 | 72 | And it also works with *both* legacy annotation styles. 73 | To Mypy, this code is equivalent to the one above: 74 | 75 | ```python 76 | @attr.s 77 | class SomeClass: 78 | a_number = attr.ib(default=42) # type: int 79 | list_of_numbers = attr.ib(factory=list, type=list[int]) 80 | ``` 81 | 82 | The approach used for `list_of_numbers` one is only a available in our [old-style API](names.md) which is why the example still uses it. 83 | 84 | 85 | ## Pyright 86 | 87 | *attrs* provides support for [Pyright] through the `dataclass_transform` / {pep}`681` specification. 88 | This provides static type inference for a subset of *attrs* equivalent to standard-library {mod}`dataclasses`, 89 | and requires explicit type annotations using the {func}`attrs.define` or `@attr.s(auto_attribs=True)` API. 90 | 91 | Given the following definition, Pyright will generate static type signatures for `SomeClass` attribute access, `__init__`, `__eq__`, and comparison methods: 92 | 93 | ``` 94 | @attrs.define 95 | class SomeClass: 96 | a_number: int = 42 97 | list_of_numbers: list[int] = attr.field(factory=list) 98 | ``` 99 | 100 | :::{warning} 101 | The Pyright inferred types are a tiny subset of those supported by Mypy, including: 102 | 103 | - The `attrs.frozen` decorator is not typed with frozen attributes, which are properly typed via `attrs.define(frozen=True)`. 104 | 105 | Your constructive feedback is welcome in both [attrs#795](https://github.com/python-attrs/attrs/issues/795) and [pyright#1782](https://github.com/microsoft/pyright/discussions/1782). 106 | Generally speaking, the decision on improving *attrs* support in Pyright is entirely Microsoft's prerogative and they unequivocally indicated that they'll only add support for features that go through the PEP process, though. 107 | ::: 108 | 109 | 110 | ## Class variables and constants 111 | 112 | If you are adding type annotations to all of your code, you might wonder how to define a class variable (as opposed to an instance variable), because a value assigned at class scope becomes a default for that attribute. 113 | The proper way to type such a class variable, though, is with {data}`typing.ClassVar`, which indicates that the variable should only be assigned in the class (or its subclasses) and not in instances of the class. 114 | *attrs* will skip over members annotated with {data}`typing.ClassVar`, allowing you to write a type annotation without turning the member into an attribute. 115 | Class variables are often used for constants, though they can also be used for mutable singleton data shared across all instances of the class. 116 | 117 | ``` 118 | @attrs.define 119 | class PngHeader: 120 | SIGNATURE: typing.ClassVar[bytes] = b'\x89PNG\r\n\x1a\n' 121 | height: int 122 | width: int 123 | interlaced: int = 0 124 | ... 125 | ``` 126 | 127 | [Mypy]: http://mypy-lang.org 128 | [Pyright]: https://github.com/microsoft/pyright 129 | [*pytype*]: https://google.github.io/pytype/ 130 | -------------------------------------------------------------------------------- /src/attr/__init__.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: MIT 2 | 3 | """ 4 | Classes Without Boilerplate 5 | """ 6 | 7 | from functools import partial 8 | from typing import Callable, Literal, Protocol 9 | 10 | from . import converters, exceptions, filters, setters, validators 11 | from ._cmp import cmp_using 12 | from ._config import get_run_validators, set_run_validators 13 | from ._funcs import asdict, assoc, astuple, has, resolve_types 14 | from ._make import ( 15 | NOTHING, 16 | Attribute, 17 | Converter, 18 | Factory, 19 | _Nothing, 20 | attrib, 21 | attrs, 22 | evolve, 23 | fields, 24 | fields_dict, 25 | make_class, 26 | validate, 27 | ) 28 | from ._next_gen import define, field, frozen, mutable 29 | from ._version_info import VersionInfo 30 | 31 | 32 | s = attributes = attrs 33 | ib = attr = attrib 34 | dataclass = partial(attrs, auto_attribs=True) # happy Easter ;) 35 | 36 | 37 | class AttrsInstance(Protocol): 38 | pass 39 | 40 | 41 | NothingType = Literal[_Nothing.NOTHING] 42 | 43 | __all__ = [ 44 | "NOTHING", 45 | "Attribute", 46 | "AttrsInstance", 47 | "Converter", 48 | "Factory", 49 | "NothingType", 50 | "asdict", 51 | "assoc", 52 | "astuple", 53 | "attr", 54 | "attrib", 55 | "attributes", 56 | "attrs", 57 | "cmp_using", 58 | "converters", 59 | "define", 60 | "evolve", 61 | "exceptions", 62 | "field", 63 | "fields", 64 | "fields_dict", 65 | "filters", 66 | "frozen", 67 | "get_run_validators", 68 | "has", 69 | "ib", 70 | "make_class", 71 | "mutable", 72 | "resolve_types", 73 | "s", 74 | "set_run_validators", 75 | "setters", 76 | "validate", 77 | "validators", 78 | ] 79 | 80 | 81 | def _make_getattr(mod_name: str) -> Callable: 82 | """ 83 | Create a metadata proxy for packaging information that uses *mod_name* in 84 | its warnings and errors. 85 | """ 86 | 87 | def __getattr__(name: str) -> str: 88 | if name not in ("__version__", "__version_info__"): 89 | msg = f"module {mod_name} has no attribute {name}" 90 | raise AttributeError(msg) 91 | 92 | from importlib.metadata import metadata 93 | 94 | meta = metadata("attrs") 95 | 96 | if name == "__version_info__": 97 | return VersionInfo._from_version_string(meta["version"]) 98 | 99 | return meta["version"] 100 | 101 | return __getattr__ 102 | 103 | 104 | __getattr__ = _make_getattr(__name__) 105 | -------------------------------------------------------------------------------- /src/attr/_cmp.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: MIT 2 | 3 | 4 | import functools 5 | import types 6 | 7 | from ._make import __ne__ 8 | 9 | 10 | _operation_names = {"eq": "==", "lt": "<", "le": "<=", "gt": ">", "ge": ">="} 11 | 12 | 13 | def cmp_using( 14 | eq=None, 15 | lt=None, 16 | le=None, 17 | gt=None, 18 | ge=None, 19 | require_same_type=True, 20 | class_name="Comparable", 21 | ): 22 | """ 23 | Create a class that can be passed into `attrs.field`'s ``eq``, ``order``, 24 | and ``cmp`` arguments to customize field comparison. 25 | 26 | The resulting class will have a full set of ordering methods if at least 27 | one of ``{lt, le, gt, ge}`` and ``eq`` are provided. 28 | 29 | Args: 30 | eq (typing.Callable | None): 31 | Callable used to evaluate equality of two objects. 32 | 33 | lt (typing.Callable | None): 34 | Callable used to evaluate whether one object is less than another 35 | object. 36 | 37 | le (typing.Callable | None): 38 | Callable used to evaluate whether one object is less than or equal 39 | to another object. 40 | 41 | gt (typing.Callable | None): 42 | Callable used to evaluate whether one object is greater than 43 | another object. 44 | 45 | ge (typing.Callable | None): 46 | Callable used to evaluate whether one object is greater than or 47 | equal to another object. 48 | 49 | require_same_type (bool): 50 | When `True`, equality and ordering methods will return 51 | `NotImplemented` if objects are not of the same type. 52 | 53 | class_name (str | None): Name of class. Defaults to "Comparable". 54 | 55 | See `comparison` for more details. 56 | 57 | .. versionadded:: 21.1.0 58 | """ 59 | 60 | body = { 61 | "__slots__": ["value"], 62 | "__init__": _make_init(), 63 | "_requirements": [], 64 | "_is_comparable_to": _is_comparable_to, 65 | } 66 | 67 | # Add operations. 68 | num_order_functions = 0 69 | has_eq_function = False 70 | 71 | if eq is not None: 72 | has_eq_function = True 73 | body["__eq__"] = _make_operator("eq", eq) 74 | body["__ne__"] = __ne__ 75 | 76 | if lt is not None: 77 | num_order_functions += 1 78 | body["__lt__"] = _make_operator("lt", lt) 79 | 80 | if le is not None: 81 | num_order_functions += 1 82 | body["__le__"] = _make_operator("le", le) 83 | 84 | if gt is not None: 85 | num_order_functions += 1 86 | body["__gt__"] = _make_operator("gt", gt) 87 | 88 | if ge is not None: 89 | num_order_functions += 1 90 | body["__ge__"] = _make_operator("ge", ge) 91 | 92 | type_ = types.new_class( 93 | class_name, (object,), {}, lambda ns: ns.update(body) 94 | ) 95 | 96 | # Add same type requirement. 97 | if require_same_type: 98 | type_._requirements.append(_check_same_type) 99 | 100 | # Add total ordering if at least one operation was defined. 101 | if 0 < num_order_functions < 4: 102 | if not has_eq_function: 103 | # functools.total_ordering requires __eq__ to be defined, 104 | # so raise early error here to keep a nice stack. 105 | msg = "eq must be define is order to complete ordering from lt, le, gt, ge." 106 | raise ValueError(msg) 107 | type_ = functools.total_ordering(type_) 108 | 109 | return type_ 110 | 111 | 112 | def _make_init(): 113 | """ 114 | Create __init__ method. 115 | """ 116 | 117 | def __init__(self, value): 118 | """ 119 | Initialize object with *value*. 120 | """ 121 | self.value = value 122 | 123 | return __init__ 124 | 125 | 126 | def _make_operator(name, func): 127 | """ 128 | Create operator method. 129 | """ 130 | 131 | def method(self, other): 132 | if not self._is_comparable_to(other): 133 | return NotImplemented 134 | 135 | result = func(self.value, other.value) 136 | if result is NotImplemented: 137 | return NotImplemented 138 | 139 | return result 140 | 141 | method.__name__ = f"__{name}__" 142 | method.__doc__ = ( 143 | f"Return a {_operation_names[name]} b. Computed by attrs." 144 | ) 145 | 146 | return method 147 | 148 | 149 | def _is_comparable_to(self, other): 150 | """ 151 | Check whether `other` is comparable to `self`. 152 | """ 153 | return all(func(self, other) for func in self._requirements) 154 | 155 | 156 | def _check_same_type(self, other): 157 | """ 158 | Return True if *self* and *other* are of the same type, False otherwise. 159 | """ 160 | return other.value.__class__ is self.value.__class__ 161 | -------------------------------------------------------------------------------- /src/attr/_cmp.pyi: -------------------------------------------------------------------------------- 1 | from typing import Any, Callable 2 | 3 | _CompareWithType = Callable[[Any, Any], bool] 4 | 5 | def cmp_using( 6 | eq: _CompareWithType | None = ..., 7 | lt: _CompareWithType | None = ..., 8 | le: _CompareWithType | None = ..., 9 | gt: _CompareWithType | None = ..., 10 | ge: _CompareWithType | None = ..., 11 | require_same_type: bool = ..., 12 | class_name: str = ..., 13 | ) -> type: ... 14 | -------------------------------------------------------------------------------- /src/attr/_compat.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: MIT 2 | 3 | import inspect 4 | import platform 5 | import sys 6 | import threading 7 | 8 | from collections.abc import Mapping, Sequence # noqa: F401 9 | from typing import _GenericAlias 10 | 11 | 12 | PYPY = platform.python_implementation() == "PyPy" 13 | PY_3_10_PLUS = sys.version_info[:2] >= (3, 10) 14 | PY_3_11_PLUS = sys.version_info[:2] >= (3, 11) 15 | PY_3_12_PLUS = sys.version_info[:2] >= (3, 12) 16 | PY_3_13_PLUS = sys.version_info[:2] >= (3, 13) 17 | PY_3_14_PLUS = sys.version_info[:2] >= (3, 14) 18 | 19 | 20 | if PY_3_14_PLUS: # pragma: no cover 21 | import annotationlib 22 | 23 | _get_annotations = annotationlib.get_annotations 24 | 25 | else: 26 | 27 | def _get_annotations(cls): 28 | """ 29 | Get annotations for *cls*. 30 | """ 31 | return cls.__dict__.get("__annotations__", {}) 32 | 33 | 34 | class _AnnotationExtractor: 35 | """ 36 | Extract type annotations from a callable, returning None whenever there 37 | is none. 38 | """ 39 | 40 | __slots__ = ["sig"] 41 | 42 | def __init__(self, callable): 43 | try: 44 | self.sig = inspect.signature(callable) 45 | except (ValueError, TypeError): # inspect failed 46 | self.sig = None 47 | 48 | def get_first_param_type(self): 49 | """ 50 | Return the type annotation of the first argument if it's not empty. 51 | """ 52 | if not self.sig: 53 | return None 54 | 55 | params = list(self.sig.parameters.values()) 56 | if params and params[0].annotation is not inspect.Parameter.empty: 57 | return params[0].annotation 58 | 59 | return None 60 | 61 | def get_return_type(self): 62 | """ 63 | Return the return type if it's not empty. 64 | """ 65 | if ( 66 | self.sig 67 | and self.sig.return_annotation is not inspect.Signature.empty 68 | ): 69 | return self.sig.return_annotation 70 | 71 | return None 72 | 73 | 74 | # Thread-local global to track attrs instances which are already being repr'd. 75 | # This is needed because there is no other (thread-safe) way to pass info 76 | # about the instances that are already being repr'd through the call stack 77 | # in order to ensure we don't perform infinite recursion. 78 | # 79 | # For instance, if an instance contains a dict which contains that instance, 80 | # we need to know that we're already repr'ing the outside instance from within 81 | # the dict's repr() call. 82 | # 83 | # This lives here rather than in _make.py so that the functions in _make.py 84 | # don't have a direct reference to the thread-local in their globals dict. 85 | # If they have such a reference, it breaks cloudpickle. 86 | repr_context = threading.local() 87 | 88 | 89 | def get_generic_base(cl): 90 | """If this is a generic class (A[str]), return the generic base for it.""" 91 | if cl.__class__ is _GenericAlias: 92 | return cl.__origin__ 93 | return None 94 | -------------------------------------------------------------------------------- /src/attr/_config.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: MIT 2 | 3 | __all__ = ["get_run_validators", "set_run_validators"] 4 | 5 | _run_validators = True 6 | 7 | 8 | def set_run_validators(run): 9 | """ 10 | Set whether or not validators are run. By default, they are run. 11 | 12 | .. deprecated:: 21.3.0 It will not be removed, but it also will not be 13 | moved to new ``attrs`` namespace. Use `attrs.validators.set_disabled()` 14 | instead. 15 | """ 16 | if not isinstance(run, bool): 17 | msg = "'run' must be bool." 18 | raise TypeError(msg) 19 | global _run_validators 20 | _run_validators = run 21 | 22 | 23 | def get_run_validators(): 24 | """ 25 | Return whether or not validators are run. 26 | 27 | .. deprecated:: 21.3.0 It will not be removed, but it also will not be 28 | moved to new ``attrs`` namespace. Use `attrs.validators.get_disabled()` 29 | instead. 30 | """ 31 | return _run_validators 32 | -------------------------------------------------------------------------------- /src/attr/_typing_compat.pyi: -------------------------------------------------------------------------------- 1 | from typing import Any, ClassVar, Protocol 2 | 3 | # MYPY is a special constant in mypy which works the same way as `TYPE_CHECKING`. 4 | MYPY = False 5 | 6 | if MYPY: 7 | # A protocol to be able to statically accept an attrs class. 8 | class AttrsInstance_(Protocol): 9 | __attrs_attrs__: ClassVar[Any] 10 | 11 | else: 12 | # For type checkers without plug-in support use an empty protocol that 13 | # will (hopefully) be combined into a union. 14 | class AttrsInstance_(Protocol): 15 | pass 16 | -------------------------------------------------------------------------------- /src/attr/_version_info.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: MIT 2 | 3 | 4 | from functools import total_ordering 5 | 6 | from ._funcs import astuple 7 | from ._make import attrib, attrs 8 | 9 | 10 | @total_ordering 11 | @attrs(eq=False, order=False, slots=True, frozen=True) 12 | class VersionInfo: 13 | """ 14 | A version object that can be compared to tuple of length 1--4: 15 | 16 | >>> attr.VersionInfo(19, 1, 0, "final") <= (19, 2) 17 | True 18 | >>> attr.VersionInfo(19, 1, 0, "final") < (19, 1, 1) 19 | True 20 | >>> vi = attr.VersionInfo(19, 2, 0, "final") 21 | >>> vi < (19, 1, 1) 22 | False 23 | >>> vi < (19,) 24 | False 25 | >>> vi == (19, 2,) 26 | True 27 | >>> vi == (19, 2, 1) 28 | False 29 | 30 | .. versionadded:: 19.2 31 | """ 32 | 33 | year = attrib(type=int) 34 | minor = attrib(type=int) 35 | micro = attrib(type=int) 36 | releaselevel = attrib(type=str) 37 | 38 | @classmethod 39 | def _from_version_string(cls, s): 40 | """ 41 | Parse *s* and return a _VersionInfo. 42 | """ 43 | v = s.split(".") 44 | if len(v) == 3: 45 | v.append("final") 46 | 47 | return cls( 48 | year=int(v[0]), minor=int(v[1]), micro=int(v[2]), releaselevel=v[3] 49 | ) 50 | 51 | def _ensure_tuple(self, other): 52 | """ 53 | Ensure *other* is a tuple of a valid length. 54 | 55 | Returns a possibly transformed *other* and ourselves as a tuple of 56 | the same length as *other*. 57 | """ 58 | 59 | if self.__class__ is other.__class__: 60 | other = astuple(other) 61 | 62 | if not isinstance(other, tuple): 63 | raise NotImplementedError 64 | 65 | if not (1 <= len(other) <= 4): 66 | raise NotImplementedError 67 | 68 | return astuple(self)[: len(other)], other 69 | 70 | def __eq__(self, other): 71 | try: 72 | us, them = self._ensure_tuple(other) 73 | except NotImplementedError: 74 | return NotImplemented 75 | 76 | return us == them 77 | 78 | def __lt__(self, other): 79 | try: 80 | us, them = self._ensure_tuple(other) 81 | except NotImplementedError: 82 | return NotImplemented 83 | 84 | # Since alphabetically "dev0" < "final" < "post1" < "post2", we don't 85 | # have to do anything special with releaselevel for now. 86 | return us < them 87 | -------------------------------------------------------------------------------- /src/attr/_version_info.pyi: -------------------------------------------------------------------------------- 1 | class VersionInfo: 2 | @property 3 | def year(self) -> int: ... 4 | @property 5 | def minor(self) -> int: ... 6 | @property 7 | def micro(self) -> int: ... 8 | @property 9 | def releaselevel(self) -> str: ... 10 | -------------------------------------------------------------------------------- /src/attr/converters.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: MIT 2 | 3 | """ 4 | Commonly useful converters. 5 | """ 6 | 7 | import typing 8 | 9 | from ._compat import _AnnotationExtractor 10 | from ._make import NOTHING, Converter, Factory, pipe 11 | 12 | 13 | __all__ = [ 14 | "default_if_none", 15 | "optional", 16 | "pipe", 17 | "to_bool", 18 | ] 19 | 20 | 21 | def optional(converter): 22 | """ 23 | A converter that allows an attribute to be optional. An optional attribute 24 | is one which can be set to `None`. 25 | 26 | Type annotations will be inferred from the wrapped converter's, if it has 27 | any. 28 | 29 | Args: 30 | converter (typing.Callable): 31 | the converter that is used for non-`None` values. 32 | 33 | .. versionadded:: 17.1.0 34 | """ 35 | 36 | if isinstance(converter, Converter): 37 | 38 | def optional_converter(val, inst, field): 39 | if val is None: 40 | return None 41 | return converter(val, inst, field) 42 | 43 | else: 44 | 45 | def optional_converter(val): 46 | if val is None: 47 | return None 48 | return converter(val) 49 | 50 | xtr = _AnnotationExtractor(converter) 51 | 52 | t = xtr.get_first_param_type() 53 | if t: 54 | optional_converter.__annotations__["val"] = typing.Optional[t] 55 | 56 | rt = xtr.get_return_type() 57 | if rt: 58 | optional_converter.__annotations__["return"] = typing.Optional[rt] 59 | 60 | if isinstance(converter, Converter): 61 | return Converter(optional_converter, takes_self=True, takes_field=True) 62 | 63 | return optional_converter 64 | 65 | 66 | def default_if_none(default=NOTHING, factory=None): 67 | """ 68 | A converter that allows to replace `None` values by *default* or the result 69 | of *factory*. 70 | 71 | Args: 72 | default: 73 | Value to be used if `None` is passed. Passing an instance of 74 | `attrs.Factory` is supported, however the ``takes_self`` option is 75 | *not*. 76 | 77 | factory (typing.Callable): 78 | A callable that takes no parameters whose result is used if `None` 79 | is passed. 80 | 81 | Raises: 82 | TypeError: If **neither** *default* or *factory* is passed. 83 | 84 | TypeError: If **both** *default* and *factory* are passed. 85 | 86 | ValueError: 87 | If an instance of `attrs.Factory` is passed with 88 | ``takes_self=True``. 89 | 90 | .. versionadded:: 18.2.0 91 | """ 92 | if default is NOTHING and factory is None: 93 | msg = "Must pass either `default` or `factory`." 94 | raise TypeError(msg) 95 | 96 | if default is not NOTHING and factory is not None: 97 | msg = "Must pass either `default` or `factory` but not both." 98 | raise TypeError(msg) 99 | 100 | if factory is not None: 101 | default = Factory(factory) 102 | 103 | if isinstance(default, Factory): 104 | if default.takes_self: 105 | msg = "`takes_self` is not supported by default_if_none." 106 | raise ValueError(msg) 107 | 108 | def default_if_none_converter(val): 109 | if val is not None: 110 | return val 111 | 112 | return default.factory() 113 | 114 | else: 115 | 116 | def default_if_none_converter(val): 117 | if val is not None: 118 | return val 119 | 120 | return default 121 | 122 | return default_if_none_converter 123 | 124 | 125 | def to_bool(val): 126 | """ 127 | Convert "boolean" strings (for example, from environment variables) to real 128 | booleans. 129 | 130 | Values mapping to `True`: 131 | 132 | - ``True`` 133 | - ``"true"`` / ``"t"`` 134 | - ``"yes"`` / ``"y"`` 135 | - ``"on"`` 136 | - ``"1"`` 137 | - ``1`` 138 | 139 | Values mapping to `False`: 140 | 141 | - ``False`` 142 | - ``"false"`` / ``"f"`` 143 | - ``"no"`` / ``"n"`` 144 | - ``"off"`` 145 | - ``"0"`` 146 | - ``0`` 147 | 148 | Raises: 149 | ValueError: For any other value. 150 | 151 | .. versionadded:: 21.3.0 152 | """ 153 | if isinstance(val, str): 154 | val = val.lower() 155 | 156 | if val in (True, "true", "t", "yes", "y", "on", "1", 1): 157 | return True 158 | if val in (False, "false", "f", "no", "n", "off", "0", 0): 159 | return False 160 | 161 | msg = f"Cannot convert value to bool: {val!r}" 162 | raise ValueError(msg) 163 | -------------------------------------------------------------------------------- /src/attr/converters.pyi: -------------------------------------------------------------------------------- 1 | from typing import Callable, Any, overload 2 | 3 | from attrs import _ConverterType, _CallableConverterType 4 | 5 | @overload 6 | def pipe(*validators: _CallableConverterType) -> _CallableConverterType: ... 7 | @overload 8 | def pipe(*validators: _ConverterType) -> _ConverterType: ... 9 | @overload 10 | def optional(converter: _CallableConverterType) -> _CallableConverterType: ... 11 | @overload 12 | def optional(converter: _ConverterType) -> _ConverterType: ... 13 | @overload 14 | def default_if_none(default: Any) -> _CallableConverterType: ... 15 | @overload 16 | def default_if_none( 17 | *, factory: Callable[[], Any] 18 | ) -> _CallableConverterType: ... 19 | def to_bool(val: str | int | bool) -> bool: ... 20 | -------------------------------------------------------------------------------- /src/attr/exceptions.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: MIT 2 | 3 | from __future__ import annotations 4 | 5 | from typing import ClassVar 6 | 7 | 8 | class FrozenError(AttributeError): 9 | """ 10 | A frozen/immutable instance or attribute have been attempted to be 11 | modified. 12 | 13 | It mirrors the behavior of ``namedtuples`` by using the same error message 14 | and subclassing `AttributeError`. 15 | 16 | .. versionadded:: 20.1.0 17 | """ 18 | 19 | msg = "can't set attribute" 20 | args: ClassVar[tuple[str]] = [msg] 21 | 22 | 23 | class FrozenInstanceError(FrozenError): 24 | """ 25 | A frozen instance has been attempted to be modified. 26 | 27 | .. versionadded:: 16.1.0 28 | """ 29 | 30 | 31 | class FrozenAttributeError(FrozenError): 32 | """ 33 | A frozen attribute has been attempted to be modified. 34 | 35 | .. versionadded:: 20.1.0 36 | """ 37 | 38 | 39 | class AttrsAttributeNotFoundError(ValueError): 40 | """ 41 | An *attrs* function couldn't find an attribute that the user asked for. 42 | 43 | .. versionadded:: 16.2.0 44 | """ 45 | 46 | 47 | class NotAnAttrsClassError(ValueError): 48 | """ 49 | A non-*attrs* class has been passed into an *attrs* function. 50 | 51 | .. versionadded:: 16.2.0 52 | """ 53 | 54 | 55 | class DefaultAlreadySetError(RuntimeError): 56 | """ 57 | A default has been set when defining the field and is attempted to be reset 58 | using the decorator. 59 | 60 | .. versionadded:: 17.1.0 61 | """ 62 | 63 | 64 | class UnannotatedAttributeError(RuntimeError): 65 | """ 66 | A class with ``auto_attribs=True`` has a field without a type annotation. 67 | 68 | .. versionadded:: 17.3.0 69 | """ 70 | 71 | 72 | class PythonTooOldError(RuntimeError): 73 | """ 74 | It was attempted to use an *attrs* feature that requires a newer Python 75 | version. 76 | 77 | .. versionadded:: 18.2.0 78 | """ 79 | 80 | 81 | class NotCallableError(TypeError): 82 | """ 83 | A field requiring a callable has been set with a value that is not 84 | callable. 85 | 86 | .. versionadded:: 19.2.0 87 | """ 88 | 89 | def __init__(self, msg, value): 90 | super(TypeError, self).__init__(msg, value) 91 | self.msg = msg 92 | self.value = value 93 | 94 | def __str__(self): 95 | return str(self.msg) 96 | -------------------------------------------------------------------------------- /src/attr/exceptions.pyi: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | 3 | class FrozenError(AttributeError): 4 | msg: str = ... 5 | 6 | class FrozenInstanceError(FrozenError): ... 7 | class FrozenAttributeError(FrozenError): ... 8 | class AttrsAttributeNotFoundError(ValueError): ... 9 | class NotAnAttrsClassError(ValueError): ... 10 | class DefaultAlreadySetError(RuntimeError): ... 11 | class UnannotatedAttributeError(RuntimeError): ... 12 | class PythonTooOldError(RuntimeError): ... 13 | 14 | class NotCallableError(TypeError): 15 | msg: str = ... 16 | value: Any = ... 17 | def __init__(self, msg: str, value: Any) -> None: ... 18 | -------------------------------------------------------------------------------- /src/attr/filters.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: MIT 2 | 3 | """ 4 | Commonly useful filters for `attrs.asdict` and `attrs.astuple`. 5 | """ 6 | 7 | from ._make import Attribute 8 | 9 | 10 | def _split_what(what): 11 | """ 12 | Returns a tuple of `frozenset`s of classes and attributes. 13 | """ 14 | return ( 15 | frozenset(cls for cls in what if isinstance(cls, type)), 16 | frozenset(cls for cls in what if isinstance(cls, str)), 17 | frozenset(cls for cls in what if isinstance(cls, Attribute)), 18 | ) 19 | 20 | 21 | def include(*what): 22 | """ 23 | Create a filter that only allows *what*. 24 | 25 | Args: 26 | what (list[type, str, attrs.Attribute]): 27 | What to include. Can be a type, a name, or an attribute. 28 | 29 | Returns: 30 | Callable: 31 | A callable that can be passed to `attrs.asdict`'s and 32 | `attrs.astuple`'s *filter* argument. 33 | 34 | .. versionchanged:: 23.1.0 Accept strings with field names. 35 | """ 36 | cls, names, attrs = _split_what(what) 37 | 38 | def include_(attribute, value): 39 | return ( 40 | value.__class__ in cls 41 | or attribute.name in names 42 | or attribute in attrs 43 | ) 44 | 45 | return include_ 46 | 47 | 48 | def exclude(*what): 49 | """ 50 | Create a filter that does **not** allow *what*. 51 | 52 | Args: 53 | what (list[type, str, attrs.Attribute]): 54 | What to exclude. Can be a type, a name, or an attribute. 55 | 56 | Returns: 57 | Callable: 58 | A callable that can be passed to `attrs.asdict`'s and 59 | `attrs.astuple`'s *filter* argument. 60 | 61 | .. versionchanged:: 23.3.0 Accept field name string as input argument 62 | """ 63 | cls, names, attrs = _split_what(what) 64 | 65 | def exclude_(attribute, value): 66 | return not ( 67 | value.__class__ in cls 68 | or attribute.name in names 69 | or attribute in attrs 70 | ) 71 | 72 | return exclude_ 73 | -------------------------------------------------------------------------------- /src/attr/filters.pyi: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | 3 | from . import Attribute, _FilterType 4 | 5 | def include(*what: type | str | Attribute[Any]) -> _FilterType[Any]: ... 6 | def exclude(*what: type | str | Attribute[Any]) -> _FilterType[Any]: ... 7 | -------------------------------------------------------------------------------- /src/attr/py.typed: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/python-attrs/attrs/a6ae894aad9bc09edc7cdad8c416898784ceec9b/src/attr/py.typed -------------------------------------------------------------------------------- /src/attr/setters.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: MIT 2 | 3 | """ 4 | Commonly used hooks for on_setattr. 5 | """ 6 | 7 | from . import _config 8 | from .exceptions import FrozenAttributeError 9 | 10 | 11 | def pipe(*setters): 12 | """ 13 | Run all *setters* and return the return value of the last one. 14 | 15 | .. versionadded:: 20.1.0 16 | """ 17 | 18 | def wrapped_pipe(instance, attrib, new_value): 19 | rv = new_value 20 | 21 | for setter in setters: 22 | rv = setter(instance, attrib, rv) 23 | 24 | return rv 25 | 26 | return wrapped_pipe 27 | 28 | 29 | def frozen(_, __, ___): 30 | """ 31 | Prevent an attribute to be modified. 32 | 33 | .. versionadded:: 20.1.0 34 | """ 35 | raise FrozenAttributeError 36 | 37 | 38 | def validate(instance, attrib, new_value): 39 | """ 40 | Run *attrib*'s validator on *new_value* if it has one. 41 | 42 | .. versionadded:: 20.1.0 43 | """ 44 | if _config._run_validators is False: 45 | return new_value 46 | 47 | v = attrib.validator 48 | if not v: 49 | return new_value 50 | 51 | v(instance, attrib, new_value) 52 | 53 | return new_value 54 | 55 | 56 | def convert(instance, attrib, new_value): 57 | """ 58 | Run *attrib*'s converter -- if it has one -- on *new_value* and return the 59 | result. 60 | 61 | .. versionadded:: 20.1.0 62 | """ 63 | c = attrib.converter 64 | if c: 65 | # This can be removed once we drop 3.8 and use attrs.Converter instead. 66 | from ._make import Converter 67 | 68 | if not isinstance(c, Converter): 69 | return c(new_value) 70 | 71 | return c(new_value, instance, attrib) 72 | 73 | return new_value 74 | 75 | 76 | # Sentinel for disabling class-wide *on_setattr* hooks for certain attributes. 77 | # Sphinx's autodata stopped working, so the docstring is inlined in the API 78 | # docs. 79 | NO_OP = object() 80 | -------------------------------------------------------------------------------- /src/attr/setters.pyi: -------------------------------------------------------------------------------- 1 | from typing import Any, NewType, NoReturn, TypeVar 2 | 3 | from . import Attribute 4 | from attrs import _OnSetAttrType 5 | 6 | _T = TypeVar("_T") 7 | 8 | def frozen( 9 | instance: Any, attribute: Attribute[Any], new_value: Any 10 | ) -> NoReturn: ... 11 | def pipe(*setters: _OnSetAttrType) -> _OnSetAttrType: ... 12 | def validate(instance: Any, attribute: Attribute[_T], new_value: _T) -> _T: ... 13 | 14 | # convert is allowed to return Any, because they can be chained using pipe. 15 | def convert( 16 | instance: Any, attribute: Attribute[Any], new_value: Any 17 | ) -> Any: ... 18 | 19 | _NoOpType = NewType("_NoOpType", object) 20 | NO_OP: _NoOpType 21 | -------------------------------------------------------------------------------- /src/attr/validators.pyi: -------------------------------------------------------------------------------- 1 | from types import UnionType 2 | from typing import ( 3 | Any, 4 | AnyStr, 5 | Callable, 6 | Container, 7 | ContextManager, 8 | Iterable, 9 | Mapping, 10 | Match, 11 | Pattern, 12 | TypeVar, 13 | overload, 14 | ) 15 | 16 | from attrs import _ValidatorType 17 | from attrs import _ValidatorArgType 18 | 19 | _T = TypeVar("_T") 20 | _T1 = TypeVar("_T1") 21 | _T2 = TypeVar("_T2") 22 | _T3 = TypeVar("_T3") 23 | _I = TypeVar("_I", bound=Iterable) 24 | _K = TypeVar("_K") 25 | _V = TypeVar("_V") 26 | _M = TypeVar("_M", bound=Mapping) 27 | 28 | def set_disabled(run: bool) -> None: ... 29 | def get_disabled() -> bool: ... 30 | def disabled() -> ContextManager[None]: ... 31 | 32 | # To be more precise on instance_of use some overloads. 33 | # If there are more than 3 items in the tuple then we fall back to Any 34 | @overload 35 | def instance_of(type: type[_T]) -> _ValidatorType[_T]: ... 36 | @overload 37 | def instance_of(type: tuple[type[_T]]) -> _ValidatorType[_T]: ... 38 | @overload 39 | def instance_of( 40 | type: tuple[type[_T1], type[_T2]], 41 | ) -> _ValidatorType[_T1 | _T2]: ... 42 | @overload 43 | def instance_of( 44 | type: tuple[type[_T1], type[_T2], type[_T3]], 45 | ) -> _ValidatorType[_T1 | _T2 | _T3]: ... 46 | @overload 47 | def instance_of(type: tuple[type, ...]) -> _ValidatorType[Any]: ... 48 | @overload 49 | def instance_of(type: UnionType) -> _ValidatorType[Any]: ... 50 | def optional( 51 | validator: ( 52 | _ValidatorType[_T] 53 | | list[_ValidatorType[_T]] 54 | | tuple[_ValidatorType[_T]] 55 | ), 56 | ) -> _ValidatorType[_T | None]: ... 57 | def in_(options: Container[_T]) -> _ValidatorType[_T]: ... 58 | def and_(*validators: _ValidatorType[_T]) -> _ValidatorType[_T]: ... 59 | def matches_re( 60 | regex: Pattern[AnyStr] | AnyStr, 61 | flags: int = ..., 62 | func: Callable[[AnyStr, AnyStr, int], Match[AnyStr] | None] | None = ..., 63 | ) -> _ValidatorType[AnyStr]: ... 64 | def deep_iterable( 65 | member_validator: _ValidatorArgType[_T], 66 | iterable_validator: _ValidatorType[_I] | None = ..., 67 | ) -> _ValidatorType[_I]: ... 68 | def deep_mapping( 69 | key_validator: _ValidatorType[_K], 70 | value_validator: _ValidatorType[_V], 71 | mapping_validator: _ValidatorType[_M] | None = ..., 72 | ) -> _ValidatorType[_M]: ... 73 | def is_callable() -> _ValidatorType[_T]: ... 74 | def lt(val: _T) -> _ValidatorType[_T]: ... 75 | def le(val: _T) -> _ValidatorType[_T]: ... 76 | def ge(val: _T) -> _ValidatorType[_T]: ... 77 | def gt(val: _T) -> _ValidatorType[_T]: ... 78 | def max_len(length: int) -> _ValidatorType[_T]: ... 79 | def min_len(length: int) -> _ValidatorType[_T]: ... 80 | def not_( 81 | validator: _ValidatorType[_T], 82 | *, 83 | msg: str | None = None, 84 | exc_types: type[Exception] | Iterable[type[Exception]] = ..., 85 | ) -> _ValidatorType[_T]: ... 86 | def or_(*validators: _ValidatorType[_T]) -> _ValidatorType[_T]: ... 87 | -------------------------------------------------------------------------------- /src/attrs/__init__.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: MIT 2 | 3 | from attr import ( 4 | NOTHING, 5 | Attribute, 6 | AttrsInstance, 7 | Converter, 8 | Factory, 9 | NothingType, 10 | _make_getattr, 11 | assoc, 12 | cmp_using, 13 | define, 14 | evolve, 15 | field, 16 | fields, 17 | fields_dict, 18 | frozen, 19 | has, 20 | make_class, 21 | mutable, 22 | resolve_types, 23 | validate, 24 | ) 25 | from attr._next_gen import asdict, astuple 26 | 27 | from . import converters, exceptions, filters, setters, validators 28 | 29 | 30 | __all__ = [ 31 | "NOTHING", 32 | "Attribute", 33 | "AttrsInstance", 34 | "Converter", 35 | "Factory", 36 | "NothingType", 37 | "__author__", 38 | "__copyright__", 39 | "__description__", 40 | "__doc__", 41 | "__email__", 42 | "__license__", 43 | "__title__", 44 | "__url__", 45 | "__version__", 46 | "__version_info__", 47 | "asdict", 48 | "assoc", 49 | "astuple", 50 | "cmp_using", 51 | "converters", 52 | "define", 53 | "evolve", 54 | "exceptions", 55 | "field", 56 | "fields", 57 | "fields_dict", 58 | "filters", 59 | "frozen", 60 | "has", 61 | "make_class", 62 | "mutable", 63 | "resolve_types", 64 | "setters", 65 | "validate", 66 | "validators", 67 | ] 68 | 69 | __getattr__ = _make_getattr(__name__) 70 | -------------------------------------------------------------------------------- /src/attrs/__init__.pyi: -------------------------------------------------------------------------------- 1 | import sys 2 | 3 | from typing import ( 4 | Any, 5 | Callable, 6 | Mapping, 7 | Sequence, 8 | overload, 9 | TypeVar, 10 | ) 11 | 12 | # Because we need to type our own stuff, we have to make everything from 13 | # attr explicitly public too. 14 | from attr import __author__ as __author__ 15 | from attr import __copyright__ as __copyright__ 16 | from attr import __description__ as __description__ 17 | from attr import __email__ as __email__ 18 | from attr import __license__ as __license__ 19 | from attr import __title__ as __title__ 20 | from attr import __url__ as __url__ 21 | from attr import __version__ as __version__ 22 | from attr import __version_info__ as __version_info__ 23 | from attr import assoc as assoc 24 | from attr import Attribute as Attribute 25 | from attr import AttrsInstance as AttrsInstance 26 | from attr import cmp_using as cmp_using 27 | from attr import converters as converters 28 | from attr import Converter as Converter 29 | from attr import evolve as evolve 30 | from attr import exceptions as exceptions 31 | from attr import Factory as Factory 32 | from attr import fields as fields 33 | from attr import fields_dict as fields_dict 34 | from attr import filters as filters 35 | from attr import has as has 36 | from attr import make_class as make_class 37 | from attr import NOTHING as NOTHING 38 | from attr import resolve_types as resolve_types 39 | from attr import setters as setters 40 | from attr import validate as validate 41 | from attr import validators as validators 42 | from attr import attrib, asdict as asdict, astuple as astuple 43 | from attr import NothingType as NothingType 44 | 45 | if sys.version_info >= (3, 11): 46 | from typing import dataclass_transform 47 | else: 48 | from typing_extensions import dataclass_transform 49 | 50 | _T = TypeVar("_T") 51 | _C = TypeVar("_C", bound=type) 52 | 53 | _EqOrderType = bool | Callable[[Any], Any] 54 | _ValidatorType = Callable[[Any, "Attribute[_T]", _T], Any] 55 | _CallableConverterType = Callable[[Any], Any] 56 | _ConverterType = _CallableConverterType | Converter[Any, Any] 57 | _ReprType = Callable[[Any], str] 58 | _ReprArgType = bool | _ReprType 59 | _OnSetAttrType = Callable[[Any, "Attribute[Any]", Any], Any] 60 | _OnSetAttrArgType = _OnSetAttrType | list[_OnSetAttrType] | setters._NoOpType 61 | _FieldTransformer = Callable[ 62 | [type, list["Attribute[Any]"]], list["Attribute[Any]"] 63 | ] 64 | # FIXME: in reality, if multiple validators are passed they must be in a list 65 | # or tuple, but those are invariant and so would prevent subtypes of 66 | # _ValidatorType from working when passed in a list or tuple. 67 | _ValidatorArgType = _ValidatorType[_T] | Sequence[_ValidatorType[_T]] 68 | 69 | @overload 70 | def field( 71 | *, 72 | default: None = ..., 73 | validator: None = ..., 74 | repr: _ReprArgType = ..., 75 | hash: bool | None = ..., 76 | init: bool = ..., 77 | metadata: Mapping[Any, Any] | None = ..., 78 | converter: None = ..., 79 | factory: None = ..., 80 | kw_only: bool = ..., 81 | eq: bool | None = ..., 82 | order: bool | None = ..., 83 | on_setattr: _OnSetAttrArgType | None = ..., 84 | alias: str | None = ..., 85 | type: type | None = ..., 86 | ) -> Any: ... 87 | 88 | # This form catches an explicit None or no default and infers the type from the 89 | # other arguments. 90 | @overload 91 | def field( 92 | *, 93 | default: None = ..., 94 | validator: _ValidatorArgType[_T] | None = ..., 95 | repr: _ReprArgType = ..., 96 | hash: bool | None = ..., 97 | init: bool = ..., 98 | metadata: Mapping[Any, Any] | None = ..., 99 | converter: _ConverterType 100 | | list[_ConverterType] 101 | | tuple[_ConverterType] 102 | | None = ..., 103 | factory: Callable[[], _T] | None = ..., 104 | kw_only: bool = ..., 105 | eq: _EqOrderType | None = ..., 106 | order: _EqOrderType | None = ..., 107 | on_setattr: _OnSetAttrArgType | None = ..., 108 | alias: str | None = ..., 109 | type: type | None = ..., 110 | ) -> _T: ... 111 | 112 | # This form catches an explicit default argument. 113 | @overload 114 | def field( 115 | *, 116 | default: _T, 117 | validator: _ValidatorArgType[_T] | None = ..., 118 | repr: _ReprArgType = ..., 119 | hash: bool | None = ..., 120 | init: bool = ..., 121 | metadata: Mapping[Any, Any] | None = ..., 122 | converter: _ConverterType 123 | | list[_ConverterType] 124 | | tuple[_ConverterType] 125 | | None = ..., 126 | factory: Callable[[], _T] | None = ..., 127 | kw_only: bool = ..., 128 | eq: _EqOrderType | None = ..., 129 | order: _EqOrderType | None = ..., 130 | on_setattr: _OnSetAttrArgType | None = ..., 131 | alias: str | None = ..., 132 | type: type | None = ..., 133 | ) -> _T: ... 134 | 135 | # This form covers type=non-Type: e.g. forward references (str), Any 136 | @overload 137 | def field( 138 | *, 139 | default: _T | None = ..., 140 | validator: _ValidatorArgType[_T] | None = ..., 141 | repr: _ReprArgType = ..., 142 | hash: bool | None = ..., 143 | init: bool = ..., 144 | metadata: Mapping[Any, Any] | None = ..., 145 | converter: _ConverterType 146 | | list[_ConverterType] 147 | | tuple[_ConverterType] 148 | | None = ..., 149 | factory: Callable[[], _T] | None = ..., 150 | kw_only: bool = ..., 151 | eq: _EqOrderType | None = ..., 152 | order: _EqOrderType | None = ..., 153 | on_setattr: _OnSetAttrArgType | None = ..., 154 | alias: str | None = ..., 155 | type: type | None = ..., 156 | ) -> Any: ... 157 | @overload 158 | @dataclass_transform(field_specifiers=(attrib, field)) 159 | def define( 160 | maybe_cls: _C, 161 | *, 162 | these: dict[str, Any] | None = ..., 163 | repr: bool = ..., 164 | unsafe_hash: bool | None = ..., 165 | hash: bool | None = ..., 166 | init: bool = ..., 167 | slots: bool = ..., 168 | frozen: bool = ..., 169 | weakref_slot: bool = ..., 170 | str: bool = ..., 171 | auto_attribs: bool = ..., 172 | kw_only: bool = ..., 173 | cache_hash: bool = ..., 174 | auto_exc: bool = ..., 175 | eq: bool | None = ..., 176 | order: bool | None = ..., 177 | auto_detect: bool = ..., 178 | getstate_setstate: bool | None = ..., 179 | on_setattr: _OnSetAttrArgType | None = ..., 180 | field_transformer: _FieldTransformer | None = ..., 181 | match_args: bool = ..., 182 | ) -> _C: ... 183 | @overload 184 | @dataclass_transform(field_specifiers=(attrib, field)) 185 | def define( 186 | maybe_cls: None = ..., 187 | *, 188 | these: dict[str, Any] | None = ..., 189 | repr: bool = ..., 190 | unsafe_hash: bool | None = ..., 191 | hash: bool | None = ..., 192 | init: bool = ..., 193 | slots: bool = ..., 194 | frozen: bool = ..., 195 | weakref_slot: bool = ..., 196 | str: bool = ..., 197 | auto_attribs: bool = ..., 198 | kw_only: bool = ..., 199 | cache_hash: bool = ..., 200 | auto_exc: bool = ..., 201 | eq: bool | None = ..., 202 | order: bool | None = ..., 203 | auto_detect: bool = ..., 204 | getstate_setstate: bool | None = ..., 205 | on_setattr: _OnSetAttrArgType | None = ..., 206 | field_transformer: _FieldTransformer | None = ..., 207 | match_args: bool = ..., 208 | ) -> Callable[[_C], _C]: ... 209 | 210 | mutable = define 211 | 212 | @overload 213 | @dataclass_transform(frozen_default=True, field_specifiers=(attrib, field)) 214 | def frozen( 215 | maybe_cls: _C, 216 | *, 217 | these: dict[str, Any] | None = ..., 218 | repr: bool = ..., 219 | unsafe_hash: bool | None = ..., 220 | hash: bool | None = ..., 221 | init: bool = ..., 222 | slots: bool = ..., 223 | frozen: bool = ..., 224 | weakref_slot: bool = ..., 225 | str: bool = ..., 226 | auto_attribs: bool = ..., 227 | kw_only: bool = ..., 228 | cache_hash: bool = ..., 229 | auto_exc: bool = ..., 230 | eq: bool | None = ..., 231 | order: bool | None = ..., 232 | auto_detect: bool = ..., 233 | getstate_setstate: bool | None = ..., 234 | on_setattr: _OnSetAttrArgType | None = ..., 235 | field_transformer: _FieldTransformer | None = ..., 236 | match_args: bool = ..., 237 | ) -> _C: ... 238 | @overload 239 | @dataclass_transform(frozen_default=True, field_specifiers=(attrib, field)) 240 | def frozen( 241 | maybe_cls: None = ..., 242 | *, 243 | these: dict[str, Any] | None = ..., 244 | repr: bool = ..., 245 | unsafe_hash: bool | None = ..., 246 | hash: bool | None = ..., 247 | init: bool = ..., 248 | slots: bool = ..., 249 | frozen: bool = ..., 250 | weakref_slot: bool = ..., 251 | str: bool = ..., 252 | auto_attribs: bool = ..., 253 | kw_only: bool = ..., 254 | cache_hash: bool = ..., 255 | auto_exc: bool = ..., 256 | eq: bool | None = ..., 257 | order: bool | None = ..., 258 | auto_detect: bool = ..., 259 | getstate_setstate: bool | None = ..., 260 | on_setattr: _OnSetAttrArgType | None = ..., 261 | field_transformer: _FieldTransformer | None = ..., 262 | match_args: bool = ..., 263 | ) -> Callable[[_C], _C]: ... 264 | -------------------------------------------------------------------------------- /src/attrs/converters.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: MIT 2 | 3 | from attr.converters import * # noqa: F403 4 | -------------------------------------------------------------------------------- /src/attrs/exceptions.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: MIT 2 | 3 | from attr.exceptions import * # noqa: F403 4 | -------------------------------------------------------------------------------- /src/attrs/filters.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: MIT 2 | 3 | from attr.filters import * # noqa: F403 4 | -------------------------------------------------------------------------------- /src/attrs/py.typed: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/python-attrs/attrs/a6ae894aad9bc09edc7cdad8c416898784ceec9b/src/attrs/py.typed -------------------------------------------------------------------------------- /src/attrs/setters.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: MIT 2 | 3 | from attr.setters import * # noqa: F403 4 | -------------------------------------------------------------------------------- /src/attrs/validators.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: MIT 2 | 3 | from attr.validators import * # noqa: F403 4 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: MIT 2 | -------------------------------------------------------------------------------- /tests/attr_import_star.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: MIT 2 | 3 | 4 | from attr import * # noqa: F403 5 | 6 | 7 | # This is imported by test_import::test_from_attr_import_star; this must 8 | # be done indirectly because importing * is only allowed on module level, 9 | # so can't be done inside a test. 10 | -------------------------------------------------------------------------------- /tests/dataclass_transform_example.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: MIT 2 | 3 | import attr 4 | import attrs 5 | 6 | 7 | @attr.define() 8 | class Define: 9 | a: str 10 | b: int 11 | 12 | 13 | reveal_type(Define.__init__) # noqa: F821 14 | 15 | 16 | @attr.define() 17 | class DefineConverter: 18 | with_converter: int = attr.field(converter=int) 19 | 20 | 21 | reveal_type(DefineConverter.__init__) # noqa: F821 22 | 23 | DefineConverter(with_converter=b"42") 24 | 25 | 26 | @attr.frozen() 27 | class Frozen: 28 | a: str 29 | 30 | 31 | d = Frozen("a") 32 | d.a = "new" 33 | 34 | reveal_type(d.a) # noqa: F821 35 | 36 | 37 | @attr.define(frozen=True) 38 | class FrozenDefine: 39 | a: str 40 | 41 | 42 | d2 = FrozenDefine("a") 43 | d2.a = "new" 44 | 45 | reveal_type(d2.a) # noqa: F821 46 | 47 | 48 | # Field-aliasing works 49 | @attrs.define 50 | class AliasedField: 51 | _a: int = attrs.field(alias="_a") 52 | 53 | 54 | af = AliasedField(42) 55 | 56 | reveal_type(af.__init__) # noqa: F821 57 | 58 | 59 | # unsafe_hash is accepted 60 | @attrs.define(unsafe_hash=True) 61 | class Hashable: 62 | pass 63 | -------------------------------------------------------------------------------- /tests/strategies.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: MIT 2 | 3 | """ 4 | Testing strategies for Hypothesis-based tests. 5 | """ 6 | 7 | import functools 8 | import keyword 9 | import string 10 | 11 | from collections import OrderedDict 12 | 13 | from hypothesis import strategies as st 14 | 15 | import attr 16 | 17 | from .utils import make_class 18 | 19 | 20 | optional_bool = st.one_of(st.none(), st.booleans()) 21 | 22 | 23 | def gen_attr_names(): 24 | """ 25 | Generate names for attributes, 'a'...'z', then 'aa'...'zz'. 26 | 27 | ~702 different attribute names should be enough in practice. 28 | 29 | Some short strings (such as 'as') are keywords, so we skip them. 30 | """ 31 | lc = string.ascii_lowercase 32 | yield from lc 33 | for outer in lc: 34 | for inner in lc: 35 | res = outer + inner 36 | if keyword.iskeyword(res): 37 | continue 38 | yield outer + inner 39 | 40 | 41 | def maybe_underscore_prefix(source): 42 | """ 43 | A generator to sometimes prepend an underscore. 44 | """ 45 | to_underscore = False 46 | for val in source: 47 | yield val if not to_underscore else "_" + val 48 | to_underscore = not to_underscore 49 | 50 | 51 | @st.composite 52 | def _create_hyp_nested_strategy(draw, simple_class_strategy): 53 | """ 54 | Create a recursive attrs class. 55 | 56 | Given a strategy for building (simpler) classes, create and return 57 | a strategy for building classes that have as an attribute: either just 58 | the simpler class, a list of simpler classes, a tuple of simpler classes, 59 | an ordered dict or a dict mapping the string "cls" to a simpler class. 60 | """ 61 | cls = draw(simple_class_strategy) 62 | factories = [ 63 | cls, 64 | lambda: [cls()], 65 | lambda: (cls(),), 66 | lambda: {"cls": cls()}, 67 | lambda: OrderedDict([("cls", cls())]), 68 | ] 69 | factory = draw(st.sampled_from(factories)) 70 | attrs = [*draw(list_of_attrs), attr.ib(default=attr.Factory(factory))] 71 | return make_class("HypClass", dict(zip(gen_attr_names(), attrs))) 72 | 73 | 74 | bare_attrs = st.builds(attr.ib, default=st.none()) 75 | int_attrs = st.integers().map(lambda i: attr.ib(default=i)) 76 | str_attrs = st.text().map(lambda s: attr.ib(default=s)) 77 | float_attrs = st.floats(allow_nan=False).map(lambda f: attr.ib(default=f)) 78 | dict_attrs = st.dictionaries(keys=st.text(), values=st.integers()).map( 79 | lambda d: attr.ib(default=d) 80 | ) 81 | 82 | simple_attrs_without_metadata = ( 83 | bare_attrs | int_attrs | str_attrs | float_attrs | dict_attrs 84 | ) 85 | 86 | 87 | @st.composite 88 | def simple_attrs_with_metadata(draw): 89 | """ 90 | Create a simple attribute with arbitrary metadata. 91 | """ 92 | c_attr = draw(simple_attrs) 93 | keys = st.booleans() | st.binary() | st.integers() | st.text() 94 | vals = st.booleans() | st.binary() | st.integers() | st.text() 95 | metadata = draw( 96 | st.dictionaries(keys=keys, values=vals, min_size=1, max_size=3) 97 | ) 98 | 99 | return attr.ib( 100 | default=c_attr._default, 101 | validator=c_attr._validator, 102 | repr=c_attr.repr, 103 | eq=c_attr.eq, 104 | order=c_attr.order, 105 | hash=c_attr.hash, 106 | init=c_attr.init, 107 | metadata=metadata, 108 | type=None, 109 | converter=c_attr.converter, 110 | ) 111 | 112 | 113 | simple_attrs = simple_attrs_without_metadata | simple_attrs_with_metadata() 114 | 115 | 116 | # Python functions support up to 255 arguments. 117 | list_of_attrs = st.lists(simple_attrs, max_size=3) 118 | 119 | 120 | @st.composite 121 | def simple_classes( 122 | draw, 123 | slots=None, 124 | frozen=None, 125 | weakref_slot=None, 126 | private_attrs=None, 127 | cached_property=None, 128 | ): 129 | """ 130 | A strategy that generates classes with default non-attr attributes. 131 | 132 | For example, this strategy might generate a class such as: 133 | 134 | @attr.s(slots=True, frozen=True, weakref_slot=True) 135 | class HypClass: 136 | a = attr.ib(default=1) 137 | _b = attr.ib(default=None) 138 | c = attr.ib(default='text') 139 | _d = attr.ib(default=1.0) 140 | c = attr.ib(default={'t': 1}) 141 | 142 | By default, all combinations of slots, frozen, and weakref_slot classes 143 | will be generated. If `slots=True` is passed in, only slotted classes will 144 | be generated, and if `slots=False` is passed in, no slotted classes will be 145 | generated. The same applies to `frozen` and `weakref_slot`. 146 | 147 | By default, some attributes will be private (those prefixed with an 148 | underscore). If `private_attrs=True` is passed in, all attributes will be 149 | private, and if `private_attrs=False`, no attributes will be private. 150 | """ 151 | attrs = draw(list_of_attrs) 152 | frozen_flag = draw(st.booleans()) 153 | slots_flag = draw(st.booleans()) 154 | weakref_flag = draw(st.booleans()) 155 | 156 | if private_attrs is None: 157 | attr_names = maybe_underscore_prefix(gen_attr_names()) 158 | elif private_attrs is True: 159 | attr_names = ("_" + n for n in gen_attr_names()) 160 | elif private_attrs is False: 161 | attr_names = gen_attr_names() 162 | 163 | cls_dict = dict(zip(attr_names, attrs)) 164 | pre_init_flag = draw(st.booleans()) 165 | post_init_flag = draw(st.booleans()) 166 | init_flag = draw(st.booleans()) 167 | cached_property_flag = draw(st.booleans()) 168 | 169 | if pre_init_flag: 170 | 171 | def pre_init(self): 172 | pass 173 | 174 | cls_dict["__attrs_pre_init__"] = pre_init 175 | 176 | if post_init_flag: 177 | 178 | def post_init(self): 179 | pass 180 | 181 | cls_dict["__attrs_post_init__"] = post_init 182 | 183 | if not init_flag: 184 | 185 | def init(self, *args, **kwargs): 186 | self.__attrs_init__(*args, **kwargs) 187 | 188 | cls_dict["__init__"] = init 189 | 190 | bases = (object,) 191 | if cached_property or (cached_property is None and cached_property_flag): 192 | 193 | class BaseWithCachedProperty: 194 | @functools.cached_property 195 | def _cached_property(self) -> int: 196 | return 1 197 | 198 | bases = (BaseWithCachedProperty,) 199 | 200 | return make_class( 201 | "HypClass", 202 | cls_dict, 203 | bases=bases, 204 | slots=slots_flag if slots is None else slots, 205 | frozen=frozen_flag if frozen is None else frozen, 206 | weakref_slot=weakref_flag if weakref_slot is None else weakref_slot, 207 | init=init_flag, 208 | ) 209 | 210 | 211 | # st.recursive works by taking a base strategy (in this case, simple_classes) 212 | # and a special function. This function receives a strategy, and returns 213 | # another strategy (building on top of the base strategy). 214 | nested_classes = st.recursive( 215 | simple_classes(), _create_hyp_nested_strategy, max_leaves=3 216 | ) 217 | -------------------------------------------------------------------------------- /tests/test_3rd_party.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: MIT 2 | 3 | """ 4 | Tests for compatibility against other Python modules. 5 | """ 6 | 7 | import pytest 8 | 9 | from hypothesis import given 10 | 11 | from .strategies import simple_classes 12 | 13 | 14 | cloudpickle = pytest.importorskip("cloudpickle") 15 | 16 | 17 | class TestCloudpickleCompat: 18 | """ 19 | Tests for compatibility with ``cloudpickle``. 20 | """ 21 | 22 | @given(simple_classes(cached_property=False)) 23 | def test_repr(self, cls): 24 | """ 25 | attrs instances can be pickled and un-pickled with cloudpickle. 26 | """ 27 | inst = cls() 28 | # Exact values aren't a concern so long as neither direction 29 | # raises an exception. 30 | pkl = cloudpickle.dumps(inst) 31 | cloudpickle.loads(pkl) 32 | -------------------------------------------------------------------------------- /tests/test_abc.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: MIT 2 | 3 | import abc 4 | import inspect 5 | 6 | import pytest 7 | 8 | import attrs 9 | 10 | from attr._compat import PY_3_10_PLUS, PY_3_12_PLUS 11 | 12 | 13 | @pytest.mark.skipif( 14 | not PY_3_10_PLUS, reason="abc.update_abstractmethods is 3.10+" 15 | ) 16 | class TestUpdateAbstractMethods: 17 | def test_abc_implementation(self, slots): 18 | """ 19 | If an attrs class implements an abstract method, it stops being 20 | abstract. 21 | """ 22 | 23 | class Ordered(abc.ABC): 24 | @abc.abstractmethod 25 | def __lt__(self, other): 26 | pass 27 | 28 | @abc.abstractmethod 29 | def __le__(self, other): 30 | pass 31 | 32 | @attrs.define(order=True, slots=slots) 33 | class Concrete(Ordered): 34 | x: int 35 | 36 | assert not inspect.isabstract(Concrete) 37 | assert Concrete(2) > Concrete(1) 38 | 39 | def test_remain_abstract(self, slots): 40 | """ 41 | If an attrs class inherits from an abstract class but doesn't implement 42 | abstract methods, it remains abstract. 43 | """ 44 | 45 | class A(abc.ABC): 46 | @abc.abstractmethod 47 | def foo(self): 48 | pass 49 | 50 | @attrs.define(slots=slots) 51 | class StillAbstract(A): 52 | pass 53 | 54 | assert inspect.isabstract(StillAbstract) 55 | expected_exception_message = ( 56 | "^Can't instantiate abstract class StillAbstract without an " 57 | "implementation for abstract method 'foo'$" 58 | if PY_3_12_PLUS 59 | else "class StillAbstract with abstract method foo" 60 | ) 61 | with pytest.raises(TypeError, match=expected_exception_message): 62 | StillAbstract() 63 | -------------------------------------------------------------------------------- /tests/test_compat.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: MIT 2 | 3 | import types 4 | 5 | from typing import Protocol 6 | 7 | import pytest 8 | 9 | import attr 10 | 11 | 12 | @pytest.fixture(name="mp") 13 | def _mp(): 14 | return types.MappingProxyType({"x": 42, "y": "foo"}) 15 | 16 | 17 | class TestMetadataProxy: 18 | """ 19 | Ensure properties of metadata proxy independently of hypothesis strategies. 20 | """ 21 | 22 | def test_repr(self, mp): 23 | """ 24 | repr makes sense and is consistent across Python versions. 25 | """ 26 | assert any( 27 | [ 28 | "mappingproxy({'x': 42, 'y': 'foo'})" == repr(mp), 29 | "mappingproxy({'y': 'foo', 'x': 42})" == repr(mp), 30 | ] 31 | ) 32 | 33 | def test_immutable(self, mp): 34 | """ 35 | All mutating methods raise errors. 36 | """ 37 | with pytest.raises(TypeError, match="not support item assignment"): 38 | mp["z"] = 23 39 | 40 | with pytest.raises(TypeError, match="not support item deletion"): 41 | del mp["x"] 42 | 43 | with pytest.raises(AttributeError, match="no attribute 'update'"): 44 | mp.update({}) 45 | 46 | with pytest.raises(AttributeError, match="no attribute 'clear'"): 47 | mp.clear() 48 | 49 | with pytest.raises(AttributeError, match="no attribute 'pop'"): 50 | mp.pop("x") 51 | 52 | with pytest.raises(AttributeError, match="no attribute 'popitem'"): 53 | mp.popitem() 54 | 55 | with pytest.raises(AttributeError, match="no attribute 'setdefault'"): 56 | mp.setdefault("x") 57 | 58 | 59 | def test_attrsinstance_subclass_protocol(): 60 | """ 61 | It's possible to subclass AttrsInstance and Protocol at once. 62 | """ 63 | 64 | class Foo(attr.AttrsInstance, Protocol): 65 | def attribute(self) -> int: ... 66 | -------------------------------------------------------------------------------- /tests/test_config.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: MIT 2 | 3 | """ 4 | Tests for `attr._config`. 5 | """ 6 | 7 | import pytest 8 | 9 | from attr import _config 10 | 11 | 12 | class TestConfig: 13 | def test_default(self): 14 | """ 15 | Run validators by default. 16 | """ 17 | assert True is _config._run_validators 18 | 19 | def test_set_run_validators(self): 20 | """ 21 | Sets `_run_validators`. 22 | """ 23 | _config.set_run_validators(False) 24 | assert False is _config._run_validators 25 | _config.set_run_validators(True) 26 | assert True is _config._run_validators 27 | 28 | def test_get_run_validators(self): 29 | """ 30 | Returns `_run_validators`. 31 | """ 32 | _config._run_validators = False 33 | assert _config._run_validators is _config.get_run_validators() 34 | _config._run_validators = True 35 | assert _config._run_validators is _config.get_run_validators() 36 | 37 | def test_wrong_type(self): 38 | """ 39 | Passing anything else than a boolean raises TypeError. 40 | """ 41 | with pytest.raises(TypeError) as e: 42 | _config.set_run_validators("False") 43 | assert "'run' must be bool." == e.value.args[0] 44 | -------------------------------------------------------------------------------- /tests/test_filters.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: MIT 2 | 3 | """ 4 | Tests for `attr.filters`. 5 | """ 6 | 7 | import pytest 8 | 9 | import attr 10 | 11 | from attr import fields 12 | from attr.filters import _split_what, exclude, include 13 | 14 | 15 | @attr.s 16 | class C: 17 | a = attr.ib() 18 | b = attr.ib() 19 | 20 | 21 | class TestSplitWhat: 22 | """ 23 | Tests for `_split_what`. 24 | """ 25 | 26 | def test_splits(self): 27 | """ 28 | Splits correctly. 29 | """ 30 | assert ( 31 | frozenset((int, str)), 32 | frozenset(("abcd", "123")), 33 | frozenset((fields(C).a,)), 34 | ) == _split_what((str, "123", fields(C).a, int, "abcd")) 35 | 36 | 37 | class TestInclude: 38 | """ 39 | Tests for `include`. 40 | """ 41 | 42 | @pytest.mark.parametrize( 43 | ("incl", "value"), 44 | [ 45 | ((int,), 42), 46 | ((str,), "hello"), 47 | ((str, fields(C).a), 42), 48 | ((str, fields(C).b), "hello"), 49 | (("a",), 42), 50 | (("a",), "hello"), 51 | (("a", str), 42), 52 | (("a", fields(C).b), "hello"), 53 | ], 54 | ) 55 | def test_allow(self, incl, value): 56 | """ 57 | Return True if a class or attribute is included. 58 | """ 59 | i = include(*incl) 60 | assert i(fields(C).a, value) is True 61 | 62 | @pytest.mark.parametrize( 63 | ("incl", "value"), 64 | [ 65 | ((str,), 42), 66 | ((int,), "hello"), 67 | ((str, fields(C).b), 42), 68 | ((int, fields(C).b), "hello"), 69 | (("b",), 42), 70 | (("b",), "hello"), 71 | (("b", str), 42), 72 | (("b", fields(C).b), "hello"), 73 | ], 74 | ) 75 | def test_drop_class(self, incl, value): 76 | """ 77 | Return False on non-included classes and attributes. 78 | """ 79 | i = include(*incl) 80 | assert i(fields(C).a, value) is False 81 | 82 | 83 | class TestExclude: 84 | """ 85 | Tests for `exclude`. 86 | """ 87 | 88 | @pytest.mark.parametrize( 89 | ("excl", "value"), 90 | [ 91 | ((str,), 42), 92 | ((int,), "hello"), 93 | ((str, fields(C).b), 42), 94 | ((int, fields(C).b), "hello"), 95 | (("b",), 42), 96 | (("b",), "hello"), 97 | (("b", str), 42), 98 | (("b", fields(C).b), "hello"), 99 | ], 100 | ) 101 | def test_allow(self, excl, value): 102 | """ 103 | Return True if class or attribute is not excluded. 104 | """ 105 | e = exclude(*excl) 106 | assert e(fields(C).a, value) is True 107 | 108 | @pytest.mark.parametrize( 109 | ("excl", "value"), 110 | [ 111 | ((int,), 42), 112 | ((str,), "hello"), 113 | ((str, fields(C).a), 42), 114 | ((str, fields(C).b), "hello"), 115 | (("a",), 42), 116 | (("a",), "hello"), 117 | (("a", str), 42), 118 | (("a", fields(C).b), "hello"), 119 | ], 120 | ) 121 | def test_drop_class(self, excl, value): 122 | """ 123 | Return True on non-excluded classes and attributes. 124 | """ 125 | e = exclude(*excl) 126 | assert e(fields(C).a, value) is False 127 | -------------------------------------------------------------------------------- /tests/test_import.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: MIT 2 | 3 | 4 | class TestImportStar: 5 | def test_from_attr_import_star(self): 6 | """ 7 | import * from attr 8 | """ 9 | # attr_import_star contains `from attr import *`, which cannot 10 | # be done here because *-imports are only allowed on module level. 11 | from . import attr_import_star # noqa: F401 12 | -------------------------------------------------------------------------------- /tests/test_init_subclass.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: MIT 2 | 3 | """ 4 | Tests for `__init_subclass__` related functionality. 5 | """ 6 | 7 | import attr 8 | 9 | 10 | def test_init_subclass_vanilla(slots): 11 | """ 12 | `super().__init_subclass__` can be used if the subclass is not an attrs 13 | class both with dict and slotted classes. 14 | """ 15 | 16 | @attr.s(slots=slots) 17 | class Base: 18 | def __init_subclass__(cls, param, **kw): 19 | super().__init_subclass__(**kw) 20 | cls.param = param 21 | 22 | class Vanilla(Base, param="foo"): 23 | pass 24 | 25 | assert "foo" == Vanilla().param 26 | 27 | 28 | def test_init_subclass_attrs(): 29 | """ 30 | `__init_subclass__` works with attrs classes as long as slots=False. 31 | """ 32 | 33 | @attr.s(slots=False) 34 | class Base: 35 | def __init_subclass__(cls, param, **kw): 36 | super().__init_subclass__(**kw) 37 | cls.param = param 38 | 39 | @attr.s 40 | class Attrs(Base, param="foo"): 41 | pass 42 | 43 | assert "foo" == Attrs().param 44 | 45 | 46 | def test_init_subclass_slots_workaround(): 47 | """ 48 | `__init_subclass__` works with modern APIs if care is taken around classes 49 | existing twice. 50 | """ 51 | subs = {} 52 | 53 | @attr.define 54 | class Base: 55 | def __init_subclass__(cls): 56 | subs[cls.__qualname__] = cls 57 | 58 | @attr.define 59 | class Sub1(Base): 60 | x: int 61 | 62 | @attr.define 63 | class Sub2(Base): 64 | y: int 65 | 66 | assert (Sub1, Sub2) == tuple(subs.values()) 67 | -------------------------------------------------------------------------------- /tests/test_packaging.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: MIT 2 | 3 | 4 | from importlib import metadata 5 | 6 | import pytest 7 | 8 | import attr 9 | import attrs 10 | 11 | 12 | @pytest.fixture(name="mod", params=(attr, attrs)) 13 | def _mod(request): 14 | return request.param 15 | 16 | 17 | class TestLegacyMetadataHack: 18 | def test_version(self, mod, recwarn): 19 | """ 20 | __version__ returns the correct version and doesn't warn. 21 | """ 22 | assert metadata.version("attrs") == mod.__version__ 23 | 24 | assert [] == recwarn.list 25 | 26 | def test_does_not_exist(self, mod): 27 | """ 28 | Asking for unsupported dunders raises an AttributeError. 29 | """ 30 | with pytest.raises( 31 | AttributeError, 32 | match=f"module {mod.__name__} has no attribute __yolo__", 33 | ): 34 | mod.__yolo__ 35 | 36 | def test_version_info(self, recwarn, mod): 37 | """ 38 | ___version_info__ is not deprecated, therefore doesn't raise a warning 39 | and parses correctly. 40 | """ 41 | assert isinstance(mod.__version_info__, attr.VersionInfo) 42 | assert [] == recwarn.list 43 | -------------------------------------------------------------------------------- /tests/test_pattern_matching.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: MIT 2 | 3 | import pytest 4 | 5 | import attr 6 | 7 | 8 | class TestPatternMatching: 9 | """ 10 | Pattern matching syntax test cases. 11 | """ 12 | 13 | @pytest.mark.parametrize("dec", [attr.s, attr.define, attr.frozen]) 14 | def test_simple_match_case(self, dec): 15 | """ 16 | Simple match case statement works as expected with all class 17 | decorators. 18 | """ 19 | 20 | @dec 21 | class C: 22 | a = attr.ib() 23 | 24 | assert ("a",) == C.__match_args__ 25 | 26 | matched = False 27 | c = C(a=1) 28 | match c: 29 | case C(a): 30 | matched = True 31 | 32 | assert matched 33 | assert 1 == a 34 | 35 | def test_explicit_match_args(self): 36 | """ 37 | Does not overwrite a manually set empty __match_args__. 38 | """ 39 | 40 | ma = () 41 | 42 | @attr.define 43 | class C: 44 | a = attr.field() 45 | __match_args__ = ma 46 | 47 | c = C(a=1) 48 | 49 | msg = r"C\(\) accepts 0 positional sub-patterns \(1 given\)" 50 | with pytest.raises(TypeError, match=msg): 51 | match c: 52 | case C(_): 53 | pass 54 | 55 | def test_match_args_kw_only(self): 56 | """ 57 | kw_only classes don't generate __match_args__. 58 | kw_only fields are not included in __match_args__. 59 | """ 60 | 61 | @attr.define 62 | class C: 63 | a = attr.field(kw_only=True) 64 | b = attr.field() 65 | 66 | assert ("b",) == C.__match_args__ 67 | 68 | c = C(a=1, b=1) 69 | msg = r"C\(\) accepts 1 positional sub-pattern \(2 given\)" 70 | with pytest.raises(TypeError, match=msg): 71 | match c: 72 | case C(a, b): 73 | pass 74 | 75 | found = False 76 | match c: 77 | case C(b, a=a): 78 | found = True 79 | 80 | assert found 81 | 82 | @attr.define(kw_only=True) 83 | class C: 84 | a = attr.field() 85 | b = attr.field() 86 | 87 | c = C(a=1, b=1) 88 | msg = r"C\(\) accepts 0 positional sub-patterns \(2 given\)" 89 | with pytest.raises(TypeError, match=msg): 90 | match c: 91 | case C(a, b): 92 | pass 93 | 94 | found = False 95 | match c: 96 | case C(a=a, b=b): 97 | found = True 98 | 99 | assert found 100 | assert (1, 1) == (a, b) 101 | -------------------------------------------------------------------------------- /tests/test_pyright.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: MIT 2 | 3 | from __future__ import annotations 4 | 5 | import json 6 | import shutil 7 | import subprocess 8 | 9 | from pathlib import Path 10 | 11 | import pytest 12 | 13 | 14 | pytestmark = [ 15 | pytest.mark.skipif( 16 | shutil.which("pyright") is None, reason="Requires pyright." 17 | ), 18 | ] 19 | 20 | 21 | def parse_pyright_output(test_file: Path) -> set[tuple[str, str]]: 22 | pyright = subprocess.run( # noqa: PLW1510 23 | ["pyright", "--outputjson", str(test_file)], capture_output=True 24 | ) 25 | 26 | pyright_result = json.loads(pyright.stdout) 27 | 28 | # We use tuples instead of proper classes to get nicer diffs from pytest. 29 | return { 30 | (d["severity"], d["message"]) 31 | for d in pyright_result["generalDiagnostics"] 32 | } 33 | 34 | 35 | def test_pyright_baseline(): 36 | """ 37 | The typing.dataclass_transform decorator allows pyright to determine 38 | attrs decorated class types. 39 | """ 40 | 41 | test_file = Path(__file__).parent / "dataclass_transform_example.py" 42 | 43 | diagnostics = parse_pyright_output(test_file) 44 | 45 | expected_diagnostics = { 46 | ( 47 | "information", 48 | 'Type of "Define.__init__" is "(self: Define, a: str, b: int) -> None"', 49 | ), 50 | ( 51 | "information", 52 | 'Type of "DefineConverter.__init__" is ' 53 | '"(self: DefineConverter, with_converter: str | Buffer | ' 54 | 'SupportsInt | SupportsIndex | SupportsTrunc) -> None"', 55 | ), 56 | ( 57 | "error", 58 | 'Cannot assign to attribute "a" for class ' 59 | '"Frozen"\n\xa0\xa0Attribute "a" is read-only', 60 | ), 61 | ( 62 | "information", 63 | 'Type of "d.a" is "Literal[\'new\']"', 64 | ), 65 | ( 66 | "error", 67 | 'Cannot assign to attribute "a" for class ' 68 | '"FrozenDefine"\n\xa0\xa0Attribute "a" is read-only', 69 | ), 70 | ( 71 | "information", 72 | 'Type of "d2.a" is "Literal[\'new\']"', 73 | ), 74 | ( 75 | "information", 76 | 'Type of "af.__init__" is "(_a: int) -> None"', 77 | ), 78 | } 79 | 80 | assert expected_diagnostics == diagnostics 81 | 82 | 83 | def test_pyright_attrsinstance_compat(tmp_path): 84 | """ 85 | Test that `AttrsInstance` is compatible with Pyright. 86 | """ 87 | test_pyright_attrsinstance_compat_path = ( 88 | tmp_path / "test_pyright_attrsinstance_compat.py" 89 | ) 90 | test_pyright_attrsinstance_compat_path.write_text( 91 | """\ 92 | import attrs 93 | 94 | # We can assign any old object to `AttrsInstance`. 95 | foo: attrs.AttrsInstance = object() 96 | 97 | reveal_type(attrs.AttrsInstance) 98 | """ 99 | ) 100 | 101 | diagnostics = parse_pyright_output(test_pyright_attrsinstance_compat_path) 102 | expected_diagnostics = { 103 | ( 104 | "information", 105 | 'Type of "attrs.AttrsInstance" is "type[AttrsInstance]"', 106 | ) 107 | } 108 | assert diagnostics == expected_diagnostics 109 | -------------------------------------------------------------------------------- /tests/test_utils.py: -------------------------------------------------------------------------------- 1 | from .utils import simple_class 2 | 3 | 4 | class TestSimpleClass: 5 | """ 6 | Tests for the testing helper function `make_class`. 7 | """ 8 | 9 | def test_returns_class(self): 10 | """ 11 | Returns a class object. 12 | """ 13 | assert type is simple_class().__class__ 14 | 15 | def test_returns_distinct_classes(self): 16 | """ 17 | Each call returns a completely new class. 18 | """ 19 | assert simple_class() is not simple_class() 20 | -------------------------------------------------------------------------------- /tests/test_version_info.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: MIT 2 | 3 | 4 | import pytest 5 | 6 | from attr import VersionInfo 7 | 8 | 9 | @pytest.fixture(name="vi") 10 | def fixture_vi(): 11 | return VersionInfo(19, 2, 0, "final") 12 | 13 | 14 | class TestVersionInfo: 15 | def test_from_string_no_releaselevel(self, vi): 16 | """ 17 | If there is no suffix, the releaselevel becomes "final" by default. 18 | """ 19 | assert vi == VersionInfo._from_version_string("19.2.0") 20 | 21 | def test_suffix_is_preserved(self): 22 | """ 23 | If there is a suffix, it's preserved. 24 | """ 25 | assert ( 26 | "dev0" 27 | == VersionInfo._from_version_string("19.2.0.dev0").releaselevel 28 | ) 29 | 30 | @pytest.mark.parametrize("other", [(), (19, 2, 0, "final", "garbage")]) 31 | def test_wrong_len(self, vi, other): 32 | """ 33 | Comparing with a tuple that has the wrong length raises an error. 34 | """ 35 | assert vi != other 36 | 37 | with pytest.raises(TypeError): 38 | vi < other 39 | 40 | @pytest.mark.parametrize("other", [[19, 2, 0, "final"]]) 41 | def test_wrong_type(self, vi, other): 42 | """ 43 | Only compare to other VersionInfos or tuples. 44 | """ 45 | assert vi != other 46 | 47 | def test_order(self, vi): 48 | """ 49 | Ordering works as expected. 50 | """ 51 | assert vi < (20,) 52 | assert vi < (19, 2, 1) 53 | assert vi > (0,) 54 | assert vi <= (19, 2) 55 | assert vi >= (19, 2) 56 | assert vi > (19, 2, 0, "dev0") 57 | assert vi < (19, 2, 0, "post1") 58 | -------------------------------------------------------------------------------- /tests/utils.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: MIT 2 | 3 | """ 4 | Common helper functions for tests. 5 | """ 6 | 7 | from attr import Attribute 8 | from attr._make import NOTHING, _default_init_alias_for, make_class 9 | 10 | 11 | def simple_class( 12 | eq=False, 13 | order=False, 14 | repr=False, 15 | unsafe_hash=False, 16 | str=False, 17 | slots=False, 18 | frozen=False, 19 | cache_hash=False, 20 | ): 21 | """ 22 | Return a new simple class. 23 | """ 24 | return make_class( 25 | "C", 26 | ["a", "b"], 27 | eq=eq or order, 28 | order=order, 29 | repr=repr, 30 | unsafe_hash=unsafe_hash, 31 | init=True, 32 | slots=slots, 33 | str=str, 34 | frozen=frozen, 35 | cache_hash=cache_hash, 36 | ) 37 | 38 | 39 | def simple_attr( 40 | name, 41 | default=NOTHING, 42 | validator=None, 43 | repr=True, 44 | eq=True, 45 | hash=None, 46 | init=True, 47 | converter=None, 48 | kw_only=False, 49 | inherited=False, 50 | ): 51 | """ 52 | Return an attribute with a name and no other bells and whistles. 53 | """ 54 | return Attribute( 55 | name=name, 56 | default=default, 57 | validator=validator, 58 | repr=repr, 59 | cmp=None, 60 | eq=eq, 61 | hash=hash, 62 | init=init, 63 | converter=converter, 64 | kw_only=kw_only, 65 | inherited=inherited, 66 | alias=_default_init_alias_for(name), 67 | ) 68 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | min_version = 4 3 | env_list = 4 | pre-commit, 5 | py3{9,10,11,12,13,14}-tests, 6 | py3{10,11,12,13,14}-mypy, 7 | pypy3-tests, 8 | pyright, 9 | docs-{sponsors,doctests}, 10 | changelog, 11 | coverage-report 12 | 13 | 14 | [pkgenv] 15 | pass_env = SETUPTOOLS_SCM_PRETEND_VERSION 16 | 17 | 18 | [testenv] 19 | runner = uv-venv-lock-runner 20 | package = wheel 21 | wheel_build_env = .pkg 22 | dependency_groups = 23 | tests: tests 24 | mypy: tests-mypy 25 | commands = 26 | tests: pytest {posargs} 27 | mypy: mypy tests/typing_example.py 28 | mypy: mypy src/attrs/__init__.pyi src/attr/__init__.pyi src/attr/_typing_compat.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 29 | 30 | [testenv:pypy3-tests] 31 | dependency_groups = tests 32 | commands = pytest tests/test_functional.py 33 | 34 | [testenv:py3{9,10,13}-tests] 35 | dependency_groups = cov 36 | # Python 3.6+ has a number of compile-time warnings on invalid string escapes. 37 | # PYTHONWARNINGS=d makes them visible during the tox run. 38 | set_env = 39 | COVERAGE_PROCESS_START={toxinidir}/pyproject.toml 40 | PYTHONWARNINGS=d 41 | commands_pre = python -c 'import pathlib; pathlib.Path("{env_site_packages_dir}/cov.pth").write_text("import coverage; coverage.process_startup()")' 42 | commands = 43 | coverage run -m pytest {posargs} 44 | 45 | [testenv:coverage-report] 46 | # Keep base_python in-sync with .python-version-default 47 | base_python = py313 48 | # Keep depends in-sync with testenv above that has the cov dependency group. 49 | depends = py3{9,10,13}-tests 50 | skip_install = true 51 | dependency_groups = cov 52 | commands = 53 | coverage combine 54 | coverage report 55 | 56 | 57 | [testenv:codspeed] 58 | dependency_groups = benchmark 59 | pass_env = 60 | CODSPEED_TOKEN 61 | CODSPEED_ENV 62 | ARCH 63 | PYTHONHASHSEED 64 | PYTHONMALLOC 65 | commands = pytest --codspeed -n auto bench/test_benchmarks.py 66 | 67 | 68 | [testenv:docs-{build,doctests,linkcheck}] 69 | runner = uv-venv-lock-runner 70 | # Keep base_python in-sync with .readthedocs.yaml. 71 | base_python = py313 72 | dependency_groups = docs 73 | commands = 74 | build: sphinx-build -n -T -W -b html -d {envtmpdir}/doctrees docs {posargs:docs/_build/}html 75 | doctests: sphinx-build -n -T -W -b doctest -d {envtmpdir}/doctrees docs {posargs:docs/_build/}html 76 | linkcheck: sphinx-build -W -b linkcheck -d {envtmpdir}/doctrees docs docs/_build/html 77 | 78 | [testenv:docs-watch] 79 | package = editable 80 | base_python = {[testenv:docs-build]base_python} 81 | dependency_groups = docs-watch 82 | deps = watchfiles 83 | commands = 84 | watchfiles \ 85 | --ignore-paths docs/_build/ \ 86 | 'sphinx-build -W -n --jobs auto -b html -d {envtmpdir}/doctrees docs docs/_build/html' \ 87 | README.md \ 88 | src \ 89 | docs 90 | 91 | [testenv:docs-sponsors] 92 | runner = uv-venv-runner 93 | skip_install = true 94 | description = Ensure sponsor logos are up to date. 95 | deps = cogapp 96 | commands = cog -rP README.md docs/index.md 97 | 98 | 99 | [testenv:pre-commit] 100 | runner = uv-venv-runner 101 | skip_install = true 102 | deps = pre-commit-uv 103 | commands = pre-commit run --all-files 104 | 105 | 106 | [testenv:changelog] 107 | dependency_groups = docs 108 | skip_install = true 109 | commands = 110 | towncrier --version 111 | towncrier build --version main --draft 112 | 113 | 114 | [testenv:pyright] 115 | dependency_groups = pyright 116 | commands = pytest tests/test_pyright.py -vv 117 | 118 | 119 | [testenv:docset] 120 | runner = uv-venv-runner 121 | deps = doc2dash 122 | dependency_groups = docs 123 | allowlist_externals = 124 | rm 125 | cp 126 | tar 127 | commands = 128 | rm -rf attrs.docset attrs.tgz docs/_build 129 | sphinx-build -n -T -W -b html -d {envtmpdir}/doctrees docs docs/_build/html 130 | doc2dash --index-page index.html --icon docs/_static/docset-icon.png --icon-2x docs/_static/docset-icon@2x.png --online-redirect-url https://www.attrs.org/en/latest/ docs/_build/html 131 | tar --exclude='.DS_Store' -cvzf attrs.tgz attrs.docset 132 | --------------------------------------------------------------------------------