├── .github └── workflows │ └── test.yml ├── .gitignore ├── LICENSE ├── README.md ├── examples └── color.py ├── poetry.lock ├── pyproject.toml ├── src └── declare │ ├── __init__.py │ ├── _declare.py │ └── py.typed └── tests └── test_declare.py /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | branches: 9 | - main 10 | 11 | env: 12 | PYTEST_ADDOPTS: "--color=yes" 13 | 14 | jobs: 15 | build: 16 | runs-on: ${{ matrix.os }} 17 | strategy: 18 | matrix: 19 | os: [ubuntu-latest, windows-latest, macos-latest] 20 | python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"] 21 | defaults: 22 | run: 23 | shell: bash 24 | steps: 25 | - uses: actions/checkout@v4.1.1 26 | - name: Install Poetry 27 | run: pipx install poetry==1.8.2 28 | - name: Set up Python ${{ matrix.python-version }} 29 | uses: actions/setup-python@v4.7.1 30 | with: 31 | python-version: ${{ matrix.python-version }} 32 | cache: 'poetry' 33 | - name: Install dependencies 34 | run: poetry install --no-interaction 35 | - name: Test with pytest 36 | run: | 37 | poetry run pytest tests -v 38 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | share/python-wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .nox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | *.py,cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | cover/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | .pybuilder/ 76 | target/ 77 | 78 | # Jupyter Notebook 79 | .ipynb_checkpoints 80 | 81 | # IPython 82 | profile_default/ 83 | ipython_config.py 84 | 85 | # pyenv 86 | # For a library or package, you might want to ignore these files since the code is 87 | # intended to run in multiple environments; otherwise, check them in: 88 | # .python-version 89 | 90 | # pipenv 91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 93 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 94 | # install all needed dependencies. 95 | #Pipfile.lock 96 | 97 | # poetry 98 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 99 | # This is especially recommended for binary packages to ensure reproducibility, and is more 100 | # commonly ignored for libraries. 101 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 102 | #poetry.lock 103 | 104 | # pdm 105 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 106 | #pdm.lock 107 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 108 | # in version control. 109 | # https://pdm.fming.dev/#use-with-ide 110 | .pdm.toml 111 | 112 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 113 | __pypackages__/ 114 | 115 | # Celery stuff 116 | celerybeat-schedule 117 | celerybeat.pid 118 | 119 | # SageMath parsed files 120 | *.sage.py 121 | 122 | # Environments 123 | .env 124 | .venv 125 | env/ 126 | venv/ 127 | ENV/ 128 | env.bak/ 129 | venv.bak/ 130 | 131 | # Spyder project settings 132 | .spyderproject 133 | .spyproject 134 | 135 | # Rope project settings 136 | .ropeproject 137 | 138 | # mkdocs documentation 139 | /site 140 | 141 | # mypy 142 | .mypy_cache/ 143 | .dmypy.json 144 | dmypy.json 145 | 146 | # Pyre type checker 147 | .pyre/ 148 | 149 | # pytype static type analyzer 150 | .pytype/ 151 | 152 | # Cython debug symbols 153 | cython_debug/ 154 | 155 | # PyCharm 156 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 157 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 158 | # and can be added to the global gitignore or merged into this file. For a more nuclear 159 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 160 | #.idea/ 161 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Will McGugan 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 | # What? 2 | 3 | Declare is a syntactical sugar for adding attributes to Python classes, with support for validation and *watching* attributes for changes. 4 | 5 | Declare works well with type-checkers such as MyPy, even though in many cases you don't need to write type annotations. 6 | 7 | ## Example 8 | 9 | let's look at a simple example. 10 | The following code creates a class to represent a color, with attributes for red, green, blue, plus an alpha (transparency) component: 11 | 12 | ```python 13 | import declare 14 | 15 | 16 | class Color: 17 | """A color object with RGB, and alpha components.""" 18 | 19 | red = declare.Int(0) 20 | green = declare.Int(0) 21 | blue = declare.Int(0) 22 | alpha = declare.Float(1.0) 23 | ``` 24 | 25 | If you construct a `Color` instance, it will have the four attributes. 26 | 27 | Perhaps surprisingly, these attributes are typed -- without any explicit type annotations. 28 | Mypy and other type-checkers will understand that `Color` instances have three `int` attributes, and an additional `float` for alpha. 29 | 30 | ### Validation 31 | 32 | So far, there is little benefit over conventional attributes, but Declare adds a convenient way to add validation. 33 | 34 | The following extends the `Color` class with validation for all four attributes: 35 | 36 | ```python 37 | import declare 38 | 39 | 40 | class Color: 41 | """A color object with RGB, and alpha components.""" 42 | 43 | red = declare.Int(0) 44 | green = declare.Int(0) 45 | blue = declare.Int(0) 46 | alpha = declare.Float(1.0) 47 | 48 | @red.validate 49 | @green.validate 50 | @blue.validate 51 | def _validate_component(self, component: int) -> int: 52 | """Restrict RGB to 0 -> 255.""" 53 | return max(0, min(255, component)) 54 | 55 | @alpha.validate 56 | def _validate_alpha(self, alpha: float) -> float: 57 | return max(0.0, min(1.0, alpha)) 58 | ``` 59 | 60 | If you were to attempt to assign a value to an attribute outside of its expected range, that value will be restricted to be within an upper and lower range. 61 | In other words, setting `my_color.red=300` would actually set the `red` attribute to `255`. 62 | 63 | You can do anything you wish in the validator to change the value being stored, or perhaps to raise an exception if the value is invalid. 64 | 65 | ### Watching 66 | 67 | In addition to validating attributes, you can *watch* attributes for changes. 68 | When an attribute has a watch method, that method will be called when the value changes. 69 | The method will receive the original value and the new value being set. 70 | 71 | Here's the color class extended with a watch method: 72 | 73 | ```python 74 | import declare 75 | 76 | 77 | class Color: 78 | """A color object with RGB, and alpha components.""" 79 | 80 | red = declare.Int(0) 81 | green = declare.Int(0) 82 | blue = declare.Int(0) 83 | alpha = declare.Float(1.0) 84 | 85 | @red.validate 86 | @green.validate 87 | @blue.validate 88 | def _validate_component(self, component: int) -> int: 89 | """Restrict RGB to 0 -> 255.""" 90 | return max(0, min(255, component)) 91 | 92 | @alpha.validate 93 | def _validate_alpha(self, alpha: float) -> float: 94 | return max(0.0, min(1.0, alpha)) 95 | 96 | @alpha.watch 97 | def _watch_alpha(self, old_alpha: float, alpha: float) -> None: 98 | print(f"alpha changed from {old_alpha} to {alpha}!") 99 | ``` 100 | 101 | The addition of the `@alpha.watch` decorator will cause the `_watch_alpha` method to be called when any value is assigned to the `alpha` attribute. 102 | 103 | ### Declare types 104 | 105 | In the above code `declare.Int` and `declare.Float` are pre-defined declarations for standard types (there is also `Bool`, `Str`, and `Bytes`). 106 | You can also also declare custom type by importing `Declare`. 107 | 108 | let's add a `Palette` class which contains a name, and a list of colors: 109 | 110 | ```python 111 | import declare 112 | from declare import Declare 113 | 114 | 115 | class Color: 116 | """A color object with RGB, and alpha components.""" 117 | 118 | red = declare.Int(0) 119 | green = declare.Int(0) 120 | blue = declare.Int(0) 121 | alpha = declare.Float(1.0) 122 | 123 | @red.validate 124 | @green.validate 125 | @blue.validate 126 | def _validate_component(self, component: int) -> int: 127 | """Restrict RGB to 0 -> 255.""" 128 | return max(0, min(255, component)) 129 | 130 | @alpha.validate 131 | def _validate_alpha(self, alpha: float) -> float: 132 | return max(0.0, min(1.0, alpha)) 133 | 134 | @alpha.watch 135 | def _watch_alpha(self, old_alpha: float, alpha: float) -> None: 136 | print(f"alpha changed from {old_alpha} to {alpha}!") 137 | 138 | 139 | class Palette: 140 | """A container of colors.""" 141 | name = declare.Str("") 142 | colors = Declare[list[Color]]([]) 143 | 144 | ``` 145 | 146 | The `colors` attribute is created with this invocation: `Declare[list[Color]]([])`, which creates a list of colors, defaulting to an empty list. 147 | 148 | Let's break that down a bit: 149 | 150 | - `Declare` is the descriptor which create the attributes. 151 | - `Declare[list[Color]]` tells the type checker you are declaring a list of Color objects. 152 | - `Declare[list[Color]]([])` sets the default to an empty list. Note that this doesn't suffer from the classic Python issue of default mutable arguments. You will get a new instance every time you construct a `Palette`. 153 | 154 | 155 | ## Installation 156 | 157 | Install via `pip` or your favorite package manager. 158 | 159 | ```bash 160 | pip install declare 161 | ``` 162 | 163 | # Why? 164 | 165 | Textual uses a similar approach to declare [reactive attributes](https://textual.textualize.io/guide/reactivity/), which are a useful general programming concept. Alas, without Textual as a runtime, it wouldn't be possible to use Textual's reactive attributes in another project. 166 | 167 | This library extracts some of the core features and makes them work without any other dependencies. 168 | 169 | There is some overlap with dataclasses, [traitlets](https://traitlets.readthedocs.io/en/stable/using_traitlets.html), [Pydantic](https://docs.pydantic.dev/latest/), [attrs](https://www.attrs.org/en/stable/), and other similar projects. 170 | But Declare isn't intended to replace any of these projects, which offer way more features. 171 | In fact, you can add Declared attributes to the class objects created by these other libraries. 172 | 173 | # How? 174 | 175 | In a word: ["descriptors"](https://mathspp.com/blog/pydonts/describing-descriptors). 176 | Python's descriptor protocol has been around forever, but remains a under used feature, IMHO. 177 | 178 | # Who? 179 | 180 | - [@willmcgugan](https://twitter.com/willmcgugan) 181 | - [mastodon.social/@willmcgugan](https://mastodon.social/@willmcgugan) 182 | 183 | 184 | # Thanks! 185 | 186 | A huge thank you to [Chris Cardillo](https://github.com/chriscardillo) who very kindly let me have the `declare` name on Pypi. 187 | -------------------------------------------------------------------------------- /examples/color.py: -------------------------------------------------------------------------------- 1 | import declare 2 | from declare import Declare 3 | 4 | 5 | class Color: 6 | """A color object with RGB, and alpha components.""" 7 | 8 | red = declare.Int(0) 9 | green = declare.Int(0) 10 | blue = declare.Int(0) 11 | alpha = declare.Float(1.0) 12 | 13 | @red.validate 14 | @green.validate 15 | @blue.validate 16 | def _validate_component(self, component: int) -> int: 17 | """Restrict RGB to 0 -> 255.""" 18 | return max(0, min(255, component)) 19 | 20 | @alpha.validate 21 | def _validate_alpha(self, alpha: float) -> float: 22 | return max(0.0, min(1.0, alpha)) 23 | 24 | @alpha.watch 25 | def _watch_alpha(self, old_alpha: float, alpha: float) -> None: 26 | print(f"alpha changed from {old_alpha} to {alpha}!") 27 | 28 | 29 | class Palette: 30 | name = declare.Str("") 31 | colors = Declare[list[Color]]([]) 32 | 33 | 34 | if __name__ == "__main__": 35 | red = Color() 36 | red.red = 300 37 | red.alpha = 0.5 38 | print(red.red, red.green, red.blue) 39 | 40 | palette = Palette() 41 | palette.colors = [Color()] 42 | -------------------------------------------------------------------------------- /poetry.lock: -------------------------------------------------------------------------------- 1 | # This file is automatically @generated by Poetry 1.8.2 and should not be changed by hand. 2 | 3 | [[package]] 4 | name = "colorama" 5 | version = "0.4.6" 6 | description = "Cross-platform colored terminal text." 7 | optional = false 8 | python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" 9 | files = [ 10 | {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, 11 | {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, 12 | ] 13 | 14 | [[package]] 15 | name = "exceptiongroup" 16 | version = "1.2.1" 17 | description = "Backport of PEP 654 (exception groups)" 18 | optional = false 19 | python-versions = ">=3.7" 20 | files = [ 21 | {file = "exceptiongroup-1.2.1-py3-none-any.whl", hash = "sha256:5258b9ed329c5bbdd31a309f53cbfb0b155341807f6ff7606a1e801a891b29ad"}, 22 | {file = "exceptiongroup-1.2.1.tar.gz", hash = "sha256:a4785e48b045528f5bfe627b6ad554ff32def154f42372786903b7abcfe1aa16"}, 23 | ] 24 | 25 | [package.extras] 26 | test = ["pytest (>=6)"] 27 | 28 | [[package]] 29 | name = "iniconfig" 30 | version = "2.0.0" 31 | description = "brain-dead simple config-ini parsing" 32 | optional = false 33 | python-versions = ">=3.7" 34 | files = [ 35 | {file = "iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374"}, 36 | {file = "iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3"}, 37 | ] 38 | 39 | [[package]] 40 | name = "mypy" 41 | version = "1.9.0" 42 | description = "Optional static typing for Python" 43 | optional = false 44 | python-versions = ">=3.8" 45 | files = [ 46 | {file = "mypy-1.9.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:f8a67616990062232ee4c3952f41c779afac41405806042a8126fe96e098419f"}, 47 | {file = "mypy-1.9.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:d357423fa57a489e8c47b7c85dfb96698caba13d66e086b412298a1a0ea3b0ed"}, 48 | {file = "mypy-1.9.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:49c87c15aed320de9b438ae7b00c1ac91cd393c1b854c2ce538e2a72d55df150"}, 49 | {file = "mypy-1.9.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:48533cdd345c3c2e5ef48ba3b0d3880b257b423e7995dada04248725c6f77374"}, 50 | {file = "mypy-1.9.0-cp310-cp310-win_amd64.whl", hash = "sha256:4d3dbd346cfec7cb98e6cbb6e0f3c23618af826316188d587d1c1bc34f0ede03"}, 51 | {file = "mypy-1.9.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:653265f9a2784db65bfca694d1edd23093ce49740b2244cde583aeb134c008f3"}, 52 | {file = "mypy-1.9.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:3a3c007ff3ee90f69cf0a15cbcdf0995749569b86b6d2f327af01fd1b8aee9dc"}, 53 | {file = "mypy-1.9.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2418488264eb41f69cc64a69a745fad4a8f86649af4b1041a4c64ee61fc61129"}, 54 | {file = "mypy-1.9.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:68edad3dc7d70f2f17ae4c6c1b9471a56138ca22722487eebacfd1eb5321d612"}, 55 | {file = "mypy-1.9.0-cp311-cp311-win_amd64.whl", hash = "sha256:85ca5fcc24f0b4aeedc1d02f93707bccc04733f21d41c88334c5482219b1ccb3"}, 56 | {file = "mypy-1.9.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:aceb1db093b04db5cd390821464504111b8ec3e351eb85afd1433490163d60cd"}, 57 | {file = "mypy-1.9.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0235391f1c6f6ce487b23b9dbd1327b4ec33bb93934aa986efe8a9563d9349e6"}, 58 | {file = "mypy-1.9.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d4d5ddc13421ba3e2e082a6c2d74c2ddb3979c39b582dacd53dd5d9431237185"}, 59 | {file = "mypy-1.9.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:190da1ee69b427d7efa8aa0d5e5ccd67a4fb04038c380237a0d96829cb157913"}, 60 | {file = "mypy-1.9.0-cp312-cp312-win_amd64.whl", hash = "sha256:fe28657de3bfec596bbeef01cb219833ad9d38dd5393fc649f4b366840baefe6"}, 61 | {file = "mypy-1.9.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:e54396d70be04b34f31d2edf3362c1edd023246c82f1730bbf8768c28db5361b"}, 62 | {file = "mypy-1.9.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:5e6061f44f2313b94f920e91b204ec600982961e07a17e0f6cd83371cb23f5c2"}, 63 | {file = "mypy-1.9.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:81a10926e5473c5fc3da8abb04119a1f5811a236dc3a38d92015cb1e6ba4cb9e"}, 64 | {file = "mypy-1.9.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:b685154e22e4e9199fc95f298661deea28aaede5ae16ccc8cbb1045e716b3e04"}, 65 | {file = "mypy-1.9.0-cp38-cp38-win_amd64.whl", hash = "sha256:5d741d3fc7c4da608764073089e5f58ef6352bedc223ff58f2f038c2c4698a89"}, 66 | {file = "mypy-1.9.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:587ce887f75dd9700252a3abbc9c97bbe165a4a630597845c61279cf32dfbf02"}, 67 | {file = "mypy-1.9.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:f88566144752999351725ac623471661c9d1cd8caa0134ff98cceeea181789f4"}, 68 | {file = "mypy-1.9.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:61758fabd58ce4b0720ae1e2fea5cfd4431591d6d590b197775329264f86311d"}, 69 | {file = "mypy-1.9.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:e49499be624dead83927e70c756970a0bc8240e9f769389cdf5714b0784ca6bf"}, 70 | {file = "mypy-1.9.0-cp39-cp39-win_amd64.whl", hash = "sha256:571741dc4194b4f82d344b15e8837e8c5fcc462d66d076748142327626a1b6e9"}, 71 | {file = "mypy-1.9.0-py3-none-any.whl", hash = "sha256:a260627a570559181a9ea5de61ac6297aa5af202f06fd7ab093ce74e7181e43e"}, 72 | {file = "mypy-1.9.0.tar.gz", hash = "sha256:3cc5da0127e6a478cddd906068496a97a7618a21ce9b54bde5bf7e539c7af974"}, 73 | ] 74 | 75 | [package.dependencies] 76 | mypy-extensions = ">=1.0.0" 77 | tomli = {version = ">=1.1.0", markers = "python_version < \"3.11\""} 78 | typing-extensions = ">=4.1.0" 79 | 80 | [package.extras] 81 | dmypy = ["psutil (>=4.0)"] 82 | install-types = ["pip"] 83 | mypyc = ["setuptools (>=50)"] 84 | reports = ["lxml"] 85 | 86 | [[package]] 87 | name = "mypy-extensions" 88 | version = "1.0.0" 89 | description = "Type system extensions for programs checked with the mypy type checker." 90 | optional = false 91 | python-versions = ">=3.5" 92 | files = [ 93 | {file = "mypy_extensions-1.0.0-py3-none-any.whl", hash = "sha256:4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d"}, 94 | {file = "mypy_extensions-1.0.0.tar.gz", hash = "sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782"}, 95 | ] 96 | 97 | [[package]] 98 | name = "packaging" 99 | version = "24.0" 100 | description = "Core utilities for Python packages" 101 | optional = false 102 | python-versions = ">=3.7" 103 | files = [ 104 | {file = "packaging-24.0-py3-none-any.whl", hash = "sha256:2ddfb553fdf02fb784c234c7ba6ccc288296ceabec964ad2eae3777778130bc5"}, 105 | {file = "packaging-24.0.tar.gz", hash = "sha256:eb82c5e3e56209074766e6885bb04b8c38a0c015d0a30036ebe7ece34c9989e9"}, 106 | ] 107 | 108 | [[package]] 109 | name = "pluggy" 110 | version = "1.4.0" 111 | description = "plugin and hook calling mechanisms for python" 112 | optional = false 113 | python-versions = ">=3.8" 114 | files = [ 115 | {file = "pluggy-1.4.0-py3-none-any.whl", hash = "sha256:7db9f7b503d67d1c5b95f59773ebb58a8c1c288129a88665838012cfb07b8981"}, 116 | {file = "pluggy-1.4.0.tar.gz", hash = "sha256:8c85c2876142a764e5b7548e7d9a0e0ddb46f5185161049a79b7e974454223be"}, 117 | ] 118 | 119 | [package.extras] 120 | dev = ["pre-commit", "tox"] 121 | testing = ["pytest", "pytest-benchmark"] 122 | 123 | [[package]] 124 | name = "pytest" 125 | version = "8.1.1" 126 | description = "pytest: simple powerful testing with Python" 127 | optional = false 128 | python-versions = ">=3.8" 129 | files = [ 130 | {file = "pytest-8.1.1-py3-none-any.whl", hash = "sha256:2a8386cfc11fa9d2c50ee7b2a57e7d898ef90470a7a34c4b949ff59662bb78b7"}, 131 | {file = "pytest-8.1.1.tar.gz", hash = "sha256:ac978141a75948948817d360297b7aae0fcb9d6ff6bc9ec6d514b85d5a65c044"}, 132 | ] 133 | 134 | [package.dependencies] 135 | colorama = {version = "*", markers = "sys_platform == \"win32\""} 136 | exceptiongroup = {version = ">=1.0.0rc8", markers = "python_version < \"3.11\""} 137 | iniconfig = "*" 138 | packaging = "*" 139 | pluggy = ">=1.4,<2.0" 140 | tomli = {version = ">=1", markers = "python_version < \"3.11\""} 141 | 142 | [package.extras] 143 | testing = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "pygments (>=2.7.2)", "requests", "setuptools", "xmlschema"] 144 | 145 | [[package]] 146 | name = "tomli" 147 | version = "2.0.1" 148 | description = "A lil' TOML parser" 149 | optional = false 150 | python-versions = ">=3.7" 151 | files = [ 152 | {file = "tomli-2.0.1-py3-none-any.whl", hash = "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc"}, 153 | {file = "tomli-2.0.1.tar.gz", hash = "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f"}, 154 | ] 155 | 156 | [[package]] 157 | name = "typing-extensions" 158 | version = "4.11.0" 159 | description = "Backported and Experimental Type Hints for Python 3.8+" 160 | optional = false 161 | python-versions = ">=3.8" 162 | files = [ 163 | {file = "typing_extensions-4.11.0-py3-none-any.whl", hash = "sha256:c1f94d72897edaf4ce775bb7558d5b79d8126906a14ea5ed1635921406c0387a"}, 164 | {file = "typing_extensions-4.11.0.tar.gz", hash = "sha256:83f085bd5ca59c80295fc2a82ab5dac679cbe02b9f33f7d83af68e241bea51b0"}, 165 | ] 166 | 167 | [metadata] 168 | lock-version = "2.0" 169 | python-versions = "^3.8" 170 | content-hash = "b373cc58d40efa5100c2500a4a9825b2fb0104b7733e674b9f7aa6e9b2e0b603" 171 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "declare" 3 | version = "1.0.1" 4 | description = "Declare attributes" 5 | authors = ["Will McGugan "] 6 | license = "MIT" 7 | readme = "README.md" 8 | 9 | [tool.poetry.dependencies] 10 | python = "^3.8" 11 | 12 | [tool.poetry.group.dev.dependencies] 13 | pytest = ">=8.1.1" 14 | mypy = ">=1.9.0" 15 | 16 | [build-system] 17 | requires = ["poetry-core"] 18 | build-backend = "poetry.core.masonry.api" 19 | -------------------------------------------------------------------------------- /src/declare/__init__.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from ._declare import Declare as Declare 4 | 5 | __all__ = [ 6 | "Declare", 7 | "Int", 8 | "Float", 9 | "Bool", 10 | "Str", 11 | "Bytes", 12 | ] 13 | 14 | Int: type[Declare[int]] = Declare 15 | Float: type[Declare[float]] = Declare 16 | Bool: type[Declare[bool]] = Declare 17 | Str: type[Declare[str]] = Declare 18 | Bytes: type[Declare[bytes]] = Declare 19 | -------------------------------------------------------------------------------- /src/declare/_declare.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from copy import copy 4 | from typing import TYPE_CHECKING, Generic, TypeVar, cast, overload 5 | 6 | if TYPE_CHECKING: 7 | from collections.abc import Callable 8 | 9 | from typing_extensions import TypeAlias 10 | 11 | 12 | ObjectType = TypeVar("ObjectType") 13 | ValueType = TypeVar("ValueType") 14 | 15 | Validator: TypeAlias = "Callable[[ObjectType, ValueType], ValueType]" 16 | Watcher: TypeAlias = "Callable[[ObjectType, ValueType | None, ValueType], None]" 17 | 18 | ValidateMethodType = TypeVar("ValidateMethodType", bound=Validator) 19 | WatchMethodType = TypeVar("WatchMethodType", bound=Watcher) 20 | 21 | 22 | class NoValue: 23 | """Sentinel type.""" 24 | 25 | 26 | _NO_VALUE = NoValue() 27 | 28 | 29 | class DeclareError(Exception): 30 | """Raised when an Declare related error occurs,""" 31 | 32 | 33 | class ValidateDecorator(Generic[ValidateMethodType]): 34 | """Validate decorator. 35 | 36 | Decorate a Widget method to make it a validator for the attribute. 37 | """ 38 | 39 | def __init__(self, declare: Declare | None = None) -> None: 40 | self._declare = declare 41 | self._validator: ValidateMethodType | None = None 42 | 43 | @overload 44 | def __call__(self) -> ValidateDecorator[ValidateMethodType]: ... 45 | 46 | @overload 47 | def __call__(self, method: ValidateMethodType) -> ValidateMethodType: ... 48 | 49 | def __call__( 50 | self, method: ValidateMethodType | None = None 51 | ) -> ValidateMethodType | ValidateDecorator[ValidateMethodType]: 52 | if method is None: 53 | return self 54 | assert self._declare is not None 55 | 56 | if self._declare._validator is not None: 57 | raise DeclareError(f"A validator has already been set on {self._declare!r}") 58 | self._declare._validator = method 59 | return method 60 | 61 | 62 | class WatchDecorator(Generic[WatchMethodType]): 63 | """Validate decorator. 64 | 65 | Decorate a Widget method to make it a validator for the attribute. 66 | """ 67 | 68 | def __init__(self, declare: Declare | None = None) -> None: 69 | self._declare = declare 70 | 71 | @overload 72 | def __call__(self) -> WatchDecorator[WatchMethodType]: ... 73 | 74 | @overload 75 | def __call__(self, method: WatchMethodType) -> WatchMethodType: ... 76 | 77 | def __call__( 78 | self, method: WatchMethodType | None = None 79 | ) -> WatchMethodType | WatchDecorator[WatchMethodType]: 80 | if method is None: 81 | return self 82 | assert self._declare is not None 83 | 84 | if self._declare._watcher is not None: 85 | raise DeclareError(f"A watcher has already been set on {self._declare!r}") 86 | self._declare._watcher = method 87 | return method 88 | 89 | 90 | class Declare(Generic[ValueType]): 91 | """A descriptor to declare attributes.""" 92 | 93 | def __init__( 94 | self, 95 | default: ValueType, 96 | *, 97 | validate: Validator | None = None, 98 | watch: Watcher | None = None, 99 | ) -> None: 100 | self._name = "" 101 | self._private_name = "" 102 | self._default = default 103 | self._validator = validate 104 | self._watcher = watch 105 | self._copy_default = not isinstance(default, (int, float, bool, str, complex)) 106 | 107 | def copy(self) -> Declare[ValueType]: 108 | """Return a copy of the Declare descriptor. 109 | 110 | Returns: 111 | A Declare descriptor. 112 | """ 113 | declare = Declare( 114 | self._default, 115 | validate=self._validator, 116 | watch=self._watcher, 117 | ) 118 | return declare 119 | 120 | def __call__( 121 | self, 122 | default: ValueType | NoValue = _NO_VALUE, 123 | *, 124 | validate: Validator | None = None, 125 | watch: Watcher | None = None, 126 | ) -> Declare[ValueType]: 127 | """Update the declaration. 128 | 129 | Args: 130 | default: New default. 131 | validate: A validator function. 132 | watch: A watch function. 133 | 134 | Returns: 135 | A new Declare. 136 | """ 137 | declare = self.copy() 138 | if not isinstance(default, NoValue): 139 | declare._default = default 140 | if validate is not None: 141 | declare._validator = validate 142 | if watch is not None: 143 | declare._watcher = watch 144 | return declare 145 | 146 | def __set_name__(self, owner: Type, name: str) -> None: 147 | self._owner = owner 148 | self._name = name 149 | self._private_name = f"__declare_private_{name}" 150 | 151 | @overload 152 | def __get__( 153 | self: Declare[ValueType], obj: None, obj_type: type[ObjectType] 154 | ) -> Declare[ValueType]: ... 155 | 156 | @overload 157 | def __get__( 158 | self: Declare[ValueType], obj: ObjectType, obj_type: type[ObjectType] 159 | ) -> ValueType: ... 160 | 161 | def __get__( 162 | self: Declare[ValueType], obj: ObjectType | None, obj_type: type[ObjectType] 163 | ) -> Declare[ValueType] | ValueType: 164 | if obj is None: 165 | return self 166 | if isinstance((value := getattr(obj, self._private_name, _NO_VALUE)), NoValue): 167 | value = copy(self._default) if self._copy_default else self._default 168 | setattr(obj, self._private_name, value) 169 | return value 170 | else: 171 | return value 172 | 173 | def __set__(self, obj: object, value: ValueType) -> None: 174 | if self._watcher: 175 | current_value = getattr(obj, self._name, None) 176 | new_value = ( 177 | value if self._validator is None else self._validator(obj, value) 178 | ) 179 | setattr(obj, self._private_name, new_value) 180 | if current_value != new_value: 181 | self._watcher(obj, current_value, new_value) 182 | 183 | else: 184 | setattr( 185 | obj, 186 | self._private_name, 187 | value if self._validator is None else self._validator(obj, value), 188 | ) 189 | 190 | @property 191 | def optional(self) -> Declare[ValueType | None]: 192 | """Make the type optional.""" 193 | # We're just changing the type, so this doesn't do anything at runtime. 194 | return cast("Declare[ValueType | None]", self) 195 | 196 | @property 197 | def validate(self) -> ValidateDecorator: 198 | """Decorator to define a validator.""" 199 | return ValidateDecorator(self) 200 | 201 | @property 202 | def watch(self) -> WatchDecorator: 203 | """Decorator to create a watcher.""" 204 | return WatchDecorator(self) 205 | -------------------------------------------------------------------------------- /src/declare/py.typed: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/willmcgugan/declare/63c8bdec6de492d2512404b0d2f80a2c8fe62edb/src/declare/py.typed -------------------------------------------------------------------------------- /tests/test_declare.py: -------------------------------------------------------------------------------- 1 | from typing import List 2 | 3 | import declare 4 | from declare import Declare 5 | 6 | 7 | def test_predefined(): 8 | class Foo: 9 | my_int = declare.Int(1) 10 | my_float = declare.Float(3.14) 11 | my_bool = declare.Bool(True) 12 | my_str = declare.Str("Foo") 13 | my_bytes = declare.Bytes(b"bar") 14 | 15 | foo = Foo() 16 | assert foo.my_int == 1 17 | assert foo.my_float == 3.14 18 | assert foo.my_bool == True 19 | assert foo.my_str == "Foo" 20 | assert foo.my_bytes == b"bar" 21 | 22 | 23 | def test_validate(): 24 | class Foo: 25 | positive = declare.Int(0) 26 | 27 | @positive.validate 28 | def _validate_positive(self, value: int) -> int: 29 | return max(0, value) 30 | 31 | foo = Foo() 32 | foo.positive = -1 33 | assert foo.positive == 0 34 | foo.positive = 1 35 | assert foo.positive == 1 36 | 37 | 38 | def test_watch() -> None: 39 | changes: list[tuple[int, int]] = [] 40 | 41 | class Foo: 42 | value = declare.Int(0) 43 | 44 | @value.watch 45 | def _watch_foo(self, old: int, new: int) -> None: 46 | changes.append((old, new)) 47 | 48 | foo = Foo() 49 | foo.value = 1 50 | assert changes == [(0, 1)] 51 | foo.value = 2 52 | assert changes == [(0, 1), (1, 2)] 53 | 54 | 55 | def test_custom(): 56 | class Foo: 57 | things = Declare[List[str]](["foo", "bar"]) 58 | 59 | foo = Foo() 60 | assert foo.things == ["foo", "bar"] 61 | --------------------------------------------------------------------------------