diff --git a/.coveragerc b/.coveragerc deleted file mode 100644 index f2042188..00000000 --- a/.coveragerc +++ /dev/null @@ -1,4 +0,0 @@ -[run] -omit = - */migrations/* - eav/__init__.py diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 00000000..55ad84da --- /dev/null +++ b/.editorconfig @@ -0,0 +1,17 @@ +# Check https://editorconfig.org for more information +# This is the main config file for this project: +root = true + +[*] +charset = utf-8 +trim_trailing_whitespace = true +end_of_line = lf +indent_style = space +insert_final_newline = true +indent_size = 2 + +[*.py] +indent_size = 4 + +[*.pyi] +indent_size = 4 diff --git a/.git-blame-ignore-revs b/.git-blame-ignore-revs new file mode 100644 index 00000000..6bef57f3 --- /dev/null +++ b/.git-blame-ignore-revs @@ -0,0 +1,2 @@ +# Apply ruff linter rules and standardize code style +c4d7cedeb8b7a8bded8db9a658ae635195071ce3 diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index eb083910..8977fbc4 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -17,8 +17,8 @@ Tell us what happens instead. Not obligatory, but you can suggest a fix or reason for the bug. ## Steps to Reproduce -This is not required, but it would be highly appreciated if you -provided a link to a live example, or an unambiguous set of steps to +This is not required, but it would be highly appreciated if you +provided a link to a live example, or an unambiguous set of steps to reproduce this bug. Include code to reproduce, if relevant. diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 00000000..f079ffa7 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,15 @@ +version: 2 +updates: +- package-ecosystem: pip + directory: "/" + schedule: + interval: daily + time: "02:00" + open-pull-requests-limit: 10 + +- package-ecosystem: github-actions + directory: "/" + schedule: + interval: daily + time: "02:00" + open-pull-requests-limit: 10 diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 00000000..75e82656 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,54 @@ +# I'm helping! + + + +## Checklist + + + +- [ ] I have double checked that there are no unrelated changes in this pull request (old patches, accidental config files, etc.) +- [ ] I have created at least one test case for the changes I have made +- [ ] I have updated the documentation for the changes I have made +- [ ] I have added my changes to the `CHANGELOG.md` + +## Pull Request type + + + +Please check the type of change your PR introduces: + +- [ ] Bugfix +- [ ] Feature +- [ ] Code style update (formatting, renaming) +- [ ] Refactoring (no functional changes, no api changes) +- [ ] Build related changes +- [ ] Documentation content changes +- [ ] Other (please describe): + +## Related issue(s) + + + +## Other Information + + diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 00000000..91be5163 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,39 @@ +name: Release + +on: + push: + tags: + - '*' + +jobs: + build: + if: github.repository == 'jazzband/django-eav2' + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: 3.8 + + - name: Install dependencies + run: | + python -m pip install -U pip + python -m pip install -U poetry twine + + - name: Build package + run: | + poetry build + twine check dist/* + + - name: Upload packages to Jazzband + if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags') + uses: pypa/gh-action-pypi-publish@release/v1 + with: + user: jazzband + password: ${{ secrets.JAZZBAND_RELEASE_KEY }} + repository_url: https://jazzband.co/projects/django-eav2/upload diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 00000000..bb555569 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,65 @@ +# https://docs.djangoproject.com/en/stable/faq/install/#what-python-version-can-i-use-with-django +name: test + +"on": + push: + branches: + - '**' + pull_request: + workflow_dispatch: + +jobs: + test-matrix: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ['3.9', '3.10', '3.11', '3.12', '3.13'] + django-version: ['4.2', '5.1', '5.2'] + exclude: + # Exclude Python 3.9 with Django 5.1 and 5.2 + - python-version: '3.9' + django-version: '5.1' + - python-version: '3.9' + django-version: '5.2' + # Exclude Python 3.13 with Django 4.2 + - python-version: '3.13' + django-version: '4.2' + + steps: + - uses: actions/checkout@v4 + + - name: Set up python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + + - name: Install Poetry + uses: snok/install-poetry@v1 + with: + version: 1.8.4 + virtualenvs-create: true + virtualenvs-in-project: true + installer-parallel: true + + - name: Set up cache + uses: actions/cache@v4 + with: + path: .venv + key: venv-${{ matrix.python-version }}-${{ hashFiles('poetry.lock') }} + + - name: Install dependencies + run: | + poetry install + poetry run pip install --upgrade pip + poetry run pip install --upgrade "django==${{ matrix.django-version }}.*" + + - name: Run tests + run: | + poetry run pytest + poetry check + poetry run pip check + + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v5 + with: + file: ./coverage.xml diff --git a/.gitignore b/.gitignore index e7c49130..60b68e62 100644 --- a/.gitignore +++ b/.gitignore @@ -126,3 +126,8 @@ tags ## Mac .DS_Store + + +## IDE +.idea +.vscode diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 00000000..1a1d1bd5 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,27 @@ +# See https://pre-commit.com for more information +repos: +- repo: https://github.com/pre-commit/pre-commit-hooks + rev: v5.0.0 + hooks: + - id: trailing-whitespace + - id: end-of-file-fixer + - id: check-yaml + - id: check-added-large-files + - id: mixed-line-ending + +- repo: https://github.com/astral-sh/ruff-pre-commit + # Ruff version. + rev: v0.11.12 + hooks: + # Run the linter. + - id: ruff + args: [ --fix ] + # Run the formatter. + - id: ruff-format + +- repo: https://github.com/remastr/pre-commit-django-check-migrations + rev: v0.1.0 + hooks: + - id: check-migrations-created + args: [--manage-path=manage.py] + additional_dependencies: [django==4.1] diff --git a/.readthedocs.yml b/.readthedocs.yml new file mode 100644 index 00000000..3e573621 --- /dev/null +++ b/.readthedocs.yml @@ -0,0 +1,24 @@ +# .readthedocs.yaml +# Read the Docs configuration file +# See https://docs.readthedocs.io/en/stable/config-file/v2.html for details +version: 2 + +# Set the version of Python and other tools you might need +build: + os: ubuntu-20.04 + tools: + python: '3.9' + jobs: + # See https://github.com/readthedocs/readthedocs.org/issues/4912 + pre_create_environment: + - asdf plugin add poetry + - asdf install poetry latest + - asdf global poetry latest + - poetry config virtualenvs.create false + post_install: + - . "$(pwd | rev | sed 's/stuokcehc/svne/' | rev)/bin/activate" && poetry install --only main --only docs + +# Build documentation in the docs/ directory with Sphinx +sphinx: + configuration: docs/source/conf.py + fail_on_warning: true diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 37b97703..00000000 --- a/.travis.yml +++ /dev/null @@ -1,26 +0,0 @@ -language: python -matrix: - include: - - python: 2.7 - env: TOXENV=py27-django111 - - python: 3.5 - env: TOXENV=py35-django111 - - python: 3.6 - env: TOXENV=py36-django111 - - python: 3.7-dev - env: TOXENV=py37-django20 - - python: 3.7-dev - env: TOXENV=py37-django30 - - python: 3.7-dev - env: TOXENV=py37-djangotip -install: - - pip install Django>=1.11 - - pip install coveralls==1.3.0 - - pip install coverage==4.5.1 - - pip install tox-travis==0.10 -before_script: - - coverage erase -script: - - coverage run --source=eav runtests; tox -after_success: - - COVERALLS_REPO_TOKEN=71NkMDQFpFKB9QYXoK12LYuWUEmQ2wD6V coveralls diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 00000000..a1c67413 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,171 @@ +# Version History + +We follow [Semantic Versions](https://semver.org/) starting at the `0.14.0` release. + +## 1.8.1 (2025-06-02) + +## What's Changed + +- Added support for Django 5.2 +- Updated dependencies to their latest versions + +## 1.8.0 (2025-02-24) + +## What's Changed + +- Add database constraints to Value model for data integrity by @Dresdn in https://github.com/jazzband/django-eav2/pull/706 +- Fix for issue #648: Ensure choices are valid (value, label) tuples by @altimore in https://github.com/jazzband/django-eav2/pull/707 + +## 1.7.1 (2024-09-01) + +## What's Changed +* Restore backward compatibility for Attribute creation with invalid slugs by @Dresdn in https://github.com/jazzband/django-eav2/pull/639 + +## 1.7.0 (2024-09-01) + +### What's Changed + +- Enhance slug validation for Python identifier compliance +- Migrate to ruff +- Drop support for Django 3.2 +- Add support for Django 5.1 + +## 1.6.1 (2024-06-23) + +### What's Changed + +- Ensure eav.register() Maintains Manager Order by @Dresdn in https://github.com/jazzband/django-eav2/pull/595 +- Update downstream dependencies by @Dresdn in https://github.com/jazzband/django-eav2/pull/597 + +## 1.6.0 (2024-03-14) + +### What's Changed + +- Corrects `BaseEntityAdmin` integration into Django Admin site +- Split model modules by @iacobfred in https://github.com/jazzband/django-eav2/pull/467 +- Add Django 5.0 and Python 3.12 to the testing by @cclauss in https://github.com/jazzband/django-eav2/pull/487 +- Fix typos with codespell by @cclauss in https://github.com/jazzband/django-eav2/pull/489 +- Enhance BaseEntityAdmin by @Dresdn in https://github.com/jazzband/django-eav2/pull/541 +- Remove support for Django < 3.2 and Python < 3.8 by @Dresdn in https://github.com/jazzband/django-eav2/pull/542 + +## 1.5.0 (2023-11-08) + +### Bug Fixes + +- Fixes querying with multiple eav kwargs [#395](https://github.com/jazzband/django-eav2/issues/395) + +### Features + +- Support for many type of primary key (UUIDField, BigAutoField) +- Support for natural key use for some models for serialization (EnumValue, EnumGroup, Attribute, Value) +- Add support for Django 4.2 +- Add support for Python 3.11 + +## 1.4.0 (2023-07-07) + +### Features + +- Support Bahasa Indonesia Translations +- Support Django 4.2 + +## 1.3.1 (2023-02-22) + +### Bug Fixes + +- Generate missing migrations [#331](https://github.com/jazzband/django-eav2/issues/331) + +## 1.3.0 (2023-02-10) + +### Features + +- Add support for Django 4.1 + +### Bug Fixes + +- Fixes missing `Add another` button for inlines in `BaseEntityAdmin` +- Fixes saving of Attribute date types rendering using `BaseDynamicEntityForm` [#261](https://github.com/jazzband/django-eav2/issues/261) + +### Misc + +- Drops support for Django 2.2 and Python 3.7 + +## 1.2.3 (2022-08-15) + +### Bug Fixes + +- Don't mark doc8 as a dependency [#235](https://github.com/jazzband/django-eav2/issues/235) +- Make Read the Docs dependencies all optional + +## 1.2.2 (2022-08-13) + +### Bug Fixes + +- Fixes AttributeError when using CSVFormField [#187](https://github.com/jazzband/django-eav2/issues/187) +- Fixes slug generation for Attribute.name fields longer than 50 characters [#223](https://github.com/jazzband/django-eav2/issues/223) +- Migrates Attribute.slug to django.db.models.SlugField() [#223](https://github.com/jazzband/django-eav2/issues/223) + +## 1.2.1 (2022-02-08) + +### Bug Fixes + +- Fixes FieldError when filtering on foreign keys [#163](https://github.com/jazzband/django-eav2/issues/163) + +## 1.2.0 (2021-12-18) + +### Features + +- Adds 64-bit support for `Value.value_int` +- Adds Django 4.0 and Python 3.10 support + +### Misc + +- Drops support for Django 3.1 and Python 3.6 + +## 1.1.0 (2021-11-07) + +### Features + +- Adds support for entity models with UUId as a primary key #38 + +### Bug Fixes + +- Fixes `ValueError` for models without local managers #41 +- Fixes `str()` and `repr()` for `EnumGroup` and `EnumValue` objects #91 + +### Misc + +- Bumps min python version to `3.6.2` + +**Full Changelog**: + +## 1.0.0 (2021-10-21) + +### Breaking Changes + +- Drops support for `django1.x` +- Drops support for `django3.0` +- Moves `JSONField()` datatype to `django-jsonfield-backport` for Django2.2 instances + +### Features + +- Adds support for `django3.2` +- Adds support for `python3.9` +- Adds support for `defaults` keyword on `get_or_create()` + +### #Misc + +- Revamps all tooling, including moving to `poetry`, `pytest`, and `black` +- Adds Github Actions and Dependabot + +**Full Changelog**: + +## 0.14.0 (2021-04-23) + +### Misc + +- This release will be the last to support this range of Django versions: 1.11, 2.0, 2.1, 2.2, 3.0. SInce all of their extended support was ended by Django Project. +- From the next release only will be supported 2.2 LTS, 3.1, and 3.2 LTS (eventually 4.x) + +**Full Changelog**: + +(Anything before 0.14.0 was not recorded.) diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 00000000..e0d5efab --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,46 @@ +# Code of Conduct + +As contributors and maintainers of the Jazzband projects, and in the interest of +fostering an open and welcoming community, we pledge to respect all people who +contribute through reporting issues, posting feature requests, updating documentation, +submitting pull requests or patches, and other activities. + +We are committed to making participation in the Jazzband a harassment-free experience +for everyone, regardless of the level of experience, gender, gender identity and +expression, sexual orientation, disability, personal appearance, body size, race, +ethnicity, age, religion, or nationality. + +Examples of unacceptable behavior by participants include: + +- The use of sexualized language or imagery +- Personal attacks +- Trolling or insulting/derogatory comments +- Public or private harassment +- Publishing other's private information, such as physical or electronic addresses, + without explicit permission +- Other unethical or unprofessional conduct + +The Jazzband roadies have the right and responsibility to remove, edit, or reject +comments, commits, code, wiki edits, issues, and other contributions that are not +aligned to this Code of Conduct, or to ban temporarily or permanently any contributor +for other behaviors that they deem inappropriate, threatening, offensive, or harmful. + +By adopting this Code of Conduct, the roadies commit themselves to fairly and +consistently applying these principles to every aspect of managing the jazzband +projects. Roadies who do not follow or enforce the Code of Conduct may be permanently +removed from the Jazzband roadies. + +This code of conduct applies both within project spaces and in public spaces when an +individual is representing the project or its community. + +Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by +contacting the roadies at `roadies@jazzband.co`. All complaints will be reviewed and +investigated and will result in a response that is deemed necessary and appropriate to +the circumstances. Roadies are obligated to maintain confidentiality with regard to the +reporter of an incident. + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], version +1.3.0, available at [https://contributor-covenant.org/version/1/3/0/][version] + +[homepage]: https://contributor-covenant.org +[version]: https://contributor-covenant.org/version/1/3/0/ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index adce25ec..ba24fa05 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,3 +1,7 @@ +[![Jazzband](https://jazzband.co/static/img/jazzband.svg)](https://jazzband.co/) + +This is a [Jazzband](https://jazzband.co/) project. By contributing you agree to abide by the [Contributor Code of Conduct](https://jazzband.co/about/conduct) and follow the [guidelines](https://jazzband.co/about/guidelines). + # Contributing We love your input! We want to make contributing to this project as easy and transparent as possible, whether it's: @@ -7,7 +11,30 @@ We love your input! We want to make contributing to this project as easy and tra - Proposing new features - Becoming a maintainer -### We develop with Github +## Dependencies + +We use [poetry](https://github.com/sdispater/poetry) to manage the dependencies. + +To install them you would need to run `install` command: + +```bash +poetry install +``` + +To activate your `virtualenv` run `poetry shell`. + + +## Tests + +We use `pytest` and `flake8` for quality control. + +To run all tests: + +```bash +pytest +``` + +## We develop with Github We use github to host code, to track issues and feature requests, as well as accept pull requests. ### We use [Github Flow](https://guides.github.com/introduction/flow/index.html), so all code changes from community happen through pull requests @@ -18,18 +45,18 @@ Pull requests are the best way to propose changes to the codebase (we use [Githu 3. If you've changed APIs, update the documentation. 4. Ensure the test suite passes. 5. Make sure your code lints. -6. Describe the pull request using [this](https://github.com/makimo/django-eav2/blob/master/PULL_REQUEST_TEMPLATE.md) template. +6. Describe the pull request using [this](https://github.com/jazzband/django-eav2/blob/master/PULL_REQUEST_TEMPLATE.md) template. ### Any contributions you make will be under the GNU Lesser General Public License v3.0 In short, when you submit code changes, your submissions are understood to be under the same [LGPLv3](https://choosealicense.com/licenses/lgpl-3.0/) that covers the project. Feel free to contact the maintainers if that's a concern. -### Report bugs using Github's [issues](https://github.com/makimo/django-eav2/issues) -We use GitHub issues to track public bugs. Report a bug by opening a new issue. Use [this](https://github.com/makimo/django-eav2/blob/master/.github/ISSUE_TEMPLATE/bug_report.md) template to describe your reports. +### Report bugs using Github's [issues](https://github.com/jazzband/django-eav2/issues) +We use GitHub issues to track public bugs. Report a bug by opening a new issue. Use [this](https://github.com/jazzband/django-eav2/blob/master/.github/ISSUE_TEMPLATE/bug_report.md) template to describe your reports. ### Use a consistent coding style -We use [PEP-8](https://www.python.org/dev/peps/pep-0008/) (whenever it makes sense) for code and [Google-style](http://sphinxcontrib-napoleon.readthedocs.io/en/latest/example_google.html) reStructuredText for doc-strings. +We use [Black](https://github.com/psf/black) and (working towards) [wemake-python-styleguide](https://github.com/wemake-services/wemake-python-styleguide) for code and [Google-style](https://sphinxcontrib-napoleon.readthedocs.io/en/latest/example_google.html) reStructuredText for doc-strings.
diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md index bf1d39bd..7544c576 100644 --- a/CONTRIBUTORS.md +++ b/CONTRIBUTORS.md @@ -9,6 +9,7 @@ Contributors, alphabetically: * IwoHerka (Iwo Herka) * jpwhite3 * katembu (Moses Katembu) + * lvm (Mauro Lizaur) * madEng84 * MajekX (Adam Majczyk) * nicpottier (Nic Pottier) diff --git a/LICENSE b/LICENSE index 7dff5180..b7d05aa9 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ This software is derived from Django EAV (https://github.com/mvpdev/django-eav) which, in turn, was derived from EAV Django, originally written and copyrighted -by Andrey Mikhaylenko . +by Andrey Mikhaylenko . This is free software: you can redistribute it and/or modify it under the terms of the GNU Lesser General Public License as published diff --git a/MANIFEST.in b/MANIFEST.in deleted file mode 100644 index 895bafb7..00000000 --- a/MANIFEST.in +++ /dev/null @@ -1,2 +0,0 @@ -# file GENERATED by distutils, do NOT edit -exclude tests/**.py diff --git a/PULL_REQUEST_TEMPLATE.md b/PULL_REQUEST_TEMPLATE.md deleted file mode 100644 index dfc8d7a2..00000000 --- a/PULL_REQUEST_TEMPLATE.md +++ /dev/null @@ -1,32 +0,0 @@ -(Provide a general summary of your changes in the Title above) - -## Description -* What is the current behavior? You can also link to an open issue here. -* If this is a feature change, what is the new behavior? -* Describe your changes in detail. - -## Motivation and Context -* Why is this change required? What problem does it solve? -* If it fixes an open issue, please link to the issue here. - -## How Has This Been Tested? -* Did you add appriopriate tests? -* Please describe in detail how you tested your changes. - -## Types of Changes -* What types of changes does your code introduce? Put an `x` in all the boxes that apply: -- [ ] Bug fix (non-breaking change which fixes an issue) -- [ ] New feature (non-breaking change which adds functionality) -- [ ] Breaking change (fix or feature that would cause existing functionality to change) - -## Checklist: - Go over all the following points, and put an `x` in all the boxes that apply. -If you're unsure about any of these, don't hesitate to ask. We're here to help! -- [ ] My code follows the code style of this project. -- [ ] My change requires a change to the documentation. -- [ ] I have updated the documentation accordingly. -- [ ] I have read the **CONTRIBUTING** document. -- [ ] I have added tests to cover my changes. -- [ ] All new and existing tests passed. - -Thank you! diff --git a/README.md b/README.md index 1a69d1a4..eddd1043 100644 --- a/README.md +++ b/README.md @@ -1,14 +1,13 @@ -[![Build Status](https://travis-ci.org/makimo/django-eav2.svg?branch=master)](https://travis-ci.org/makimo/django-eav2) -[![Coverage Status](https://coveralls.io/repos/github/makimo/django-eav2/badge.svg?branch=master)](https://coveralls.io/github/makimo/django-eav2?branch=master) -[![Codacy Badge](https://api.codacy.com/project/badge/Grade/159540d899bd41bb860f0ce996427e1f)](https://www.codacy.com/app/IwoHerka/django-eav2?utm_source=github.com&utm_medium=referral&utm_content=makimo/django-eav2&utm_campaign=Badge_Grade) -[![Maintainability](https://api.codeclimate.com/v1/badges/b90eacf7a90db4b58f13/maintainability)](https://codeclimate.com/github/makimo/django-eav2/maintainability) -![Python Version](https://img.shields.io/badge/Python-2.7,%203.5,%203.6,%203.7dev-blue.svg) -![Django Version](https://img.shields.io/badge/Django-1.11,%202.0,%203.0,%20tip-green.svg) +[![Build Status](https://github.com/jazzband/django-eav2/actions/workflows/test.yml/badge.svg)](https://github.com/jazzband/django-eav2/actions/workflows/test.yml) +[![codecov](https://codecov.io/gh/jazzband/django-eav2/branch/master/graph/badge.svg?token=BJk3zS22BS)](https://codecov.io/gh/jazzband/django-eav2) +[![Python Version](https://img.shields.io/pypi/pyversions/django-eav2.svg)](https://pypi.org/project/django-eav2/) +[![Django Version](https://img.shields.io/pypi/djversions/django-eav2.svg?color=green)](https://pypi.org/project/django-eav2/) +[![Jazzband](https://jazzband.co/static/img/badge.svg)](https://jazzband.co/) ## Django EAV 2 - Entity-Attribute-Value storage for Django Django EAV 2 is a fork of django-eav (which itself was derived from eav-django). -You can find documentation here. +You can find documentation here. ## What is EAV anyway? @@ -16,20 +15,20 @@ You can find documentation here. Data in EAV is stored as a 3-tuple (typically corresponding to three distinct tables): -* The entity: the item being described, e.g. `Person(name='Mike')`. -* The attribute: often a foreign key into a table of attributes, e.g. `Attribute(slug='height', datatype=FLOAT)`. -* The value of the attribute, with links both an attribute and an entity, e.g. `Value(value_float=15.5, person=mike, attr=height)`. +- The entity: the item being described, e.g. `Person(name='Mike')`. +- The attribute: often a foreign key into a table of attributes, e.g. `Attribute(slug='height', datatype=FLOAT)`. +- The value of the attribute, with links both an attribute and an entity, e.g. `Value(value_float=15.5, person=mike, attr=height)`. Entities in **django-eav2** are your typical Django model instances. Attributes (name and type) are stored in their own table, which makes it easy to manipulate the list of available attributes in the system. Values are an intermediate table between attributes and entities, each instance holding a single value. This implementation also makes it easy to edit attributes in Django Admin and form instances. You will find detailed description of the EAV here: -* [Wikipedia - Entity–attribute–value model](https://en.wikipedia.org/wiki/Entity%E2%80%93attribute%E2%80%93value_model) +- [Wikipedia - Entity–attribute–value model](https://en.wikipedia.org/wiki/Entity%E2%80%93attribute%E2%80%93value_model) ## EAV - The Good, the Bad or the Ugly? -EAV is a trade-off between flexibility and complexity. As such, it should not be thought of as either an amelioration pattern, nor an anti-pattern. It is more of a [gray pattern](http://wiki.c2.com/?GreyPattern) - it exists in some context, to solve certain set of problems. When used appropriately, it can introduce great flexibility, cut prototyping time or deacrease complexity. When used carelessly, however, it can complicate database schema, degrade the performance and make maintainance hard. **As with every tool, it should not be overused.** In the following paragraphs we briefly discuss the pros, the cons and pointers to keep in mind when using EAV. +EAV is a trade-off between flexibility and complexity. As such, it should not be thought of as either an amelioration pattern, nor an anti-pattern. It is more of a [gray pattern](https://wiki.c2.com/?GreyPattern) - it exists in some context, to solve certain set of problems. When used appropriately, it can introduce great flexibility, cut prototyping time or deacrease complexity. When used carelessly, however, it can complicate database schema, degrade the performance and make maintenance hard. **As with every tool, it should not be overused.** In the following paragraphs we briefly discuss the pros, the cons and pointers to keep in mind when using EAV. ### When to use EAV? @@ -37,30 +36,30 @@ Originally, EAV was introduced to workaround a problem which cannot be easily so Typical application of the EAV model sets to solve the problem of sparse data with a large number of applicable attributes, but only a small fraction that applies to a given entity that may not be known beforehand. Consider the classic example: - > A problem that data modelers commonly encounter in the biomedical domain is organizing and storing highly diverse and heterogeneous data. For example, a single patient may have thousands of applicable descriptive parameters, all of which need to be easily accessible in an electronic patient record system. These requirements pose significant modeling and implementation challenges. [1] +> A problem that data modelers commonly encounter in the biomedical domain is organizing and storing highly diverse and heterogeneous data. For example, a single patient may have thousands of applicable descriptive parameters, all of which need to be easily accessible in an electronic patient record system. These requirements pose significant modeling and implementation challenges. [1] - And: +And: - > [...] what do you do when you have customers that demand real-time, on-demand addition of attributes that they want to store? In one of the systems I manage, our customers wanted to do exactly this. Since we run a SaaS (software as a service) application, we have many customers across several different industries, who in turn want to use our system to store different types of information about *their* customers. A salon chain might want to record facts such as 'hair color,' 'hair type,' and 'haircut frequency'; while an investment company might want to record facts such as 'portfolio name,' 'last portfolio adjustment date,' and 'current portfolio balance.' [2] +> [...] what do you do when you have customers that demand real-time, on-demand addition of attributes that they want to store? In one of the systems I manage, our customers wanted to do exactly this. Since we run a SaaS (software as a service) application, we have many customers across several different industries, who in turn want to use our system to store different types of information about _their_ customers. A salon chain might want to record facts such as 'hair color,' 'hair type,' and 'haircut frequency'; while an investment company might want to record facts such as 'portfolio name,' 'last portfolio adjustment date,' and 'current portfolio balance.' [2] - In both of these problems we have to deal with sparse and heterogeneous properties that apply only to potentially different subsets of particular entities. Applying EAV to a sub-schema of the database allows to model the desired behaviour. Traditional solution would involves wide tables with many columns storing NULL values for attributes that don't apply to an entity. +In both of these problems we have to deal with sparse and heterogeneous properties that apply only to potentially different subsets of particular entities. Applying EAV to a sub-schema of the database allows to model the desired behaviour. Traditional solution would involves wide tables with many columns storing NULL values for attributes that don't apply to an entity. Very common use case for EAV are custom product attributes in E-commerce implementations, such as Magento. [3] - As a rule of thumb, EAV can be used when: - - * Model attributes are to be added and removed by end users (or are unknowable in some different way). EAV supports these without ALTER TABLE statements and allows the attributes to be strongly typed and easily searchable. - * There will be many attributes and values are sparse, in contrast to having tables with mostly-null columns. - * The data is highly dynamic/volatile/vulnerable to change. This problem is present in the second example given above. Other example would be rapidly evolving system, such as a prototype with constantly changing requirements. - * We want to store meta-data or supporting information, e.g. to customize system's behavior. - * Numerous classes of data need to be represented, each class has a limited number of attributes, but the number of instances of each class is very small. -* We want to minimise programmer's input when changing the data model. +As a rule of thumb, EAV can be used when: -For more throughout discussion on the appriopriate use-cases see: +- Model attributes are to be added and removed by end users (or are unknowable in some different way). EAV supports these without ALTER TABLE statements and allows the attributes to be strongly typed and easily searchable. +- There will be many attributes and values are sparse, in contrast to having tables with mostly-null columns. +- The data is highly dynamic/volatile/vulnerable to change. This problem is present in the second example given above. Other example would be rapidly evolving system, such as a prototype with constantly changing requirements. +- We want to store meta-data or supporting information, e.g. to customize system's behavior. +- Numerous classes of data need to be represented, each class has a limited number of attributes, but the number of instances of each class is very small. +- We want to minimise programmer's input when changing the data model. + +For more throughout discussion on the appropriate use-cases see: 1. [Wikipedia - Scenarios that are appropriate for EAV modeling](https://en.wikipedia.org/wiki/Entity%E2%80%93attribute%E2%80%93value_model#Scenarios_that_are_appropriate_for_EAV_modeling) 2. [StackOverflow - Entity Attribute Value Database vs. strict Relational Model E-commerce](https://stackoverflow.com/questions/870808/entity-attribute-value-database-vs-strict-relational-model-ecommerce) -3. [WikiWikiWeb - Generic Data Model](http://wiki.c2.com/?GenericDataModel) +3. [WikiWikiWeb - Generic Data Model](https://wiki.c2.com/?GenericDataModel) ## When to avoid it? @@ -84,24 +83,76 @@ However, it is important to note that: In some use-cases, JSONB (binary JSON data) datatype (Postgres 9.4+ and analogous in other RDMSs) can be used as an alternative to EAV. JSONB supports indexing, which amortizes performance trade-off. It's important to keep in mind that JSONB is not RDMS-agnostic solution and has it's own problems, such as typing. + ## Installation -You can install **django-eav2** from three sources: +Install with pip + ```bash -# From PyPI via pip pip install django-eav2 +``` + +## Configuration + +Add `eav` to `INSTALLED_APPS` in your settings. + +```python +INSTALLED_APPS = [ + ... + 'eav', +] +``` + +Add `django.db.models.UUIDField` or `django.db.models.BigAutoField` as value of `EAV2_PRIMARY_KEY_FIELD` in your settings + +``` python +EAV2_PRIMARY_KEY_FIELD = "django.db.models.UUIDField" # as example +``` + +### Note: Primary key mandatory modification field -# From source via pip -pip install git+https://github.com/makimo/django-eav2@master +If the primary key of eav models are to be modified (UUIDField -> BigAutoField, BigAutoField -> UUIDField) in the middle of the project when the migrations are already done, you have to change the value of `EAV2_PRIMARY_KEY_FIELD` in your settings. -# From source via setuptools -git clone git@github.com:makimo/django-eav2.git -cd django-eav2 -python setup.py install +##### Step 1 + Change the value of `EAV2_PRIMARY_KEY_FIELD` into `django.db.models.CharField` in your settings. -# To uninstall: -python setup.py install --record files.txt -rm $(cat files.txt) + ```python + EAV2_PRIMARY_KEY_FIELD = "django.db.models.CharField" + ``` + + Run the migrations + + ```bash + python manage.py makemigrations + python manage.py migrate + ``` + +##### Step 2 + Change the value of `EAV2_PRIMARY_KEY_FIELD` into the desired value (`django.db.models.BigAutoField` or `django.db.models.UUIDField`) in your settings. + + ```python + EAV2_PRIMARY_KEY_FIELD = "django.db.models.BigAutoField" # as example + ``` + + Run again the migrations. + +```bash + python manage.py makemigrations + python manage.py migrate + ``` + +### Note: Django 2.2 Users + +Since `models.JSONField()` isn't supported in Django 2.2, we use [django-jsonfield-backport](https://github.com/laymonage/django-jsonfield-backport) to provide [JSONField](https://docs.djangoproject.com/en/dev/releases/3.1/#jsonfield-for-all-supported-database-backends) functionality. + +This requires adding `django_jsonfield_backport` to your `INSTALLED_APPS` as well. + +```python +INSTALLED_APPS = [ + ... + 'eav', + 'django_jsonfield_backport', +] ``` ## Getting started @@ -139,10 +190,9 @@ Supplier.objects.filter(eav__city='London') # = ]> ``` -### What next? Check out documentation. +**What next? Check out the documentation.** -
-
+--- ### References diff --git a/docs/Makefile b/docs/Makefile index a11fe278..8a7cc4db 100644 --- a/docs/Makefile +++ b/docs/Makefile @@ -17,4 +17,4 @@ help: # Catch-all target: route all unknown targets to Sphinx using the new # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). %: Makefile - @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) \ No newline at end of file + @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) diff --git a/docs/source/_static/RobotoSlab-Regular.ttf b/docs/source/_static/RobotoSlab-Regular.ttf deleted file mode 100644 index eb52a790..00000000 Binary files a/docs/source/_static/RobotoSlab-Regular.ttf and /dev/null differ diff --git a/docs/source/_static/styles.css b/docs/source/_static/styles.css deleted file mode 100644 index 23ac1222..00000000 --- a/docs/source/_static/styles.css +++ /dev/null @@ -1,66 +0,0 @@ -@import url('https://fonts.googleapis.com/css?family=Roboto'); - -@font-face { - font-family: "Roboto Slab"; - src: url("./RobotoSlab-Regular.ttf"); -} - -pre { - background-color: #f6f6f6 !important; -} - -.doc-title h1 { - text-align: center; - padding: 2rem !important; -} - -.doc-api { - font-size: 14px; -} - -div.sphinxsidebar h3 { - font-size: 21px !important; -} - -body { - font-size: 16px; -} - -div.admonition p.admonition-title, div.sphinxsidebar h3, div.sphinxsidebar h4, - div.sphinxsidebar input, div.body h1, div.body h2, div.body h3, div.body h4, - div.body h5, div.body h6 { font-family: 'Roboto Slab', 'Helvetica', 'Arial', - sans-serif; font-weight: 400; } -div.body h1, div.body h2, div.body h3, div.body h4, - div.body h5, div.body h6 { color: #353535; } -pre, code { font-family: 'Ubuntu Mono', 'Consolas', 'Menlo', - 'Deja Vu Sans Mono', 'Bitstream Vera Sans Mono'; - font-size: 15px; background: transparent; } -pre, * pre { padding: 7px 0 7px 30px!important; - margin: 15px 0!important; - line-height: 1.3; } - - -div.body { color: #3E4349; } -a { color: #5D2CD1; } -a:hover { color: #7546E3; } -p.version-warning { background-color: #7546E3; } -a.reference { border-bottom: 1px dotted #5D2CD1; } -a.reference:hover { border-bottom: 1px solid #7546E3; } -a.footnote-reference { border-bottom: 1px dotted #5D2CD1; } -a.footnote-reference:hover { border-bottom: 1px solid #7546E3; } -a:hover code { background-color: #eeeeee; } -code.xref, a code { background-color: #E8EFF0; - border-bottom: 1px solid white; } - -div.indexwrapper h1 { - text-indent: -999999px; - background: url(click.png) no-repeat center center; - height: 200px; -} - -@media (-webkit-min-device-pixel-ratio: 2), (min-resolution: 192dpi) { - div.indexwrapper h1 { - background: url(click@2x.png) no-repeat center center; - background-size: 420px 175px; - } -} diff --git a/docs/source/_templates/layout.html b/docs/source/_templates/layout.html index ab21b0bd..0a135962 100644 --- a/docs/source/_templates/layout.html +++ b/docs/source/_templates/layout.html @@ -1,4 +1,4 @@ {% extends "!layout.html" %} {% block extrahead %} -Fork me on GitHub +Fork me on GitHub {% endblock %} diff --git a/docs/source/_templates/sidebarintro.html b/docs/source/_templates/sidebarintro.html index 92ed6030..9fa0d68d 100644 --- a/docs/source/_templates/sidebarintro.html +++ b/docs/source/_templates/sidebarintro.html @@ -6,6 +6,6 @@

Useful Links

diff --git a/docs/source/conf.py b/docs/source/conf.py index cdbb1093..d7e8db51 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -1,32 +1,37 @@ -# -*- coding: utf-8 -*- - -# -- Path setup -------------------------------------------------------------- - -# If extensions (or modules to document with autodoc) are in another directory, -# add these directories to sys.path here. If the directory is relative to the -# documentation root, use os.path.abspath to make it absolute, like shown here. +# Sphinx documentation generator configuration. +# +# More information on the configuration options is available at: +# https://www.sphinx-doc.org/en/master/usage/configuration.html +from __future__ import annotations import os import sys +from pathlib import Path import django from django.conf import settings from sphinx.ext.autodoc import between -sys.path.insert(0, os.path.abspath('.')) -sys.path.insert(0, os.path.abspath('../../')) +# For discovery of Python modules +sys.path.insert(0, str(Path().cwd())) + +# For finding the django_settings.py file +sys.path.insert(0, str(Path("../../").resolve())) + # Pass settings into configure. settings.configure( - INSTALLED_APPS = [ - 'django.contrib.admin', - 'django.contrib.auth', - 'django.contrib.contenttypes', - 'django.contrib.sessions', - 'django.contrib.messages', - 'django.contrib.staticfiles', - 'eav' - ] + INSTALLED_APPS=[ + "django.contrib.admin", + "django.contrib.auth", + "django.contrib.contenttypes", + "django.contrib.sessions", + "django.contrib.messages", + "django.contrib.staticfiles", + "eav", + ], + SECRET_KEY=os.environ.get("DJANGO_SECRET_KEY", "this-is-not-s3cur3"), + EAV2_PRIMARY_KEY_FIELD="django.db.models.BigAutoField", ) # Call django.setup to load installed apps and other stuff. @@ -34,128 +39,82 @@ # -- Project information ----------------------------------------------------- -project = 'Django EAV 2' -copyright = '2018, Iwo Herka and team at MAKIMO' -author = '-' +project = "Django EAV 2" +copyright = "2018, Iwo Herka and team at MAKIMO" # noqa: A001 +author = "-" # The short X.Y version -version = '' +version = "" # The full version, including alpha/beta/rc tags -release = '0.10.0' +release = "0.10.0" + + +def setup(app): + """Use the configuration file itself as an extension.""" + app.connect( + "autodoc-process-docstring", + between( + "^.*IGNORE.*$", + exclude=True, + ), + ) + return app # -- General configuration --------------------------------------------------- extensions = [ - 'sphinx.ext.napoleon', - 'sphinx.ext.autodoc', - 'sphinx.ext.intersphinx', - 'sphinx.ext.coverage', - 'sphinx.ext.viewcode', + "sphinx.ext.napoleon", + "sphinx.ext.autodoc", + "sphinx.ext.intersphinx", + "sphinx.ext.coverage", + "sphinx.ext.viewcode", + "sphinx_rtd_theme", ] -html_theme_options = dict( - show_powered_by = False, - show_related = True, - fixed_sidebar = True, - font_family = 'Roboto' -) - -templates_path = ['_templates'] +templates_path = ["_templates"] -source_suffix = '.rst' +source_suffix = ".rst" -master_doc = 'index' +master_doc = "index" -language = None +language = "en" -exclude_patterns = [] +exclude_patterns = ["build"] # The name of the Pygments (syntax highlighting) style to use. -pygments_style = 'sphinx' +pygments_style = "sphinx" # -- Options for HTML output ------------------------------------------------- -# The theme to use for HTML and HTML Help pages. See the documentation for -# a list of builtin themes. -# -html_theme = 'alabaster' -# Theme options are theme-specific and customize the look and feel of a theme -# further. For a list of options available for each theme, see the -# documentation. -# -# html_theme_options = {} - -# Add any paths that contain custom static files (such as style sheets) here, -# relative to this directory. They are copied after the builtin static files, -# so a file named "default.css" will overwrite the builtin "default.css". -html_static_path = ['_static'] - -# Custom sidebar templates, must be a dictionary that maps document names -# to template names. -# -# The default sidebars (for documents that don't match any pattern) are -# defined by theme itself. Builtin themes are using these templates by -# default: ``['localtoc.html', 'relations.html', 'sourcelink.html', -# 'searchbox.html']``. -# - - -def setup(app): - app.add_stylesheet('styles.css') - app.connect('autodoc-process-docstring', between('^.*IGNORE.*$', exclude=True)) - return app +html_theme = "sphinx_rtd_theme" +html_static_path = ["_static"] html_sidebars = { - 'index': [ - 'sidebarintro.html', - 'localtoc.html' + "index": ["sidebarintro.html", "localtoc.html"], + "**": [ + "sidebarintro.html", + "localtoc.html", + "relations.html", + "searchbox.html", ], - '**': [ - 'sidebarintro.html', - 'localtoc.html', - - 'relations.html', - 'searchbox.html' - ] } - -# -- Options for HTMLHelp output --------------------------------------------- - -# Output file base name for HTML help builder. -htmlhelp_basename = 'DjangoEAV2doc' +htmlhelp_basename = "DjangoEAV2doc" # -- Options for LaTeX output ------------------------------------------------ -latex_elements = { - # The paper size ('letterpaper' or 'a4paper'). - # - # 'papersize': 'letterpaper', - - # The font size ('10pt', '11pt' or '12pt'). - # - # 'pointsize': '10pt', - - # Additional stuff for the LaTeX preamble. - # - # 'preamble': '', - - # Latex figure (float) alignment - # - # 'figure_align': 'htbp', -} +latex_elements: dict[str, str] = {} # Grouping the document tree into LaTeX files. List of tuples # (source start file, target name, title, # author, documentclass [howto, manual, or own class]). latex_documents = [ - (master_doc, 'DjangoEAV2.tex', 'Django EAV 2 Documentation', - '-', 'manual'), + (master_doc, "DjangoEAV2.tex", "Django EAV 2 Documentation", "-", "manual"), ] @@ -164,8 +123,13 @@ def setup(app): # One entry per manual page. List of tuples # (source start file, name, description, authors, manual section). man_pages = [ - (master_doc, 'djangoeav2', 'Django EAV 2 Documentation', - [author], 1) + ( + master_doc, + "djangoeav2", + "Django EAV 2 Documentation", + [author], + 1, + ), ] @@ -175,9 +139,15 @@ def setup(app): # (source start file, target name, title, author, # dir menu entry, description, category) texinfo_documents = [ - (master_doc, 'DjangoEAV2', 'Django EAV 2 Documentation', - author, 'DjangoEAV2', 'One line description of project.', - 'Miscellaneous'), + ( + master_doc, + "DjangoEAV2", + "Django EAV 2 Documentation", + author, + "DjangoEAV2", + "One line description of project.", + "Miscellaneous", + ), ] # -- Extension configuration ------------------------------------------------- @@ -185,7 +155,7 @@ def setup(app): # -- Options for intersphinx extension --------------------------------------- # Example configuration for intersphinx: refer to the Python standard library. -intersphinx_mapping = {'https://docs.python.org/': None} +intersphinx_mapping = {"python": ("https://docs.python.org/3", None)} # -- Autodoc configuration --------------------------------------------------- diff --git a/docs/source/getting_started.rst b/docs/source/getting_started.rst index b8d2bac8..5e84aa5e 100644 --- a/docs/source/getting_started.rst +++ b/docs/source/getting_started.rst @@ -3,43 +3,35 @@ Getting Started Installation ------------ - -You can install **django-eav2** from PyPI, git or directly from source: - -From PyPI -^^^^^^^^^ :: pip install django-eav2 -With pip via git -^^^^^^^^^^^^^^^^ -:: - - pip install git+https://github.com/makimo/django-eav2@master +Configuration +------------- -From source -^^^^^^^^^^^ +After you've installed the package, you have to add it to your Django apps :: - git clone git@github.com:makimo/django-eav2.git - cd django-eav2 - python setup.py install + INSTALLED_APPS = [ + ... + 'eav', + ] -To uninstall:: +Note: Django 2.2 Users +^^^^^^^^^^^^^^^^^^^^^^ - python setup.py install --record files.txt - rm $(cat files.txt) +Since ``models.JSONField()`` isn't supported in Django 2.2, we use `django-jsonfield-backport `_ + to provide `JSONField `_ + functionality. -Configuration -------------- +This requires adding ``django_jsonfield_backport`` to your INSTALLED_APPS as well. -After you've installed the package, you have to add it to your Django apps:: - INSTALLED_APPS = [ - # ... +:: + INSTALLED_APPS = [ + ... 'eav', - - # ... + 'django_jsonfield_backport', ] diff --git a/docs/source/usage.rst b/docs/source/usage.rst index 58bc2662..b1e9bb19 100644 --- a/docs/source/usage.rst +++ b/docs/source/usage.rst @@ -25,8 +25,8 @@ or with decorators: class Supplier(models.Model): ... -Generally, if you chose the former, the most appriopriate place for the -statement would be at the bottom of your ``models.py`` or immmediately after +Generally, if you chose the former, the most appropriate place for the +statement would be at the bottom of your ``models.py`` or immediately after model definition. Advanced Registration @@ -160,6 +160,8 @@ stored in :class:`~eav.models.Value`). Available choices are: *bool* ``TYPE_BOOLEAN`` *object* ``TYPE_OBJECT`` *enum* ``TYPE_ENUM`` +*json* ``TYPE_JSON`` +*csv* ``TYPE_CSV`` ========= ================== If you want to create an attribute with data-type *enum*, you need to provide @@ -181,6 +183,64 @@ it with ``enum_group``: ) # = +The attribute type *json* allows to store them in JSON format, which internally use JSONField: + +.. code-block:: python + + Attribute.objects.create(name='name_intl', datatype=Attribute.TYPE_JSON) + + prod = Product.objects.create(sku='PRD00001', eav__name_intl={ + "es": "Escoba Verde", + "en": "Green Broom", + "it": "Scopa Verde" + }) + + prod2 = Product.objects.create(sku='PRD00002', eav__name_intl={ + "es": "Escoba Roja", + "en": "Red Broom" + }) + + prod3 = Product.objects.create(sku='PRD00003', eav__name_intl={ + "es": "Escoba Azul", + "it": "Scopa Blu" + }) + + prod.eav.name_intl + {'es': 'Escoba Verde', 'en': 'Green Broom', 'it': 'Scopa Verde'} + + type(prod.eav.name_intl) + dict + + Product.objects.filter(eav__name_intl__has_key="it") + , ]> + +The attribute type *csv* allows to store Comma Separated Values, using ";" as a separator: + +.. code-block:: python + + Attribute.objects.create(name='colors', datatype=Attribute.TYPE_CSV) + + prod = Product.objects.create(sku='PRD00001', eav__colors="red;green;blue") + + prod2 = Product.objects.create(sku='PRD00002', eav__colors="red;green") + + prod3 = Product.objects.create(sku='PRD00003', eav__colors="red;blue") + + prod4 = Product.objects.create(sku='PRD00004', eav__colors="") + + prod.eav.colors + ["red", "green", "blue"] + + type(prod.eav.name_intl) + list + + Product.objects.filter(eav__name_colors="green") + , ]> + + Product.objects.filter(~Q(eav__name_colors__isnull=False)) + ]> + + Finally, attribute type *object* allows to relate Django model instances via generic foreign keys: @@ -226,8 +286,10 @@ You can use ``Q`` expressions too: Admin Integration ----------------- -Django EAV 2 includes integration for Django's admin. As usual, you need to -register your model first: +Django EAV 2 seamlessly integrates with Django's admin interface by providing +dynamic attribute management directly within the admin panel. This feature +provides the EAV Attributes as a separate fieldset, whether use the base +fieldset or when providing your own. .. code-block:: python @@ -242,3 +304,11 @@ register your model first: form = PatientAdminForm admin.site.register(Patient, PatientAdmin) + +Customizing the EAV Fieldset +~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +The Django EAV 2 integration allows you to customize the presentation of EAV +attributes in the admin interface through the use of a dedicated fieldset. You +can configure this fieldset by setting ``eav_fieldset_title`` and +``eav_fieldset_description`` within your admin class. diff --git a/eav/__init__.py b/eav/__init__.py index aaff0bec..af095506 100644 --- a/eav/__init__.py +++ b/eav/__init__.py @@ -1,9 +1,10 @@ -__version__ = '0.13.0' - def register(model_cls, config_cls=None): - from .registry import Registry + from eav.registry import Registry + Registry.register(model_cls, config_cls) + def unregister(model_cls): - from .registry import Registry + from eav.registry import Registry + Registry.unregister(model_cls) diff --git a/eav/admin.py b/eav/admin.py index 8bd8db2d..895e6e4d 100644 --- a/eav/admin.py +++ b/eav/admin.py @@ -1,47 +1,129 @@ """This module contains classes used for admin integration.""" +from __future__ import annotations + +from typing import TYPE_CHECKING, Any, ClassVar, Dict, List, Union + from django.contrib import admin from django.contrib.admin.options import InlineModelAdmin, ModelAdmin from django.forms.models import BaseInlineFormSet from django.utils.safestring import mark_safe -from .models import Attribute, EnumGroup, EnumValue, Value +from eav.models import Attribute, EnumGroup, EnumValue, Value + +if TYPE_CHECKING: + from collections.abc import Sequence + +_FIELDSET_TYPE = List[Union[str, Dict[str, Any]]] # type: ignore[misc] + +some_attribute = ClassVar[Dict[str, str]] class BaseEntityAdmin(ModelAdmin): + """Custom admin model to support dynamic EAV fieldsets. + + Overrides the default rendering of the change form in the Django admin to + dynamically integrate EAV fields into the form fieldsets. This approach + allows EAV attributes to be rendered alongside standard model fields within + the admin interface. + + Attributes: + eav_fieldset_title (str): Title for the dynamically added EAV fieldset. + eav_fieldset_description (str): Optional description for the EAV fieldset. + """ + + eav_fieldset_title: str = "EAV Attributes" + eav_fieldset_description: str | None = None + def render_change_form(self, request, context, *args, **kwargs): + """Dynamically modifies the admin form to include EAV fields. + + Identifies EAV fields associated with the instance being edited and + dynamically inserts them into the admin form's fieldsets. This method + ensures EAV fields are appropriately displayed in a dedicated fieldset + and avoids field duplication. + + Args: + request: HttpRequest object representing the current request. + context: Dictionary containing context data for the form template. + *args: Variable length argument list. + **kwargs: Arbitrary keyword arguments. + + Returns: + HttpResponse object representing the rendered change form. """ - Wrapper for ``ModelAdmin.render_change_form``. Replaces standard static - ``AdminForm`` with an EAV-friendly one. The point is that our form - generates fields dynamically and fieldsets must be inferred from a - prepared and validated form instance, not just the form class. Django - does not seem to provide hooks for this purpose, so we simply wrap the - view and substitute some data. + form = context["adminform"].form + + # Identify EAV fields based on the form instance's configuration. + eav_fields = self._get_eav_fields(form.instance) + + # # Fallback to default if no EAV fields exist + if not eav_fields: + return super().render_change_form(request, context, *args, **kwargs) + + # Get the non-EAV fieldsets and then append our own + fieldsets = list(self.get_fieldsets(request, kwargs["obj"])) + fieldsets.append(self._get_eav_fieldset(eav_fields)) + + # Reconstruct the admin form with updated fieldsets. + adminform = admin.helpers.AdminForm( + form, + fieldsets, + # Clear prepopulated fields on a view-only form to avoid a crash. + ( + self.prepopulated_fields + if self.has_change_permission(request, kwargs["obj"]) + else {} + ), + readonly_fields=self.readonly_fields, + model_admin=self, + ) + media = mark_safe(context["media"] + adminform.media) # noqa: S308 + context.update(adminform=adminform, media=media) + + return super().render_change_form(request, context, *args, **kwargs) + + def _get_eav_fields(self, instance) -> list[str]: + """Retrieves a list of EAV field slugs for the given instance. + + Args: + instance: The model instance for which EAV fields are determined. + + Returns: + A list of strings representing the slugs of EAV fields. """ - form = context['adminform'].form + entity = getattr(instance, instance._eav_config_cls.eav_attr) # noqa: SLF001 + return list(entity.get_all_attributes().values_list("slug", flat=True)) - # Infer correct data from the form. - fieldsets = self.fieldsets or [(None, {'fields': form.fields.keys()})] - adminform = admin.helpers.AdminForm(form, fieldsets, self.prepopulated_fields) - media = mark_safe(self.media + adminform.media) + def _get_eav_fieldset(self, eav_fields) -> _FIELDSET_TYPE: + """Constructs an EAV Attributes fieldset for inclusion in admin form fieldsets. - context.update(adminform=adminform, media=media) + Generates a list representing a fieldset specifically for Entity-Attribute-Value + (EAV) fields, intended to be appended to the admin form's fieldsets + configuration. This facilitates the dynamic inclusion of EAV fields within the + Django admin interface by creating a designated section for these attributes. - return super(BaseEntityAdmin, self).render_change_form( - request, context, *args, **kwargs - ) + Args: + eav_fields (List[str]): A list of slugs representing the EAV fields to be + included in the EAV Attributes fieldset. + """ + return [ + self.eav_fieldset_title, + {"fields": eav_fields, "description": self.eav_fieldset_description}, + ] class BaseEntityInlineFormSet(BaseInlineFormSet): """ An inline formset that correctly initializes EAV forms. """ + def add_fields(self, form, index): if self.instance: setattr(form.instance, self.fk.name, self.instance) - form._build_dynamic_fields() + form._build_dynamic_fields() # noqa: SLF001 - super(BaseEntityInlineFormSet, self).add_fields(form, index) + super().add_fields(form, index) class BaseEntityInline(InlineModelAdmin): @@ -59,6 +141,7 @@ class ItemInline(BaseEntityInline, StackedInline): with EAV-Django. You can copy or symlink the ``admin`` directory to your templates search path (see Django documentation). """ + formset = BaseEntityInlineFormSet def get_fieldsets(self, request, obj=None): @@ -71,12 +154,12 @@ def get_fieldsets(self, request, obj=None): instance = self.model(**kw) form = formset.form(request.POST, instance=instance) - return [(None, {'fields': form.fields.keys()})] + return [(None, {"fields": form.fields.keys()})] class AttributeAdmin(ModelAdmin): - list_display = ('name', 'slug', 'datatype', 'description') - prepopulated_fields = {'slug': ('name',)} + list_display = ("name", "slug", "datatype", "description") + prepopulated_fields: ClassVar[dict[str, Sequence[str]]] = {"slug": ("name",)} admin.site.register(Attribute, AttributeAdmin) diff --git a/eav/decorators.py b/eav/decorators.py index 5c8503d0..580af6ca 100644 --- a/eav/decorators.py +++ b/eav/decorators.py @@ -3,6 +3,7 @@ Functions in this module should be simple and not involve complex logic. """ + def register_eav(**kwargs): """ Registers the given model(s) classes and wrapped ``Model`` class with @@ -12,12 +13,13 @@ def register_eav(**kwargs): class Author(models.Model): pass """ - from . import register from django.db.models import Model + from eav import register + def _model_eav_wrapper(model_class): if not issubclass(model_class, Model): - raise ValueError('Wrapped class must subclass Model.') + raise TypeError("Wrapped class must subclass Model.") register(model_class, **kwargs) return model_class diff --git a/eav/exceptions.py b/eav/exceptions.py index 924a8c05..f071031a 100644 --- a/eav/exceptions.py +++ b/eav/exceptions.py @@ -1,2 +1,2 @@ -class IllegalAssignmentException(Exception): - pass +class IllegalAssignmentException(Exception): # noqa: N818 + pass diff --git a/eav/fields.py b/eav/fields.py index f5733323..82b107c2 100644 --- a/eav/fields.py +++ b/eav/fields.py @@ -1,41 +1,8 @@ -import re - from django.core.exceptions import ValidationError from django.db import models -from django.utils.translation import ugettext_lazy as _ - - -class EavSlugField(models.SlugField): - """ - The slug field used by :class:`~eav.models.Attribute` - """ - - def validate(self, value, instance): - """ - Slugs are used to convert the Python attribute name to a database - lookup and vice versa. We need it to be a valid Python identifier. We - don't want it to start with a '_', underscore will be used in - variables we don't want to be saved in the database. - """ - super(EavSlugField, self).validate(value, instance) - slug_regex = r'[a-z][a-z0-9_]*' - - if not re.match(slug_regex, value): - raise ValidationError(_( - 'Must be all lower case, start with a letter, and contain ' - 'only letters, numbers, or underscores.' - )) - - @staticmethod - def create_slug_from_name(name): - """Creates a slug based on the name.""" - name = name.strip().lower() - - # Change spaces to underscores. - name = '_'.join(name.split()) +from django.utils.translation import gettext_lazy as _ - # Remove non alphanumeric characters. - return re.sub('[^\w]', '', name) +from eav.forms import CSVFormField class EavDatatypeField(models.CharField): @@ -49,15 +16,68 @@ def validate(self, value, instance): :class:`~eav.models.Attribute` that is already used by :class:`~eav.models.Value` objects. """ - super(EavDatatypeField, self).validate(value, instance) + super().validate(value, instance) if not instance.pk: return + # added + if not type(instance).objects.filter(pk=instance.pk).exists(): + return + if type(instance).objects.get(pk=instance.pk).datatype == instance.datatype: return if instance.value_set.count(): - raise ValidationError(_( - 'You cannot change the datatype of an attribute that is already in use.' - )) + raise ValidationError( + _( + "You cannot change the datatype of an " + + "attribute that is already in use.", + ), + ) + + +class CSVField(models.TextField): # (models.Field): + description = _("A Comma-Separated-Value field.") + default_separator = ";" + + def __init__(self, separator=";", *args, **kwargs): + self.separator = separator + kwargs.setdefault("default", "") + super().__init__(*args, **kwargs) + + def deconstruct(self): + name, path, args, kwargs = super().deconstruct() + if self.separator != self.default_separator: + kwargs["separator"] = self.separator + return name, path, args, kwargs + + def formfield(self, **kwargs): + defaults = {"form_class": CSVFormField} + defaults.update(kwargs) + return super().formfield(**defaults) + + def from_db_value(self, value, expression, connection): + if value is None: + return [] + return value.split(self.separator) + + def to_python(self, value): + if value is None: + return [] + if isinstance(value, list): + return value + return value.split(self.separator) + + def get_prep_value(self, value): + if not value: + return "" + if isinstance(value, str): + return value + if isinstance(value, list): + return self.separator.join(value) + return value + + def value_to_string(self, obj): + value = self.value_from_object(obj) + return self.get_prep_value(value) diff --git a/eav/forms.py b/eav/forms.py index b1f97b1c..95950434 100644 --- a/eav/forms.py +++ b/eav/forms.py @@ -1,11 +1,49 @@ """This module contains forms used for admin integration.""" +from __future__ import annotations + from copy import deepcopy +from typing import ClassVar from django.contrib.admin.widgets import AdminSplitDateTime -from django.forms import (BooleanField, CharField, ChoiceField, DateTimeField, - FloatField, IntegerField, ModelForm) -from django.utils.translation import ugettext_lazy as _ +from django.core.exceptions import ValidationError +from django.forms import ( + BooleanField, + CharField, + ChoiceField, + Field, + FloatField, + IntegerField, + JSONField, + ModelForm, + SplitDateTimeField, +) +from django.utils.translation import gettext_lazy as _ + +from eav.widgets import CSVWidget + + +class CSVFormField(Field): + message = _("Enter comma-separated-values. eg: one;two;three.") + code = "invalid" + widget = CSVWidget + default_separator = ";" + + def __init__(self, *args, **kwargs): + kwargs.pop("max_length", None) + self.separator = kwargs.pop("separator", self.default_separator) + super().__init__(*args, **kwargs) + + def to_python(self, value): + if not value: + return [] + return [v.strip() for v in value.split(self.separator) if v] + + def validate(self, field_value): + super().validate(field_value) + + if not isinstance(field_value, list): + raise ValidationError(self.message, code=self.code) class BaseDynamicEntityForm(ModelForm): @@ -26,22 +64,28 @@ class BaseDynamicEntityForm(ModelForm): text CharField float IntegerField int DateTimeField + date SplitDateTimeField bool BooleanField enum ChoiceField + json JSONField + csv CSVField ===== ============= """ - FIELD_CLASSES = { - 'text': CharField, - 'float': FloatField, - 'int': IntegerField, - 'date': DateTimeField, - 'bool': BooleanField, - 'enum': ChoiceField, + + FIELD_CLASSES: ClassVar[dict[str, Field]] = { + "text": CharField, + "float": FloatField, + "int": IntegerField, + "date": SplitDateTimeField, + "bool": BooleanField, + "enum": ChoiceField, + "json": JSONField, + "csv": CSVFormField, } def __init__(self, data=None, *args, **kwargs): - super(BaseDynamicEntityForm, self).__init__(data, *args, **kwargs) - config_cls = self.instance._eav_config_cls + super().__init__(data, *args, **kwargs) + config_cls = self.instance._eav_config_cls # noqa: SLF001 self.entity = getattr(self.instance, config_cls.eav_attr) self._build_dynamic_fields() @@ -53,57 +97,56 @@ def _build_dynamic_fields(self): value = getattr(self.entity, attribute.slug) defaults = { - 'label': attribute.name.capitalize(), - 'required': attribute.required, - 'help_text': attribute.help_text, - 'validators': attribute.get_validators(), + "label": attribute.name.capitalize(), + "required": attribute.required, + "help_text": attribute.help_text, + "validators": attribute.get_validators(), } datatype = attribute.datatype if datatype == attribute.TYPE_ENUM: - values = attribute.get_choices().values_list('id', 'value') - choices = [('', '-----')] + list(values) - defaults.update({'choices': choices}) + values = attribute.get_choices().values_list("id", "value") + choices = [("", ""), ("-----", "-----"), *list(values)] + defaults.update({"choices": choices}) if value: - defaults.update({'initial': value.pk}) + defaults.update({"initial": value.pk}) elif datatype == attribute.TYPE_DATE: - defaults.update({'widget': AdminSplitDateTime}) + defaults.update({"widget": AdminSplitDateTime}) elif datatype == attribute.TYPE_OBJECT: continue - MappedField = self.FIELD_CLASSES[datatype] + MappedField = self.FIELD_CLASSES[datatype] # noqa: N806 self.fields[attribute.slug] = MappedField(**defaults) # Fill initial data (if attribute was already defined). - if value and not datatype == attribute.TYPE_ENUM: + if value and datatype != attribute.TYPE_ENUM: self.initial[attribute.slug] = value - def save(self, commit=True): + def save(self, *, commit=True): """ Saves this ``form``'s cleaned_data into model instance ``self.instance`` and related EAV attributes. Returns ``instance``. """ if self.errors: - raise ValueError(_( - 'The %s could not be saved because the data' - 'didn\'t validate.' % self.instance._meta.object_name - )) + raise ValueError( + _( + "The %s could not be saved because the data didn't validate.", + ) + % self.instance._meta.object_name, # noqa: SLF001 + ) # Create entity instance, don't save yet. - instance = super(BaseDynamicEntityForm, self).save(commit=False) + instance = super().save(commit=False) # Assign attributes. for attribute in self.entity.get_all_attributes(): value = self.cleaned_data.get(attribute.slug) if attribute.datatype == attribute.TYPE_ENUM: - if value: - value = attribute.enum_group.values.get(pk=value) - else: - value = None + value = attribute.enum_group.values.get(pk=value) if value else None setattr(self.entity, attribute.slug, value) diff --git a/eav/locale/id/LC_MESSAGES/django.po b/eav/locale/id/LC_MESSAGES/django.po new file mode 100644 index 00000000..fa09417d --- /dev/null +++ b/eav/locale/id/LC_MESSAGES/django.po @@ -0,0 +1,278 @@ +# Indonesian translation for django-eav2 +# Copyright (C) 2023 +# This file is distributed under the same license as the django-eav2 package. +# Kira , 2023. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: django-eav2 1.3.1\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2023-06-29 16:43+0800\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: Kira , 2023\n" +"Language-Team: LANGUAGE \n" +"Language: id\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=1; plural=0;\n" +#: .\eav\fields.py:30 +msgid "You cannot change the datatype of an attribute that is already in use." +msgstr "Anda tidak dapat mengubah tipe data atribut yang sudah digunakan." + +#: .\eav\fields.py:36 +msgid "A Comma-Separated-Value field." +msgstr "Bidang Nilai-yang-Dipisahkan-Koma." + +#: .\eav\forms.py:28 +msgid "Enter comma-separated-values. eg: one;two;three." +msgstr "Masukkan nilai-yang-dipisahkan-koma. misalnya: satu;dua;tiga." + +#: .\eav\forms.py:138 +#, python-format +msgid "The %s could not be saved because the datadidn't validate." +msgstr "%s tidak dapat disimpan karena datanya tidak tervalidasi." + +#: .\eav\models.py:77 +msgid "EnumValue" +msgstr "EnumValue" + +#: .\eav\models.py:78 +msgid "EnumValues" +msgstr "EnumValues" + +#: .\eav\models.py:81 .\eav\models.py:439 +msgid "Value" +msgstr "Nilai" + +#: .\eav\models.py:106 +msgid "EnumGroup" +msgstr "EnumGroup" + +#: .\eav\models.py:107 +msgid "EnumGroups" +msgstr "EnumGroups" + +#: .\eav\models.py:112 .\eav\models.py:218 +msgid "Name" +msgstr "Nama" + +#: .\eav\models.py:116 +msgid "Enum group" +msgstr "Grup enum" + +#: .\eav\models.py:182 .\eav\models.py:447 +msgid "Attribute" +msgstr "Atribut" + +#: .\eav\models.py:183 +msgid "Attributes" +msgstr "Atribut" + +#: .\eav\models.py:196 +msgid "Text" +msgstr "Teks" + +#: .\eav\models.py:197 +msgid "Date" +msgstr "Tanggal" + +#: .\eav\models.py:198 +msgid "Float" +msgstr "Bilangan desimal" + +#: .\eav\models.py:199 +msgid "Integer" +msgstr "Bilangan bulat" + +#: .\eav\models.py:200 +msgid "True / False" +msgstr "Benar / Salah" + +#: .\eav\models.py:201 +msgid "Django Object" +msgstr "Objek Django" + +#: .\eav\models.py:202 +msgid "Multiple Choice" +msgstr "Pilihan Ganda" + +#: .\eav\models.py:203 +msgid "JSON Object" +msgstr "Objek JSON" + +#: .\eav\models.py:204 +msgid "Comma-Separated-Value" +msgstr "Nilai-yang-Dipisahkan-Koma" + +#: .\eav\models.py:212 +msgid "Data Type" +msgstr "Tipe Data" + +#: .\eav\models.py:217 +msgid "User-friendly attribute name" +msgstr "Nama atribut yang ramah pengguna" + +#: .\eav\models.py:230 +msgid "Short unique attribute label" +msgstr "Label atribut unik yang pendek" + +#: .\eav\models.py:231 +msgid "Slug" +msgstr "Slug" + +#: .\eav\models.py:242 +msgid "Required" +msgstr "Diperlukan" + +#: .\eav\models.py:248 +msgid "Entity content type" +msgstr "Jenis konten entitas" + +#: .\eav\models.py:262 +msgid "Choice Group" +msgstr "Grup Pilihan" + +#: .\eav\models.py:269 +msgid "Short description" +msgstr "Deskripsi singkat" + +#: .\eav\models.py:270 +msgid "Description" +msgstr "Deskripsi" + +#: .\eav\models.py:277 +msgid "Display order" +msgstr "Urutan tampilan" + +#: .\eav\models.py:282 .\eav\models.py:490 +msgid "Modified" +msgstr "Dimodifikasi" + +#: .\eav\models.py:288 .\eav\models.py:485 +msgid "Created" +msgstr "Dibuat" + +#: .\eav\models.py:332 +#, python-format +msgid "%(val)s is not a valid choice for %(attr)s" +msgstr "%(val)s bukan pilihan yang valid untuk %(attr)s" + +#: .\eav\models.py:355 +msgid "You must set the choice group for multiple choice attributes" +msgstr "Anda harus mengatur grup pilihan untuk atribut pilihan ganda" + +#: .\eav\models.py:360 +msgid "You can only assign a choice group to multiple choice attributes" +msgstr "Anda hanya dapat menetapkan grup pilihan ke atribut pilihan ganda" + +#: .\eav\models.py:440 +msgid "Values" +msgstr "Nilai" + +#: .\eav\models.py:456 +msgid "Entity id" +msgstr "id entitas" + +#: .\eav\models.py:462 +msgid "Entity uuid" +msgstr "uuid entitas" + +#: .\eav\models.py:469 +msgid "Entity ct" +msgstr "Entitas ct" + +#: .\eav\models.py:497 +msgid "Value bool" +msgstr "Nilai bool" + +#: .\eav\models.py:502 +msgid "Value CSV" +msgstr "Nilai CSV" + +#: .\eav\models.py:507 +msgid "Value date" +msgstr "Nilai tanggal" + +#: .\eav\models.py:512 +msgid "Value float" +msgstr "Nilai float" + +#: .\eav\models.py:517 +msgid "Value int" +msgstr "Nilai int" + +#: .\eav\models.py:522 +msgid "Value text" +msgstr "Nilai teks" + +#: .\eav\models.py:530 +msgid "Value JSON" +msgstr "Nilai JSON" + +#: .\eav\models.py:539 +msgid "Value enum" +msgstr "Nilai enum" + +#: .\eav\models.py:546 +msgid "Generic value id" +msgstr "Id nilai generik" + +#: .\eav\models.py:555 +msgid "Generic value content type" +msgstr "Jenis konten nilai generik" + +#: .\eav\models.py:653 +#, python-format +msgid "%(obj)s has no EAV attribute named %(attr)s" +msgstr "%(obj)s tidak memiliki atribut EAV bernama %(attr)s" + +#: .\eav\models.py:725 +msgid "{} EAV field cannot be blank" +msgstr "{} Bidang EAV tidak boleh kosong" + +#: .\eav\models.py:732 +#, python-format +msgid "%(attr)s EAV field %(err)s" +msgstr "%(attr)s bidang EAV %(err)s" + +#: .\eav\validators.py:26 +msgid "Must be str or unicode" +msgstr "Harus berupa str atau unicode" + +#: .\eav\validators.py:36 +msgid "Must be a float" +msgstr "Harus berupa float" + +#: .\eav\validators.py:46 +msgid "Must be an integer" +msgstr "Harus berupa integer" + +#: .\eav\validators.py:57 +msgid "Must be a date or datetime" +msgstr "Harus berupa date atau datetime" + +#: .\eav\validators.py:65 +msgid "Must be a boolean" +msgstr "Harus berupa boolean" + +#: .\eav\validators.py:74 +msgid "Must be a django model object instance" +msgstr "Harus berupa instance objek model django" + +#: .\eav\validators.py:77 +msgid "Model has not been saved yet" +msgstr "Model belum disimpan" + +#: .\eav\validators.py:88 +msgid "EnumValue has not been saved yet" +msgstr "EnumValue belum disimpan" + +#: .\eav\validators.py:99 .\eav\validators.py:101 +msgid "Must be a JSON Serializable object" +msgstr "Harus berupa objek JSON yang dapat diserialisasikan" + +#: .\eav\validators.py:111 +msgid "Must be Comma-Separated-Value." +msgstr "Harus berupa Nilai-ang-Dipisahkan-Koma." diff --git a/eav/locale/zh_Hans/LC_MESSAGES/django.mo b/eav/locale/zh_Hans/LC_MESSAGES/django.mo new file mode 100644 index 00000000..78480de5 Binary files /dev/null and b/eav/locale/zh_Hans/LC_MESSAGES/django.mo differ diff --git a/eav/locale/zh_Hans/LC_MESSAGES/django.po b/eav/locale/zh_Hans/LC_MESSAGES/django.po new file mode 100644 index 00000000..fbdea4cb --- /dev/null +++ b/eav/locale/zh_Hans/LC_MESSAGES/django.po @@ -0,0 +1,279 @@ +# Simplified Chinese translation for django-eav2 +# Copyright (C) 2023 +# This file is distributed under the same license as the django-eav2 package. +# FIRST 954-Ivory <954ivory@gmail.com>, 2023. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2023-02-07 02:17+0800\n" +"PO-Revision-Date: 2023-02-27 16:36+0800\n" +"Last-Translator: 954-Ivory <954ivory@gmail.com>\n" +"Language-Team: \n" +"Language: zh-Hans\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=1; plural=0;\n" + +#: .\fields.py:30 +msgid "You cannot change the datatype of an attribute that is already in use." +msgstr "您不能更改已使用属性的数据类型。" + +#: .\fields.py:36 +msgid "A Comma-Separated-Value field." +msgstr "字符分隔值(CSV)字段。" + +#: .\forms.py:28 +msgid "Enter comma-separated-values. eg: one;two;three." +msgstr "输入字符分隔值(CSV)字段,例如:one;two;three。" + +#: .\forms.py:137 +#, python-format +msgid "The %s could not be saved because the datadidn't validate." +msgstr "由于数据未验证,无法保存 %s 。" + +#: .\models.py:81 +msgid "EnumValue" +msgstr "枚举值" + +#: .\models.py:82 +msgid "EnumValues" +msgstr "枚举值" + +#: .\models.py:85 .\models.py:443 +msgid "Value" +msgstr "值" + +#: .\models.py:110 +msgid "EnumGroup" +msgstr "枚举组" + +#: .\models.py:111 +msgid "EnumGroups" +msgstr "枚举组" + +#: .\models.py:116 .\models.py:222 +msgid "Name" +msgstr "名称" + +#: .\models.py:120 +msgid "Enum group" +msgstr "枚举组" + +#: .\models.py:186 .\models.py:451 +msgid "Attribute" +msgstr "属性" + +#: .\models.py:187 +msgid "Attributes" +msgstr "属性" + +#: .\models.py:200 +msgid "Text" +msgstr "文本" + +#: .\models.py:201 +msgid "Date" +msgstr "日期" + +#: .\models.py:202 +msgid "Float" +msgstr "浮点数" + +#: .\models.py:203 +msgid "Integer" +msgstr "整数" + +#: .\models.py:204 +msgid "True / False" +msgstr "布尔值" + +#: .\models.py:205 +msgid "Django Object" +msgstr "Django 对象" + +#: .\models.py:206 +msgid "Multiple Choice" +msgstr "多项选择" + +#: .\models.py:207 +msgid "JSON Object" +msgstr "JSON 对象" + +#: .\models.py:208 +msgid "Comma-Separated-Value" +msgstr "字符分隔值(CSV)" + +#: .\models.py:216 +msgid "Data Type" +msgstr "数据类型" + +#: .\models.py:221 +msgid "User-friendly attribute name" +msgstr "面向用户的名称" + +#: .\models.py:234 +msgid "Short unique attribute label" +msgstr "唯一的属性短标识符" + +#: .\models.py:235 +msgid "Slug" +msgstr "短标识符(Slug)" + +#: .\models.py:246 +msgid "Required" +msgstr "必填项" + +#: .\models.py:252 +msgid "Entity content type" +msgstr "实体内容类型" + +#: .\models.py:266 +msgid "Choice Group" +msgstr "选项组" + +#: .\models.py:273 +msgid "Short description" +msgstr "简短描述" + +#: .\models.py:274 +msgid "Description" +msgstr "描述" + +#: .\models.py:281 +msgid "Display order" +msgstr "显示顺序" + +#: .\models.py:286 .\models.py:494 +msgid "Modified" +msgstr "修改" + +#: .\models.py:292 .\models.py:489 +msgid "Created" +msgstr "创建" + +#: .\models.py:336 +#, python-format +msgid "%(val)s is not a valid choice for %(attr)s" +msgstr "%(val)s 不是有效的 %(attr)s 选项" + +#: .\models.py:359 +msgid "You must set the choice group for multiple choice attributes" +msgstr "您必须为多项选择属性设置选项组" + +#: .\models.py:364 +msgid "You can only assign a choice group to multiple choice attributes" +msgstr "您只能将选项组分配给多项选择属性" + +#: .\models.py:444 +msgid "Values" +msgstr "值" + +#: .\models.py:460 +msgid "Entity id" +msgstr "实体 ID" + +#: .\models.py:466 +msgid "Entity uuid" +msgstr "实体 UUID" + +#: .\models.py:473 +msgid "Entity ct" +msgstr "实体内容类型" + +#: .\models.py:501 +msgid "Value bool" +msgstr "布尔值" + +#: .\models.py:506 +msgid "Value CSV" +msgstr "字符分隔值(CSV)" + +#: .\models.py:511 +msgid "Value date" +msgstr "日期值" + +#: .\models.py:516 +msgid "Value float" +msgstr "浮点值" + +#: .\models.py:521 +msgid "Value int" +msgstr "整型值" + +#: .\models.py:526 +msgid "Value text" +msgstr "文本值" + +#: .\models.py:534 +msgid "Value JSON" +msgstr "JSON 值" + +#: .\models.py:543 +msgid "Value enum" +msgstr "枚举值" + +#: .\models.py:550 +msgid "Generic value id" +msgstr "通用值 ID" + +#: .\models.py:559 +msgid "Generic value content type" +msgstr "通用值内容类型" + +#: .\models.py:657 +#, python-format +msgid "%(obj)s has no EAV attribute named %(attr)s" +msgstr "%(obj)s 中不存在为 %(attr)s 的属性" + +#: .\models.py:729 +msgid "{} EAV field cannot be blank" +msgstr "{} 字段不能为空白(blank)" + +#: .\models.py:736 +#, python-format +msgid "%(attr)s EAV field %(err)s" +msgstr "%(attr)s 字段错误:%(err)s" + +#: .\validators.py:26 +msgid "Must be str or unicode" +msgstr "必须是一个 str 或 unicode" + +#: .\validators.py:36 +msgid "Must be a float" +msgstr "必须是一个浮点数" + +#: .\validators.py:46 +msgid "Must be an integer" +msgstr "必须是一个整数" + +#: .\validators.py:57 +msgid "Must be a date or datetime" +msgstr "必须是一个日期(date)或者日期时间(datetime)" + +#: .\validators.py:65 +msgid "Must be a boolean" +msgstr "必须是一个布尔值" + +#: .\validators.py:74 +msgid "Must be a django model object instance" +msgstr "必须是一个 Django Model 对象的实例" + +#: .\validators.py:77 +msgid "Model has not been saved yet" +msgstr "Model 尚未保存" + +#: .\validators.py:88 +msgid "EnumValue has not been saved yet" +msgstr "枚举值尚未保存" + +#: .\validators.py:99 .\validators.py:101 +msgid "Must be a JSON Serializable object" +msgstr "必须是一个 JSON 序列化对象" + +#: .\validators.py:111 +msgid "Must be Comma-Separated-Value." +msgstr "必须是一个字符分隔值(CSV)" diff --git a/tests/__init__.py b/eav/logic/__init__.py similarity index 100% rename from tests/__init__.py rename to eav/logic/__init__.py diff --git a/eav/logic/entity_pk.py b/eav/logic/entity_pk.py new file mode 100644 index 00000000..97cc16c0 --- /dev/null +++ b/eav/logic/entity_pk.py @@ -0,0 +1,12 @@ +from django.db.models.fields import UUIDField + + +def get_entity_pk_type(entity_cls) -> str: + """Returns the entity PK type to use. + + These values map to `models.Value` as potential fields to use to relate + to the proper entity via the correct PK type. + """ + if isinstance(entity_cls._meta.pk, UUIDField): # noqa: SLF001 + return "entity_uuid" + return "entity_id" diff --git a/eav/logic/managers.py b/eav/logic/managers.py new file mode 100644 index 00000000..f80a26a3 --- /dev/null +++ b/eav/logic/managers.py @@ -0,0 +1,97 @@ +from django.db import models + + +class EnumValueManager(models.Manager): + """ + Custom manager for `EnumValue` model. + + This manager adds utility methods specific to the `EnumValue` model. + """ + + def get_by_natural_key(self, value): + """ + Retrieves an EnumValue instance using its `value` as a natural key. + + Args: + value (str): The value of the EnumValue instance. + + Returns: + EnumValue: The instance matching the provided value. + """ + return self.get(value=value) + + +class EnumGroupManager(models.Manager): + """ + Custom manager for `EnumGroup` model. + + This manager adds utility methods specific to the `EnumGroup` model. + """ + + def get_by_natural_key(self, name): + """ + Retrieves an EnumGroup instance using its `name` as a natural key. + + Args: + name (str): The name of the EnumGroup instance. + + Returns: + EnumGroup: The instance matching the provided name. + """ + return self.get(name=name) + + +class AttributeManager(models.Manager): + """ + Custom manager for `Attribute` model. + + This manager adds utility methods specific to the `Attribute` model. + """ + + def get_by_natural_key(self, name, slug): + """ + Retrieves an Attribute instance using its `name` and `slug` as natural keys. + + Args: + name (str): The name of the Attribute instance. + slug (str): The slug of the Attribute instance. + + Returns: + Attribute: The instance matching the provided name and slug. + """ + return self.get(name=name, slug=slug) + + +class ValueManager(models.Manager): + """ + Custom manager for `Value` model. + + This manager adds utility methods specific to the `Value` model. + """ + + def get_by_natural_key(self, attribute, entity_id, entity_uuid): + """ + Retrieve a Value instance using multiple natural keys. + + This method utilizes a combination of an `attribute` (defined by its + name and slug), `entity_id`, and `entity_uuid` to retrieve a unique + Value instance. + + Args: + attribute (tuple): A tuple containing the name and slug of the + Attribute instance. + entity_id (int): The ID of the associated entity. + entity_uuid (str): The UUID of the associated entity. + + Returns: + Value: The instance matching the provided keys. + """ + from eav.models import Attribute + + attribute = Attribute.objects.get(name=attribute[0], slug=attribute[1]) + + return self.get( + attribute=attribute, + entity_id=entity_id, + entity_uuid=entity_uuid, + ) diff --git a/eav/logic/object_pk.py b/eav/logic/object_pk.py new file mode 100644 index 00000000..8e0282cf --- /dev/null +++ b/eav/logic/object_pk.py @@ -0,0 +1,44 @@ +import uuid +from functools import partial + +from django.conf import settings +from django.db import models + +#: Constants +_DEFAULT_CHARFIELD_LEN: int = 40 + +_FIELD_MAPPING = { + "django.db.models.UUIDField": partial( + models.UUIDField, + primary_key=True, + editable=False, + default=uuid.uuid4, + ), + "django.db.models.CharField": partial( + models.CharField, + primary_key=True, + editable=False, + max_length=_DEFAULT_CHARFIELD_LEN, + ), +} + + +def get_pk_format() -> models.Field: + """ + Get the primary key field format based on the Django settings. + + This function returns a field factory function that corresponds to the + primary key format specified in Django settings. If the primary key + format is not recognized, it defaults to using BigAutoField. + + Returns: + Type[models.Field]: A field factory function that can be used to + create the primary key field instance. + """ + field_factory = _FIELD_MAPPING.get( + settings.EAV2_PRIMARY_KEY_FIELD, + partial(models.BigAutoField, primary_key=True, editable=False), + ) + + # Create and return the field instance + return field_factory() diff --git a/eav/logic/slug.py b/eav/logic/slug.py new file mode 100644 index 00000000..ffe74251 --- /dev/null +++ b/eav/logic/slug.py @@ -0,0 +1,61 @@ +from __future__ import annotations + +import secrets +import string +from typing import Final + +from django.utils.text import slugify + +SLUGFIELD_MAX_LENGTH: Final = 50 + + +def non_identifier_chars() -> dict[str, str]: + """Generate a mapping of non-identifier characters to their Unicode representations. + + Returns: + dict[str, str]: A dictionary where keys are special characters and values + are their Unicode representations. + """ + # Start with all printable characters + all_chars = string.printable + + # Filter out characters that are valid in Python identifiers + special_chars = [ + char for char in all_chars if not char.isalnum() and char not in ["_", " "] + ] + + return {char: f"u{ord(char):04x}" for char in special_chars} + + +def generate_slug(value: str) -> str: + """Generate a valid slug based on the given value. + + This function converts the input value into a Python-identifier-friendly slug. + It handles special characters, ensures a valid Python identifier, and truncates + the result to fit within the maximum allowed length. + + Args: + value (str): The input string to generate a slug from. + + Returns: + str: A valid Python identifier slug, with a maximum + length of SLUGFIELD_MAX_LENGTH. + """ + for char, replacement in non_identifier_chars().items(): + value = value.replace(char, replacement) + + # Use slugify to create a URL-friendly base slug. + slug = slugify(value, allow_unicode=False).replace("-", "_") + + # If slugify returns an empty string, generate a fallback + # slug to ensure it's never empty. + if not slug: + chars = string.ascii_lowercase + string.digits + randstr = "".join(secrets.choice(chars) for _ in range(8)) + slug = f"rand_{randstr}" + + # Ensure the slug doesn't start with a digit to make it a valid Python identifier. + if slug[0].isdigit(): + slug = "_" + slug + + return slug[:SLUGFIELD_MAX_LENGTH] diff --git a/eav/managers.py b/eav/managers.py index b30acf90..4141c096 100644 --- a/eav/managers.py +++ b/eav/managers.py @@ -4,13 +4,14 @@ from django.db import models -from .queryset import EavQuerySet +from eav.queryset import EavQuerySet class EntityManager(models.Manager): """ Our custom manager, overrides ``models.Manager``. """ + _queryset_class = EavQuerySet def create(self, **kwargs): @@ -18,18 +19,18 @@ def create(self, **kwargs): Parse eav attributes out of *kwargs*, then try to create and save the object, then assign and save it's eav attributes. """ - config_cls = getattr(self.model, '_eav_config_cls', None) + config_cls = getattr(self.model, "_eav_config_cls", None) if not config_cls or config_cls.manager_only: - return super(EntityManager, self).create(**kwargs) + return super().create(**kwargs) - prefix = '%s__' % config_cls.eav_attr + prefix = f"{config_cls.eav_attr}__" new_kwargs = {} eav_kwargs = {} for key, value in kwargs.items(): if key.startswith(prefix): - eav_kwargs.update({key[len(prefix):]: value}) + eav_kwargs.update({key[len(prefix) :]: value}) else: new_kwargs.update({key: value}) @@ -42,11 +43,13 @@ def create(self, **kwargs): obj.save() return obj - def get_or_create(self, **kwargs): + def get_or_create(self, defaults=None, **kwargs): """ Reproduces the behavior of get_or_create, eav friendly. """ try: return self.get(**kwargs), False except self.model.DoesNotExist: + if defaults: + kwargs = {**kwargs, **defaults} return self.create(**kwargs), True diff --git a/eav/migrations/0001_initial.py b/eav/migrations/0001_initial.py index fe013342..648ffd9f 100644 --- a/eav/migrations/0001_initial.py +++ b/eav/migrations/0001_initial.py @@ -1,79 +1,227 @@ # Generated by Django 2.0.4 on 2018-06-01 09:36 -from django.db import migrations, models import django.db.models.deletion import django.utils.timezone +from django.db import migrations, models + import eav.fields class Migration(migrations.Migration): + """Initial migration for the Attribute, EnumGroup, EnumValue, and Value models.""" initial = True dependencies = [ - ('contenttypes', '0002_remove_content_type_name'), + ("contenttypes", "0002_remove_content_type_name"), ] operations = [ migrations.CreateModel( - name='Attribute', + name="Attribute", fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('name', models.CharField(help_text='User-friendly attribute name', max_length=100, verbose_name='Name')), - ('slug', eav.fields.EavSlugField(help_text='Short unique attribute label', unique=True, verbose_name='Slug')), - ('description', models.CharField(blank=True, help_text='Short description', max_length=256, null=True, verbose_name='Description')), - ('datatype', eav.fields.EavDatatypeField(choices=[('text', 'Text'), ('date', 'Date'), ('float', 'Float'), ('int', 'Integer'), ('bool', 'True / False'), ('object', 'Django Object'), ('enum', 'Multiple Choice')], max_length=6, verbose_name='Data Type')), - ('created', models.DateTimeField(default=django.utils.timezone.now, editable=False, verbose_name='Created')), - ('modified', models.DateTimeField(auto_now=True, verbose_name='Modified')), - ('required', models.BooleanField(default=False, verbose_name='Required')), - ('display_order', models.PositiveIntegerField(default=1, verbose_name='Display order')), - ('entity_ct', models.ManyToManyField(to='contenttypes.ContentType', blank=True)), + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "name", + models.CharField( + help_text="User-friendly attribute name", + max_length=100, + verbose_name="Name", + ), + ), + ( + "slug", + models.SlugField( + help_text="Short unique attribute label", + unique=True, + verbose_name="Slug", + ), + ), + ( + "description", + models.CharField( + blank=True, + help_text="Short description", + max_length=256, + null=True, + verbose_name="Description", + ), + ), + ( + "datatype", + eav.fields.EavDatatypeField( + choices=[ + ("text", "Text"), + ("date", "Date"), + ("float", "Float"), + ("int", "Integer"), + ("bool", "True / False"), + ("object", "Django Object"), + ("enum", "Multiple Choice"), + ], + max_length=6, + verbose_name="Data Type", + ), + ), + ( + "created", + models.DateTimeField( + default=django.utils.timezone.now, + editable=False, + verbose_name="Created", + ), + ), + ( + "modified", + models.DateTimeField(auto_now=True, verbose_name="Modified"), + ), + ( + "required", + models.BooleanField(default=False, verbose_name="Required"), + ), + ( + "display_order", + models.PositiveIntegerField( + default=1, + verbose_name="Display order", + ), + ), ], options={ - 'ordering': ['name'], + "ordering": ["name"], }, ), migrations.CreateModel( - name='EnumGroup', + name="EnumGroup", fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('name', models.CharField(max_length=100, unique=True, verbose_name='Name')), + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "name", + models.CharField(max_length=100, unique=True, verbose_name="Name"), + ), ], ), migrations.CreateModel( - name='EnumValue', + name="EnumValue", fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('value', models.CharField(db_index=True, max_length=50, unique=True, verbose_name='Value')), + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "value", + models.CharField( + db_index=True, + max_length=50, + unique=True, + verbose_name="Value", + ), + ), ], ), migrations.CreateModel( - name='Value', + name="Value", fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('entity_id', models.IntegerField()), - ('value_text', models.TextField(blank=True, null=True)), - ('value_float', models.FloatField(blank=True, null=True)), - ('value_int', models.IntegerField(blank=True, null=True)), - ('value_date', models.DateTimeField(blank=True, null=True)), - ('value_bool', models.NullBooleanField()), - ('generic_value_id', models.IntegerField(blank=True, null=True)), - ('created', models.DateTimeField(default=django.utils.timezone.now, verbose_name='Created')), - ('modified', models.DateTimeField(auto_now=True, verbose_name='Modified')), - ('attribute', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='eav.Attribute', verbose_name='Attribute')), - ('entity_ct', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='value_entities', to='contenttypes.ContentType')), - ('generic_value_ct', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='value_values', to='contenttypes.ContentType')), - ('value_enum', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='eav_values', to='eav.EnumValue')), + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("entity_id", models.IntegerField()), + ("value_text", models.TextField(blank=True, null=True)), + ("value_float", models.FloatField(blank=True, null=True)), + ("value_int", models.IntegerField(blank=True, null=True)), + ("value_date", models.DateTimeField(blank=True, null=True)), + ("value_bool", models.NullBooleanField()), + ("generic_value_id", models.IntegerField(blank=True, null=True)), + ( + "created", + models.DateTimeField( + default=django.utils.timezone.now, + verbose_name="Created", + ), + ), + ( + "modified", + models.DateTimeField(auto_now=True, verbose_name="Modified"), + ), + ( + "attribute", + models.ForeignKey( + on_delete=django.db.models.deletion.PROTECT, + to="eav.Attribute", + verbose_name="Attribute", + ), + ), + ( + "entity_ct", + models.ForeignKey( + on_delete=django.db.models.deletion.PROTECT, + related_name="value_entities", + to="contenttypes.ContentType", + ), + ), + ( + "generic_value_ct", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.PROTECT, + related_name="value_values", + to="contenttypes.ContentType", + ), + ), + ( + "value_enum", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.PROTECT, + related_name="eav_values", + to="eav.EnumValue", + ), + ), ], ), migrations.AddField( - model_name='enumgroup', - name='values', - field=models.ManyToManyField(to='eav.EnumValue', verbose_name='Enum group'), + model_name="enumgroup", + name="values", + field=models.ManyToManyField(to="eav.EnumValue", verbose_name="Enum group"), ), migrations.AddField( - model_name='attribute', - name='enum_group', - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='eav.EnumGroup', verbose_name='Choice Group'), + model_name="attribute", + name="enum_group", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.PROTECT, + to="eav.EnumGroup", + verbose_name="Choice Group", + ), ), ] diff --git a/eav/migrations/0002_add_entity_ct_field.py b/eav/migrations/0002_add_entity_ct_field.py new file mode 100644 index 00000000..8c5f057c --- /dev/null +++ b/eav/migrations/0002_add_entity_ct_field.py @@ -0,0 +1,18 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + """Add entity_ct field to Attribute model.""" + + dependencies = [ + ("contenttypes", "0002_remove_content_type_name"), + ("eav", "0001_initial"), + ] + + operations = [ + migrations.AddField( + model_name="attribute", + name="entity_ct", + field=models.ManyToManyField(blank=True, to="contenttypes.ContentType"), + ), + ] diff --git a/eav/migrations/0003_auto_20210404_2209.py b/eav/migrations/0003_auto_20210404_2209.py new file mode 100644 index 00000000..8182881d --- /dev/null +++ b/eav/migrations/0003_auto_20210404_2209.py @@ -0,0 +1,44 @@ +# Generated by Django 3.1.6 on 2021-04-04 22:09 + +import django.core.serializers.json +from django.db import migrations +from django.db.models import JSONField + +import eav.fields + + +class Migration(migrations.Migration): + dependencies = [ + ("eav", "0002_add_entity_ct_field"), + ] + + operations = [ + migrations.AddField( + model_name="value", + name="value_json", + field=JSONField( + blank=True, + default=dict, + encoder=django.core.serializers.json.DjangoJSONEncoder, + null=True, + ), + ), + migrations.AlterField( + model_name="attribute", + name="datatype", + field=eav.fields.EavDatatypeField( + choices=[ + ("text", "Text"), + ("date", "Date"), + ("float", "Float"), + ("int", "Integer"), + ("bool", "True / False"), + ("object", "Django Object"), + ("enum", "Multiple Choice"), + ("json", "JSON Object"), + ], + max_length=6, + verbose_name="Data Type", + ), + ), + ] diff --git a/eav/migrations/0004_alter_value_value_bool.py b/eav/migrations/0004_alter_value_value_bool.py new file mode 100644 index 00000000..06a0a869 --- /dev/null +++ b/eav/migrations/0004_alter_value_value_bool.py @@ -0,0 +1,17 @@ +# Generated by Django 3.2 on 2021-04-23 19:06 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("eav", "0003_auto_20210404_2209"), + ] + + operations = [ + migrations.AlterField( + model_name="value", + name="value_bool", + field=models.BooleanField(blank=True, null=True), + ), + ] diff --git a/eav/migrations/0005_auto_20210510_1305.py b/eav/migrations/0005_auto_20210510_1305.py new file mode 100644 index 00000000..a4ff6118 --- /dev/null +++ b/eav/migrations/0005_auto_20210510_1305.py @@ -0,0 +1,38 @@ +# Generated by Django 3.2 on 2021-05-10 13:05 + +from django.db import migrations + +import eav.fields + + +class Migration(migrations.Migration): + dependencies = [ + ("eav", "0004_alter_value_value_bool"), + ] + + operations = [ + migrations.AddField( + model_name="value", + name="value_csv", + field=eav.fields.CSVField(blank=True, default="", null=True), + ), + migrations.AlterField( + model_name="attribute", + name="datatype", + field=eav.fields.EavDatatypeField( + choices=[ + ("text", "Text"), + ("date", "Date"), + ("float", "Float"), + ("int", "Integer"), + ("bool", "True / False"), + ("object", "Django Object"), + ("enum", "Multiple Choice"), + ("json", "JSON Object"), + ("csv", "Comma-Separated-Value"), + ], + max_length=6, + verbose_name="Data Type", + ), + ), + ] diff --git a/eav/migrations/0006_add_entity_uuid.py b/eav/migrations/0006_add_entity_uuid.py new file mode 100644 index 00000000..83e9f5c8 --- /dev/null +++ b/eav/migrations/0006_add_entity_uuid.py @@ -0,0 +1,22 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + """Creates UUID field to map to Entity FK.""" + + dependencies = [ + ("eav", "0005_auto_20210510_1305"), + ] + + operations = [ + migrations.AddField( + model_name="value", + name="entity_uuid", + field=models.UUIDField(blank=True, null=True), + ), + migrations.AlterField( + model_name="value", + name="entity_id", + field=models.IntegerField(blank=True, null=True), + ), + ] diff --git a/eav/migrations/0007_alter_value_value_int.py b/eav/migrations/0007_alter_value_value_int.py new file mode 100644 index 00000000..405c0589 --- /dev/null +++ b/eav/migrations/0007_alter_value_value_int.py @@ -0,0 +1,17 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + """Convert Value.value_int to BigInteger.""" + + dependencies = [ + ("eav", "0006_add_entity_uuid"), + ] + + operations = [ + migrations.AlterField( + model_name="value", + name="value_int", + field=models.BigIntegerField(blank=True, null=True), + ), + ] diff --git a/eav/migrations/0008_use_native_slugfield.py b/eav/migrations/0008_use_native_slugfield.py new file mode 100644 index 00000000..f3e81e2a --- /dev/null +++ b/eav/migrations/0008_use_native_slugfield.py @@ -0,0 +1,21 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + """Use Django SlugField() for Attribute.slug.""" + + dependencies = [ + ("eav", "0007_alter_value_value_int"), + ] + + operations = [ + migrations.AlterField( + model_name="attribute", + name="slug", + field=models.SlugField( + help_text="Short unique attribute label", + unique=True, + verbose_name="Slug", + ), + ), + ] diff --git a/eav/migrations/0009_enchance_naming.py b/eav/migrations/0009_enchance_naming.py new file mode 100644 index 00000000..a01a1c31 --- /dev/null +++ b/eav/migrations/0009_enchance_naming.py @@ -0,0 +1,178 @@ +from django.core.serializers.json import DjangoJSONEncoder +from django.db import migrations, models + +from eav.fields import CSVField + + +class Migration(migrations.Migration): + """Define verbose naming for models and fields.""" + + dependencies = [ + ("contenttypes", "0002_remove_content_type_name"), + ("eav", "0008_use_native_slugfield"), + ] + + operations = [ + migrations.AlterModelOptions( + name="attribute", + options={ + "ordering": ["name"], + "verbose_name": "Attribute", + "verbose_name_plural": "Attributes", + }, + ), + migrations.AlterModelOptions( + name="enumgroup", + options={ + "verbose_name": "EnumGroup", + "verbose_name_plural": "EnumGroups", + }, + ), + migrations.AlterModelOptions( + name="enumvalue", + options={ + "verbose_name": "EnumValue", + "verbose_name_plural": "EnumValues", + }, + ), + migrations.AlterModelOptions( + name="value", + options={"verbose_name": "Value", "verbose_name_plural": "Values"}, + ), + migrations.AlterField( + model_name="attribute", + name="entity_ct", + field=models.ManyToManyField( + blank=True, + to="contenttypes.contenttype", + verbose_name="Entity content type", + ), + ), + migrations.AlterField( + model_name="value", + name="entity_ct", + field=models.ForeignKey( + on_delete=models.deletion.PROTECT, + related_name="value_entities", + to="contenttypes.contenttype", + verbose_name="Entity ct", + ), + ), + migrations.AlterField( + model_name="value", + name="entity_id", + field=models.IntegerField( + blank=True, + null=True, + verbose_name="Entity id", + ), + ), + migrations.AlterField( + model_name="value", + name="entity_uuid", + field=models.UUIDField( + blank=True, + null=True, + verbose_name="Entity uuid", + ), + ), + migrations.AlterField( + model_name="value", + name="generic_value_ct", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=models.deletion.PROTECT, + related_name="value_values", + to="contenttypes.contenttype", + verbose_name="Generic value content type", + ), + ), + migrations.AlterField( + model_name="value", + name="generic_value_id", + field=models.IntegerField( + blank=True, + null=True, + verbose_name="Generic value id", + ), + ), + migrations.AlterField( + model_name="value", + name="value_bool", + field=models.BooleanField( + blank=True, + null=True, + verbose_name="Value bool", + ), + ), + migrations.AlterField( + model_name="value", + name="value_csv", + field=CSVField( + blank=True, + default="", + null=True, + verbose_name="Value CSV", + ), + ), + migrations.AlterField( + model_name="value", + name="value_date", + field=models.DateTimeField( + blank=True, + null=True, + verbose_name="Value date", + ), + ), + migrations.AlterField( + model_name="value", + name="value_enum", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=models.deletion.PROTECT, + related_name="eav_values", + to="eav.enumvalue", + verbose_name="Value enum", + ), + ), + migrations.AlterField( + model_name="value", + name="value_float", + field=models.FloatField( + blank=True, + null=True, + verbose_name="Value float", + ), + ), + migrations.AlterField( + model_name="value", + name="value_int", + field=models.BigIntegerField( + blank=True, + null=True, + verbose_name="Value int", + ), + ), + migrations.AlterField( + model_name="value", + name="value_json", + field=models.JSONField( + blank=True, + default=dict, + encoder=DjangoJSONEncoder, + null=True, + verbose_name="Value JSON", + ), + ), + migrations.AlterField( + model_name="value", + name="value_text", + field=models.TextField( + blank=True, + null=True, + verbose_name="Value text", + ), + ), + ] diff --git a/eav/migrations/0010_dynamic_pk_type_for_models.py b/eav/migrations/0010_dynamic_pk_type_for_models.py new file mode 100644 index 00000000..5984f88a --- /dev/null +++ b/eav/migrations/0010_dynamic_pk_type_for_models.py @@ -0,0 +1,48 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + """Migration to use BigAutoField as default for all models.""" + + dependencies = [ + ("eav", "0009_enchance_naming"), + ] + + operations = [ + migrations.AlterField( + model_name="attribute", + name="id", + field=models.BigAutoField( + editable=False, + primary_key=True, + serialize=False, + ), + ), + migrations.AlterField( + model_name="enumgroup", + name="id", + field=models.BigAutoField( + editable=False, + primary_key=True, + serialize=False, + ), + ), + migrations.AlterField( + model_name="enumvalue", + name="id", + field=models.BigAutoField( + editable=False, + primary_key=True, + serialize=False, + ), + ), + migrations.AlterField( + model_name="value", + name="id", + field=models.BigAutoField( + editable=False, + primary_key=True, + serialize=False, + ), + ), + ] diff --git a/eav/migrations/0011_update_defaults_and_meta.py b/eav/migrations/0011_update_defaults_and_meta.py new file mode 100644 index 00000000..a2c67c08 --- /dev/null +++ b/eav/migrations/0011_update_defaults_and_meta.py @@ -0,0 +1,36 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + """Update default values and meta options for Attribute and Value models.""" + + dependencies = [ + ("eav", "0010_dynamic_pk_type_for_models"), + ] + + operations = [ + migrations.AlterModelOptions( + name="attribute", + options={ + "ordering": ("name",), + "verbose_name": "Attribute", + "verbose_name_plural": "Attributes", + }, + ), + migrations.AlterField( + model_name="attribute", + name="description", + field=models.CharField( + blank=True, + default="", + help_text="Short description", + max_length=256, + verbose_name="Description", + ), + ), + migrations.AlterField( + model_name="value", + name="value_text", + field=models.TextField(blank=True, default="", verbose_name="Value text"), + ), + ] diff --git a/eav/migrations/0012_add_value_uniqueness_checks.py b/eav/migrations/0012_add_value_uniqueness_checks.py new file mode 100644 index 00000000..d35b9d81 --- /dev/null +++ b/eav/migrations/0012_add_value_uniqueness_checks.py @@ -0,0 +1,54 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + """ + Add uniqueness and integrity constraints to the Value model. + + This migration adds database-level constraints to ensure: + 1. Each entity (identified by UUID) can have only one value per attribute + 2. Each entity (identified by integer ID) can have only one value per attribute + 3. Each value must use either entity_id OR entity_uuid, never both or neither + + These constraints ensure data integrity by preventing duplicate attribute values + for the same entity and enforcing the XOR relationship between the two types of + entity identification (integer ID vs UUID). + """ + + dependencies = [ + ("eav", "0011_update_defaults_and_meta"), + ] + + operations = [ + migrations.AddConstraint( + model_name="value", + constraint=models.UniqueConstraint( + fields=("entity_ct", "attribute", "entity_uuid"), + name="unique_entity_uuid_per_attribute", + ), + ), + migrations.AddConstraint( + model_name="value", + constraint=models.UniqueConstraint( + fields=("entity_ct", "attribute", "entity_id"), + name="unique_entity_id_per_attribute", + ), + ), + migrations.AddConstraint( + model_name="value", + constraint=models.CheckConstraint( + check=models.Q( + models.Q( + ("entity_id__isnull", False), + ("entity_uuid__isnull", True), + ), + models.Q( + ("entity_id__isnull", True), + ("entity_uuid__isnull", False), + ), + _connector="OR", + ), + name="ensure_entity_id_xor_entity_uuid", + ), + ), + ] diff --git a/eav/models.py b/eav/models.py deleted file mode 100644 index 6975cd8a..00000000 --- a/eav/models.py +++ /dev/null @@ -1,638 +0,0 @@ -""" -This module defines the four concrete, non-abstract models: - * :class:`Value` - * :class:`Attribute` - * :class:`EnumValue` - * :class:`EnumGroup` - -Along with the :class:`Entity` helper class and :class:`EAVModelMeta` -optional metaclass for each eav model class. -""" - -from copy import copy - -from django.contrib.contenttypes import fields as generic -from django.contrib.contenttypes.models import ContentType -from django.core.exceptions import ValidationError -from django.db import models -from django.db.models.base import ModelBase -from django.utils import timezone -from django.utils.translation import ugettext_lazy as _ - -from .validators import ( - validate_text, - validate_float, - validate_int, - validate_date, - validate_bool, - validate_object, - validate_enum -) -from .exceptions import IllegalAssignmentException -from .fields import EavDatatypeField, EavSlugField -from . import register - - -class EnumValue(models.Model): - """ - *EnumValue* objects are the value 'choices' to multiple choice *TYPE_ENUM* - :class:`Attribute` objects. They have only one field, *value*, a - ``CharField`` that must be unique. - - For example:: - - yes = EnumValue.objects.create(value='Yes') # doctest: SKIP - no = EnumValue.objects.create(value='No') - unknown = EnumValue.objects.create(value='Unknown') - - ynu = EnumGroup.objects.create(name='Yes / No / Unknown') - ynu.values.add(yes, no, unknown) - - Attribute.objects.create(name='has fever?', - datatype=Attribute.TYPE_ENUM, enum_group=ynu) - # = - - .. note:: - The same *EnumValue* objects should be reused within multiple - *EnumGroups*. For example, if you have one *EnumGroup* called: *Yes / - No / Unknown* and another called *Yes / No / Not applicable*, you should - only have a total of four *EnumValues* objects, as you should have used - the same *Yes* and *No* *EnumValues* for both *EnumGroups*. - """ - value = models.CharField(_('Value'), db_index=True, unique=True, max_length=50) - - def __str__(self): - return ''.format(self.value) - - -class EnumGroup(models.Model): - """ - *EnumGroup* objects have two fields - a *name* ``CharField`` and *values*, - a ``ManyToManyField`` to :class:`EnumValue`. :class:`Attribute` classes - with datatype *TYPE_ENUM* have a ``ForeignKey`` field to *EnumGroup*. - - See :class:`EnumValue` for an example. - """ - name = models.CharField(_('Name'), unique = True, max_length = 100) - values = models.ManyToManyField(EnumValue, verbose_name = _('Enum group')) - - def __str__(self): - return ''.format(self.name) - - -class Attribute(models.Model): - """ - Putting the **A** in *EAV*. This holds the attributes, or concepts. - Examples of possible *Attributes*: color, height, weight, number of - children, number of patients, has fever?, etc... - - Each attribute has a name, and a description, along with a slug that must - be unique. If you don't provide a slug, a default slug (derived from - name), will be created. - - The *required* field is a boolean that indicates whether this EAV attribute - is required for entities to which it applies. It defaults to *False*. - - .. warning:: - Just like a normal model field that is required, you will not be able - to save or create any entity object for which this attribute applies, - without first setting this EAV attribute. - - There are 7 possible values for datatype: - - * int (TYPE_INT) - * float (TYPE_FLOAT) - * text (TYPE_TEXT) - * date (TYPE_DATE) - * bool (TYPE_BOOLEAN) - * object (TYPE_OBJECT) - * enum (TYPE_ENUM) - - Examples:: - - Attribute.objects.create(name='Height', datatype=Attribute.TYPE_INT) - # = - - Attribute.objects.create(name='Color', datatype=Attribute.TYPE_TEXT) - # = - - yes = EnumValue.objects.create(value='yes') - no = EnumValue.objects.create(value='no') - unknown = EnumValue.objects.create(value='unknown') - ynu = EnumGroup.objects.create(name='Yes / No / Unknown') - ynu.values.add(yes, no, unknown) - - Attribute.objects.create(name='has fever?', datatype=Attribute.TYPE_ENUM, enum_group=ynu) - # = - - .. warning:: Once an Attribute has been used by an entity, you can not - change it's datatype. - """ - class Meta: - ordering = ['name'] - - TYPE_TEXT = 'text' - TYPE_FLOAT = 'float' - TYPE_INT = 'int' - TYPE_DATE = 'date' - TYPE_BOOLEAN = 'bool' - TYPE_OBJECT = 'object' - TYPE_ENUM = 'enum' - - DATATYPE_CHOICES = ( - (TYPE_TEXT, _('Text')), - (TYPE_DATE, _('Date')), - (TYPE_FLOAT, _('Float')), - (TYPE_INT, _('Integer')), - (TYPE_BOOLEAN, _('True / False')), - (TYPE_OBJECT, _('Django Object')), - (TYPE_ENUM, _('Multiple Choice')), - ) - - # Core attributes - - datatype = EavDatatypeField( - verbose_name = _('Data Type'), - choices = DATATYPE_CHOICES, - max_length = 6 - ) - - name = models.CharField( - verbose_name = _('Name'), - max_length = 100, - help_text = _('User-friendly attribute name') - ) - - """ - Main identifer for the attribute. - Upon creation, slug is autogenerated from the name. - (see :meth:`~eav.fields.EavSlugField.create_slug_from_name`). - """ - slug = EavSlugField( - verbose_name = _('Slug'), - max_length = 50, - db_index = True, - unique = True, - help_text = _('Short unique attribute label') - ) - - """ - .. warning:: - This attribute should be used with caution. Setting this to *True* - means that *all* entities that *can* have this attribute will - be required to have a value for it. - """ - required = models.BooleanField(verbose_name = _('Required'), default = False) - - entity_ct = models.ManyToManyField(ContentType, blank=True) - """ - This field allows you to specify a relationship with any number of content types. - This would be useful, for example, if you wanted an attribute to apply only to - a subset of entities. In that case, you could filter by content type in the - :meth:`~eav.registry.EavConfig.get_attributes` method of that entity's config. - """ - - enum_group = models.ForeignKey( - EnumGroup, - verbose_name = _('Choice Group'), - on_delete = models.PROTECT, - blank = True, - null = True - ) - - description = models.CharField( - verbose_name = _('Description'), - max_length = 256, - blank = True, - null = True, - help_text = _('Short description') - ) - - # Useful meta-information - - display_order = models.PositiveIntegerField( - verbose_name = _('Display order'), - default = 1 - ) - - modified = models.DateTimeField( - verbose_name = _('Modified'), - auto_now = True - ) - - created = models.DateTimeField( - verbose_name = _('Created'), - default = timezone.now, - editable = False - ) - - @property - def help_text(self): - return self.description - - def get_validators(self): - """ - Returns the appropriate validator function from :mod:`~eav.validators` - as a list (of length one) for the datatype. - - .. note:: - The reason it returns it as a list, is eventually we may want this - method to look elsewhere for additional attribute specific - validators to return as well as the default, built-in one. - """ - DATATYPE_VALIDATORS = { - 'text': validate_text, - 'float': validate_float, - 'int': validate_int, - 'date': validate_date, - 'bool': validate_bool, - 'object': validate_object, - 'enum': validate_enum, - } - - return [DATATYPE_VALIDATORS[self.datatype]] - - def validate_value(self, value): - """ - Check *value* against the validators returned by - :meth:`get_validators` for this attribute. - """ - for validator in self.get_validators(): - validator(value) - - if self.datatype == self.TYPE_ENUM: - if isinstance(value, EnumValue): - value = value.value - if not self.enum_group.values.filter(value=value).exists(): - raise ValidationError( - _('%(val)s is not a valid choice for %(attr)s') - % dict(val = value, attr = self) - ) - - def save(self, *args, **kwargs): - """ - Saves the Attribute and auto-generates a slug field - if one wasn't provided. - """ - if not self.slug: - self.slug = EavSlugField.create_slug_from_name(self.name) - - self.full_clean() - super(Attribute, self).save(*args, **kwargs) - - def clean(self): - """ - Validates the attribute. Will raise ``ValidationError`` if the - attribute's datatype is *TYPE_ENUM* and enum_group is not set, or if - the attribute is not *TYPE_ENUM* and the enum group is set. - """ - if self.datatype == self.TYPE_ENUM and not self.enum_group: - raise ValidationError( - _('You must set the choice group for multiple choice attributes') - ) - - if self.datatype != self.TYPE_ENUM and self.enum_group: - raise ValidationError( - _('You can only assign a choice group to multiple choice attributes') - ) - - def get_choices(self): - """ - Returns a query set of :class:`EnumValue` objects for this attribute. - Returns None if the datatype of this attribute is not *TYPE_ENUM*. - """ - return self.enum_group.values.all() if self.datatype == Attribute.TYPE_ENUM else None - - def save_value(self, entity, value): - """ - Called with *entity*, any Django object registered with eav, and - *value*, the :class:`Value` this attribute for *entity* should - be set to. - - If a :class:`Value` object for this *entity* and attribute doesn't - exist, one will be created. - - .. note:: - If *value* is None and a :class:`Value` object exists for this - Attribute and *entity*, it will delete that :class:`Value` object. - """ - ct = ContentType.objects.get_for_model(entity) - - try: - value_obj = self.value_set.get( - entity_ct = ct, - entity_id = entity.pk, - attribute = self - ) - except Value.DoesNotExist: - if value == None or value == '': - return - - value_obj = Value.objects.create( - entity_ct = ct, - entity_id = entity.pk, - attribute = self - ) - - if value == None or value == '': - value_obj.delete() - return - - if value != value_obj.value: - value_obj.value = value - value_obj.save() - - def __str__(self): - return '{} ({})'.format(self.name, self.get_datatype_display()) - - -class Value(models.Model): - """ - Putting the **V** in *EAV*. This model stores the value for one particular - :class:`Attribute` for some entity. - - As with most EAV implementations, most of the columns of this model will - be blank, as onle one *value_* field will be used. - - Example:: - - import eav - from django.contrib.auth.models import User - - eav.register(User) - - u = User.objects.create(username='crazy_dev_user') - a = Attribute.objects.create(name='Fav Drink', datatype='text') - - Value.objects.create(entity = u, attribute = a, value_text = 'red bull') - # = - """ - - entity_ct = models.ForeignKey( - ContentType, - on_delete = models.PROTECT, - related_name = 'value_entities' - ) - - entity_id = models.IntegerField() - entity = generic.GenericForeignKey(ct_field = 'entity_ct', fk_field = 'entity_id') - - value_text = models.TextField(blank = True, null = True) - value_float = models.FloatField(blank = True, null = True) - value_int = models.IntegerField(blank = True, null = True) - value_date = models.DateTimeField(blank = True, null = True) - value_bool = models.NullBooleanField(blank = True, null = True) - - value_enum = models.ForeignKey( - EnumValue, - blank = True, - null = True, - on_delete = models.PROTECT, - related_name = 'eav_values' - ) - - generic_value_id = models.IntegerField(blank=True, null=True) - - generic_value_ct = models.ForeignKey( - ContentType, - blank = True, - null = True, - on_delete = models.PROTECT, - related_name ='value_values' - ) - - value_object = generic.GenericForeignKey( - ct_field = 'generic_value_ct', - fk_field = 'generic_value_id' - ) - - created = models.DateTimeField(_('Created'), default = timezone.now) - modified = models.DateTimeField(_('Modified'), auto_now = True) - - attribute = models.ForeignKey( - Attribute, - db_index = True, - on_delete = models.PROTECT, - verbose_name = _('Attribute') - ) - - def save(self, *args, **kwargs): - """ - Validate and save this value. - """ - self.full_clean() - super(Value, self).save(*args, **kwargs) - - def _get_value(self): - """ - Return the python object this value is holding - """ - return getattr(self, 'value_%s' % self.attribute.datatype) - - def _set_value(self, new_value): - """ - Set the object this value is holding - """ - setattr(self, 'value_%s' % self.attribute.datatype, new_value) - - value = property(_get_value, _set_value) - - def __str__(self): - return '{}: "{}" ({})'.format(self.attribute.name, self.value, self.entity) - - def __repr__(self): - return '{}: "{}" ({})'.format(self.attribute.name, self.value, self.entity.pk) - - -class Entity(object): - """ - The helper class that will be attached to any entity - registered with eav. - """ - @staticmethod - def pre_save_handler(sender, *args, **kwargs): - """ - Pre save handler attached to self.instance. Called before the - model instance we are attached to is saved. This allows us to call - :meth:`validate_attributes` before the entity is saved. - """ - instance = kwargs['instance'] - entity = getattr(kwargs['instance'], instance._eav_config_cls.eav_attr) - entity.validate_attributes() - - @staticmethod - def post_save_handler(sender, *args, **kwargs): - """ - Post save handler attached to self.instance. Calls :meth:`save` when - the model instance we are attached to is saved. - """ - instance = kwargs['instance'] - entity = getattr(instance, instance._eav_config_cls.eav_attr) - entity.save() - - def __init__(self, instance): - """ - Set self.instance equal to the instance of the model that we're attached - to. Also, store the content type of that instance. - """ - self.instance = instance - self.ct = ContentType.objects.get_for_model(instance) - - def __getattr__(self, name): - """ - Tha magic getattr helper. This is called whenever user invokes:: - - instance. - - Checks if *name* is a valid slug for attributes available to this - instances. If it is, tries to lookup the :class:`Value` with that - attribute slug. If there is one, it returns the value of the - class:`Value` object, otherwise it hasn't been set, so it returns - None. - """ - if not name.startswith('_'): - try: - attribute = self.get_attribute_by_slug(name) - except Attribute.DoesNotExist: - raise AttributeError( - _('%(obj)s has no EAV attribute named %(attr)s') - % dict(obj = self.instance, attr = name) - ) - - try: - return self.get_value_by_attribute(attribute).value - except Value.DoesNotExist: - return None - - return getattr(super(Entity, self), name) - - def get_all_attributes(self): - """ - Return a query set of all :class:`Attribute` objects that can be set - for this entity. - """ - return self.instance._eav_config_cls.get_attributes( - instance=self.instance - ).order_by('display_order') - - def _hasattr(self, attribute_slug): - """ - Since we override __getattr__ with a backdown to the database, this - exists as a way of checking whether a user has set a real attribute on - ourselves, without going to the db if not. - """ - return attribute_slug in self.__dict__ - - def _getattr(self, attribute_slug): - """ - Since we override __getattr__ with a backdown to the database, this - exists as a way of getting the value a user set for one of our - attributes, without going to the db to check. - """ - return self.__dict__[attribute_slug] - - def save(self): - """ - Saves all the EAV values that have been set on this entity. - """ - for attribute in self.get_all_attributes(): - if self._hasattr(attribute.slug): - attribute_value = self._getattr(attribute.slug) - if attribute.datatype == Attribute.TYPE_ENUM and not isinstance(attribute_value, EnumValue): - if attribute_value is not None: - attribute_value = EnumValue.objects.get(value=attribute_value) - attribute.save_value(self.instance, attribute_value) - - def validate_attributes(self): - """ - Called before :meth:`save`, first validate all the entity values to - make sure they can be created / saved cleanly. - Raises ``ValidationError`` if they can't be. - """ - values_dict = self.get_values_dict() - - for attribute in self.get_all_attributes(): - value = None - - # Value was assigned to this instance. - if self._hasattr(attribute.slug): - value = self._getattr(attribute.slug) - values_dict.pop(attribute.slug, None) - # Otherwise try pre-loaded from DB. - else: - value = values_dict.pop(attribute.slug, None) - - if value is None: - if attribute.required: - raise ValidationError( - _('{} EAV field cannot be blank'.format(attribute.slug)) - ) - else: - try: - attribute.validate_value(value) - except ValidationError as e: - raise ValidationError( - _('%(attr)s EAV field %(err)s') - % dict(attr = attribute.slug, err = e) - ) - - illegal = values_dict or ( - self.get_object_attributes() - self.get_all_attribute_slugs()) - - if illegal: - raise IllegalAssignmentException( - 'Instance of the class {} cannot have values for attributes: {}.' - .format(self.instance.__class__, ', '.join(illegal)) - ) - - def get_values_dict(self): - return {v.attribute.slug: v.value for v in self.get_values()} - - def get_values(self): - """ - Get all set :class:`Value` objects for self.instance - """ - return Value.objects.filter( - entity_ct = self.ct, - entity_id = self.instance.pk - ).select_related() - - def get_all_attribute_slugs(self): - """ - Returns a list of slugs for all attributes available to this entity. - """ - return set(self.get_all_attributes().values_list('slug', flat=True)) - - def get_attribute_by_slug(self, slug): - """ - Returns a single :class:`Attribute` with *slug*. - """ - return self.get_all_attributes().get(slug=slug) - - def get_value_by_attribute(self, attribute): - """ - Returns a single :class:`Value` for *attribute*. - """ - return self.get_values().get(attribute=attribute) - - def get_object_attributes(self): - """ - Returns entity instance attributes, except for - ``instance`` and ``ct`` which are used internally. - """ - return set(copy(self.__dict__).keys()) - set(['instance', 'ct']) - - def __iter__(self): - """ - Iterate over set eav values. This would allow you to do:: - - for i in m.eav: print(i) - """ - return iter(self.get_values()) - - -class EAVModelMeta(ModelBase): - def __new__(cls, name, bases, namespace, **kwds): - result = super(EAVModelMeta, cls).__new__(cls, name, bases, dict(namespace)) - register(result) - return result diff --git a/eav/models/__init__.py b/eav/models/__init__.py new file mode 100644 index 00000000..952d586b --- /dev/null +++ b/eav/models/__init__.py @@ -0,0 +1,25 @@ +""" +This module defines the four concrete, non-abstract models: + * :class:`Value` + * :class:`Attribute` + * :class:`EnumValue` + * :class:`EnumGroup`. + +Along with the :class:`Entity` helper class and :class:`EAVModelMeta` +optional metaclass for each eav model class. +""" + +from .attribute import Attribute +from .entity import EAVModelMeta, Entity +from .enum_group import EnumGroup +from .enum_value import EnumValue +from .value import Value + +__all__ = [ + "Attribute", + "EAVModelMeta", + "Entity", + "EnumGroup", + "EnumValue", + "Value", +] diff --git a/eav/models/attribute.py b/eav/models/attribute.py new file mode 100644 index 00000000..1f52e7d3 --- /dev/null +++ b/eav/models/attribute.py @@ -0,0 +1,367 @@ +# ruff: noqa: UP007 + +from __future__ import annotations + +import warnings +from typing import TYPE_CHECKING, Optional + +from django.contrib.contenttypes.models import ContentType +from django.core.exceptions import ValidationError +from django.db import models +from django.db.models import ForeignKey +from django.utils import timezone +from django.utils.translation import gettext_lazy as _ + +from eav.fields import EavDatatypeField +from eav.logic.entity_pk import get_entity_pk_type +from eav.logic.managers import AttributeManager +from eav.logic.object_pk import get_pk_format +from eav.logic.slug import SLUGFIELD_MAX_LENGTH, generate_slug +from eav.settings import CHARFIELD_LENGTH +from eav.validators import ( + validate_bool, + validate_csv, + validate_date, + validate_enum, + validate_float, + validate_int, + validate_json, + validate_object, + validate_text, +) + +from .enum_value import EnumValue +from .value import Value + +if TYPE_CHECKING: + from .enum_group import EnumGroup + + +class Attribute(models.Model): + """ + Putting the **A** in *EAV*. This holds the attributes, or concepts. + Examples of possible *Attributes*: color, height, weight, number of + children, number of patients, has fever?, etc... + + Each attribute has a name, and a description, along with a slug that must + be unique. If you don't provide a slug, a default slug (derived from + name), will be created. + + The *required* field is a boolean that indicates whether this EAV attribute + is required for entities to which it applies. It defaults to *False*. + + .. warning:: + Just like a normal model field that is required, you will not be able + to save or create any entity object for which this attribute applies, + without first setting this EAV attribute. + + There are 7 possible values for datatype: + + * int (TYPE_INT) + * float (TYPE_FLOAT) + * text (TYPE_TEXT) + * date (TYPE_DATE) + * bool (TYPE_BOOLEAN) + * object (TYPE_OBJECT) + * enum (TYPE_ENUM) + * json (TYPE_JSON) + * csv (TYPE_CSV) + + + Examples:: + + Attribute.objects.create(name='Height', datatype=Attribute.TYPE_INT) + # = + + Attribute.objects.create(name='Color', datatype=Attribute.TYPE_TEXT) + # = + + yes = EnumValue.objects.create(value='yes') + no = EnumValue.objects.create(value='no') + unknown = EnumValue.objects.create(value='unknown') + ynu = EnumGroup.objects.create(name='Yes / No / Unknown') + ynu.values.add(yes, no, unknown) + + Attribute.objects.create(name='has fever?', + datatype=Attribute.TYPE_ENUM, + enum_group=ynu + ) + # = + + .. warning:: Once an Attribute has been used by an entity, you can not + change it's datatype. + """ + + TYPE_TEXT = "text" + TYPE_FLOAT = "float" + TYPE_INT = "int" + TYPE_DATE = "date" + TYPE_BOOLEAN = "bool" + TYPE_OBJECT = "object" + TYPE_ENUM = "enum" + TYPE_JSON = "json" + TYPE_CSV = "csv" + + DATATYPE_CHOICES = ( + (TYPE_TEXT, _("Text")), + (TYPE_DATE, _("Date")), + (TYPE_FLOAT, _("Float")), + (TYPE_INT, _("Integer")), + (TYPE_BOOLEAN, _("True / False")), + (TYPE_OBJECT, _("Django Object")), + (TYPE_ENUM, _("Multiple Choice")), + (TYPE_JSON, _("JSON Object")), + (TYPE_CSV, _("Comma-Separated-Value")), + ) + + # Core attributes + id = get_pk_format() + + datatype = EavDatatypeField( + choices=DATATYPE_CHOICES, + max_length=6, + verbose_name=_("Data Type"), + ) + + name = models.CharField( + max_length=CHARFIELD_LENGTH, + help_text=_("User-friendly attribute name"), + verbose_name=_("Name"), + ) + + """ + Main identifier for the attribute. + Upon creation, slug is autogenerated from the name. + (see :meth:`~eav.fields.EavSlugField.create_slug_from_name`). + """ + slug = models.SlugField( + max_length=SLUGFIELD_MAX_LENGTH, + db_index=True, + unique=True, + help_text=_("Short unique attribute label"), + verbose_name=_("Slug"), + ) + + """ + .. warning:: + This attribute should be used with caution. Setting this to *True* + means that *all* entities that *can* have this attribute will + be required to have a value for it. + """ + required = models.BooleanField( + default=False, + verbose_name=_("Required"), + ) + + entity_ct = models.ManyToManyField( + ContentType, + blank=True, + verbose_name=_("Entity content type"), + ) + """ + This field allows you to specify a relationship with any number of content types. + This would be useful, for example, if you wanted an attribute to apply only to + a subset of entities. In that case, you could filter by content type in the + :meth:`~eav.registry.EavConfig.get_attributes` method of that entity's config. + """ + + enum_group: ForeignKey[Optional[EnumGroup]] = ForeignKey( + "eav.EnumGroup", + on_delete=models.PROTECT, + blank=True, + null=True, + verbose_name=_("Choice Group"), + ) + + description = models.CharField( + max_length=256, + blank=True, + default="", + help_text=_("Short description"), + verbose_name=_("Description"), + ) + + # Useful meta-information + + display_order = models.PositiveIntegerField( + default=1, + verbose_name=_("Display order"), + ) + + modified = models.DateTimeField( + auto_now=True, + verbose_name=_("Modified"), + ) + + created = models.DateTimeField( + default=timezone.now, + editable=False, + verbose_name=_("Created"), + ) + + objects = AttributeManager() + + class Meta: + ordering = ("name",) + verbose_name = _("Attribute") + verbose_name_plural = _("Attributes") + + def __str__(self) -> str: + return f"{self.name} ({self.get_datatype_display()})" + + def save(self, *args, **kwargs): + """ + Saves the Attribute and auto-generates a slug field + if one wasn't provided. + """ + if not self.slug: + self.slug = generate_slug(self.name) + + self.full_clean() + super().save(*args, **kwargs) + + def natural_key(self) -> tuple[str, str]: + """ + Retrieve the natural key for the Attribute instance. + + The natural key for an Attribute is defined by its `name` and `slug`. This + method returns a tuple containing these two attributes of the instance. + + Returns + ------- + tuple: A tuple containing the name and slug of the Attribute instance. + """ + return ( + self.name, + self.slug, + ) + + @property + def help_text(self): + return self.description + + def get_validators(self): + """ + Returns the appropriate validator function from :mod:`~eav.validators` + as a list (of length one) for the datatype. + + .. note:: + The reason it returns it as a list, is eventually we may want this + method to look elsewhere for additional attribute specific + validators to return as well as the default, built-in one. + """ + datatype_validators = { + "text": validate_text, + "float": validate_float, + "int": validate_int, + "date": validate_date, + "bool": validate_bool, + "object": validate_object, + "enum": validate_enum, + "json": validate_json, + "csv": validate_csv, + } + + return [datatype_validators[self.datatype]] + + def validate_value(self, value): + """ + Check *value* against the validators returned by + :meth:`get_validators` for this attribute. + """ + for validator in self.get_validators(): + validator(value) + + if self.datatype == self.TYPE_ENUM: + if isinstance(value, EnumValue): + value = value.value + if not self.enum_group.values.filter(value=value).exists(): + raise ValidationError( + _("%(val)s is not a valid choice for %(attr)s") + % {"val": value, "attr": self}, + ) + + def clean(self): + """ + Validates the attribute. Will raise ``ValidationError`` if the + attribute's datatype is *TYPE_ENUM* and enum_group is not set, or if + the attribute is not *TYPE_ENUM* and the enum group is set. + """ + if self.datatype == self.TYPE_ENUM and not self.enum_group: + raise ValidationError( + _("You must set the choice group for multiple choice attributes"), + ) + + if self.datatype != self.TYPE_ENUM and self.enum_group: + raise ValidationError( + _("You can only assign a choice group to multiple choice attributes"), + ) + + def clean_fields(self, exclude=None): + """Perform field-specific validation on the model's fields. + + This method extends the default field cleaning process to include + custom validation for the slug field. + + Args: + exclude (list): Fields to exclude from cleaning. + + Raises: + ValidationError: If the slug is not a valid Python identifier. + """ + super().clean_fields(exclude=exclude) + + if not self.slug.isidentifier(): + warnings.warn( + f"Slug '{self.slug}' is not a valid Python identifier. " + + "Consider updating it.", + stacklevel=3, + ) + + def get_choices(self): + """ + Returns a query set of :class:`EnumValue` objects for this attribute. + Returns None if the datatype of this attribute is not *TYPE_ENUM*. + """ + return ( + self.enum_group.values.all() + if self.datatype == Attribute.TYPE_ENUM + else None + ) + + def save_value(self, entity, value): + """ + Called with *entity*, any Django object registered with eav, and + *value*, the :class:`Value` this attribute for *entity* should + be set to. + + If a :class:`Value` object for this *entity* and attribute doesn't + exist, one will be created. + + .. note:: + If *value* is None and a :class:`Value` object exists for this + Attribute and *entity*, it will delete that :class:`Value` object. + """ + ct = ContentType.objects.get_for_model(entity) + + entity_filter = { + "entity_ct": ct, + "attribute": self, + f"{get_entity_pk_type(entity)}": entity.pk, + } + + try: + value_obj = self.value_set.get(**entity_filter) + except Value.DoesNotExist: + if value is None or value == "": + return + + value_obj = Value.objects.create(**entity_filter) + + if value is None or value == "": + value_obj.delete() + return + + if value != value_obj.value: + value_obj.value = value + value_obj.save() diff --git a/eav/models/entity.py b/eav/models/entity.py new file mode 100644 index 00000000..c57ac4af --- /dev/null +++ b/eav/models/entity.py @@ -0,0 +1,208 @@ +from copy import copy + +from django.contrib.contenttypes.models import ContentType +from django.core.exceptions import ValidationError +from django.db.models.base import ModelBase +from django.utils.translation import gettext_lazy as _ + +from eav import register +from eav.exceptions import IllegalAssignmentException +from eav.logic.entity_pk import get_entity_pk_type + +from .attribute import Attribute +from .enum_value import EnumValue +from .value import Value + + +class Entity: + """Helper class that will be attached to entities registered with eav.""" + + @staticmethod + def pre_save_handler(sender, *args, **kwargs): + """ + Pre save handler attached to self.instance. Called before the + model instance we are attached to is saved. This allows us to call + :meth:`validate_attributes` before the entity is saved. + """ + instance = kwargs["instance"] + entity = getattr(kwargs["instance"], instance._eav_config_cls.eav_attr) # noqa: SLF001 + entity.validate_attributes() + + @staticmethod + def post_save_handler(sender, *args, **kwargs): + """ + Post save handler attached to self.instance. Calls :meth:`save` when + the model instance we are attached to is saved. + """ + instance = kwargs["instance"] + entity = getattr(instance, instance._eav_config_cls.eav_attr) # noqa: SLF001 + entity.save() + + def __init__(self, instance) -> None: + """ + Set self.instance equal to the instance of the model that we're attached + to. Also, store the content type of that instance. + """ + self.instance = instance + self.ct = ContentType.objects.get_for_model(instance) + + def __getattr__(self, name): + """ + The magic getattr helper. This is called whenever user invokes:: + + instance. + + Checks if *name* is a valid slug for attributes available to this + instances. If it is, tries to lookup the :class:`Value` with that + attribute slug. If there is one, it returns the value of the + class:`Value` object, otherwise it hasn't been set, so it returns + None. + """ + if not name.startswith("_"): + try: + attribute = self.get_attribute_by_slug(name) + except Attribute.DoesNotExist as err: + raise AttributeError( + _("%(obj)s has no EAV attribute named %(attr)s") + % {"obj": self.instance, "attr": name}, + ) from err + + try: + return self.get_value_by_attribute(attribute).value + except Value.DoesNotExist: + return None + + return getattr(super(), name) + + def get_all_attributes(self): + """ + Return a query set of all :class:`Attribute` objects that can be set + for this entity. + """ + return self.instance._eav_config_cls.get_attributes( # noqa: SLF001 + instance=self.instance, + ).order_by("display_order") + + def _hasattr(self, attribute_slug): + """ + Since we override __getattr__ with a backdown to the database, this + exists as a way of checking whether a user has set a real attribute on + ourselves, without going to the db if not. + """ + return attribute_slug in self.__dict__ + + def _getattr(self, attribute_slug): + """ + Since we override __getattr__ with a backdown to the database, this + exists as a way of getting the value a user set for one of our + attributes, without going to the db to check. + """ + return self.__dict__[attribute_slug] + + def save(self): + """Saves all the EAV values that have been set on this entity.""" + for attribute in self.get_all_attributes(): + if self._hasattr(attribute.slug): + attribute_value = self._getattr(attribute.slug) + if ( + attribute.datatype == Attribute.TYPE_ENUM + and not isinstance( + attribute_value, + EnumValue, + ) + and attribute_value is not None + ): + attribute_value = EnumValue.objects.get(value=attribute_value) + attribute.save_value(self.instance, attribute_value) + + def validate_attributes(self): + """ + Called before :meth:`save`, first validate all the entity values to + make sure they can be created / saved cleanly. + Raises ``ValidationError`` if they can't be. + """ + values_dict = self.get_values_dict() + + for attribute in self.get_all_attributes(): + value = None + + # Value was assigned to this instance. + if self._hasattr(attribute.slug): + value = self._getattr(attribute.slug) + values_dict.pop(attribute.slug, None) + # Otherwise try pre-loaded from DB. + else: + value = values_dict.pop(attribute.slug, None) + + if value is None: + if attribute.required: + raise ValidationError( + _("%s EAV field cannot be blank") % attribute.slug, + ) + else: + try: + attribute.validate_value(value) + except ValidationError as err: + raise ValidationError( + _("%(attr)s EAV field %(err)s") + % {"attr": attribute.slug, "err": err}, + ) from err + + illegal = values_dict or ( + self.get_object_attributes() - self.get_all_attribute_slugs() + ) + + if illegal: + message = ( + "Instance of the class {} cannot have values for attributes: {}." + ).format( + self.instance.__class__, + ", ".join(illegal), + ) + raise IllegalAssignmentException(message) + + def get_values_dict(self): + return {v.attribute.slug: v.value for v in self.get_values()} + + def get_values(self): + """Get all set :class:`Value` objects for self.instance.""" + entity_filter = { + "entity_ct": self.ct, + f"{get_entity_pk_type(self.instance)}": self.instance.pk, + } + + return Value.objects.filter(**entity_filter).select_related() + + def get_all_attribute_slugs(self): + """Returns a list of slugs for all attributes available to this entity.""" + return set(self.get_all_attributes().values_list("slug", flat=True)) + + def get_attribute_by_slug(self, slug): + """Returns a single :class:`Attribute` with *slug*.""" + return self.get_all_attributes().get(slug=slug) + + def get_value_by_attribute(self, attribute): + """Returns a single :class:`Value` for *attribute*.""" + return self.get_values().get(attribute=attribute) + + def get_object_attributes(self): + """ + Returns entity instance attributes, except for + ``instance`` and ``ct`` which are used internally. + """ + return set(copy(self.__dict__).keys()) - {"instance", "ct"} + + def __iter__(self): + """ + Iterate over set eav values. This would allow you to do:: + + for i in m.eav: print(i) + """ + return iter(self.get_values()) + + +class EAVModelMeta(ModelBase): + def __new__(cls, name, bases, namespace, **kwds): + result = super().__new__(cls, name, bases, dict(namespace)) + register(result) + return result diff --git a/eav/models/enum_group.py b/eav/models/enum_group.py new file mode 100644 index 00000000..7865cf80 --- /dev/null +++ b/eav/models/enum_group.py @@ -0,0 +1,63 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +from django.db import models +from django.db.models import ManyToManyField +from django.utils.translation import gettext_lazy as _ + +from eav.logic.managers import EnumGroupManager +from eav.logic.object_pk import get_pk_format +from eav.settings import CHARFIELD_LENGTH + +if TYPE_CHECKING: + from .enum_value import EnumValue + + +class EnumGroup(models.Model): + """ + *EnumGroup* objects have two fields - a *name* ``CharField`` and *values*, + a ``ManyToManyField`` to :class:`EnumValue`. :class:`Attribute` classes + with datatype *TYPE_ENUM* have a ``ForeignKey`` field to *EnumGroup*. + + See :class:`EnumValue` for an example. + """ + + id = get_pk_format() + + name = models.CharField( + unique=True, + max_length=CHARFIELD_LENGTH, + verbose_name=_("Name"), + ) + values: ManyToManyField[EnumValue, Any] = ManyToManyField( + "eav.EnumValue", + verbose_name=_("Enum group"), + ) + + objects = EnumGroupManager() + + class Meta: + verbose_name = _("EnumGroup") + verbose_name_plural = _("EnumGroups") + + def __str__(self) -> str: + """String representation of `EnumGroup` instance.""" + return str(self.name) + + def __repr__(self) -> str: + """String representation of `EnumGroup` object.""" + return f"" + + def natural_key(self) -> tuple[str]: + """ + Retrieve the natural key for the EnumGroup instance. + + The natural key for an EnumGroup is defined by its `name`. This method + returns the name of the instance as a single-element tuple. + + Returns + ------- + tuple: A tuple containing the name of the EnumGroup instance. + """ + return (self.name,) diff --git a/eav/models/enum_value.py b/eav/models/enum_value.py new file mode 100644 index 00000000..b54fea5d --- /dev/null +++ b/eav/models/enum_value.py @@ -0,0 +1,74 @@ +from __future__ import annotations + +from django.db import models +from django.utils.translation import gettext_lazy as _ + +from eav.logic.managers import EnumValueManager +from eav.logic.object_pk import get_pk_format +from eav.logic.slug import SLUGFIELD_MAX_LENGTH + + +class EnumValue(models.Model): + """ + *EnumValue* objects are the value 'choices' to multiple choice *TYPE_ENUM* + :class:`Attribute` objects. They have only one field, *value*, a + ``CharField`` that must be unique. + + For example:: + + yes = EnumValue.objects.create(value='Yes') # doctest: SKIP + no = EnumValue.objects.create(value='No') + unknown = EnumValue.objects.create(value='Unknown') + + ynu = EnumGroup.objects.create(name='Yes / No / Unknown') + ynu.values.add(yes, no, unknown) + + Attribute.objects.create(name='has fever?', + datatype=Attribute.TYPE_ENUM, enum_group=ynu) + # = + + .. note:: + The same *EnumValue* objects should be reused within multiple + *EnumGroups*. For example, if you have one *EnumGroup* called: *Yes / + No / Unknown* and another called *Yes / No / Not applicable*, you should + only have a total of four *EnumValues* objects, as you should have used + the same *Yes* and *No* *EnumValues* for both *EnumGroups*. + """ + + id = get_pk_format() + + value = models.CharField( + _("Value"), + db_index=True, + unique=True, + max_length=SLUGFIELD_MAX_LENGTH, + ) + + objects = EnumValueManager() + + class Meta: + verbose_name = _("EnumValue") + verbose_name_plural = _("EnumValues") + + def __str__(self) -> str: + """String representation of `EnumValue` instance.""" + return str( + self.value, + ) + + def __repr__(self) -> str: + """String representation of `EnumValue` object.""" + return f"" + + def natural_key(self) -> tuple[str]: + """ + Retrieve the natural key for the EnumValue instance. + + The natural key for an EnumValue is defined by its `value`. This method returns + the value of the instance as a single-element tuple. + + Returns + ------- + tuple: A tuple containing the value of the EnumValue instance. + """ + return (self.value,) diff --git a/eav/models/value.py b/eav/models/value.py new file mode 100644 index 00000000..5d8c0fdf --- /dev/null +++ b/eav/models/value.py @@ -0,0 +1,232 @@ +# ruff: noqa: UP007 +from __future__ import annotations + +from typing import TYPE_CHECKING, ClassVar, Optional + +from django.contrib.contenttypes import fields as generic +from django.contrib.contenttypes.models import ContentType +from django.core.serializers.json import DjangoJSONEncoder +from django.db import models +from django.db.models import ForeignKey +from django.utils import timezone +from django.utils.translation import gettext_lazy as _ + +from eav.fields import CSVField +from eav.logic.managers import ValueManager +from eav.logic.object_pk import get_pk_format + +if TYPE_CHECKING: + from .attribute import Attribute + from .enum_value import EnumValue + + +class Value(models.Model): + """ + Putting the **V** in *EAV*. + + This model stores the value for one particular :class:`Attribute` for + some entity. + + As with most EAV implementations, most of the columns of this model will + be blank, as onle one *value_* field will be used. + + Example:: + + import eav + from django.contrib.auth.models import User + + eav.register(User) + + u = User.objects.create(username='crazy_dev_user') + a = Attribute.objects.create(name='Fav Drink', datatype='text') + + Value.objects.create(entity = u, attribute = a, value_text = 'red bull') + # = + """ + + id = get_pk_format() + + # Direct foreign keys + attribute: ForeignKey[Attribute] = ForeignKey( + "eav.Attribute", + db_index=True, + on_delete=models.PROTECT, + verbose_name=_("Attribute"), + ) + + # Entity generic relationships. Rather than rely on database casting, + # this will instead use a separate ForeignKey field attribute that matches + # the FK type of the entity. + entity_id = models.IntegerField( + blank=True, + null=True, + verbose_name=_("Entity id"), + ) + + entity_uuid = models.UUIDField( + blank=True, + null=True, + verbose_name=_("Entity uuid"), + ) + + entity_ct = ForeignKey( + ContentType, + on_delete=models.PROTECT, + related_name="value_entities", + verbose_name=_("Entity ct"), + ) + + entity_pk_int = generic.GenericForeignKey( + ct_field="entity_ct", + fk_field="entity_id", + ) + + entity_pk_uuid = generic.GenericForeignKey( + ct_field="entity_ct", + fk_field="entity_uuid", + ) + + # Model attributes + created = models.DateTimeField( + default=timezone.now, + verbose_name=_("Created"), + ) + + modified = models.DateTimeField( + auto_now=True, + verbose_name=_("Modified"), + ) + + # Value attributes + value_bool = models.BooleanField( + blank=True, + null=True, + verbose_name=_("Value bool"), + ) + value_csv = CSVField( + blank=True, + null=True, + verbose_name=_("Value CSV"), + ) + value_date = models.DateTimeField( + blank=True, + null=True, + verbose_name=_("Value date"), + ) + value_float = models.FloatField( + blank=True, + null=True, + verbose_name=_("Value float"), + ) + value_int = models.BigIntegerField( + blank=True, + null=True, + verbose_name=_("Value int"), + ) + value_text = models.TextField( + blank=True, + default="", + verbose_name=_("Value text"), + ) + + value_json = models.JSONField( + default=dict, + encoder=DjangoJSONEncoder, + blank=True, + null=True, + verbose_name=_("Value JSON"), + ) + + value_enum: ForeignKey[Optional[EnumValue]] = ForeignKey( + "eav.EnumValue", + blank=True, + null=True, + on_delete=models.PROTECT, + related_name="eav_values", + verbose_name=_("Value enum"), + ) + + # Value object relationship + generic_value_id = models.IntegerField( + blank=True, + null=True, + verbose_name=_("Generic value id"), + ) + + generic_value_ct = ForeignKey( + ContentType, + blank=True, + null=True, + on_delete=models.PROTECT, + related_name="value_values", + verbose_name=_("Generic value content type"), + ) + + value_object = generic.GenericForeignKey( + ct_field="generic_value_ct", + fk_field="generic_value_id", + ) + + objects = ValueManager() + + class Meta: + verbose_name = _("Value") + verbose_name_plural = _("Values") + + constraints: ClassVar[list[models.Constraint]] = [ + models.UniqueConstraint( + fields=["entity_ct", "attribute", "entity_uuid"], + name="unique_entity_uuid_per_attribute", + ), + models.UniqueConstraint( + fields=["entity_ct", "attribute", "entity_id"], + name="unique_entity_id_per_attribute", + ), + models.CheckConstraint( + check=( + models.Q(entity_id__isnull=False, entity_uuid__isnull=True) + | models.Q(entity_id__isnull=True, entity_uuid__isnull=False) + ), + name="ensure_entity_id_xor_entity_uuid", + ), + ] + + def __str__(self) -> str: + """String representation of a Value.""" + entity = self.entity_pk_uuid if self.entity_uuid else self.entity_pk_int + return f'{self.attribute.name}: "{self.value}" ({entity})' + + def __repr__(self) -> str: + """Representation of Value object.""" + entity = self.entity_pk_uuid if self.entity_uuid else self.entity_pk_int + return f'{self.attribute.name}: "{self.value}" ({entity})' + + def save(self, *args, **kwargs): + """Validate and save this value.""" + self.full_clean() + super().save(*args, **kwargs) + + def natural_key(self) -> tuple[tuple[str, str], int, str]: + """ + Retrieve the natural key for the Value instance. + + The natural key for a Value is a combination of its `attribute` natural key, + `entity_id`, and `entity_uuid`. This method returns a tuple containing these + three elements. + + Returns + ------- + tuple: A tuple containing the natural key of the attribute, entity ID, + and entity UUID of the Value instance. + """ + return (self.attribute.natural_key(), self.entity_id, self.entity_uuid) + + def _get_value(self): + """Return the python object this value is holding.""" + return getattr(self, f"value_{self.attribute.datatype}") + + def _set_value(self, new_value): + """Set the object this value is holding.""" + setattr(self, f"value_{self.attribute.datatype}", new_value) + + value = property(_get_value, _set_value) diff --git a/eav/queryset.py b/eav/queryset.py index 243d75b8..08c1b0ff 100644 --- a/eav/queryset.py +++ b/eav/queryset.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- """ This module contains custom :class:`EavQuerySet` class used for overriding relational operators and pure functions for rewriting Q-expressions. @@ -19,17 +18,16 @@ 2. To ensure that Q-expression tree is compiled to valid SQL. For details see: :func:`rewrite_q_expr`. """ -from itertools import count + from functools import wraps +from itertools import count -from django.core.exceptions import FieldDoesNotExist -from django.core.exceptions import FieldError, ObjectDoesNotExist -from django.db import models +from django.core.exceptions import ObjectDoesNotExist from django.db.models import Case, IntegerField, Q, When from django.db.models.query import QuerySet from django.db.utils import NotSupportedError -from .models import Attribute, Value, EnumValue +from eav.models import Attribute, EnumValue, Value def is_eav_and_leaf(expr, gr_name): @@ -44,65 +42,65 @@ def is_eav_and_leaf(expr, gr_name): bool """ return ( - getattr(expr, 'connector', None) == 'AND' and - len(expr.children) == 1 and - expr.children[0][0] in ['pk__in', '{}__in'.format(gr_name)] + getattr(expr, "connector", None) == "AND" + and len(expr.children) == 1 + and expr.children[0][0] in ["pk__in", f"{gr_name}__in"] ) def rewrite_q_expr(model_cls, expr): """ - Rewrites Q-expression to safe form, in order to ensure that - generated SQL is valid. - -IGNORE: - Suppose we have the following Q-expression: - - └── OR - ├── AND - │ └── eav_values__in [1, 2, 3] - └── AND (1) - ├── AND - │ └── eav_values__in [4, 5] - └── AND - └── eav_values__in [6, 7, 8] -IGNORE - - All EAV values are stored in a single table. Therefore, INNER JOIN - generated for the AND-expression (1) will always fail, i.e. - single row in a eav_values table cannot be both in two disjoint sets at - the same time (and the whole point of using AND, usually, is two have - two different sets). Therefore, we must paritially rewrite the - expression so that the generated SQL is valid:: - -IGNORE: - └── OR - ├── AND - │ └── eav_values__in [1, 2, 3] - └── AND - └── pk__in [1, 2] -IGNORE - - This is done by merging dangerous AND's and substituting them with - explicit ``pk__in`` filter, where pks are taken from evaluted - Q-expr branch. - - Args: - model_cls (TypeVar): model class used to construct :meth:`QuerySet` - from leaf attribute-value expression. - expr: (Q | tuple): Q-expression (or attr-val leaf) to be rewritten. - - Returns: - Union[Q, tuple] + Rewrites Q-expression to safe form, in order to ensure that + generated SQL is valid. + + IGNORE: + Suppose we have the following Q-expression: + + └── OR + ├── AND + │ └── eav_values__in [1, 2, 3] + └── AND (1) + ├── AND + │ └── eav_values__in [4, 5] + └── AND + └── eav_values__in [6, 7, 8] + IGNORE + + All EAV values are stored in a single table. Therefore, INNER JOIN + generated for the AND-expression (1) will always fail, i.e. + single row in a eav_values table cannot be both in two disjoint sets at + the same time (and the whole point of using AND, usually, is two have + two different sets). Therefore, we must paritially rewrite the + expression so that the generated SQL is valid. + + IGNORE: + └── OR + ├── AND + │ └── eav_values__in [1, 2, 3] + └── AND + └── pk__in [1, 2] + IGNORE + + This is done by merging dangerous AND's and substituting them with + explicit ``pk__in`` filter, where pks are taken from evaluated + Q-expr branch. + + Args: + model_cls (TypeVar): model class used to construct :meth:`QuerySet` + from leaf attribute-value expression. + expr: (Q | tuple): Q-expression (or attr-val leaf) to be rewritten. + + Returns: + Union[Q, tuple] """ # Node in a Q-expr can be a Q or an attribute-value tuple (leaf). # We are only interested in Qs. if isinstance(expr, Q): - config_cls = getattr(model_cls, '_eav_config_cls', None) + config_cls = getattr(model_cls, "_eav_config_cls", None) gr_name = config_cls.generic_relation_attr - # Recurively check child nodes. + # Recursively check child nodes. expr.children = [rewrite_q_expr(model_cls, c) for c in expr.children] # Check which ones need a rewrite. rewritable = [c for c in expr.children if is_eav_and_leaf(c, gr_name)] @@ -113,18 +111,18 @@ def rewrite_q_expr(model_cls, expr): if len(rewritable) > 1: q = None # Save nodes which shouldn't be merged (non-EAV). - other = [c for c in expr.children if not c in rewritable] + other = [c for c in expr.children if c not in rewritable] for child in rewritable: if not (child.children and len(child.children) == 1): - raise AssertionError('Child must have exactly one descendant') + raise AssertionError("Child must have exactly one descendant") # Child to be merged is always a terminal Q node, # i.e. it's an AND expression with attribute-value tuple child. attrval = child.children[0] if not isinstance(attrval, tuple): - raise AssertionError('Attribute-value must be a tuple') + raise TypeError("Attribute-value must be a tuple") - fname = '{}__in'.format(gr_name) + fname = f"{gr_name}__in" # Child can be either a 'eav_values__in' or 'pk__in' query. # If it's the former then transform it into the latter. @@ -132,7 +130,7 @@ def rewrite_q_expr(model_cls, expr): # If so, reverse it back to QuerySet so that set operators # can be applied. - if attrval[0] == fname or hasattr(attrval[1], '__contains__'): + if attrval[0] == fname or hasattr(attrval[1], "__contains__"): # Create model queryset. _q = model_cls.objects.filter(**{fname: attrval[1]}) else: @@ -141,17 +139,17 @@ def rewrite_q_expr(model_cls, expr): # Explicitly check for None. 'or' doesn't work here # as empty QuerySet, which is valid, is falsy. - q = q if q != None else _q + q = q if q is not None else _q - if expr.connector == 'AND': + if expr.connector == "AND": q &= _q else: q |= _q # If any two children were merged, # update parent expression. - if q != None: - expr.children = other + [('pk__in', q)] + if q is not None: + expr.children = [*other, ("pk__in", q)] return expr @@ -162,6 +160,7 @@ def eav_filter(func): :func:`expand_q_filters` and kwargs through :func:`expand_eav_filter`. Returns the called function (filter or exclude). """ + @wraps(func) def wrapper(self, *args, **kwargs): nargs = [] @@ -170,9 +169,9 @@ def wrapper(self, *args, **kwargs): for arg in args: if isinstance(arg, Q): # Modify Q objects (warning: recursion ahead). - arg = expand_q_filters(arg, self.model) + arg = expand_q_filters(arg, self.model) # noqa: PLW2901 # Rewrite Q-expression to safeform. - arg = rewrite_q_expr(self.model, arg) + arg = rewrite_q_expr(self.model, arg) # noqa: PLW2901 nargs.append(arg) for key, value in kwargs.items(): @@ -180,8 +179,11 @@ def wrapper(self, *args, **kwargs): nkey, nval = expand_eav_filter(self.model, key, value) if nkey in nkwargs: - # Apply AND to both querysets. - nkwargs[nkey] = (nkwargs[nkey] & nval).distinct() + # Add filter to check if matching entity_id is + # in the previous queryset with same nkey + nkwargs[nkey] = nval.filter( + entity_id__in=nkwargs[nkey].values_list("entity_id", flat=True), + ).distinct() else: nkwargs.update({nkey: nval}) @@ -227,42 +229,30 @@ def expand_eav_filter(model_cls, key, value): key = 'eav_values__in' value = Values.objects.filter(value_int=5, attribute__slug='height') """ - fields = key.split('__') - config_cls = getattr(model_cls, '_eav_config_cls', None) + fields = key.split("__") + config_cls = getattr(model_cls, "_eav_config_cls", None) if len(fields) > 1 and config_cls and fields[0] == config_cls.eav_attr: slug = fields[1] gr_name = config_cls.generic_relation_attr datatype = Attribute.objects.get(slug=slug).datatype - value_key = '' + value_key = "" if datatype == Attribute.TYPE_ENUM and not isinstance(value, EnumValue): - lookup = '__value__{}'.format(fields[2]) if len(fields) > 2 else '__value' - value_key = 'value_{}{}'.format(datatype, lookup) + lookup = f"__value__{fields[2]}" if len(fields) > 2 else "__value" # noqa: PLR2004 + value_key = f"value_{datatype}{lookup}" elif datatype == Attribute.TYPE_OBJECT: - value_key = 'generic_value_id' + value_key = "generic_value_id" else: - lookup = '__{}'.format(fields[2]) if len(fields) > 2 else '' - value_key = 'value_{}{}'.format(datatype, lookup) - kwargs = { - value_key: value, - 'attribute__slug': slug - } + lookup = f"__{fields[2]}" if len(fields) > 2 else "" # noqa: PLR2004 + value_key = f"value_{datatype}{lookup}" + kwargs = {value_key: value, "attribute__slug": slug} value = Value.objects.filter(**kwargs) - return '%s__in' % gr_name, value + return f"{gr_name}__in", value - try: - field = model_cls._meta.get_field(fields[0]) - except FieldDoesNotExist: - return key, value - - if not field.auto_created or field.concrete: - return key, value - else: - sub_key = '__'.join(fields[1:]) - key, value = expand_eav_filter(field.model, sub_key, value) - return '{}__{}'.format(fields[0], key), value + # Not an eav field, so keep as is + return key, value class EavQuerySet(QuerySet): @@ -276,7 +266,7 @@ def filter(self, *args, **kwargs): Pass *args* and *kwargs* through :func:`eav_filter`, then pass to the ``Manager`` filter method. """ - return super(EavQuerySet, self).filter(*args, **kwargs) + return super().filter(*args, **kwargs) @eav_filter def exclude(self, *args, **kwargs): @@ -284,7 +274,7 @@ def exclude(self, *args, **kwargs): Pass *args* and *kwargs* through :func:`eav_filter`, then pass to the ``Manager`` exclude method. """ - return super(EavQuerySet, self).exclude(*args, **kwargs) + return super().exclude(*args, **kwargs) @eav_filter def get(self, *args, **kwargs): @@ -292,7 +282,7 @@ def get(self, *args, **kwargs): Pass *args* and *kwargs* through :func:`eav_filter`, then pass to the ``Manager`` get method. """ - return super(EavQuerySet, self).get(*args, **kwargs) + return super().get(*args, **kwargs) def order_by(self, *fields): # Django only allows to order querysets by direct fields and @@ -302,38 +292,43 @@ def order_by(self, *fields): # This will be slow, of course. order_clauses = [] query_clause = self - config_cls = self.model._eav_config_cls + config_cls = self.model._eav_config_cls # noqa: SLF001 - for term in [t.split('__') for t in fields]: + for term in [t.split("__") for t in fields]: # Continue only for EAV attributes. - if len(term) == 2 and term[0] == config_cls.eav_attr: + if len(term) == 2 and term[0] == config_cls.eav_attr: # noqa: PLR2004 # Retrieve Attribute over which the ordering is performed. try: attr = Attribute.objects.get(slug=term[1]) - except ObjectDoesNotExist: + except ObjectDoesNotExist as err: raise ObjectDoesNotExist( - 'Cannot find EAV attribute "{}"'.format(term[1]) + f'Cannot find EAV attribute "{term[1]}"', + ) from err + + field_name = f"value_{attr.datatype}" + + pks_values = ( + Value.objects.filter( + # Retrieve pk-values pairs of the related values + # (i.e. values for the specified attribute and + # belonging to entities in the queryset). + attribute__slug=attr.slug, + entity_id__in=self, + ) + .order_by( + # Order values by their value-field of + # appropriate attribute data-type. + field_name, + ) + .values_list( + # Retrieve only primary-keys of the entities + # in the current queryset. + "entity_id", + field_name, ) - - field_name = 'value_%s' % attr.datatype - - pks_values = Value.objects.filter( - # Retrieve pk-values pairs of the related values - # (i.e. values for the specified attribute and - # belonging to entities in the queryset). - attribute__slug=attr.slug, - entity_id__in=self - ).order_by( - # Order values by their value-field of - # appriopriate attribute data-type. - field_name - ).values_list( - # Retrieve only primary-keys of the entities - # in the current queryset. - 'entity_id', field_name ) - # Retrive ordered values from pk-value list. + # Retrieve ordered values from pk-value list. _, ordered_values = zip(*pks_values) # Add explicit ordering and turn @@ -353,29 +348,20 @@ def order_by(self, *fields): # WHEN id = 4 THEN 3 # END # - when_clauses = [ - When(id=pk, then=i) - for pk, i in entities_pk - ] - - order_clause = Case( - *when_clauses, - output_field=IntegerField() - ) + when_clauses = [When(id=pk, then=i) for pk, i in entities_pk] + + order_clause = Case(*when_clauses, output_field=IntegerField()) - clause_name = '__'.join(term) + clause_name = "__".join(term) # Use when-clause to construct # custom order-by clause. - query_clause = query_clause.annotate( - **{clause_name: order_clause} - ) + query_clause = query_clause.annotate(**{clause_name: order_clause}) order_clauses.append(clause_name) - elif len(term) >= 2 and term[0] == config_cls.eav_attr: + elif len(term) >= 2 and term[0] == config_cls.eav_attr: # noqa: PLR2004 raise NotSupportedError( - 'EAV does not support ordering through ' - 'foreign-key chains' + "EAV does not support ordering through foreign-key chains", ) else: diff --git a/eav/registry.py b/eav/registry.py index 91f3cfd8..39f1b881 100644 --- a/eav/registry.py +++ b/eav/registry.py @@ -3,13 +3,14 @@ from django.contrib.contenttypes import fields as generic from django.db.models.signals import post_init, post_save, pre_save -from .managers import EntityManager -from .models import Attribute, Entity, Value +from eav.logic.entity_pk import get_entity_pk_type +from eav.managers import EntityManager +from eav.models import Attribute, Entity, Value -class EavConfig(object): +class EavConfig: """ - The default ``EavConfig`` class used if it is not overriden on registration. + The default ``EavConfig`` class used if it is not overridden on registration. This is where all the default eav attribute names are defined. Available options are as follows: @@ -26,10 +27,11 @@ class EavConfig(object): GenericRelation from Entity to Value. None by default. Therefore, if not overridden, it is not possible to query Values by Entities. """ - manager_attr = 'objects' + + manager_attr = "objects" manager_only = False - eav_attr = 'eav' - generic_relation_attr = 'eav_values' + eav_attr = "eav" + generic_relation_attr = "eav_values" generic_relation_related_name = None @classmethod @@ -41,7 +43,7 @@ def get_attributes(cls, instance=None): return Attribute.objects.all() -class Registry(object): +class Registry: """ Handles registration through the :meth:`register` and :meth:`unregister` methods. @@ -56,15 +58,14 @@ def register(model_cls, config_cls=None): .. note:: Multiple registrations for the same entity are harmlessly ignored. """ - if hasattr(model_cls, '_eav_config_cls'): + if hasattr(model_cls, "_eav_config_cls"): return if config_cls is EavConfig or config_cls is None: - config_cls = type("%sConfig" % model_cls.__name__, - (EavConfig,), {}) + config_cls = type(f"{model_cls.__name__}Config", (EavConfig,), {}) # set _eav_config_cls on the model so we can access it there - setattr(model_cls, '_eav_config_cls', config_cls) + model_cls._eav_config_cls = config_cls reg = Registry(model_cls) reg._register_self() @@ -77,19 +78,19 @@ def unregister(model_cls): .. note:: Unregistering a class not already registered is harmlessly ignored. """ - if not getattr(model_cls, '_eav_config_cls', None): + if not getattr(model_cls, "_eav_config_cls", None): return reg = Registry(model_cls) reg._unregister_self() - delattr(model_cls, '_eav_config_cls') + delattr(model_cls, "_eav_config_cls") @staticmethod def attach_eav_attr(sender, *args, **kwargs): """ Attach EAV Entity toolkit to an instance after init. """ - instance = kwargs['instance'] + instance = kwargs["instance"] config_cls = instance.__class__._eav_config_cls setattr(instance, config_cls.eav_attr, Entity(instance)) @@ -100,20 +101,41 @@ def __init__(self, model_cls): self.model_cls = model_cls self.config_cls = model_cls._eav_config_cls - def _attach_manager(self): + def _attach_manager(self) -> None: """ - Attach the manager to *manager_attr* specified in *config_cls* + Attach the EntityManager to the model class. + + This method replaces the existing manager specified in the `config_cls` + with a new instance of `EntityManager`. If the specified manager is the + default manager, the `EntityManager` is set as the new default manager. + Otherwise, it is appended to the list of managers. + + If the model class already has a manager with the same name as the one + specified in `config_cls`, it is saved as `old_mgr` in the `config_cls` + for use during detachment. """ - # Save the old manager if the attribute name conflicts with the new one. - if hasattr(self.model_cls, self.config_cls.manager_attr): - mgr = getattr(self.model_cls, self.config_cls.manager_attr) - self.config_cls.old_mgr = mgr - self.model_cls._meta.local_managers.remove(mgr) - self.model_cls._meta._expire_cache() + manager_attr = self.config_cls.manager_attr + model_meta = self.model_cls._meta + current_manager = getattr(self.model_cls, manager_attr, None) + + if isinstance(current_manager, EntityManager): + # EntityManager is already attached, no need to proceed + return - # Attach the new manager to the model. - mgr = EntityManager() - mgr.contribute_to_class(self.model_cls, self.config_cls.manager_attr) + # Create a new EntityManager + new_manager = EntityManager() + + # Save and remove the old manager if it exists + if current_manager and current_manager in model_meta.local_managers: + self.config_cls.old_mgr = current_manager + model_meta.local_managers.remove(current_manager) + + # Set the creation_counter to maintain the order + # This ensures that the new manager has the same priority as the old one + new_manager.creation_counter = current_manager.creation_counter + + # Attach the new EntityManager instance to the model. + new_manager.contribute_to_class(self.model_cls, manager_attr) def _detach_manager(self): """ @@ -124,10 +146,11 @@ def _detach_manager(self): self.model_cls._meta._expire_cache() delattr(self.model_cls, self.config_cls.manager_attr) - if hasattr(self.config_cls, 'old_mgr'): - self.config_cls.old_mgr \ - .contribute_to_class(self.model_cls, - self.config_cls.manager_attr) + if hasattr(self.config_cls, "old_mgr"): + self.config_cls.old_mgr.contribute_to_class( + self.model_cls, + self.config_cls.manager_attr, + ) def _attach_signals(self): """ @@ -136,31 +159,31 @@ def _attach_signals(self): able to prepare and clean-up before and after creation / update of the user's model class instance. """ - post_init.connect(Registry.attach_eav_attr, sender = self.model_cls) - pre_save.connect(Entity.pre_save_handler, sender = self.model_cls) - post_save.connect(Entity.post_save_handler, sender = self.model_cls) + post_init.connect(Registry.attach_eav_attr, sender=self.model_cls) + pre_save.connect(Entity.pre_save_handler, sender=self.model_cls) + post_save.connect(Entity.post_save_handler, sender=self.model_cls) def _detach_signals(self): """ Detach all signals for eav. """ - post_init.disconnect(Registry.attach_eav_attr, sender = self.model_cls) - pre_save.disconnect(Entity.pre_save_handler, sender = self.model_cls) - post_save.disconnect(Entity.post_save_handler, sender = self.model_cls) + post_init.disconnect(Registry.attach_eav_attr, sender=self.model_cls) + pre_save.disconnect(Entity.pre_save_handler, sender=self.model_cls) + post_save.disconnect(Entity.post_save_handler, sender=self.model_cls) def _attach_generic_relation(self): - """ - Set up the generic relation for the entity - """ - rel_name = self.config_cls.generic_relation_related_name or \ - self.model_cls.__name__ + """Set up the generic relation for the entity.""" + rel_name = ( + self.config_cls.generic_relation_related_name or self.model_cls.__name__ + ) gr_name = self.config_cls.generic_relation_attr.lower() - generic_relation = \ - generic.GenericRelation(Value, - object_id_field='entity_id', - content_type_field='entity_ct', - related_query_name=rel_name) + generic_relation = generic.GenericRelation( + Value, + object_id_field=get_entity_pk_type(self.model_cls), + content_type_field="entity_ct", + related_query_name=rel_name, + ) generic_relation.contribute_to_class(self.model_cls, gr_name) def _detach_generic_relation(self): diff --git a/eav/settings.py b/eav/settings.py new file mode 100644 index 00000000..fcd64e7d --- /dev/null +++ b/eav/settings.py @@ -0,0 +1,3 @@ +from typing import Final + +CHARFIELD_LENGTH: Final = 100 diff --git a/eav/validators.py b/eav/validators.py index 0186482c..45180210 100644 --- a/eav/validators.py +++ b/eav/validators.py @@ -3,7 +3,7 @@ A validator is a callable that takes a value and raises a ``ValidationError`` if it doesn't meet some criteria (see `Django validators -`_). +`_). These validators are called by the :meth:`~eav.models.Attribute.validate_value` method in the @@ -11,10 +11,11 @@ """ import datetime +import json from django.core.exceptions import ValidationError from django.db import models -from django.utils.translation import ugettext_lazy as _ +from django.utils.translation import gettext_lazy as _ def validate_text(value): @@ -22,7 +23,7 @@ def validate_text(value): Raises ``ValidationError`` unless *value* type is ``str`` or ``unicode`` """ if not isinstance(value, str): - raise ValidationError(_(u"Must be str or unicode")) + raise ValidationError(_("Must be str or unicode")) def validate_float(value): @@ -31,8 +32,8 @@ def validate_float(value): """ try: float(value) - except ValueError: - raise ValidationError(_(u"Must be a float")) + except ValueError as err: + raise ValidationError(_("Must be a float")) from err def validate_int(value): @@ -41,8 +42,8 @@ def validate_int(value): """ try: int(value) - except ValueError: - raise ValidationError(_(u"Must be an integer")) + except ValueError as err: + raise ValidationError(_("Must be an integer")) from err def validate_date(value): @@ -50,8 +51,11 @@ def validate_date(value): Raises ``ValidationError`` unless *value* is an instance of ``datetime`` or ``date`` """ - if not isinstance(value, datetime.datetime) and not isinstance(value, datetime.date): - raise ValidationError(_(u"Must be a date or datetime")) + if not isinstance(value, datetime.datetime) and not isinstance( + value, + datetime.date, + ): + raise ValidationError(_("Must be a date or datetime")) def validate_bool(value): @@ -59,7 +63,7 @@ def validate_bool(value): Raises ``ValidationError`` unless *value* type is ``bool`` """ if not isinstance(value, bool): - raise ValidationError(_(u"Must be a boolean")) + raise ValidationError(_("Must be a boolean")) def validate_object(value): @@ -68,10 +72,10 @@ def validate_object(value): django model instance. """ if not isinstance(value, models.Model): - raise ValidationError(_(u"Must be a django model object instance")) + raise ValidationError(_("Must be a django model object instance")) if not value.pk: - raise ValidationError(_(u"Model has not been saved yet")) + raise ValidationError(_("Model has not been saved yet")) def validate_enum(value): @@ -79,7 +83,30 @@ def validate_enum(value): Raises ``ValidationError`` unless *value* is a saved :class:`~eav.models.EnumValue` model instance. """ - from .models import EnumValue + from eav.models import EnumValue if isinstance(value, EnumValue) and not value.pk: - raise ValidationError(_(u"EnumValue has not been saved yet")) + raise ValidationError(_("EnumValue has not been saved yet")) + + +def validate_json(value): + """ + Raises ``ValidationError`` unless *value* can be cast as an ``json object`` (a dict) + """ + try: + if isinstance(value, str): + value = json.loads(value) + if not isinstance(value, dict): + raise ValidationError(_("Must be a JSON Serializable object")) + except ValueError as err: + raise ValidationError(_("Must be a JSON Serializable object")) from err + + +def validate_csv(value): + """ + Raises ``ValidationError`` unless *value* is a c-s-v value. + """ + if isinstance(value, str): + value = value.split(";") + if not isinstance(value, list): + raise ValidationError(_("Must be Comma-Separated-Value.")) diff --git a/eav/widgets.py b/eav/widgets.py new file mode 100644 index 00000000..3e49d90f --- /dev/null +++ b/eav/widgets.py @@ -0,0 +1,39 @@ +from django.core import validators +from django.core.exceptions import ValidationError +from django.forms.widgets import Textarea + +EMPTY_VALUES = (*validators.EMPTY_VALUES, "[]") + + +class CSVWidget(Textarea): + is_hidden = False + + def prep_value(self, value): + """Prepare value before effectively render widget""" + if value in EMPTY_VALUES: + return "" + if isinstance(value, str): + return value + if isinstance(value, list): + return ";".join(value) + raise ValidationError("Invalid format.") + + def render(self, name, value, **kwargs): + value = self.prep_value(value) + return super().render(name, value, **kwargs) + + def value_from_datadict(self, data, files, name): + """ + Return the value of this widget or None. + + Since we're only given the value of the entity name and the data dict + contains the '_eav_config_cls' (which we don't have access to) as the + key, we need to loop through each field checking if the eav attribute + exists with the given 'name'. + """ + for data_value in data.values(): + widget_value = getattr(data_value, name, None) + if widget_value is not None: + return widget_value + + return None diff --git a/manage.py b/manage.py new file mode 100755 index 00000000..55f825f6 --- /dev/null +++ b/manage.py @@ -0,0 +1,31 @@ +#!/usr/bin/env python + +import os +import sys + + +def main() -> None: + """ + Main function. + + It does several things: + 1. Sets default settings module, if it is not set + 2. Warns if Django is not installed + 3. Executes any given command + """ + os.environ.setdefault("DJANGO_SETTINGS_MODULE", "test_project.settings") + + try: + from django.core import management + except ImportError as err: + raise ImportError( + "Couldn't import Django. Are you sure it's installed and " + + "available on your PYTHONPATH environment variable? Did you " + + "forget to activate a virtual environment?", + ) from err + + management.execute_from_command_line(sys.argv) + + +if __name__ == "__main__": + main() diff --git a/poetry.lock b/poetry.lock new file mode 100644 index 00000000..130d3b33 --- /dev/null +++ b/poetry.lock @@ -0,0 +1,2254 @@ +# This file is automatically @generated by Poetry 2.1.1 and should not be changed by hand. + +[[package]] +name = "alabaster" +version = "0.7.16" +description = "A light, configurable Sphinx theme" +optional = false +python-versions = ">=3.9" +groups = ["docs"] +files = [ + {file = "alabaster-0.7.16-py3-none-any.whl", hash = "sha256:b46733c07dce03ae4e150330b975c75737fa60f0a7c591b6c8bf4928a28e2c92"}, + {file = "alabaster-0.7.16.tar.gz", hash = "sha256:75a8b99c28a5dad50dd7f8ccdd447a121ddb3892da9e53d1ca5cca3106d58d65"}, +] + +[[package]] +name = "annotated-types" +version = "0.7.0" +description = "Reusable constraint types to use with typing.Annotated" +optional = false +python-versions = ">=3.8" +groups = ["test"] +files = [ + {file = "annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53"}, + {file = "annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89"}, +] + +[[package]] +name = "anyio" +version = "4.9.0" +description = "High level compatibility layer for multiple asynchronous event loop implementations" +optional = false +python-versions = ">=3.9" +groups = ["test"] +files = [ + {file = "anyio-4.9.0-py3-none-any.whl", hash = "sha256:9f76d541cad6e36af7beb62e978876f3b41e3e04f2c1fbf0884604c0a9c4d93c"}, + {file = "anyio-4.9.0.tar.gz", hash = "sha256:673c0c244e15788651a4ff38710fea9675823028a6f08a5eda409e0c9840a028"}, +] + +[package.dependencies] +exceptiongroup = {version = ">=1.0.2", markers = "python_version < \"3.11\""} +idna = ">=2.8" +sniffio = ">=1.1" +typing_extensions = {version = ">=4.5", markers = "python_version < \"3.13\""} + +[package.extras] +doc = ["Sphinx (>=8.2,<9.0)", "packaging", "sphinx-autodoc-typehints (>=1.2.0)", "sphinx_rtd_theme"] +test = ["anyio[trio]", "blockbuster (>=1.5.23)", "coverage[toml] (>=7)", "exceptiongroup (>=1.2.0)", "hypothesis (>=4.0)", "psutil (>=5.9)", "pytest (>=7.0)", "trustme", "truststore (>=0.9.1) ; python_version >= \"3.10\"", "uvloop (>=0.21) ; platform_python_implementation == \"CPython\" and platform_system != \"Windows\" and python_version < \"3.14\""] +trio = ["trio (>=0.26.1)"] + +[[package]] +name = "asgiref" +version = "3.8.1" +description = "ASGI specs, helper code, and adapters" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "asgiref-3.8.1-py3-none-any.whl", hash = "sha256:3e1e3ecc849832fe52ccf2cb6686b7a55f82bb1d6aee72a58826471390335e47"}, + {file = "asgiref-3.8.1.tar.gz", hash = "sha256:c343bd80a0bec947a9860adb4c432ffa7db769836c64238fc34bdc3fec84d590"}, +] + +[package.dependencies] +typing-extensions = {version = ">=4", markers = "python_version < \"3.11\""} + +[package.extras] +tests = ["mypy (>=0.800)", "pytest", "pytest-asyncio"] + +[[package]] +name = "attrs" +version = "25.3.0" +description = "Classes Without Boilerplate" +optional = false +python-versions = ">=3.8" +groups = ["test"] +files = [ + {file = "attrs-25.3.0-py3-none-any.whl", hash = "sha256:427318ce031701fea540783410126f03899a97ffc6f61596ad581ac2e40e3bc3"}, + {file = "attrs-25.3.0.tar.gz", hash = "sha256:75d7cefc7fb576747b2c81b4442d4d4a1ce0900973527c011d1030fd3bf4af1b"}, +] + +[package.extras] +benchmark = ["cloudpickle ; platform_python_implementation == \"CPython\"", "hypothesis", "mypy (>=1.11.1) ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\"", "pympler", "pytest (>=4.3.0)", "pytest-codspeed", "pytest-mypy-plugins ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\"", "pytest-xdist[psutil]"] +cov = ["cloudpickle ; platform_python_implementation == \"CPython\"", "coverage[toml] (>=5.3)", "hypothesis", "mypy (>=1.11.1) ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\"", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\"", "pytest-xdist[psutil]"] +dev = ["cloudpickle ; platform_python_implementation == \"CPython\"", "hypothesis", "mypy (>=1.11.1) ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\"", "pre-commit-uv", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\"", "pytest-xdist[psutil]"] +docs = ["cogapp", "furo", "myst-parser", "sphinx", "sphinx-notfound-page", "sphinxcontrib-towncrier", "towncrier"] +tests = ["cloudpickle ; platform_python_implementation == \"CPython\"", "hypothesis", "mypy (>=1.11.1) ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\"", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\"", "pytest-xdist[psutil]"] +tests-mypy = ["mypy (>=1.11.1) ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\"", "pytest-mypy-plugins ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\""] + +[[package]] +name = "authlib" +version = "1.6.0" +description = "The ultimate Python library in building OAuth and OpenID Connect servers and clients." +optional = false +python-versions = ">=3.9" +groups = ["test"] +files = [ + {file = "authlib-1.6.0-py2.py3-none-any.whl", hash = "sha256:91685589498f79e8655e8a8947431ad6288831d643f11c55c2143ffcc738048d"}, + {file = "authlib-1.6.0.tar.gz", hash = "sha256:4367d32031b7af175ad3a323d571dc7257b7099d55978087ceae4a0d88cd3210"}, +] + +[package.dependencies] +cryptography = "*" + +[[package]] +name = "babel" +version = "2.17.0" +description = "Internationalization utilities" +optional = false +python-versions = ">=3.8" +groups = ["docs"] +files = [ + {file = "babel-2.17.0-py3-none-any.whl", hash = "sha256:4d0b53093fdfb4b21c92b5213dba5a1b23885afa8383709427046b21c366e5f2"}, + {file = "babel-2.17.0.tar.gz", hash = "sha256:0c54cffb19f690cdcc52a3b50bcbf71e07a808d1c80d549f2459b9d2cf0afb9d"}, +] + +[package.extras] +dev = ["backports.zoneinfo ; python_version < \"3.9\"", "freezegun (>=1.0,<2.0)", "jinja2 (>=3.0)", "pytest (>=6.0)", "pytest-cov", "pytz", "setuptools", "tzdata ; sys_platform == \"win32\""] + +[[package]] +name = "backports-datetime-fromisoformat" +version = "2.0.3" +description = "Backport of Python 3.11's datetime.fromisoformat" +optional = false +python-versions = ">3" +groups = ["test"] +markers = "python_version < \"3.11\"" +files = [ + {file = "backports_datetime_fromisoformat-2.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:5f681f638f10588fa3c101ee9ae2b63d3734713202ddfcfb6ec6cea0778a29d4"}, + {file = "backports_datetime_fromisoformat-2.0.3-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:cd681460e9142f1249408e5aee6d178c6d89b49e06d44913c8fdfb6defda8d1c"}, + {file = "backports_datetime_fromisoformat-2.0.3-cp310-cp310-macosx_11_0_x86_64.whl", hash = "sha256:ee68bc8735ae5058695b76d3bb2aee1d137c052a11c8303f1e966aa23b72b65b"}, + {file = "backports_datetime_fromisoformat-2.0.3-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8273fe7932db65d952a43e238318966eab9e49e8dd546550a41df12175cc2be4"}, + {file = "backports_datetime_fromisoformat-2.0.3-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:39d57ea50aa5a524bb239688adc1d1d824c31b6094ebd39aa164d6cadb85de22"}, + {file = "backports_datetime_fromisoformat-2.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:ac6272f87693e78209dc72e84cf9ab58052027733cd0721c55356d3c881791cf"}, + {file = "backports_datetime_fromisoformat-2.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:44c497a71f80cd2bcfc26faae8857cf8e79388e3d5fbf79d2354b8c360547d58"}, + {file = "backports_datetime_fromisoformat-2.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:6335a4c9e8af329cb1ded5ab41a666e1448116161905a94e054f205aa6d263bc"}, + {file = "backports_datetime_fromisoformat-2.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e2e4b66e017253cdbe5a1de49e0eecff3f66cd72bcb1229d7db6e6b1832c0443"}, + {file = "backports_datetime_fromisoformat-2.0.3-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:43e2d648e150777e13bbc2549cc960373e37bf65bd8a5d2e0cef40e16e5d8dd0"}, + {file = "backports_datetime_fromisoformat-2.0.3-cp311-cp311-macosx_11_0_x86_64.whl", hash = "sha256:4ce6326fd86d5bae37813c7bf1543bae9e4c215ec6f5afe4c518be2635e2e005"}, + {file = "backports_datetime_fromisoformat-2.0.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d7c8fac333bf860208fd522a5394369ee3c790d0aa4311f515fcc4b6c5ef8d75"}, + {file = "backports_datetime_fromisoformat-2.0.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:24a4da5ab3aa0cc293dc0662a0c6d1da1a011dc1edcbc3122a288cfed13a0b45"}, + {file = "backports_datetime_fromisoformat-2.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:58ea11e3bf912bd0a36b0519eae2c5b560b3cb972ea756e66b73fb9be460af01"}, + {file = "backports_datetime_fromisoformat-2.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:8a375c7dbee4734318714a799b6c697223e4bbb57232af37fbfff88fb48a14c6"}, + {file = "backports_datetime_fromisoformat-2.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:ac677b1664c4585c2e014739f6678137c8336815406052349c85898206ec7061"}, + {file = "backports_datetime_fromisoformat-2.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:66ce47ee1ba91e146149cf40565c3d750ea1be94faf660ca733d8601e0848147"}, + {file = "backports_datetime_fromisoformat-2.0.3-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:8b7e069910a66b3bba61df35b5f879e5253ff0821a70375b9daf06444d046fa4"}, + {file = "backports_datetime_fromisoformat-2.0.3-cp312-cp312-macosx_11_0_x86_64.whl", hash = "sha256:a3b5d1d04a9e0f7b15aa1e647c750631a873b298cdd1255687bb68779fe8eb35"}, + {file = "backports_datetime_fromisoformat-2.0.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ec1b95986430e789c076610aea704db20874f0781b8624f648ca9fb6ef67c6e1"}, + {file = "backports_datetime_fromisoformat-2.0.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ffe5f793db59e2f1d45ec35a1cf51404fdd69df9f6952a0c87c3060af4c00e32"}, + {file = "backports_datetime_fromisoformat-2.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:620e8e73bd2595dfff1b4d256a12b67fce90ece3de87b38e1dde46b910f46f4d"}, + {file = "backports_datetime_fromisoformat-2.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:4cf9c0a985d68476c1cabd6385c691201dda2337d7453fb4da9679ce9f23f4e7"}, + {file = "backports_datetime_fromisoformat-2.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:d144868a73002e6e2e6fef72333e7b0129cecdd121aa8f1edba7107fd067255d"}, + {file = "backports_datetime_fromisoformat-2.0.3-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:e81b26497a17c29595bc7df20bc6a872ceea5f8c9d6537283945d4b6396aec10"}, + {file = "backports_datetime_fromisoformat-2.0.3-cp38-cp38-macosx_11_0_universal2.whl", hash = "sha256:5ba00ead8d9d82fd6123eb4891c566d30a293454e54e32ff7ead7644f5f7e575"}, + {file = "backports_datetime_fromisoformat-2.0.3-cp38-cp38-macosx_11_0_x86_64.whl", hash = "sha256:24d574cb4072e1640b00864e94c4c89858033936ece3fc0e1c6f7179f120d0a8"}, + {file = "backports_datetime_fromisoformat-2.0.3-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9735695a66aad654500b0193525e590c693ab3368478ce07b34b443a1ea5e824"}, + {file = "backports_datetime_fromisoformat-2.0.3-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:63d39709e17eb72685d052ac82acf0763e047f57c86af1b791505b1fec96915d"}, + {file = "backports_datetime_fromisoformat-2.0.3-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:1ea2cc84224937d6b9b4c07f5cb7c667f2bde28c255645ba27f8a675a7af8234"}, + {file = "backports_datetime_fromisoformat-2.0.3-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:4024e6d35a9fdc1b3fd6ac7a673bd16cb176c7e0b952af6428b7129a70f72cce"}, + {file = "backports_datetime_fromisoformat-2.0.3-cp38-cp38-win_amd64.whl", hash = "sha256:5e2dcc94dc9c9ab8704409d86fcb5236316e9dcef6feed8162287634e3568f4c"}, + {file = "backports_datetime_fromisoformat-2.0.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:fa2de871801d824c255fac7e5e7e50f2be6c9c376fd9268b40c54b5e9da91f42"}, + {file = "backports_datetime_fromisoformat-2.0.3-cp39-cp39-macosx_11_0_universal2.whl", hash = "sha256:1314d4923c1509aa9696712a7bc0c7160d3b7acf72adafbbe6c558d523f5d491"}, + {file = "backports_datetime_fromisoformat-2.0.3-cp39-cp39-macosx_11_0_x86_64.whl", hash = "sha256:b750ecba3a8815ad8bc48311552f3f8ab99dd2326d29df7ff670d9c49321f48f"}, + {file = "backports_datetime_fromisoformat-2.0.3-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b2d5117dce805d8a2f78baeddc8c6127281fa0a5e2c40c6dd992ba6b2b367876"}, + {file = "backports_datetime_fromisoformat-2.0.3-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fb35f607bd1cbe37b896379d5f5ed4dc298b536f4b959cb63180e05cacc0539d"}, + {file = "backports_datetime_fromisoformat-2.0.3-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:61c74710900602637d2d145dda9720c94e303380803bf68811b2a151deec75c2"}, + {file = "backports_datetime_fromisoformat-2.0.3-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:ece59af54ebf67ecbfbbf3ca9066f5687879e36527ad69d8b6e3ac565d565a62"}, + {file = "backports_datetime_fromisoformat-2.0.3-cp39-cp39-win_amd64.whl", hash = "sha256:d0a7c5f875068efe106f62233bc712d50db4d07c13c7db570175c7857a7b5dbd"}, + {file = "backports_datetime_fromisoformat-2.0.3-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:90e202e72a3d5aae673fcc8c9a4267d56b2f532beeb9173361293625fe4d2039"}, + {file = "backports_datetime_fromisoformat-2.0.3-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2df98ef1b76f5a58bb493dda552259ba60c3a37557d848e039524203951c9f06"}, + {file = "backports_datetime_fromisoformat-2.0.3-pp38-pypy38_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7100adcda5e818b5a894ad0626e38118bb896a347f40ebed8981155675b9ba7b"}, + {file = "backports_datetime_fromisoformat-2.0.3-pp38-pypy38_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5e410383f5d6a449a529d074e88af8bc80020bb42b402265f9c02c8358c11da5"}, + {file = "backports_datetime_fromisoformat-2.0.3-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f2797593760da6bcc32c4a13fa825af183cd4bfd333c60b3dbf84711afca26ef"}, + {file = "backports_datetime_fromisoformat-2.0.3-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:35a144fd681a0bea1013ccc4cd3fd4dc758ea17ee23dca019c02b82ec46fc0c4"}, + {file = "backports_datetime_fromisoformat-2.0.3.tar.gz", hash = "sha256:b58edc8f517b66b397abc250ecc737969486703a66eb97e01e6d51291b1a139d"}, +] + +[[package]] +name = "certifi" +version = "2025.4.26" +description = "Python package for providing Mozilla's CA Bundle." +optional = false +python-versions = ">=3.6" +groups = ["docs", "test"] +files = [ + {file = "certifi-2025.4.26-py3-none-any.whl", hash = "sha256:30350364dfe371162649852c63336a15c70c6510c2ad5015b21c2345311805f3"}, + {file = "certifi-2025.4.26.tar.gz", hash = "sha256:0a816057ea3cdefcef70270d2c515e4506bbc954f417fa5ade2021213bb8f0c6"}, +] + +[[package]] +name = "cffi" +version = "1.17.1" +description = "Foreign Function Interface for Python calling C code." +optional = false +python-versions = ">=3.8" +groups = ["test"] +markers = "platform_python_implementation != \"PyPy\"" +files = [ + {file = "cffi-1.17.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:df8b1c11f177bc2313ec4b2d46baec87a5f3e71fc8b45dab2ee7cae86d9aba14"}, + {file = "cffi-1.17.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8f2cdc858323644ab277e9bb925ad72ae0e67f69e804f4898c070998d50b1a67"}, + {file = "cffi-1.17.1-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:edae79245293e15384b51f88b00613ba9f7198016a5948b5dddf4917d4d26382"}, + {file = "cffi-1.17.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:45398b671ac6d70e67da8e4224a065cec6a93541bb7aebe1b198a61b58c7b702"}, + {file = "cffi-1.17.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ad9413ccdeda48c5afdae7e4fa2192157e991ff761e7ab8fdd8926f40b160cc3"}, + {file = "cffi-1.17.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5da5719280082ac6bd9aa7becb3938dc9f9cbd57fac7d2871717b1feb0902ab6"}, + {file = "cffi-1.17.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2bb1a08b8008b281856e5971307cc386a8e9c5b625ac297e853d36da6efe9c17"}, + {file = "cffi-1.17.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:045d61c734659cc045141be4bae381a41d89b741f795af1dd018bfb532fd0df8"}, + {file = "cffi-1.17.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:6883e737d7d9e4899a8a695e00ec36bd4e5e4f18fabe0aca0efe0a4b44cdb13e"}, + {file = "cffi-1.17.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:6b8b4a92e1c65048ff98cfe1f735ef8f1ceb72e3d5f0c25fdb12087a23da22be"}, + {file = "cffi-1.17.1-cp310-cp310-win32.whl", hash = "sha256:c9c3d058ebabb74db66e431095118094d06abf53284d9c81f27300d0e0d8bc7c"}, + {file = "cffi-1.17.1-cp310-cp310-win_amd64.whl", hash = "sha256:0f048dcf80db46f0098ccac01132761580d28e28bc0f78ae0d58048063317e15"}, + {file = "cffi-1.17.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a45e3c6913c5b87b3ff120dcdc03f6131fa0065027d0ed7ee6190736a74cd401"}, + {file = "cffi-1.17.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:30c5e0cb5ae493c04c8b42916e52ca38079f1b235c2f8ae5f4527b963c401caf"}, + {file = "cffi-1.17.1-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f75c7ab1f9e4aca5414ed4d8e5c0e303a34f4421f8a0d47a4d019ceff0ab6af4"}, + {file = "cffi-1.17.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a1ed2dd2972641495a3ec98445e09766f077aee98a1c896dcb4ad0d303628e41"}, + {file = "cffi-1.17.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:46bf43160c1a35f7ec506d254e5c890f3c03648a4dbac12d624e4490a7046cd1"}, + {file = "cffi-1.17.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a24ed04c8ffd54b0729c07cee15a81d964e6fee0e3d4d342a27b020d22959dc6"}, + {file = "cffi-1.17.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:610faea79c43e44c71e1ec53a554553fa22321b65fae24889706c0a84d4ad86d"}, + {file = "cffi-1.17.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:a9b15d491f3ad5d692e11f6b71f7857e7835eb677955c00cc0aefcd0669adaf6"}, + {file = "cffi-1.17.1-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:de2ea4b5833625383e464549fec1bc395c1bdeeb5f25c4a3a82b5a8c756ec22f"}, + {file = "cffi-1.17.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:fc48c783f9c87e60831201f2cce7f3b2e4846bf4d8728eabe54d60700b318a0b"}, + {file = "cffi-1.17.1-cp311-cp311-win32.whl", hash = "sha256:85a950a4ac9c359340d5963966e3e0a94a676bd6245a4b55bc43949eee26a655"}, + {file = "cffi-1.17.1-cp311-cp311-win_amd64.whl", hash = "sha256:caaf0640ef5f5517f49bc275eca1406b0ffa6aa184892812030f04c2abf589a0"}, + {file = "cffi-1.17.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:805b4371bf7197c329fcb3ead37e710d1bca9da5d583f5073b799d5c5bd1eee4"}, + {file = "cffi-1.17.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:733e99bc2df47476e3848417c5a4540522f234dfd4ef3ab7fafdf555b082ec0c"}, + {file = "cffi-1.17.1-cp312-cp312-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1257bdabf294dceb59f5e70c64a3e2f462c30c7ad68092d01bbbfb1c16b1ba36"}, + {file = "cffi-1.17.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:da95af8214998d77a98cc14e3a3bd00aa191526343078b530ceb0bd710fb48a5"}, + {file = "cffi-1.17.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d63afe322132c194cf832bfec0dc69a99fb9bb6bbd550f161a49e9e855cc78ff"}, + {file = "cffi-1.17.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f79fc4fc25f1c8698ff97788206bb3c2598949bfe0fef03d299eb1b5356ada99"}, + {file = "cffi-1.17.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b62ce867176a75d03a665bad002af8e6d54644fad99a3c70905c543130e39d93"}, + {file = "cffi-1.17.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:386c8bf53c502fff58903061338ce4f4950cbdcb23e2902d86c0f722b786bbe3"}, + {file = "cffi-1.17.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:4ceb10419a9adf4460ea14cfd6bc43d08701f0835e979bf821052f1805850fe8"}, + {file = "cffi-1.17.1-cp312-cp312-win32.whl", hash = "sha256:a08d7e755f8ed21095a310a693525137cfe756ce62d066e53f502a83dc550f65"}, + {file = "cffi-1.17.1-cp312-cp312-win_amd64.whl", hash = "sha256:51392eae71afec0d0c8fb1a53b204dbb3bcabcb3c9b807eedf3e1e6ccf2de903"}, + {file = "cffi-1.17.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f3a2b4222ce6b60e2e8b337bb9596923045681d71e5a082783484d845390938e"}, + {file = "cffi-1.17.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0984a4925a435b1da406122d4d7968dd861c1385afe3b45ba82b750f229811e2"}, + {file = "cffi-1.17.1-cp313-cp313-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d01b12eeeb4427d3110de311e1774046ad344f5b1a7403101878976ecd7a10f3"}, + {file = "cffi-1.17.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:706510fe141c86a69c8ddc029c7910003a17353970cff3b904ff0686a5927683"}, + {file = "cffi-1.17.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:de55b766c7aa2e2a3092c51e0483d700341182f08e67c63630d5b6f200bb28e5"}, + {file = "cffi-1.17.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c59d6e989d07460165cc5ad3c61f9fd8f1b4796eacbd81cee78957842b834af4"}, + {file = "cffi-1.17.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dd398dbc6773384a17fe0d3e7eeb8d1a21c2200473ee6806bb5e6a8e62bb73dd"}, + {file = "cffi-1.17.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:3edc8d958eb099c634dace3c7e16560ae474aa3803a5df240542b305d14e14ed"}, + {file = "cffi-1.17.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:72e72408cad3d5419375fc87d289076ee319835bdfa2caad331e377589aebba9"}, + {file = "cffi-1.17.1-cp313-cp313-win32.whl", hash = "sha256:e03eab0a8677fa80d646b5ddece1cbeaf556c313dcfac435ba11f107ba117b5d"}, + {file = "cffi-1.17.1-cp313-cp313-win_amd64.whl", hash = "sha256:f6a16c31041f09ead72d69f583767292f750d24913dadacf5756b966aacb3f1a"}, + {file = "cffi-1.17.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:636062ea65bd0195bc012fea9321aca499c0504409f413dc88af450b57ffd03b"}, + {file = "cffi-1.17.1-cp38-cp38-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c7eac2ef9b63c79431bc4b25f1cd649d7f061a28808cbc6c47b534bd789ef964"}, + {file = "cffi-1.17.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e221cf152cff04059d011ee126477f0d9588303eb57e88923578ace7baad17f9"}, + {file = "cffi-1.17.1-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:31000ec67d4221a71bd3f67df918b1f88f676f1c3b535a7eb473255fdc0b83fc"}, + {file = "cffi-1.17.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6f17be4345073b0a7b8ea599688f692ac3ef23ce28e5df79c04de519dbc4912c"}, + {file = "cffi-1.17.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0e2b1fac190ae3ebfe37b979cc1ce69c81f4e4fe5746bb401dca63a9062cdaf1"}, + {file = "cffi-1.17.1-cp38-cp38-win32.whl", hash = "sha256:7596d6620d3fa590f677e9ee430df2958d2d6d6de2feeae5b20e82c00b76fbf8"}, + {file = "cffi-1.17.1-cp38-cp38-win_amd64.whl", hash = "sha256:78122be759c3f8a014ce010908ae03364d00a1f81ab5c7f4a7a5120607ea56e1"}, + {file = "cffi-1.17.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:b2ab587605f4ba0bf81dc0cb08a41bd1c0a5906bd59243d56bad7668a6fc6c16"}, + {file = "cffi-1.17.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:28b16024becceed8c6dfbc75629e27788d8a3f9030691a1dbf9821a128b22c36"}, + {file = "cffi-1.17.1-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1d599671f396c4723d016dbddb72fe8e0397082b0a77a4fab8028923bec050e8"}, + {file = "cffi-1.17.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ca74b8dbe6e8e8263c0ffd60277de77dcee6c837a3d0881d8c1ead7268c9e576"}, + {file = "cffi-1.17.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f7f5baafcc48261359e14bcd6d9bff6d4b28d9103847c9e136694cb0501aef87"}, + {file = "cffi-1.17.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:98e3969bcff97cae1b2def8ba499ea3d6f31ddfdb7635374834cf89a1a08ecf0"}, + {file = "cffi-1.17.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cdf5ce3acdfd1661132f2a9c19cac174758dc2352bfe37d98aa7512c6b7178b3"}, + {file = "cffi-1.17.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:9755e4345d1ec879e3849e62222a18c7174d65a6a92d5b346b1863912168b595"}, + {file = "cffi-1.17.1-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:f1e22e8c4419538cb197e4dd60acc919d7696e5ef98ee4da4e01d3f8cfa4cc5a"}, + {file = "cffi-1.17.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:c03e868a0b3bc35839ba98e74211ed2b05d2119be4e8a0f224fba9384f1fe02e"}, + {file = "cffi-1.17.1-cp39-cp39-win32.whl", hash = "sha256:e31ae45bc2e29f6b2abd0de1cc3b9d5205aa847cafaecb8af1476a609a2f6eb7"}, + {file = "cffi-1.17.1-cp39-cp39-win_amd64.whl", hash = "sha256:d016c76bdd850f3c626af19b0542c9677ba156e4ee4fccfdd7848803533ef662"}, + {file = "cffi-1.17.1.tar.gz", hash = "sha256:1c39c6016c32bc48dd54561950ebd6836e1670f2ae46128f67cf49e789c52824"}, +] + +[package.dependencies] +pycparser = "*" + +[[package]] +name = "charset-normalizer" +version = "3.4.2" +description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." +optional = false +python-versions = ">=3.7" +groups = ["docs", "test"] +files = [ + {file = "charset_normalizer-3.4.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:7c48ed483eb946e6c04ccbe02c6b4d1d48e51944b6db70f697e089c193404941"}, + {file = "charset_normalizer-3.4.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b2d318c11350e10662026ad0eb71bb51c7812fc8590825304ae0bdd4ac283acd"}, + {file = "charset_normalizer-3.4.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9cbfacf36cb0ec2897ce0ebc5d08ca44213af24265bd56eca54bee7923c48fd6"}, + {file = "charset_normalizer-3.4.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:18dd2e350387c87dabe711b86f83c9c78af772c748904d372ade190b5c7c9d4d"}, + {file = "charset_normalizer-3.4.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8075c35cd58273fee266c58c0c9b670947c19df5fb98e7b66710e04ad4e9ff86"}, + {file = "charset_normalizer-3.4.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5bf4545e3b962767e5c06fe1738f951f77d27967cb2caa64c28be7c4563e162c"}, + {file = "charset_normalizer-3.4.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:7a6ab32f7210554a96cd9e33abe3ddd86732beeafc7a28e9955cdf22ffadbab0"}, + {file = "charset_normalizer-3.4.2-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:b33de11b92e9f75a2b545d6e9b6f37e398d86c3e9e9653c4864eb7e89c5773ef"}, + {file = "charset_normalizer-3.4.2-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:8755483f3c00d6c9a77f490c17e6ab0c8729e39e6390328e42521ef175380ae6"}, + {file = "charset_normalizer-3.4.2-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:68a328e5f55ec37c57f19ebb1fdc56a248db2e3e9ad769919a58672958e8f366"}, + {file = "charset_normalizer-3.4.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:21b2899062867b0e1fde9b724f8aecb1af14f2778d69aacd1a5a1853a597a5db"}, + {file = "charset_normalizer-3.4.2-cp310-cp310-win32.whl", hash = "sha256:e8082b26888e2f8b36a042a58307d5b917ef2b1cacab921ad3323ef91901c71a"}, + {file = "charset_normalizer-3.4.2-cp310-cp310-win_amd64.whl", hash = "sha256:f69a27e45c43520f5487f27627059b64aaf160415589230992cec34c5e18a509"}, + {file = "charset_normalizer-3.4.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:be1e352acbe3c78727a16a455126d9ff83ea2dfdcbc83148d2982305a04714c2"}, + {file = "charset_normalizer-3.4.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:aa88ca0b1932e93f2d961bf3addbb2db902198dca337d88c89e1559e066e7645"}, + {file = "charset_normalizer-3.4.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d524ba3f1581b35c03cb42beebab4a13e6cdad7b36246bd22541fa585a56cccd"}, + {file = "charset_normalizer-3.4.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:28a1005facc94196e1fb3e82a3d442a9d9110b8434fc1ded7a24a2983c9888d8"}, + {file = "charset_normalizer-3.4.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fdb20a30fe1175ecabed17cbf7812f7b804b8a315a25f24678bcdf120a90077f"}, + {file = "charset_normalizer-3.4.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0f5d9ed7f254402c9e7d35d2f5972c9bbea9040e99cd2861bd77dc68263277c7"}, + {file = "charset_normalizer-3.4.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:efd387a49825780ff861998cd959767800d54f8308936b21025326de4b5a42b9"}, + {file = "charset_normalizer-3.4.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:f0aa37f3c979cf2546b73e8222bbfa3dc07a641585340179d768068e3455e544"}, + {file = "charset_normalizer-3.4.2-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:e70e990b2137b29dc5564715de1e12701815dacc1d056308e2b17e9095372a82"}, + {file = "charset_normalizer-3.4.2-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:0c8c57f84ccfc871a48a47321cfa49ae1df56cd1d965a09abe84066f6853b9c0"}, + {file = "charset_normalizer-3.4.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:6b66f92b17849b85cad91259efc341dce9c1af48e2173bf38a85c6329f1033e5"}, + {file = "charset_normalizer-3.4.2-cp311-cp311-win32.whl", hash = "sha256:daac4765328a919a805fa5e2720f3e94767abd632ae410a9062dff5412bae65a"}, + {file = "charset_normalizer-3.4.2-cp311-cp311-win_amd64.whl", hash = "sha256:e53efc7c7cee4c1e70661e2e112ca46a575f90ed9ae3fef200f2a25e954f4b28"}, + {file = "charset_normalizer-3.4.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0c29de6a1a95f24b9a1aa7aefd27d2487263f00dfd55a77719b530788f75cff7"}, + {file = "charset_normalizer-3.4.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cddf7bd982eaa998934a91f69d182aec997c6c468898efe6679af88283b498d3"}, + {file = "charset_normalizer-3.4.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fcbe676a55d7445b22c10967bceaaf0ee69407fbe0ece4d032b6eb8d4565982a"}, + {file = "charset_normalizer-3.4.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d41c4d287cfc69060fa91cae9683eacffad989f1a10811995fa309df656ec214"}, + {file = "charset_normalizer-3.4.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4e594135de17ab3866138f496755f302b72157d115086d100c3f19370839dd3a"}, + {file = "charset_normalizer-3.4.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:cf713fe9a71ef6fd5adf7a79670135081cd4431c2943864757f0fa3a65b1fafd"}, + {file = "charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a370b3e078e418187da8c3674eddb9d983ec09445c99a3a263c2011993522981"}, + {file = "charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:a955b438e62efdf7e0b7b52a64dc5c3396e2634baa62471768a64bc2adb73d5c"}, + {file = "charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:7222ffd5e4de8e57e03ce2cef95a4c43c98fcb72ad86909abdfc2c17d227fc1b"}, + {file = "charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:bee093bf902e1d8fc0ac143c88902c3dfc8941f7ea1d6a8dd2bcb786d33db03d"}, + {file = "charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:dedb8adb91d11846ee08bec4c8236c8549ac721c245678282dcb06b221aab59f"}, + {file = "charset_normalizer-3.4.2-cp312-cp312-win32.whl", hash = "sha256:db4c7bf0e07fc3b7d89ac2a5880a6a8062056801b83ff56d8464b70f65482b6c"}, + {file = "charset_normalizer-3.4.2-cp312-cp312-win_amd64.whl", hash = "sha256:5a9979887252a82fefd3d3ed2a8e3b937a7a809f65dcb1e068b090e165bbe99e"}, + {file = "charset_normalizer-3.4.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:926ca93accd5d36ccdabd803392ddc3e03e6d4cd1cf17deff3b989ab8e9dbcf0"}, + {file = "charset_normalizer-3.4.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:eba9904b0f38a143592d9fc0e19e2df0fa2e41c3c3745554761c5f6447eedabf"}, + {file = "charset_normalizer-3.4.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3fddb7e2c84ac87ac3a947cb4e66d143ca5863ef48e4a5ecb83bd48619e4634e"}, + {file = "charset_normalizer-3.4.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:98f862da73774290f251b9df8d11161b6cf25b599a66baf087c1ffe340e9bfd1"}, + {file = "charset_normalizer-3.4.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6c9379d65defcab82d07b2a9dfbfc2e95bc8fe0ebb1b176a3190230a3ef0e07c"}, + {file = "charset_normalizer-3.4.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e635b87f01ebc977342e2697d05b56632f5f879a4f15955dfe8cef2448b51691"}, + {file = "charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1c95a1e2902a8b722868587c0e1184ad5c55631de5afc0eb96bc4b0d738092c0"}, + {file = "charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ef8de666d6179b009dce7bcb2ad4c4a779f113f12caf8dc77f0162c29d20490b"}, + {file = "charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:32fc0341d72e0f73f80acb0a2c94216bd704f4f0bce10aedea38f30502b271ff"}, + {file = "charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:289200a18fa698949d2b39c671c2cc7a24d44096784e76614899a7ccf2574b7b"}, + {file = "charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4a476b06fbcf359ad25d34a057b7219281286ae2477cc5ff5e3f70a246971148"}, + {file = "charset_normalizer-3.4.2-cp313-cp313-win32.whl", hash = "sha256:aaeeb6a479c7667fbe1099af9617c83aaca22182d6cf8c53966491a0f1b7ffb7"}, + {file = "charset_normalizer-3.4.2-cp313-cp313-win_amd64.whl", hash = "sha256:aa6af9e7d59f9c12b33ae4e9450619cf2488e2bbe9b44030905877f0b2324980"}, + {file = "charset_normalizer-3.4.2-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1cad5f45b3146325bb38d6855642f6fd609c3f7cad4dbaf75549bf3b904d3184"}, + {file = "charset_normalizer-3.4.2-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b2680962a4848b3c4f155dc2ee64505a9c57186d0d56b43123b17ca3de18f0fa"}, + {file = "charset_normalizer-3.4.2-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:36b31da18b8890a76ec181c3cf44326bf2c48e36d393ca1b72b3f484113ea344"}, + {file = "charset_normalizer-3.4.2-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f4074c5a429281bf056ddd4c5d3b740ebca4d43ffffe2ef4bf4d2d05114299da"}, + {file = "charset_normalizer-3.4.2-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c9e36a97bee9b86ef9a1cf7bb96747eb7a15c2f22bdb5b516434b00f2a599f02"}, + {file = "charset_normalizer-3.4.2-cp37-cp37m-musllinux_1_2_aarch64.whl", hash = "sha256:1b1bde144d98e446b056ef98e59c256e9294f6b74d7af6846bf5ffdafd687a7d"}, + {file = "charset_normalizer-3.4.2-cp37-cp37m-musllinux_1_2_i686.whl", hash = "sha256:915f3849a011c1f593ab99092f3cecfcb4d65d8feb4a64cf1bf2d22074dc0ec4"}, + {file = "charset_normalizer-3.4.2-cp37-cp37m-musllinux_1_2_ppc64le.whl", hash = "sha256:fb707f3e15060adf5b7ada797624a6c6e0138e2a26baa089df64c68ee98e040f"}, + {file = "charset_normalizer-3.4.2-cp37-cp37m-musllinux_1_2_s390x.whl", hash = "sha256:25a23ea5c7edc53e0f29bae2c44fcb5a1aa10591aae107f2a2b2583a9c5cbc64"}, + {file = "charset_normalizer-3.4.2-cp37-cp37m-musllinux_1_2_x86_64.whl", hash = "sha256:770cab594ecf99ae64c236bc9ee3439c3f46be49796e265ce0cc8bc17b10294f"}, + {file = "charset_normalizer-3.4.2-cp37-cp37m-win32.whl", hash = "sha256:6a0289e4589e8bdfef02a80478f1dfcb14f0ab696b5a00e1f4b8a14a307a3c58"}, + {file = "charset_normalizer-3.4.2-cp37-cp37m-win_amd64.whl", hash = "sha256:6fc1f5b51fa4cecaa18f2bd7a003f3dd039dd615cd69a2afd6d3b19aed6775f2"}, + {file = "charset_normalizer-3.4.2-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:76af085e67e56c8816c3ccf256ebd136def2ed9654525348cfa744b6802b69eb"}, + {file = "charset_normalizer-3.4.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e45ba65510e2647721e35323d6ef54c7974959f6081b58d4ef5d87c60c84919a"}, + {file = "charset_normalizer-3.4.2-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:046595208aae0120559a67693ecc65dd75d46f7bf687f159127046628178dc45"}, + {file = "charset_normalizer-3.4.2-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:75d10d37a47afee94919c4fab4c22b9bc2a8bf7d4f46f87363bcf0573f3ff4f5"}, + {file = "charset_normalizer-3.4.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6333b3aa5a12c26b2a4d4e7335a28f1475e0e5e17d69d55141ee3cab736f66d1"}, + {file = "charset_normalizer-3.4.2-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e8323a9b031aa0393768b87f04b4164a40037fb2a3c11ac06a03ffecd3618027"}, + {file = "charset_normalizer-3.4.2-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:24498ba8ed6c2e0b56d4acbf83f2d989720a93b41d712ebd4f4979660db4417b"}, + {file = "charset_normalizer-3.4.2-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:844da2b5728b5ce0e32d863af26f32b5ce61bc4273a9c720a9f3aa9df73b1455"}, + {file = "charset_normalizer-3.4.2-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:65c981bdbd3f57670af8b59777cbfae75364b483fa8a9f420f08094531d54a01"}, + {file = "charset_normalizer-3.4.2-cp38-cp38-musllinux_1_2_s390x.whl", hash = "sha256:3c21d4fca343c805a52c0c78edc01e3477f6dd1ad7c47653241cf2a206d4fc58"}, + {file = "charset_normalizer-3.4.2-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:dc7039885fa1baf9be153a0626e337aa7ec8bf96b0128605fb0d77788ddc1681"}, + {file = "charset_normalizer-3.4.2-cp38-cp38-win32.whl", hash = "sha256:8272b73e1c5603666618805fe821edba66892e2870058c94c53147602eab29c7"}, + {file = "charset_normalizer-3.4.2-cp38-cp38-win_amd64.whl", hash = "sha256:70f7172939fdf8790425ba31915bfbe8335030f05b9913d7ae00a87d4395620a"}, + {file = "charset_normalizer-3.4.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:005fa3432484527f9732ebd315da8da8001593e2cf46a3d817669f062c3d9ed4"}, + {file = "charset_normalizer-3.4.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e92fca20c46e9f5e1bb485887d074918b13543b1c2a1185e69bb8d17ab6236a7"}, + {file = "charset_normalizer-3.4.2-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:50bf98d5e563b83cc29471fa114366e6806bc06bc7a25fd59641e41445327836"}, + {file = "charset_normalizer-3.4.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:721c76e84fe669be19c5791da68232ca2e05ba5185575086e384352e2c309597"}, + {file = "charset_normalizer-3.4.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:82d8fd25b7f4675d0c47cf95b594d4e7b158aca33b76aa63d07186e13c0e0ab7"}, + {file = "charset_normalizer-3.4.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b3daeac64d5b371dea99714f08ffc2c208522ec6b06fbc7866a450dd446f5c0f"}, + {file = "charset_normalizer-3.4.2-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:dccab8d5fa1ef9bfba0590ecf4d46df048d18ffe3eec01eeb73a42e0d9e7a8ba"}, + {file = "charset_normalizer-3.4.2-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:aaf27faa992bfee0264dc1f03f4c75e9fcdda66a519db6b957a3f826e285cf12"}, + {file = "charset_normalizer-3.4.2-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:eb30abc20df9ab0814b5a2524f23d75dcf83cde762c161917a2b4b7b55b1e518"}, + {file = "charset_normalizer-3.4.2-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:c72fbbe68c6f32f251bdc08b8611c7b3060612236e960ef848e0a517ddbe76c5"}, + {file = "charset_normalizer-3.4.2-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:982bb1e8b4ffda883b3d0a521e23abcd6fd17418f6d2c4118d257a10199c0ce3"}, + {file = "charset_normalizer-3.4.2-cp39-cp39-win32.whl", hash = "sha256:43e0933a0eff183ee85833f341ec567c0980dae57c464d8a508e1b2ceb336471"}, + {file = "charset_normalizer-3.4.2-cp39-cp39-win_amd64.whl", hash = "sha256:d11b54acf878eef558599658b0ffca78138c8c3655cf4f3a4a673c437e67732e"}, + {file = "charset_normalizer-3.4.2-py3-none-any.whl", hash = "sha256:7f56930ab0abd1c45cd15be65cc741c28b1c9a34876ce8c17a2fa107810c0af0"}, + {file = "charset_normalizer-3.4.2.tar.gz", hash = "sha256:5baececa9ecba31eff645232d59845c07aa030f0c81ee70184a90d35099a0e63"}, +] + +[[package]] +name = "click" +version = "8.1.8" +description = "Composable command line interface toolkit" +optional = false +python-versions = ">=3.7" +groups = ["test"] +files = [ + {file = "click-8.1.8-py3-none-any.whl", hash = "sha256:63c132bbbed01578a06712a2d1f497bb62d9c1c0d329b7903a866228027263b2"}, + {file = "click-8.1.8.tar.gz", hash = "sha256:ed53c9d8990d83c2a27deae68e4ee337473f6330c040a31d4225c9574d16096a"}, +] + +[package.dependencies] +colorama = {version = "*", markers = "platform_system == \"Windows\""} + +[[package]] +name = "colorama" +version = "0.4.6" +description = "Cross-platform colored terminal text." +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" +groups = ["docs", "test"] +files = [ + {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, + {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, +] +markers = {docs = "sys_platform == \"win32\"", test = "sys_platform == \"win32\" or platform_system == \"Windows\""} + +[[package]] +name = "coverage" +version = "7.8.2" +description = "Code coverage measurement for Python" +optional = false +python-versions = ">=3.9" +groups = ["test"] +files = [ + {file = "coverage-7.8.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:bd8ec21e1443fd7a447881332f7ce9d35b8fbd2849e761bb290b584535636b0a"}, + {file = "coverage-7.8.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:4c26c2396674816deaeae7ded0e2b42c26537280f8fe313335858ffff35019be"}, + {file = "coverage-7.8.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1aec326ed237e5880bfe69ad41616d333712c7937bcefc1343145e972938f9b3"}, + {file = "coverage-7.8.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5e818796f71702d7a13e50c70de2a1924f729228580bcba1607cccf32eea46e6"}, + {file = "coverage-7.8.2-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:546e537d9e24efc765c9c891328f30f826e3e4808e31f5d0f87c4ba12bbd1622"}, + {file = "coverage-7.8.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:ab9b09a2349f58e73f8ebc06fac546dd623e23b063e5398343c5270072e3201c"}, + {file = "coverage-7.8.2-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:fd51355ab8a372d89fb0e6a31719e825cf8df8b6724bee942fb5b92c3f016ba3"}, + {file = "coverage-7.8.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:0774df1e093acb6c9e4d58bce7f86656aeed6c132a16e2337692c12786b32404"}, + {file = "coverage-7.8.2-cp310-cp310-win32.whl", hash = "sha256:00f2e2f2e37f47e5f54423aeefd6c32a7dbcedc033fcd3928a4f4948e8b96af7"}, + {file = "coverage-7.8.2-cp310-cp310-win_amd64.whl", hash = "sha256:145b07bea229821d51811bf15eeab346c236d523838eda395ea969d120d13347"}, + {file = "coverage-7.8.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b99058eef42e6a8dcd135afb068b3d53aff3921ce699e127602efff9956457a9"}, + {file = "coverage-7.8.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5feb7f2c3e6ea94d3b877def0270dff0947b8d8c04cfa34a17be0a4dc1836879"}, + {file = "coverage-7.8.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:670a13249b957bb9050fab12d86acef7bf8f6a879b9d1a883799276e0d4c674a"}, + {file = "coverage-7.8.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0bdc8bf760459a4a4187b452213e04d039990211f98644c7292adf1e471162b5"}, + {file = "coverage-7.8.2-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:07a989c867986c2a75f158f03fdb413128aad29aca9d4dbce5fc755672d96f11"}, + {file = "coverage-7.8.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:2db10dedeb619a771ef0e2949ccba7b75e33905de959c2643a4607bef2f3fb3a"}, + {file = "coverage-7.8.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:e6ea7dba4e92926b7b5f0990634b78ea02f208d04af520c73a7c876d5a8d36cb"}, + {file = "coverage-7.8.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ef2f22795a7aca99fc3c84393a55a53dd18ab8c93fb431004e4d8f0774150f54"}, + {file = "coverage-7.8.2-cp311-cp311-win32.whl", hash = "sha256:641988828bc18a6368fe72355df5f1703e44411adbe49bba5644b941ce6f2e3a"}, + {file = "coverage-7.8.2-cp311-cp311-win_amd64.whl", hash = "sha256:8ab4a51cb39dc1933ba627e0875046d150e88478dbe22ce145a68393e9652975"}, + {file = "coverage-7.8.2-cp311-cp311-win_arm64.whl", hash = "sha256:8966a821e2083c74d88cca5b7dcccc0a3a888a596a04c0b9668a891de3a0cc53"}, + {file = "coverage-7.8.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:e2f6fe3654468d061942591aef56686131335b7a8325684eda85dacdf311356c"}, + {file = "coverage-7.8.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:76090fab50610798cc05241bf83b603477c40ee87acd358b66196ab0ca44ffa1"}, + {file = "coverage-7.8.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2bd0a0a5054be160777a7920b731a0570284db5142abaaf81bcbb282b8d99279"}, + {file = "coverage-7.8.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:da23ce9a3d356d0affe9c7036030b5c8f14556bd970c9b224f9c8205505e3b99"}, + {file = "coverage-7.8.2-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c9392773cffeb8d7e042a7b15b82a414011e9d2b5fdbbd3f7e6a6b17d5e21b20"}, + {file = "coverage-7.8.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:876cbfd0b09ce09d81585d266c07a32657beb3eaec896f39484b631555be0fe2"}, + {file = "coverage-7.8.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:3da9b771c98977a13fbc3830f6caa85cae6c9c83911d24cb2d218e9394259c57"}, + {file = "coverage-7.8.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:9a990f6510b3292686713bfef26d0049cd63b9c7bb17e0864f133cbfd2e6167f"}, + {file = "coverage-7.8.2-cp312-cp312-win32.whl", hash = "sha256:bf8111cddd0f2b54d34e96613e7fbdd59a673f0cf5574b61134ae75b6f5a33b8"}, + {file = "coverage-7.8.2-cp312-cp312-win_amd64.whl", hash = "sha256:86a323a275e9e44cdf228af9b71c5030861d4d2610886ab920d9945672a81223"}, + {file = "coverage-7.8.2-cp312-cp312-win_arm64.whl", hash = "sha256:820157de3a589e992689ffcda8639fbabb313b323d26388d02e154164c57b07f"}, + {file = "coverage-7.8.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ea561010914ec1c26ab4188aef8b1567272ef6de096312716f90e5baa79ef8ca"}, + {file = "coverage-7.8.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cb86337a4fcdd0e598ff2caeb513ac604d2f3da6d53df2c8e368e07ee38e277d"}, + {file = "coverage-7.8.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:26a4636ddb666971345541b59899e969f3b301143dd86b0ddbb570bd591f1e85"}, + {file = "coverage-7.8.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5040536cf9b13fb033f76bcb5e1e5cb3b57c4807fef37db9e0ed129c6a094257"}, + {file = "coverage-7.8.2-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dc67994df9bcd7e0150a47ef41278b9e0a0ea187caba72414b71dc590b99a108"}, + {file = "coverage-7.8.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6e6c86888fd076d9e0fe848af0a2142bf606044dc5ceee0aa9eddb56e26895a0"}, + {file = "coverage-7.8.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:684ca9f58119b8e26bef860db33524ae0365601492e86ba0b71d513f525e7050"}, + {file = "coverage-7.8.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8165584ddedb49204c4e18da083913bdf6a982bfb558632a79bdaadcdafd0d48"}, + {file = "coverage-7.8.2-cp313-cp313-win32.whl", hash = "sha256:34759ee2c65362163699cc917bdb2a54114dd06d19bab860725f94ef45a3d9b7"}, + {file = "coverage-7.8.2-cp313-cp313-win_amd64.whl", hash = "sha256:2f9bc608fbafaee40eb60a9a53dbfb90f53cc66d3d32c2849dc27cf5638a21e3"}, + {file = "coverage-7.8.2-cp313-cp313-win_arm64.whl", hash = "sha256:9fe449ee461a3b0c7105690419d0b0aba1232f4ff6d120a9e241e58a556733f7"}, + {file = "coverage-7.8.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:8369a7c8ef66bded2b6484053749ff220dbf83cba84f3398c84c51a6f748a008"}, + {file = "coverage-7.8.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:159b81df53a5fcbc7d45dae3adad554fdbde9829a994e15227b3f9d816d00b36"}, + {file = "coverage-7.8.2-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e6fcbbd35a96192d042c691c9e0c49ef54bd7ed865846a3c9d624c30bb67ce46"}, + {file = "coverage-7.8.2-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:05364b9cc82f138cc86128dc4e2e1251c2981a2218bfcd556fe6b0fbaa3501be"}, + {file = "coverage-7.8.2-cp313-cp313t-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:46d532db4e5ff3979ce47d18e2fe8ecad283eeb7367726da0e5ef88e4fe64740"}, + {file = "coverage-7.8.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:4000a31c34932e7e4fa0381a3d6deb43dc0c8f458e3e7ea6502e6238e10be625"}, + {file = "coverage-7.8.2-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:43ff5033d657cd51f83015c3b7a443287250dc14e69910577c3e03bd2e06f27b"}, + {file = "coverage-7.8.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:94316e13f0981cbbba132c1f9f365cac1d26716aaac130866ca812006f662199"}, + {file = "coverage-7.8.2-cp313-cp313t-win32.whl", hash = "sha256:3f5673888d3676d0a745c3d0e16da338c5eea300cb1f4ada9c872981265e76d8"}, + {file = "coverage-7.8.2-cp313-cp313t-win_amd64.whl", hash = "sha256:2c08b05ee8d7861e45dc5a2cc4195c8c66dca5ac613144eb6ebeaff2d502e73d"}, + {file = "coverage-7.8.2-cp313-cp313t-win_arm64.whl", hash = "sha256:1e1448bb72b387755e1ff3ef1268a06617afd94188164960dba8d0245a46004b"}, + {file = "coverage-7.8.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:496948261eaac5ac9cf43f5d0a9f6eb7a6d4cb3bedb2c5d294138142f5c18f2a"}, + {file = "coverage-7.8.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:eacd2de0d30871eff893bab0b67840a96445edcb3c8fd915e6b11ac4b2f3fa6d"}, + {file = "coverage-7.8.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b039ffddc99ad65d5078ef300e0c7eed08c270dc26570440e3ef18beb816c1ca"}, + {file = "coverage-7.8.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0e49824808d4375ede9dd84e9961a59c47f9113039f1a525e6be170aa4f5c34d"}, + {file = "coverage-7.8.2-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b069938961dfad881dc2f8d02b47645cd2f455d3809ba92a8a687bf513839787"}, + {file = "coverage-7.8.2-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:de77c3ba8bb686d1c411e78ee1b97e6e0b963fb98b1637658dd9ad2c875cf9d7"}, + {file = "coverage-7.8.2-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:1676628065a498943bd3f64f099bb573e08cf1bc6088bbe33cf4424e0876f4b3"}, + {file = "coverage-7.8.2-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:8e1a26e7e50076e35f7afafde570ca2b4d7900a491174ca357d29dece5aacee7"}, + {file = "coverage-7.8.2-cp39-cp39-win32.whl", hash = "sha256:6782a12bf76fa61ad9350d5a6ef5f3f020b57f5e6305cbc663803f2ebd0f270a"}, + {file = "coverage-7.8.2-cp39-cp39-win_amd64.whl", hash = "sha256:1efa4166ba75ccefd647f2d78b64f53f14fb82622bc94c5a5cb0a622f50f1c9e"}, + {file = "coverage-7.8.2-pp39.pp310.pp311-none-any.whl", hash = "sha256:ec455eedf3ba0bbdf8f5a570012617eb305c63cb9f03428d39bf544cb2b94837"}, + {file = "coverage-7.8.2-py3-none-any.whl", hash = "sha256:726f32ee3713f7359696331a18daf0c3b3a70bb0ae71141b9d3c52be7c595e32"}, + {file = "coverage-7.8.2.tar.gz", hash = "sha256:a886d531373a1f6ff9fad2a2ba4a045b68467b779ae729ee0b3b10ac20033b27"}, +] + +[package.dependencies] +tomli = {version = "*", optional = true, markers = "python_full_version <= \"3.11.0a6\" and extra == \"toml\""} + +[package.extras] +toml = ["tomli ; python_full_version <= \"3.11.0a6\""] + +[[package]] +name = "cryptography" +version = "43.0.3" +description = "cryptography is a package which provides cryptographic recipes and primitives to Python developers." +optional = false +python-versions = ">=3.7" +groups = ["test"] +markers = "python_version < \"3.13\"" +files = [ + {file = "cryptography-43.0.3-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:bf7a1932ac4176486eab36a19ed4c0492da5d97123f1406cf15e41b05e787d2e"}, + {file = "cryptography-43.0.3-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:63efa177ff54aec6e1c0aefaa1a241232dcd37413835a9b674b6e3f0ae2bfd3e"}, + {file = "cryptography-43.0.3-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7e1ce50266f4f70bf41a2c6dc4358afadae90e2a1e5342d3c08883df1675374f"}, + {file = "cryptography-43.0.3-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:443c4a81bb10daed9a8f334365fe52542771f25aedaf889fd323a853ce7377d6"}, + {file = "cryptography-43.0.3-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:74f57f24754fe349223792466a709f8e0c093205ff0dca557af51072ff47ab18"}, + {file = "cryptography-43.0.3-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9762ea51a8fc2a88b70cf2995e5675b38d93bf36bd67d91721c309df184f49bd"}, + {file = "cryptography-43.0.3-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:81ef806b1fef6b06dcebad789f988d3b37ccaee225695cf3e07648eee0fc6b73"}, + {file = "cryptography-43.0.3-cp37-abi3-win32.whl", hash = "sha256:cbeb489927bd7af4aa98d4b261af9a5bc025bd87f0e3547e11584be9e9427be2"}, + {file = "cryptography-43.0.3-cp37-abi3-win_amd64.whl", hash = "sha256:f46304d6f0c6ab8e52770addfa2fc41e6629495548862279641972b6215451cd"}, + {file = "cryptography-43.0.3-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:8ac43ae87929a5982f5948ceda07001ee5e83227fd69cf55b109144938d96984"}, + {file = "cryptography-43.0.3-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:846da004a5804145a5f441b8530b4bf35afbf7da70f82409f151695b127213d5"}, + {file = "cryptography-43.0.3-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0f996e7268af62598f2fc1204afa98a3b5712313a55c4c9d434aef49cadc91d4"}, + {file = "cryptography-43.0.3-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:f7b178f11ed3664fd0e995a47ed2b5ff0a12d893e41dd0494f406d1cf555cab7"}, + {file = "cryptography-43.0.3-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:c2e6fc39c4ab499049df3bdf567f768a723a5e8464816e8f009f121a5a9f4405"}, + {file = "cryptography-43.0.3-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:e1be4655c7ef6e1bbe6b5d0403526601323420bcf414598955968c9ef3eb7d16"}, + {file = "cryptography-43.0.3-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:df6b6c6d742395dd77a23ea3728ab62f98379eff8fb61be2744d4679ab678f73"}, + {file = "cryptography-43.0.3-cp39-abi3-win32.whl", hash = "sha256:d56e96520b1020449bbace2b78b603442e7e378a9b3bd68de65c782db1507995"}, + {file = "cryptography-43.0.3-cp39-abi3-win_amd64.whl", hash = "sha256:0c580952eef9bf68c4747774cde7ec1d85a6e61de97281f2dba83c7d2c806362"}, + {file = "cryptography-43.0.3-pp310-pypy310_pp73-macosx_10_9_x86_64.whl", hash = "sha256:d03b5621a135bffecad2c73e9f4deb1a0f977b9a8ffe6f8e002bf6c9d07b918c"}, + {file = "cryptography-43.0.3-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:a2a431ee15799d6db9fe80c82b055bae5a752bef645bba795e8e52687c69efe3"}, + {file = "cryptography-43.0.3-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:281c945d0e28c92ca5e5930664c1cefd85efe80e5c0d2bc58dd63383fda29f83"}, + {file = "cryptography-43.0.3-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:f18c716be16bc1fea8e95def49edf46b82fccaa88587a45f8dc0ff6ab5d8e0a7"}, + {file = "cryptography-43.0.3-pp39-pypy39_pp73-macosx_10_9_x86_64.whl", hash = "sha256:4a02ded6cd4f0a5562a8887df8b3bd14e822a90f97ac5e544c162899bc467664"}, + {file = "cryptography-43.0.3-pp39-pypy39_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:53a583b6637ab4c4e3591a15bc9db855b8d9dee9a669b550f311480acab6eb08"}, + {file = "cryptography-43.0.3-pp39-pypy39_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:1ec0bcf7e17c0c5669d881b1cd38c4972fade441b27bda1051665faaa89bdcaa"}, + {file = "cryptography-43.0.3-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:2ce6fae5bdad59577b44e4dfed356944fbf1d925269114c28be377692643b4ff"}, + {file = "cryptography-43.0.3.tar.gz", hash = "sha256:315b9001266a492a6ff443b61238f956b214dbec9910a081ba5b6646a055a805"}, +] + +[package.dependencies] +cffi = {version = ">=1.12", markers = "platform_python_implementation != \"PyPy\""} + +[package.extras] +docs = ["sphinx (>=5.3.0)", "sphinx-rtd-theme (>=1.1.1)"] +docstest = ["pyenchant (>=1.6.11)", "readme-renderer", "sphinxcontrib-spelling (>=4.0.1)"] +nox = ["nox"] +pep8test = ["check-sdist", "click", "mypy", "ruff"] +sdist = ["build"] +ssh = ["bcrypt (>=3.1.5)"] +test = ["certifi", "cryptography-vectors (==43.0.3)", "pretend", "pytest (>=6.2.0)", "pytest-benchmark", "pytest-cov", "pytest-xdist"] +test-randomorder = ["pytest-randomly"] + +[[package]] +name = "cryptography" +version = "45.0.3" +description = "cryptography is a package which provides cryptographic recipes and primitives to Python developers." +optional = false +python-versions = "!=3.9.0,!=3.9.1,>=3.7" +groups = ["test"] +markers = "python_version >= \"3.13\"" +files = [ + {file = "cryptography-45.0.3-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:7573d9eebaeceeb55285205dbbb8753ac1e962af3d9640791d12b36864065e71"}, + {file = "cryptography-45.0.3-cp311-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d377dde61c5d67eb4311eace661c3efda46c62113ff56bf05e2d679e02aebb5b"}, + {file = "cryptography-45.0.3-cp311-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fae1e637f527750811588e4582988932c222f8251f7b7ea93739acb624e1487f"}, + {file = "cryptography-45.0.3-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:ca932e11218bcc9ef812aa497cdf669484870ecbcf2d99b765d6c27a86000942"}, + {file = "cryptography-45.0.3-cp311-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:af3f92b1dc25621f5fad065288a44ac790c5798e986a34d393ab27d2b27fcff9"}, + {file = "cryptography-45.0.3-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:2f8f8f0b73b885ddd7f3d8c2b2234a7d3ba49002b0223f58cfde1bedd9563c56"}, + {file = "cryptography-45.0.3-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:9cc80ce69032ffa528b5e16d217fa4d8d4bb7d6ba8659c1b4d74a1b0f4235fca"}, + {file = "cryptography-45.0.3-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:c824c9281cb628015bfc3c59335163d4ca0540d49de4582d6c2637312907e4b1"}, + {file = "cryptography-45.0.3-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:5833bb4355cb377ebd880457663a972cd044e7f49585aee39245c0d592904578"}, + {file = "cryptography-45.0.3-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:9bb5bf55dcb69f7067d80354d0a348368da907345a2c448b0babc4215ccd3497"}, + {file = "cryptography-45.0.3-cp311-abi3-win32.whl", hash = "sha256:3ad69eeb92a9de9421e1f6685e85a10fbcfb75c833b42cc9bc2ba9fb00da4710"}, + {file = "cryptography-45.0.3-cp311-abi3-win_amd64.whl", hash = "sha256:97787952246a77d77934d41b62fb1b6f3581d83f71b44796a4158d93b8f5c490"}, + {file = "cryptography-45.0.3-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:c92519d242703b675ccefd0f0562eb45e74d438e001f8ab52d628e885751fb06"}, + {file = "cryptography-45.0.3-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c5edcb90da1843df85292ef3a313513766a78fbbb83f584a5a58fb001a5a9d57"}, + {file = "cryptography-45.0.3-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:38deed72285c7ed699864f964a3f4cf11ab3fb38e8d39cfcd96710cd2b5bb716"}, + {file = "cryptography-45.0.3-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:5555365a50efe1f486eed6ac7062c33b97ccef409f5970a0b6f205a7cfab59c8"}, + {file = "cryptography-45.0.3-cp37-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:9e4253ed8f5948a3589b3caee7ad9a5bf218ffd16869c516535325fece163dcc"}, + {file = "cryptography-45.0.3-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:cfd84777b4b6684955ce86156cfb5e08d75e80dc2585e10d69e47f014f0a5342"}, + {file = "cryptography-45.0.3-cp37-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:a2b56de3417fd5f48773ad8e91abaa700b678dc7fe1e0c757e1ae340779acf7b"}, + {file = "cryptography-45.0.3-cp37-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:57a6500d459e8035e813bd8b51b671977fb149a8c95ed814989da682314d0782"}, + {file = "cryptography-45.0.3-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:f22af3c78abfbc7cbcdf2c55d23c3e022e1a462ee2481011d518c7fb9c9f3d65"}, + {file = "cryptography-45.0.3-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:232954730c362638544758a8160c4ee1b832dc011d2c41a306ad8f7cccc5bb0b"}, + {file = "cryptography-45.0.3-cp37-abi3-win32.whl", hash = "sha256:cb6ab89421bc90e0422aca911c69044c2912fc3debb19bb3c1bfe28ee3dff6ab"}, + {file = "cryptography-45.0.3-cp37-abi3-win_amd64.whl", hash = "sha256:d54ae41e6bd70ea23707843021c778f151ca258081586f0cfa31d936ae43d1b2"}, + {file = "cryptography-45.0.3-pp310-pypy310_pp73-macosx_10_9_x86_64.whl", hash = "sha256:ed43d396f42028c1f47b5fec012e9e12631266e3825e95c00e3cf94d472dac49"}, + {file = "cryptography-45.0.3-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:fed5aaca1750e46db870874c9c273cd5182a9e9deb16f06f7bdffdb5c2bde4b9"}, + {file = "cryptography-45.0.3-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:00094838ecc7c6594171e8c8a9166124c1197b074cfca23645cee573910d76bc"}, + {file = "cryptography-45.0.3-pp310-pypy310_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:92d5f428c1a0439b2040435a1d6bc1b26ebf0af88b093c3628913dd464d13fa1"}, + {file = "cryptography-45.0.3-pp310-pypy310_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:ec64ee375b5aaa354b2b273c921144a660a511f9df8785e6d1c942967106438e"}, + {file = "cryptography-45.0.3-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:71320fbefd05454ef2d457c481ba9a5b0e540f3753354fff6f780927c25d19b0"}, + {file = "cryptography-45.0.3-pp311-pypy311_pp73-macosx_10_9_x86_64.whl", hash = "sha256:edd6d51869beb7f0d472e902ef231a9b7689508e83880ea16ca3311a00bf5ce7"}, + {file = "cryptography-45.0.3-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:555e5e2d3a53b4fabeca32835878b2818b3f23966a4efb0d566689777c5a12c8"}, + {file = "cryptography-45.0.3-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:25286aacb947286620a31f78f2ed1a32cded7be5d8b729ba3fb2c988457639e4"}, + {file = "cryptography-45.0.3-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:050ce5209d5072472971e6efbfc8ec5a8f9a841de5a4db0ebd9c2e392cb81972"}, + {file = "cryptography-45.0.3-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:dc10ec1e9f21f33420cc05214989544727e776286c1c16697178978327b95c9c"}, + {file = "cryptography-45.0.3-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:9eda14f049d7f09c2e8fb411dda17dd6b16a3c76a1de5e249188a32aeb92de19"}, + {file = "cryptography-45.0.3.tar.gz", hash = "sha256:ec21313dd335c51d7877baf2972569f40a4291b76a0ce51391523ae358d05899"}, +] + +[package.dependencies] +cffi = {version = ">=1.14", markers = "platform_python_implementation != \"PyPy\""} + +[package.extras] +docs = ["sphinx (>=5.3.0)", "sphinx-inline-tabs ; python_full_version >= \"3.8.0\"", "sphinx-rtd-theme (>=3.0.0) ; python_full_version >= \"3.8.0\""] +docstest = ["pyenchant (>=3)", "readme-renderer (>=30.0)", "sphinxcontrib-spelling (>=7.3.1)"] +nox = ["nox (>=2024.4.15)", "nox[uv] (>=2024.3.2) ; python_full_version >= \"3.8.0\""] +pep8test = ["check-sdist ; python_full_version >= \"3.8.0\"", "click (>=8.0.1)", "mypy (>=1.4)", "ruff (>=0.3.6)"] +sdist = ["build (>=1.0.0)"] +ssh = ["bcrypt (>=3.1.5)"] +test = ["certifi (>=2024)", "cryptography-vectors (==45.0.3)", "pretend (>=0.7)", "pytest (>=7.4.0)", "pytest-benchmark (>=4.0)", "pytest-cov (>=2.10.1)", "pytest-xdist (>=3.5.0)"] +test-randomorder = ["pytest-randomly"] + +[[package]] +name = "django" +version = "4.2.23" +description = "A high-level Python web framework that encourages rapid development and clean, pragmatic design." +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "django-4.2.23-py3-none-any.whl", hash = "sha256:dafbfaf52c2f289bd65f4ab935791cb4fb9a198f2a5ba9faf35d7338a77e9803"}, + {file = "django-4.2.23.tar.gz", hash = "sha256:42fdeaba6e6449d88d4f66de47871015097dc6f1b87910db00a91946295cfae4"}, +] + +[package.dependencies] +asgiref = ">=3.6.0,<4" +sqlparse = ">=0.3.1" +tzdata = {version = "*", markers = "sys_platform == \"win32\""} + +[package.extras] +argon2 = ["argon2-cffi (>=19.1.0)"] +bcrypt = ["bcrypt"] + +[[package]] +name = "doc8" +version = "1.1.2" +description = "Style checker for Sphinx (or other) RST documentation" +optional = false +python-versions = ">=3.8" +groups = ["test"] +files = [ + {file = "doc8-1.1.2-py3-none-any.whl", hash = "sha256:e787b3076b391b8b49400da5d018bacafe592dfc0a04f35a9be22d0122b82b59"}, + {file = "doc8-1.1.2.tar.gz", hash = "sha256:1225f30144e1cc97e388dbaf7fe3e996d2897473a53a6dae268ddde21c354b98"}, +] + +[package.dependencies] +docutils = ">=0.19,<=0.21.2" +Pygments = "*" +restructuredtext-lint = ">=0.7" +stevedore = "*" +tomli = {version = "*", markers = "python_version < \"3.11\""} + +[[package]] +name = "docutils" +version = "0.21.2" +description = "Docutils -- Python Documentation Utilities" +optional = false +python-versions = ">=3.9" +groups = ["docs", "test"] +files = [ + {file = "docutils-0.21.2-py3-none-any.whl", hash = "sha256:dafca5b9e384f0e419294eb4d2ff9fa826435bf15f15b7bd45723e8ad76811b2"}, + {file = "docutils-0.21.2.tar.gz", hash = "sha256:3a6b18732edf182daa3cd12775bbb338cf5691468f91eeeb109deff6ebfa986f"}, +] + +[[package]] +name = "dparse" +version = "0.6.4" +description = "A parser for Python dependency files" +optional = false +python-versions = ">=3.7" +groups = ["test"] +files = [ + {file = "dparse-0.6.4-py3-none-any.whl", hash = "sha256:fbab4d50d54d0e739fbb4dedfc3d92771003a5b9aa8545ca7a7045e3b174af57"}, + {file = "dparse-0.6.4.tar.gz", hash = "sha256:90b29c39e3edc36c6284c82c4132648eaf28a01863eb3c231c2512196132201a"}, +] + +[package.dependencies] +packaging = "*" +tomli = {version = "*", markers = "python_version < \"3.11\""} + +[package.extras] +all = ["pipenv", "poetry", "pyyaml"] +conda = ["pyyaml"] +pipenv = ["pipenv"] +poetry = ["poetry"] + +[[package]] +name = "exceptiongroup" +version = "1.3.0" +description = "Backport of PEP 654 (exception groups)" +optional = false +python-versions = ">=3.7" +groups = ["test"] +markers = "python_version < \"3.11\"" +files = [ + {file = "exceptiongroup-1.3.0-py3-none-any.whl", hash = "sha256:4d111e6e0c13d0644cad6ddaa7ed0261a0b36971f6d23e7ec9b4b9097da78a10"}, + {file = "exceptiongroup-1.3.0.tar.gz", hash = "sha256:b241f5885f560bc56a59ee63ca4c6a8bfa46ae4ad651af316d4e81817bb9fd88"}, +] + +[package.dependencies] +typing-extensions = {version = ">=4.6.0", markers = "python_version < \"3.13\""} + +[package.extras] +test = ["pytest (>=6)"] + +[[package]] +name = "filelock" +version = "3.16.1" +description = "A platform independent file lock." +optional = false +python-versions = ">=3.8" +groups = ["test"] +files = [ + {file = "filelock-3.16.1-py3-none-any.whl", hash = "sha256:2082e5703d51fbf98ea75855d9d5527e33d8ff23099bec374a134febee6946b0"}, + {file = "filelock-3.16.1.tar.gz", hash = "sha256:c249fbfcd5db47e5e2d6d62198e565475ee65e4831e2561c8e313fa7eb961435"}, +] + +[package.extras] +docs = ["furo (>=2024.8.6)", "sphinx (>=8.0.2)", "sphinx-autodoc-typehints (>=2.4.1)"] +testing = ["covdefaults (>=2.3)", "coverage (>=7.6.1)", "diff-cover (>=9.2)", "pytest (>=8.3.3)", "pytest-asyncio (>=0.24)", "pytest-cov (>=5)", "pytest-mock (>=3.14)", "pytest-timeout (>=2.3.1)", "virtualenv (>=20.26.4)"] +typing = ["typing-extensions (>=4.12.2) ; python_version < \"3.11\""] + +[[package]] +name = "h11" +version = "0.16.0" +description = "A pure-Python, bring-your-own-I/O implementation of HTTP/1.1" +optional = false +python-versions = ">=3.8" +groups = ["test"] +files = [ + {file = "h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86"}, + {file = "h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1"}, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +description = "A minimal low-level HTTP client." +optional = false +python-versions = ">=3.8" +groups = ["test"] +files = [ + {file = "httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55"}, + {file = "httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8"}, +] + +[package.dependencies] +certifi = "*" +h11 = ">=0.16" + +[package.extras] +asyncio = ["anyio (>=4.0,<5.0)"] +http2 = ["h2 (>=3,<5)"] +socks = ["socksio (==1.*)"] +trio = ["trio (>=0.22.0,<1.0)"] + +[[package]] +name = "httpx" +version = "0.28.1" +description = "The next generation HTTP client." +optional = false +python-versions = ">=3.8" +groups = ["test"] +files = [ + {file = "httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad"}, + {file = "httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc"}, +] + +[package.dependencies] +anyio = "*" +certifi = "*" +httpcore = "==1.*" +idna = "*" + +[package.extras] +brotli = ["brotli ; platform_python_implementation == \"CPython\"", "brotlicffi ; platform_python_implementation != \"CPython\""] +cli = ["click (==8.*)", "pygments (==2.*)", "rich (>=10,<14)"] +http2 = ["h2 (>=3,<5)"] +socks = ["socksio (==1.*)"] +zstd = ["zstandard (>=0.18.0)"] + +[[package]] +name = "hypothesis" +version = "6.135.26" +description = "A library for property-based testing" +optional = false +python-versions = ">=3.9" +groups = ["test"] +files = [ + {file = "hypothesis-6.135.26-py3-none-any.whl", hash = "sha256:fa237cbe2ae2c31d65f7230dcb866139ace635dcfec6c30dddf25974dd8ff4b9"}, + {file = "hypothesis-6.135.26.tar.gz", hash = "sha256:73af0e46cd5039c6806f514fed6a3c185d91ef88b5a1577477099ddbd1a2e300"}, +] + +[package.dependencies] +attrs = ">=22.2.0" +exceptiongroup = {version = ">=1.0.0", markers = "python_version < \"3.11\""} +sortedcontainers = ">=2.1.0,<3.0.0" + +[package.extras] +all = ["black (>=19.10b0)", "click (>=7.0)", "crosshair-tool (>=0.0.88)", "django (>=4.2)", "dpcontracts (>=0.4)", "hypothesis-crosshair (>=0.0.23)", "lark (>=0.10.1)", "libcst (>=0.3.16)", "numpy (>=1.19.3)", "pandas (>=1.1)", "pytest (>=4.6)", "python-dateutil (>=1.4)", "pytz (>=2014.1)", "redis (>=3.0.0)", "rich (>=9.0.0)", "tzdata (>=2025.2) ; sys_platform == \"win32\" or sys_platform == \"emscripten\"", "watchdog (>=4.0.0)"] +cli = ["black (>=19.10b0)", "click (>=7.0)", "rich (>=9.0.0)"] +codemods = ["libcst (>=0.3.16)"] +crosshair = ["crosshair-tool (>=0.0.88)", "hypothesis-crosshair (>=0.0.23)"] +dateutil = ["python-dateutil (>=1.4)"] +django = ["django (>=4.2)"] +dpcontracts = ["dpcontracts (>=0.4)"] +ghostwriter = ["black (>=19.10b0)"] +lark = ["lark (>=0.10.1)"] +numpy = ["numpy (>=1.19.3)"] +pandas = ["pandas (>=1.1)"] +pytest = ["pytest (>=4.6)"] +pytz = ["pytz (>=2014.1)"] +redis = ["redis (>=3.0.0)"] +watchdog = ["watchdog (>=4.0.0)"] +zoneinfo = ["tzdata (>=2025.2) ; sys_platform == \"win32\" or sys_platform == \"emscripten\""] + +[[package]] +name = "idna" +version = "3.10" +description = "Internationalized Domain Names in Applications (IDNA)" +optional = false +python-versions = ">=3.6" +groups = ["docs", "test"] +files = [ + {file = "idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3"}, + {file = "idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9"}, +] + +[package.extras] +all = ["flake8 (>=7.1.1)", "mypy (>=1.11.2)", "pytest (>=8.3.2)", "ruff (>=0.6.2)"] + +[[package]] +name = "imagesize" +version = "1.4.1" +description = "Getting image size from png/jpeg/jpeg2000/gif file" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +groups = ["docs"] +files = [ + {file = "imagesize-1.4.1-py2.py3-none-any.whl", hash = "sha256:0d8d18d08f840c19d0ee7ca1fd82490fdc3729b7ac93f49870406ddde8ef8d8b"}, + {file = "imagesize-1.4.1.tar.gz", hash = "sha256:69150444affb9cb0d5cc5a92b3676f0b2fb7cd9ae39e947a5e11a36b4497cd4a"}, +] + +[[package]] +name = "importlib-metadata" +version = "8.7.0" +description = "Read metadata from Python packages" +optional = false +python-versions = ">=3.9" +groups = ["docs", "test"] +markers = "python_version < \"3.10\"" +files = [ + {file = "importlib_metadata-8.7.0-py3-none-any.whl", hash = "sha256:e5dd1551894c77868a30651cef00984d50e1002d06942a7101d34870c5f02afd"}, + {file = "importlib_metadata-8.7.0.tar.gz", hash = "sha256:d13b81ad223b890aa16c5471f2ac3056cf76c5f10f82d6f9292f0b415f389000"}, +] + +[package.dependencies] +zipp = ">=3.20" + +[package.extras] +check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1) ; sys_platform != \"cygwin\""] +cover = ["pytest-cov"] +doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] +enabler = ["pytest-enabler (>=2.2)"] +perf = ["ipython"] +test = ["flufl.flake8", "importlib_resources (>=1.3) ; python_version < \"3.9\"", "jaraco.test (>=5.4)", "packaging", "pyfakefs", "pytest (>=6,!=8.1.*)", "pytest-perf (>=0.9.2)"] +type = ["pytest-mypy"] + +[[package]] +name = "iniconfig" +version = "2.1.0" +description = "brain-dead simple config-ini parsing" +optional = false +python-versions = ">=3.8" +groups = ["test"] +files = [ + {file = "iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760"}, + {file = "iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7"}, +] + +[[package]] +name = "jinja2" +version = "3.1.6" +description = "A very fast and expressive template engine." +optional = false +python-versions = ">=3.7" +groups = ["docs", "test"] +files = [ + {file = "jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67"}, + {file = "jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d"}, +] + +[package.dependencies] +MarkupSafe = ">=2.0" + +[package.extras] +i18n = ["Babel (>=2.7)"] + +[[package]] +name = "joblib" +version = "1.5.1" +description = "Lightweight pipelining with Python functions" +optional = false +python-versions = ">=3.9" +groups = ["test"] +files = [ + {file = "joblib-1.5.1-py3-none-any.whl", hash = "sha256:4719a31f054c7d766948dcd83e9613686b27114f190f717cec7eaa2084f8a74a"}, + {file = "joblib-1.5.1.tar.gz", hash = "sha256:f4f86e351f39fe3d0d32a9f2c3d8af1ee4cec285aafcb27003dda5205576b444"}, +] + +[[package]] +name = "m2r2" +version = "0.3.4" +description = "Markdown and reStructuredText in a single file." +optional = false +python-versions = ">=3.7" +groups = ["docs"] +files = [ + {file = "m2r2-0.3.4-py3-none-any.whl", hash = "sha256:1a445514af8a229496bfb1380c52da8dd38313e48600359ee92b2c9d2e4df34a"}, + {file = "m2r2-0.3.4.tar.gz", hash = "sha256:e278f5f337e9aa7b2080fcc3e94b051bda9615b02e36c6fb3f23ff019872f043"}, +] + +[package.dependencies] +docutils = ">=0.19" +mistune = "0.8.4" + +[[package]] +name = "markdown-it-py" +version = "3.0.0" +description = "Python port of markdown-it. Markdown parsing, done right!" +optional = false +python-versions = ">=3.8" +groups = ["test"] +files = [ + {file = "markdown-it-py-3.0.0.tar.gz", hash = "sha256:e3f60a94fa066dc52ec76661e37c851cb232d92f9886b15cb560aaada2df8feb"}, + {file = "markdown_it_py-3.0.0-py3-none-any.whl", hash = "sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1"}, +] + +[package.dependencies] +mdurl = ">=0.1,<1.0" + +[package.extras] +benchmarking = ["psutil", "pytest", "pytest-benchmark"] +code-style = ["pre-commit (>=3.0,<4.0)"] +compare = ["commonmark (>=0.9,<1.0)", "markdown (>=3.4,<4.0)", "mistletoe (>=1.0,<2.0)", "mistune (>=2.0,<3.0)", "panflute (>=2.3,<3.0)"] +linkify = ["linkify-it-py (>=1,<3)"] +plugins = ["mdit-py-plugins"] +profiling = ["gprof2dot"] +rtd = ["jupyter_sphinx", "mdit-py-plugins", "myst-parser", "pyyaml", "sphinx", "sphinx-copybutton", "sphinx-design", "sphinx_book_theme"] +testing = ["coverage", "pytest", "pytest-cov", "pytest-regressions"] + +[[package]] +name = "markupsafe" +version = "3.0.2" +description = "Safely add untrusted strings to HTML/XML markup." +optional = false +python-versions = ">=3.9" +groups = ["docs", "test"] +files = [ + {file = "MarkupSafe-3.0.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:7e94c425039cde14257288fd61dcfb01963e658efbc0ff54f5306b06054700f8"}, + {file = "MarkupSafe-3.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9e2d922824181480953426608b81967de705c3cef4d1af983af849d7bd619158"}, + {file = "MarkupSafe-3.0.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:38a9ef736c01fccdd6600705b09dc574584b89bea478200c5fbf112a6b0d5579"}, + {file = "MarkupSafe-3.0.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bbcb445fa71794da8f178f0f6d66789a28d7319071af7a496d4d507ed566270d"}, + {file = "MarkupSafe-3.0.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:57cb5a3cf367aeb1d316576250f65edec5bb3be939e9247ae594b4bcbc317dfb"}, + {file = "MarkupSafe-3.0.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:3809ede931876f5b2ec92eef964286840ed3540dadf803dd570c3b7e13141a3b"}, + {file = "MarkupSafe-3.0.2-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e07c3764494e3776c602c1e78e298937c3315ccc9043ead7e685b7f2b8d47b3c"}, + {file = "MarkupSafe-3.0.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:b424c77b206d63d500bcb69fa55ed8d0e6a3774056bdc4839fc9298a7edca171"}, + {file = "MarkupSafe-3.0.2-cp310-cp310-win32.whl", hash = "sha256:fcabf5ff6eea076f859677f5f0b6b5c1a51e70a376b0579e0eadef8db48c6b50"}, + {file = "MarkupSafe-3.0.2-cp310-cp310-win_amd64.whl", hash = "sha256:6af100e168aa82a50e186c82875a5893c5597a0c1ccdb0d8b40240b1f28b969a"}, + {file = "MarkupSafe-3.0.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:9025b4018f3a1314059769c7bf15441064b2207cb3f065e6ea1e7359cb46db9d"}, + {file = "MarkupSafe-3.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:93335ca3812df2f366e80509ae119189886b0f3c2b81325d39efdb84a1e2ae93"}, + {file = "MarkupSafe-3.0.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2cb8438c3cbb25e220c2ab33bb226559e7afb3baec11c4f218ffa7308603c832"}, + {file = "MarkupSafe-3.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a123e330ef0853c6e822384873bef7507557d8e4a082961e1defa947aa59ba84"}, + {file = "MarkupSafe-3.0.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1e084f686b92e5b83186b07e8a17fc09e38fff551f3602b249881fec658d3eca"}, + {file = "MarkupSafe-3.0.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d8213e09c917a951de9d09ecee036d5c7d36cb6cb7dbaece4c71a60d79fb9798"}, + {file = "MarkupSafe-3.0.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:5b02fb34468b6aaa40dfc198d813a641e3a63b98c2b05a16b9f80b7ec314185e"}, + {file = "MarkupSafe-3.0.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:0bff5e0ae4ef2e1ae4fdf2dfd5b76c75e5c2fa4132d05fc1b0dabcd20c7e28c4"}, + {file = "MarkupSafe-3.0.2-cp311-cp311-win32.whl", hash = "sha256:6c89876f41da747c8d3677a2b540fb32ef5715f97b66eeb0c6b66f5e3ef6f59d"}, + {file = "MarkupSafe-3.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:70a87b411535ccad5ef2f1df5136506a10775d267e197e4cf531ced10537bd6b"}, + {file = "MarkupSafe-3.0.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:9778bd8ab0a994ebf6f84c2b949e65736d5575320a17ae8984a77fab08db94cf"}, + {file = "MarkupSafe-3.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:846ade7b71e3536c4e56b386c2a47adf5741d2d8b94ec9dc3e92e5e1ee1e2225"}, + {file = "MarkupSafe-3.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1c99d261bd2d5f6b59325c92c73df481e05e57f19837bdca8413b9eac4bd8028"}, + {file = "MarkupSafe-3.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e17c96c14e19278594aa4841ec148115f9c7615a47382ecb6b82bd8fea3ab0c8"}, + {file = "MarkupSafe-3.0.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:88416bd1e65dcea10bc7569faacb2c20ce071dd1f87539ca2ab364bf6231393c"}, + {file = "MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2181e67807fc2fa785d0592dc2d6206c019b9502410671cc905d132a92866557"}, + {file = "MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:52305740fe773d09cffb16f8ed0427942901f00adedac82ec8b67752f58a1b22"}, + {file = "MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ad10d3ded218f1039f11a75f8091880239651b52e9bb592ca27de44eed242a48"}, + {file = "MarkupSafe-3.0.2-cp312-cp312-win32.whl", hash = "sha256:0f4ca02bea9a23221c0182836703cbf8930c5e9454bacce27e767509fa286a30"}, + {file = "MarkupSafe-3.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:8e06879fc22a25ca47312fbe7c8264eb0b662f6db27cb2d3bbbc74b1df4b9b87"}, + {file = "MarkupSafe-3.0.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ba9527cdd4c926ed0760bc301f6728ef34d841f405abf9d4f959c478421e4efd"}, + {file = "MarkupSafe-3.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f8b3d067f2e40fe93e1ccdd6b2e1d16c43140e76f02fb1319a05cf2b79d99430"}, + {file = "MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:569511d3b58c8791ab4c2e1285575265991e6d8f8700c7be0e88f86cb0672094"}, + {file = "MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:15ab75ef81add55874e7ab7055e9c397312385bd9ced94920f2802310c930396"}, + {file = "MarkupSafe-3.0.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f3818cb119498c0678015754eba762e0d61e5b52d34c8b13d770f0719f7b1d79"}, + {file = "MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:cdb82a876c47801bb54a690c5ae105a46b392ac6099881cdfb9f6e95e4014c6a"}, + {file = "MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:cabc348d87e913db6ab4aa100f01b08f481097838bdddf7c7a84b7575b7309ca"}, + {file = "MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:444dcda765c8a838eaae23112db52f1efaf750daddb2d9ca300bcae1039adc5c"}, + {file = "MarkupSafe-3.0.2-cp313-cp313-win32.whl", hash = "sha256:bcf3e58998965654fdaff38e58584d8937aa3096ab5354d493c77d1fdd66d7a1"}, + {file = "MarkupSafe-3.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:e6a2a455bd412959b57a172ce6328d2dd1f01cb2135efda2e4576e8a23fa3b0f"}, + {file = "MarkupSafe-3.0.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:b5a6b3ada725cea8a5e634536b1b01c30bcdcd7f9c6fff4151548d5bf6b3a36c"}, + {file = "MarkupSafe-3.0.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:a904af0a6162c73e3edcb969eeeb53a63ceeb5d8cf642fade7d39e7963a22ddb"}, + {file = "MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4aa4e5faecf353ed117801a068ebab7b7e09ffb6e1d5e412dc852e0da018126c"}, + {file = "MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c0ef13eaeee5b615fb07c9a7dadb38eac06a0608b41570d8ade51c56539e509d"}, + {file = "MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d16a81a06776313e817c951135cf7340a3e91e8c1ff2fac444cfd75fffa04afe"}, + {file = "MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:6381026f158fdb7c72a168278597a5e3a5222e83ea18f543112b2662a9b699c5"}, + {file = "MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:3d79d162e7be8f996986c064d1c7c817f6df3a77fe3d6859f6f9e7be4b8c213a"}, + {file = "MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:131a3c7689c85f5ad20f9f6fb1b866f402c445b220c19fe4308c0b147ccd2ad9"}, + {file = "MarkupSafe-3.0.2-cp313-cp313t-win32.whl", hash = "sha256:ba8062ed2cf21c07a9e295d5b8a2a5ce678b913b45fdf68c32d95d6c1291e0b6"}, + {file = "MarkupSafe-3.0.2-cp313-cp313t-win_amd64.whl", hash = "sha256:e444a31f8db13eb18ada366ab3cf45fd4b31e4db1236a4448f68778c1d1a5a2f"}, + {file = "MarkupSafe-3.0.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:eaa0a10b7f72326f1372a713e73c3f739b524b3af41feb43e4921cb529f5929a"}, + {file = "MarkupSafe-3.0.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:48032821bbdf20f5799ff537c7ac3d1fba0ba032cfc06194faffa8cda8b560ff"}, + {file = "MarkupSafe-3.0.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1a9d3f5f0901fdec14d8d2f66ef7d035f2157240a433441719ac9a3fba440b13"}, + {file = "MarkupSafe-3.0.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:88b49a3b9ff31e19998750c38e030fc7bb937398b1f78cfa599aaef92d693144"}, + {file = "MarkupSafe-3.0.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:cfad01eed2c2e0c01fd0ecd2ef42c492f7f93902e39a42fc9ee1692961443a29"}, + {file = "MarkupSafe-3.0.2-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:1225beacc926f536dc82e45f8a4d68502949dc67eea90eab715dea3a21c1b5f0"}, + {file = "MarkupSafe-3.0.2-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:3169b1eefae027567d1ce6ee7cae382c57fe26e82775f460f0b2778beaad66c0"}, + {file = "MarkupSafe-3.0.2-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:eb7972a85c54febfb25b5c4b4f3af4dcc731994c7da0d8a0b4a6eb0640e1d178"}, + {file = "MarkupSafe-3.0.2-cp39-cp39-win32.whl", hash = "sha256:8c4e8c3ce11e1f92f6536ff07154f9d49677ebaaafc32db9db4620bc11ed480f"}, + {file = "MarkupSafe-3.0.2-cp39-cp39-win_amd64.whl", hash = "sha256:6e296a513ca3d94054c2c881cc913116e90fd030ad1c656b3869762b754f5f8a"}, + {file = "markupsafe-3.0.2.tar.gz", hash = "sha256:ee55d3edf80167e48ea11a923c7386f4669df67d7994554387f84e7d8b0a2bf0"}, +] + +[[package]] +name = "marshmallow" +version = "4.0.0" +description = "A lightweight library for converting complex datatypes to and from native Python datatypes." +optional = false +python-versions = ">=3.9" +groups = ["test"] +files = [ + {file = "marshmallow-4.0.0-py3-none-any.whl", hash = "sha256:e7b0528337e9990fd64950f8a6b3a1baabed09ad17a0dfb844d701151f92d203"}, + {file = "marshmallow-4.0.0.tar.gz", hash = "sha256:3b6e80aac299a7935cfb97ed01d1854fb90b5079430969af92118ea1b12a8d55"}, +] + +[package.dependencies] +backports-datetime-fromisoformat = {version = "*", markers = "python_version < \"3.11\""} +typing-extensions = {version = "*", markers = "python_version < \"3.11\""} + +[package.extras] +dev = ["marshmallow[tests]", "pre-commit (>=3.5,<5.0)", "tox"] +docs = ["autodocsumm (==0.2.14)", "furo (==2024.8.6)", "sphinx (==8.2.3)", "sphinx-copybutton (==0.5.2)", "sphinx-issues (==5.0.1)", "sphinxext-opengraph (==0.10.0)"] +tests = ["pytest", "simplejson"] + +[[package]] +name = "mdurl" +version = "0.1.2" +description = "Markdown URL utilities" +optional = false +python-versions = ">=3.7" +groups = ["test"] +files = [ + {file = "mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8"}, + {file = "mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba"}, +] + +[[package]] +name = "mistune" +version = "0.8.4" +description = "The fastest markdown parser in pure Python" +optional = false +python-versions = "*" +groups = ["docs"] +files = [ + {file = "mistune-0.8.4-py2.py3-none-any.whl", hash = "sha256:88a1051873018da288eee8538d476dffe1262495144b33ecb586c4ab266bb8d4"}, + {file = "mistune-0.8.4.tar.gz", hash = "sha256:59a3429db53c50b5c6bcc8a07f8848cb00d7dc8bdb431a4ab41920d201d4756e"}, +] + +[[package]] +name = "mypy" +version = "1.16.1" +description = "Optional static typing for Python" +optional = false +python-versions = ">=3.9" +groups = ["test"] +files = [ + {file = "mypy-1.16.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b4f0fed1022a63c6fec38f28b7fc77fca47fd490445c69d0a66266c59dd0b88a"}, + {file = "mypy-1.16.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:86042bbf9f5a05ea000d3203cf87aa9d0ccf9a01f73f71c58979eb9249f46d72"}, + {file = "mypy-1.16.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ea7469ee5902c95542bea7ee545f7006508c65c8c54b06dc2c92676ce526f3ea"}, + {file = "mypy-1.16.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:352025753ef6a83cb9e7f2427319bb7875d1fdda8439d1e23de12ab164179574"}, + {file = "mypy-1.16.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:ff9fa5b16e4c1364eb89a4d16bcda9987f05d39604e1e6c35378a2987c1aac2d"}, + {file = "mypy-1.16.1-cp310-cp310-win_amd64.whl", hash = "sha256:1256688e284632382f8f3b9e2123df7d279f603c561f099758e66dd6ed4e8bd6"}, + {file = "mypy-1.16.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:472e4e4c100062488ec643f6162dd0d5208e33e2f34544e1fc931372e806c0cc"}, + {file = "mypy-1.16.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:ea16e2a7d2714277e349e24d19a782a663a34ed60864006e8585db08f8ad1782"}, + {file = "mypy-1.16.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:08e850ea22adc4d8a4014651575567b0318ede51e8e9fe7a68f25391af699507"}, + {file = "mypy-1.16.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:22d76a63a42619bfb90122889b903519149879ddbf2ba4251834727944c8baca"}, + {file = "mypy-1.16.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:2c7ce0662b6b9dc8f4ed86eb7a5d505ee3298c04b40ec13b30e572c0e5ae17c4"}, + {file = "mypy-1.16.1-cp311-cp311-win_amd64.whl", hash = "sha256:211287e98e05352a2e1d4e8759c5490925a7c784ddc84207f4714822f8cf99b6"}, + {file = "mypy-1.16.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:af4792433f09575d9eeca5c63d7d90ca4aeceda9d8355e136f80f8967639183d"}, + {file = "mypy-1.16.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:66df38405fd8466ce3517eda1f6640611a0b8e70895e2a9462d1d4323c5eb4b9"}, + {file = "mypy-1.16.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:44e7acddb3c48bd2713994d098729494117803616e116032af192871aed80b79"}, + {file = "mypy-1.16.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0ab5eca37b50188163fa7c1b73c685ac66c4e9bdee4a85c9adac0e91d8895e15"}, + {file = "mypy-1.16.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:dedb6229b2c9086247e21a83c309754b9058b438704ad2f6807f0d8227f6ebdd"}, + {file = "mypy-1.16.1-cp312-cp312-win_amd64.whl", hash = "sha256:1f0435cf920e287ff68af3d10a118a73f212deb2ce087619eb4e648116d1fe9b"}, + {file = "mypy-1.16.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ddc91eb318c8751c69ddb200a5937f1232ee8efb4e64e9f4bc475a33719de438"}, + {file = "mypy-1.16.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:87ff2c13d58bdc4bbe7dc0dedfe622c0f04e2cb2a492269f3b418df2de05c536"}, + {file = "mypy-1.16.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0a7cfb0fe29fe5a9841b7c8ee6dffb52382c45acdf68f032145b75620acfbd6f"}, + {file = "mypy-1.16.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:051e1677689c9d9578b9c7f4d206d763f9bbd95723cd1416fad50db49d52f359"}, + {file = "mypy-1.16.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:d5d2309511cc56c021b4b4e462907c2b12f669b2dbeb68300110ec27723971be"}, + {file = "mypy-1.16.1-cp313-cp313-win_amd64.whl", hash = "sha256:4f58ac32771341e38a853c5d0ec0dfe27e18e27da9cdb8bbc882d2249c71a3ee"}, + {file = "mypy-1.16.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:7fc688329af6a287567f45cc1cefb9db662defeb14625213a5b7da6e692e2069"}, + {file = "mypy-1.16.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:5e198ab3f55924c03ead626ff424cad1732d0d391478dfbf7bb97b34602395da"}, + {file = "mypy-1.16.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:09aa4f91ada245f0a45dbc47e548fd94e0dd5a8433e0114917dc3b526912a30c"}, + {file = "mypy-1.16.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:13c7cd5b1cb2909aa318a90fd1b7e31f17c50b242953e7dd58345b2a814f6383"}, + {file = "mypy-1.16.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:58e07fb958bc5d752a280da0e890c538f1515b79a65757bbdc54252ba82e0b40"}, + {file = "mypy-1.16.1-cp39-cp39-win_amd64.whl", hash = "sha256:f895078594d918f93337a505f8add9bd654d1a24962b4c6ed9390e12531eb31b"}, + {file = "mypy-1.16.1-py3-none-any.whl", hash = "sha256:5fc2ac4027d0ef28d6ba69a0343737a23c4d1b83672bf38d1fe237bdc0643b37"}, + {file = "mypy-1.16.1.tar.gz", hash = "sha256:6bd00a0a2094841c5e47e7374bb42b83d64c527a502e3334e1173a0c24437bab"}, +] + +[package.dependencies] +mypy_extensions = ">=1.0.0" +pathspec = ">=0.9.0" +tomli = {version = ">=1.1.0", markers = "python_version < \"3.11\""} +typing_extensions = ">=4.6.0" + +[package.extras] +dmypy = ["psutil (>=4.0)"] +faster-cache = ["orjson"] +install-types = ["pip"] +mypyc = ["setuptools (>=50)"] +reports = ["lxml"] + +[[package]] +name = "mypy-extensions" +version = "1.1.0" +description = "Type system extensions for programs checked with the mypy type checker." +optional = false +python-versions = ">=3.8" +groups = ["test"] +files = [ + {file = "mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505"}, + {file = "mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558"}, +] + +[[package]] +name = "nltk" +version = "3.9.1" +description = "Natural Language Toolkit" +optional = false +python-versions = ">=3.8" +groups = ["test"] +files = [ + {file = "nltk-3.9.1-py3-none-any.whl", hash = "sha256:4fa26829c5b00715afe3061398a8989dc643b92ce7dd93fb4585a70930d168a1"}, + {file = "nltk-3.9.1.tar.gz", hash = "sha256:87d127bd3de4bd89a4f81265e5fa59cb1b199b27440175370f7417d2bc7ae868"}, +] + +[package.dependencies] +click = "*" +joblib = "*" +regex = ">=2021.8.3" +tqdm = "*" + +[package.extras] +all = ["matplotlib", "numpy", "pyparsing", "python-crfsuite", "requests", "scikit-learn", "scipy", "twython"] +corenlp = ["requests"] +machine-learning = ["numpy", "python-crfsuite", "scikit-learn", "scipy"] +plot = ["matplotlib"] +tgrep = ["pyparsing"] +twitter = ["twython"] + +[[package]] +name = "packaging" +version = "25.0" +description = "Core utilities for Python packages" +optional = false +python-versions = ">=3.8" +groups = ["docs", "test"] +files = [ + {file = "packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484"}, + {file = "packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f"}, +] + +[[package]] +name = "pathspec" +version = "0.12.1" +description = "Utility library for gitignore style pattern matching of file paths." +optional = false +python-versions = ">=3.8" +groups = ["test"] +files = [ + {file = "pathspec-0.12.1-py3-none-any.whl", hash = "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08"}, + {file = "pathspec-0.12.1.tar.gz", hash = "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712"}, +] + +[[package]] +name = "pbr" +version = "6.1.1" +description = "Python Build Reasonableness" +optional = false +python-versions = ">=2.6" +groups = ["test"] +files = [ + {file = "pbr-6.1.1-py2.py3-none-any.whl", hash = "sha256:38d4daea5d9fa63b3f626131b9d34947fd0c8be9b05a29276870580050a25a76"}, + {file = "pbr-6.1.1.tar.gz", hash = "sha256:93ea72ce6989eb2eed99d0f75721474f69ad88128afdef5ac377eb797c4bf76b"}, +] + +[package.dependencies] +setuptools = "*" + +[[package]] +name = "pluggy" +version = "1.6.0" +description = "plugin and hook calling mechanisms for python" +optional = false +python-versions = ">=3.9" +groups = ["test"] +files = [ + {file = "pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746"}, + {file = "pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3"}, +] + +[package.extras] +dev = ["pre-commit", "tox"] +testing = ["coverage", "pytest", "pytest-benchmark"] + +[[package]] +name = "psutil" +version = "6.1.1" +description = "Cross-platform lib for process and system monitoring in Python." +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,>=2.7" +groups = ["test"] +files = [ + {file = "psutil-6.1.1-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:9ccc4316f24409159897799b83004cb1e24f9819b0dcf9c0b68bdcb6cefee6a8"}, + {file = "psutil-6.1.1-cp27-cp27m-manylinux2010_i686.whl", hash = "sha256:ca9609c77ea3b8481ab005da74ed894035936223422dc591d6772b147421f777"}, + {file = "psutil-6.1.1-cp27-cp27m-manylinux2010_x86_64.whl", hash = "sha256:8df0178ba8a9e5bc84fed9cfa61d54601b371fbec5c8eebad27575f1e105c0d4"}, + {file = "psutil-6.1.1-cp27-cp27mu-manylinux2010_i686.whl", hash = "sha256:1924e659d6c19c647e763e78670a05dbb7feaf44a0e9c94bf9e14dfc6ba50468"}, + {file = "psutil-6.1.1-cp27-cp27mu-manylinux2010_x86_64.whl", hash = "sha256:018aeae2af92d943fdf1da6b58665124897cfc94faa2ca92098838f83e1b1bca"}, + {file = "psutil-6.1.1-cp27-none-win32.whl", hash = "sha256:6d4281f5bbca041e2292be3380ec56a9413b790579b8e593b1784499d0005dac"}, + {file = "psutil-6.1.1-cp27-none-win_amd64.whl", hash = "sha256:c777eb75bb33c47377c9af68f30e9f11bc78e0f07fbf907be4a5d70b2fe5f030"}, + {file = "psutil-6.1.1-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:fc0ed7fe2231a444fc219b9c42d0376e0a9a1a72f16c5cfa0f68d19f1a0663e8"}, + {file = "psutil-6.1.1-cp36-abi3-macosx_11_0_arm64.whl", hash = "sha256:0bdd4eab935276290ad3cb718e9809412895ca6b5b334f5a9111ee6d9aff9377"}, + {file = "psutil-6.1.1-cp36-abi3-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b6e06c20c05fe95a3d7302d74e7097756d4ba1247975ad6905441ae1b5b66003"}, + {file = "psutil-6.1.1-cp36-abi3-manylinux_2_12_x86_64.manylinux2010_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:97f7cb9921fbec4904f522d972f0c0e1f4fabbdd4e0287813b21215074a0f160"}, + {file = "psutil-6.1.1-cp36-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:33431e84fee02bc84ea36d9e2c4a6d395d479c9dd9bba2376c1f6ee8f3a4e0b3"}, + {file = "psutil-6.1.1-cp36-cp36m-win32.whl", hash = "sha256:384636b1a64b47814437d1173be1427a7c83681b17a450bfc309a1953e329603"}, + {file = "psutil-6.1.1-cp36-cp36m-win_amd64.whl", hash = "sha256:8be07491f6ebe1a693f17d4f11e69d0dc1811fa082736500f649f79df7735303"}, + {file = "psutil-6.1.1-cp37-abi3-win32.whl", hash = "sha256:eaa912e0b11848c4d9279a93d7e2783df352b082f40111e078388701fd479e53"}, + {file = "psutil-6.1.1-cp37-abi3-win_amd64.whl", hash = "sha256:f35cfccb065fff93529d2afb4a2e89e363fe63ca1e4a5da22b603a85833c2649"}, + {file = "psutil-6.1.1.tar.gz", hash = "sha256:cf8496728c18f2d0b45198f06895be52f36611711746b7f30c464b422b50e2f5"}, +] + +[package.extras] +dev = ["abi3audit", "black", "check-manifest", "coverage", "packaging", "pylint", "pyperf", "pypinfo", "pytest-cov", "requests", "rstcheck", "ruff", "sphinx", "sphinx_rtd_theme", "toml-sort", "twine", "virtualenv", "vulture", "wheel"] +test = ["pytest", "pytest-xdist", "setuptools"] + +[[package]] +name = "pycparser" +version = "2.22" +description = "C parser in Python" +optional = false +python-versions = ">=3.8" +groups = ["test"] +markers = "platform_python_implementation != \"PyPy\"" +files = [ + {file = "pycparser-2.22-py3-none-any.whl", hash = "sha256:c3702b6d3dd8c7abc1afa565d7e63d53a1d0bd86cdc24edd75470f4de499cfcc"}, + {file = "pycparser-2.22.tar.gz", hash = "sha256:491c8be9c040f5390f5bf44a5b07752bd07f56edf992381b05c701439eec10f6"}, +] + +[[package]] +name = "pydantic" +version = "2.9.2" +description = "Data validation using Python type hints" +optional = false +python-versions = ">=3.8" +groups = ["test"] +files = [ + {file = "pydantic-2.9.2-py3-none-any.whl", hash = "sha256:f048cec7b26778210e28a0459867920654d48e5e62db0958433636cde4254f12"}, + {file = "pydantic-2.9.2.tar.gz", hash = "sha256:d155cef71265d1e9807ed1c32b4c8deec042a44a50a4188b25ac67ecd81a9c0f"}, +] + +[package.dependencies] +annotated-types = ">=0.6.0" +pydantic-core = "2.23.4" +typing-extensions = [ + {version = ">=4.12.2", markers = "python_version >= \"3.13\""}, + {version = ">=4.6.1", markers = "python_version < \"3.13\""}, +] + +[package.extras] +email = ["email-validator (>=2.0.0)"] +timezone = ["tzdata ; python_version >= \"3.9\" and sys_platform == \"win32\""] + +[[package]] +name = "pydantic-core" +version = "2.23.4" +description = "Core functionality for Pydantic validation and serialization" +optional = false +python-versions = ">=3.8" +groups = ["test"] +files = [ + {file = "pydantic_core-2.23.4-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:b10bd51f823d891193d4717448fab065733958bdb6a6b351967bd349d48d5c9b"}, + {file = "pydantic_core-2.23.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:4fc714bdbfb534f94034efaa6eadd74e5b93c8fa6315565a222f7b6f42ca1166"}, + {file = "pydantic_core-2.23.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:63e46b3169866bd62849936de036f901a9356e36376079b05efa83caeaa02ceb"}, + {file = "pydantic_core-2.23.4-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ed1a53de42fbe34853ba90513cea21673481cd81ed1be739f7f2efb931b24916"}, + {file = "pydantic_core-2.23.4-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:cfdd16ab5e59fc31b5e906d1a3f666571abc367598e3e02c83403acabc092e07"}, + {file = "pydantic_core-2.23.4-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:255a8ef062cbf6674450e668482456abac99a5583bbafb73f9ad469540a3a232"}, + {file = "pydantic_core-2.23.4-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4a7cd62e831afe623fbb7aabbb4fe583212115b3ef38a9f6b71869ba644624a2"}, + {file = "pydantic_core-2.23.4-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f09e2ff1f17c2b51f2bc76d1cc33da96298f0a036a137f5440ab3ec5360b624f"}, + {file = "pydantic_core-2.23.4-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:e38e63e6f3d1cec5a27e0afe90a085af8b6806ee208b33030e65b6516353f1a3"}, + {file = "pydantic_core-2.23.4-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:0dbd8dbed2085ed23b5c04afa29d8fd2771674223135dc9bc937f3c09284d071"}, + {file = "pydantic_core-2.23.4-cp310-none-win32.whl", hash = "sha256:6531b7ca5f951d663c339002e91aaebda765ec7d61b7d1e3991051906ddde119"}, + {file = "pydantic_core-2.23.4-cp310-none-win_amd64.whl", hash = "sha256:7c9129eb40958b3d4500fa2467e6a83356b3b61bfff1b414c7361d9220f9ae8f"}, + {file = "pydantic_core-2.23.4-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:77733e3892bb0a7fa797826361ce8a9184d25c8dffaec60b7ffe928153680ba8"}, + {file = "pydantic_core-2.23.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1b84d168f6c48fabd1f2027a3d1bdfe62f92cade1fb273a5d68e621da0e44e6d"}, + {file = "pydantic_core-2.23.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:df49e7a0861a8c36d089c1ed57d308623d60416dab2647a4a17fe050ba85de0e"}, + {file = "pydantic_core-2.23.4-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ff02b6d461a6de369f07ec15e465a88895f3223eb75073ffea56b84d9331f607"}, + {file = "pydantic_core-2.23.4-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:996a38a83508c54c78a5f41456b0103c30508fed9abcad0a59b876d7398f25fd"}, + {file = "pydantic_core-2.23.4-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d97683ddee4723ae8c95d1eddac7c192e8c552da0c73a925a89fa8649bf13eea"}, + {file = "pydantic_core-2.23.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:216f9b2d7713eb98cb83c80b9c794de1f6b7e3145eef40400c62e86cee5f4e1e"}, + {file = "pydantic_core-2.23.4-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6f783e0ec4803c787bcea93e13e9932edab72068f68ecffdf86a99fd5918878b"}, + {file = "pydantic_core-2.23.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:d0776dea117cf5272382634bd2a5c1b6eb16767c223c6a5317cd3e2a757c61a0"}, + {file = "pydantic_core-2.23.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:d5f7a395a8cf1621939692dba2a6b6a830efa6b3cee787d82c7de1ad2930de64"}, + {file = "pydantic_core-2.23.4-cp311-none-win32.whl", hash = "sha256:74b9127ffea03643e998e0c5ad9bd3811d3dac8c676e47db17b0ee7c3c3bf35f"}, + {file = "pydantic_core-2.23.4-cp311-none-win_amd64.whl", hash = "sha256:98d134c954828488b153d88ba1f34e14259284f256180ce659e8d83e9c05eaa3"}, + {file = "pydantic_core-2.23.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:f3e0da4ebaef65158d4dfd7d3678aad692f7666877df0002b8a522cdf088f231"}, + {file = "pydantic_core-2.23.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f69a8e0b033b747bb3e36a44e7732f0c99f7edd5cea723d45bc0d6e95377ffee"}, + {file = "pydantic_core-2.23.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:723314c1d51722ab28bfcd5240d858512ffd3116449c557a1336cbe3919beb87"}, + {file = "pydantic_core-2.23.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bb2802e667b7051a1bebbfe93684841cc9351004e2badbd6411bf357ab8d5ac8"}, + {file = "pydantic_core-2.23.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d18ca8148bebe1b0a382a27a8ee60350091a6ddaf475fa05ef50dc35b5df6327"}, + {file = "pydantic_core-2.23.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:33e3d65a85a2a4a0dc3b092b938a4062b1a05f3a9abde65ea93b233bca0e03f2"}, + {file = "pydantic_core-2.23.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:128585782e5bfa515c590ccee4b727fb76925dd04a98864182b22e89a4e6ed36"}, + {file = "pydantic_core-2.23.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:68665f4c17edcceecc112dfed5dbe6f92261fb9d6054b47d01bf6371a6196126"}, + {file = "pydantic_core-2.23.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:20152074317d9bed6b7a95ade3b7d6054845d70584216160860425f4fbd5ee9e"}, + {file = "pydantic_core-2.23.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:9261d3ce84fa1d38ed649c3638feefeae23d32ba9182963e465d58d62203bd24"}, + {file = "pydantic_core-2.23.4-cp312-none-win32.whl", hash = "sha256:4ba762ed58e8d68657fc1281e9bb72e1c3e79cc5d464be146e260c541ec12d84"}, + {file = "pydantic_core-2.23.4-cp312-none-win_amd64.whl", hash = "sha256:97df63000f4fea395b2824da80e169731088656d1818a11b95f3b173747b6cd9"}, + {file = "pydantic_core-2.23.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:7530e201d10d7d14abce4fb54cfe5b94a0aefc87da539d0346a484ead376c3cc"}, + {file = "pydantic_core-2.23.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:df933278128ea1cd77772673c73954e53a1c95a4fdf41eef97c2b779271bd0bd"}, + {file = "pydantic_core-2.23.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cb3da3fd1b6a5d0279a01877713dbda118a2a4fc6f0d821a57da2e464793f05"}, + {file = "pydantic_core-2.23.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:42c6dcb030aefb668a2b7009c85b27f90e51e6a3b4d5c9bc4c57631292015b0d"}, + {file = "pydantic_core-2.23.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:696dd8d674d6ce621ab9d45b205df149399e4bb9aa34102c970b721554828510"}, + {file = "pydantic_core-2.23.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2971bb5ffe72cc0f555c13e19b23c85b654dd2a8f7ab493c262071377bfce9f6"}, + {file = "pydantic_core-2.23.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8394d940e5d400d04cad4f75c0598665cbb81aecefaca82ca85bd28264af7f9b"}, + {file = "pydantic_core-2.23.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:0dff76e0602ca7d4cdaacc1ac4c005e0ce0dcfe095d5b5259163a80d3a10d327"}, + {file = "pydantic_core-2.23.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:7d32706badfe136888bdea71c0def994644e09fff0bfe47441deaed8e96fdbc6"}, + {file = "pydantic_core-2.23.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:ed541d70698978a20eb63d8c5d72f2cc6d7079d9d90f6b50bad07826f1320f5f"}, + {file = "pydantic_core-2.23.4-cp313-none-win32.whl", hash = "sha256:3d5639516376dce1940ea36edf408c554475369f5da2abd45d44621cb616f769"}, + {file = "pydantic_core-2.23.4-cp313-none-win_amd64.whl", hash = "sha256:5a1504ad17ba4210df3a045132a7baeeba5a200e930f57512ee02909fc5c4cb5"}, + {file = "pydantic_core-2.23.4-cp38-cp38-macosx_10_12_x86_64.whl", hash = "sha256:d4488a93b071c04dc20f5cecc3631fc78b9789dd72483ba15d423b5b3689b555"}, + {file = "pydantic_core-2.23.4-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:81965a16b675b35e1d09dd14df53f190f9129c0202356ed44ab2728b1c905658"}, + {file = "pydantic_core-2.23.4-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4ffa2ebd4c8530079140dd2d7f794a9d9a73cbb8e9d59ffe24c63436efa8f271"}, + {file = "pydantic_core-2.23.4-cp38-cp38-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:61817945f2fe7d166e75fbfb28004034b48e44878177fc54d81688e7b85a3665"}, + {file = "pydantic_core-2.23.4-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:29d2c342c4bc01b88402d60189f3df065fb0dda3654744d5a165a5288a657368"}, + {file = "pydantic_core-2.23.4-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5e11661ce0fd30a6790e8bcdf263b9ec5988e95e63cf901972107efc49218b13"}, + {file = "pydantic_core-2.23.4-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9d18368b137c6295db49ce7218b1a9ba15c5bc254c96d7c9f9e924a9bc7825ad"}, + {file = "pydantic_core-2.23.4-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ec4e55f79b1c4ffb2eecd8a0cfba9955a2588497d96851f4c8f99aa4a1d39b12"}, + {file = "pydantic_core-2.23.4-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:374a5e5049eda9e0a44c696c7ade3ff355f06b1fe0bb945ea3cac2bc336478a2"}, + {file = "pydantic_core-2.23.4-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:5c364564d17da23db1106787675fc7af45f2f7b58b4173bfdd105564e132e6fb"}, + {file = "pydantic_core-2.23.4-cp38-none-win32.whl", hash = "sha256:d7a80d21d613eec45e3d41eb22f8f94ddc758a6c4720842dc74c0581f54993d6"}, + {file = "pydantic_core-2.23.4-cp38-none-win_amd64.whl", hash = "sha256:5f5ff8d839f4566a474a969508fe1c5e59c31c80d9e140566f9a37bba7b8d556"}, + {file = "pydantic_core-2.23.4-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:a4fa4fc04dff799089689f4fd502ce7d59de529fc2f40a2c8836886c03e0175a"}, + {file = "pydantic_core-2.23.4-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:0a7df63886be5e270da67e0966cf4afbae86069501d35c8c1b3b6c168f42cb36"}, + {file = "pydantic_core-2.23.4-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dcedcd19a557e182628afa1d553c3895a9f825b936415d0dbd3cd0bbcfd29b4b"}, + {file = "pydantic_core-2.23.4-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5f54b118ce5de9ac21c363d9b3caa6c800341e8c47a508787e5868c6b79c9323"}, + {file = "pydantic_core-2.23.4-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:86d2f57d3e1379a9525c5ab067b27dbb8a0642fb5d454e17a9ac434f9ce523e3"}, + {file = "pydantic_core-2.23.4-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:de6d1d1b9e5101508cb37ab0d972357cac5235f5c6533d1071964c47139257df"}, + {file = "pydantic_core-2.23.4-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1278e0d324f6908e872730c9102b0112477a7f7cf88b308e4fc36ce1bdb6d58c"}, + {file = "pydantic_core-2.23.4-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9a6b5099eeec78827553827f4c6b8615978bb4b6a88e5d9b93eddf8bb6790f55"}, + {file = "pydantic_core-2.23.4-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:e55541f756f9b3ee346b840103f32779c695a19826a4c442b7954550a0972040"}, + {file = "pydantic_core-2.23.4-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:a5c7ba8ffb6d6f8f2ab08743be203654bb1aaa8c9dcb09f82ddd34eadb695605"}, + {file = "pydantic_core-2.23.4-cp39-none-win32.whl", hash = "sha256:37b0fe330e4a58d3c58b24d91d1eb102aeec675a3db4c292ec3928ecd892a9a6"}, + {file = "pydantic_core-2.23.4-cp39-none-win_amd64.whl", hash = "sha256:1498bec4c05c9c787bde9125cfdcc63a41004ff167f495063191b863399b1a29"}, + {file = "pydantic_core-2.23.4-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:f455ee30a9d61d3e1a15abd5068827773d6e4dc513e795f380cdd59932c782d5"}, + {file = "pydantic_core-2.23.4-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:1e90d2e3bd2c3863d48525d297cd143fe541be8bbf6f579504b9712cb6b643ec"}, + {file = "pydantic_core-2.23.4-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2e203fdf807ac7e12ab59ca2bfcabb38c7cf0b33c41efeb00f8e5da1d86af480"}, + {file = "pydantic_core-2.23.4-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e08277a400de01bc72436a0ccd02bdf596631411f592ad985dcee21445bd0068"}, + {file = "pydantic_core-2.23.4-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f220b0eea5965dec25480b6333c788fb72ce5f9129e8759ef876a1d805d00801"}, + {file = "pydantic_core-2.23.4-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:d06b0c8da4f16d1d1e352134427cb194a0a6e19ad5db9161bf32b2113409e728"}, + {file = "pydantic_core-2.23.4-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:ba1a0996f6c2773bd83e63f18914c1de3c9dd26d55f4ac302a7efe93fb8e7433"}, + {file = "pydantic_core-2.23.4-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:9a5bce9d23aac8f0cf0836ecfc033896aa8443b501c58d0602dbfd5bd5b37753"}, + {file = "pydantic_core-2.23.4-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:78ddaaa81421a29574a682b3179d4cf9e6d405a09b99d93ddcf7e5239c742e21"}, + {file = "pydantic_core-2.23.4-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:883a91b5dd7d26492ff2f04f40fbb652de40fcc0afe07e8129e8ae779c2110eb"}, + {file = "pydantic_core-2.23.4-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:88ad334a15b32a791ea935af224b9de1bf99bcd62fabf745d5f3442199d86d59"}, + {file = "pydantic_core-2.23.4-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:233710f069d251feb12a56da21e14cca67994eab08362207785cf8c598e74577"}, + {file = "pydantic_core-2.23.4-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:19442362866a753485ba5e4be408964644dd6a09123d9416c54cd49171f50744"}, + {file = "pydantic_core-2.23.4-pp39-pypy39_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:624e278a7d29b6445e4e813af92af37820fafb6dcc55c012c834f9e26f9aaaef"}, + {file = "pydantic_core-2.23.4-pp39-pypy39_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:f5ef8f42bec47f21d07668a043f077d507e5bf4e668d5c6dfe6aaba89de1a5b8"}, + {file = "pydantic_core-2.23.4-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:aea443fffa9fbe3af1a9ba721a87f926fe548d32cab71d188a6ede77d0ff244e"}, + {file = "pydantic_core-2.23.4.tar.gz", hash = "sha256:2584f7cf844ac4d970fba483a717dbe10c1c1c96a969bf65d61ffe94df1b2863"}, +] + +[package.dependencies] +typing-extensions = ">=4.6.0,<4.7.0 || >4.7.0" + +[[package]] +name = "pygments" +version = "2.19.1" +description = "Pygments is a syntax highlighting package written in Python." +optional = false +python-versions = ">=3.8" +groups = ["docs", "test"] +files = [ + {file = "pygments-2.19.1-py3-none-any.whl", hash = "sha256:9ea1544ad55cecf4b8242fab6dd35a93bbce657034b0611ee383099054ab6d8c"}, + {file = "pygments-2.19.1.tar.gz", hash = "sha256:61c16d2a8576dc0649d9f39e089b5f02bcd27fba10d8fb4dcc28173f7a45151f"}, +] + +[package.extras] +windows-terminal = ["colorama (>=0.4.6)"] + +[[package]] +name = "pytest" +version = "8.4.1" +description = "pytest: simple powerful testing with Python" +optional = false +python-versions = ">=3.9" +groups = ["test"] +files = [ + {file = "pytest-8.4.1-py3-none-any.whl", hash = "sha256:539c70ba6fcead8e78eebbf1115e8b589e7565830d7d006a8723f19ac8a0afb7"}, + {file = "pytest-8.4.1.tar.gz", hash = "sha256:7c67fd69174877359ed9371ec3af8a3d2b04741818c51e5e99cc1742251fa93c"}, +] + +[package.dependencies] +colorama = {version = ">=0.4", markers = "sys_platform == \"win32\""} +exceptiongroup = {version = ">=1", markers = "python_version < \"3.11\""} +iniconfig = ">=1" +packaging = ">=20" +pluggy = ">=1.5,<2" +pygments = ">=2.7.2" +tomli = {version = ">=1", markers = "python_version < \"3.11\""} + +[package.extras] +dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "requests", "setuptools", "xmlschema"] + +[[package]] +name = "pytest-cov" +version = "6.2.1" +description = "Pytest plugin for measuring coverage." +optional = false +python-versions = ">=3.9" +groups = ["test"] +files = [ + {file = "pytest_cov-6.2.1-py3-none-any.whl", hash = "sha256:f5bc4c23f42f1cdd23c70b1dab1bbaef4fc505ba950d53e0081d0730dd7e86d5"}, + {file = "pytest_cov-6.2.1.tar.gz", hash = "sha256:25cc6cc0a5358204b8108ecedc51a9b57b34cc6b8c967cc2c01a4e00d8a67da2"}, +] + +[package.dependencies] +coverage = {version = ">=7.5", extras = ["toml"]} +pluggy = ">=1.2" +pytest = ">=6.2.5" + +[package.extras] +testing = ["fields", "hunter", "process-tests", "pytest-xdist", "virtualenv"] + +[[package]] +name = "pytest-django" +version = "4.11.1" +description = "A Django plugin for pytest." +optional = false +python-versions = ">=3.8" +groups = ["test"] +files = [ + {file = "pytest_django-4.11.1-py3-none-any.whl", hash = "sha256:1b63773f648aa3d8541000c26929c1ea63934be1cfa674c76436966d73fe6a10"}, + {file = "pytest_django-4.11.1.tar.gz", hash = "sha256:a949141a1ee103cb0e7a20f1451d355f83f5e4a5d07bdd4dcfdd1fd0ff227991"}, +] + +[package.dependencies] +pytest = ">=7.0.0" + +[package.extras] +docs = ["sphinx", "sphinx_rtd_theme"] +testing = ["Django", "django-configurations (>=2.0)"] + +[[package]] +name = "pytest-randomly" +version = "3.16.0" +description = "Pytest plugin to randomly order tests and control random.seed." +optional = false +python-versions = ">=3.9" +groups = ["test"] +files = [ + {file = "pytest_randomly-3.16.0-py3-none-any.whl", hash = "sha256:8633d332635a1a0983d3bba19342196807f6afb17c3eef78e02c2f85dade45d6"}, + {file = "pytest_randomly-3.16.0.tar.gz", hash = "sha256:11bf4d23a26484de7860d82f726c0629837cf4064b79157bd18ec9d41d7feb26"}, +] + +[package.dependencies] +importlib-metadata = {version = ">=3.6", markers = "python_version < \"3.10\""} +pytest = "*" + +[[package]] +name = "regex" +version = "2024.11.6" +description = "Alternative regular expression module, to replace re." +optional = false +python-versions = ">=3.8" +groups = ["test"] +files = [ + {file = "regex-2024.11.6-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:ff590880083d60acc0433f9c3f713c51f7ac6ebb9adf889c79a261ecf541aa91"}, + {file = "regex-2024.11.6-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:658f90550f38270639e83ce492f27d2c8d2cd63805c65a13a14d36ca126753f0"}, + {file = "regex-2024.11.6-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:164d8b7b3b4bcb2068b97428060b2a53be050085ef94eca7f240e7947f1b080e"}, + {file = "regex-2024.11.6-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d3660c82f209655a06b587d55e723f0b813d3a7db2e32e5e7dc64ac2a9e86fde"}, + {file = "regex-2024.11.6-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d22326fcdef5e08c154280b71163ced384b428343ae16a5ab2b3354aed12436e"}, + {file = "regex-2024.11.6-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f1ac758ef6aebfc8943560194e9fd0fa18bcb34d89fd8bd2af18183afd8da3a2"}, + {file = "regex-2024.11.6-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:997d6a487ff00807ba810e0f8332c18b4eb8d29463cfb7c820dc4b6e7562d0cf"}, + {file = "regex-2024.11.6-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:02a02d2bb04fec86ad61f3ea7f49c015a0681bf76abb9857f945d26159d2968c"}, + {file = "regex-2024.11.6-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:f02f93b92358ee3f78660e43b4b0091229260c5d5c408d17d60bf26b6c900e86"}, + {file = "regex-2024.11.6-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:06eb1be98df10e81ebaded73fcd51989dcf534e3c753466e4b60c4697a003b67"}, + {file = "regex-2024.11.6-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:040df6fe1a5504eb0f04f048e6d09cd7c7110fef851d7c567a6b6e09942feb7d"}, + {file = "regex-2024.11.6-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:fdabbfc59f2c6edba2a6622c647b716e34e8e3867e0ab975412c5c2f79b82da2"}, + {file = "regex-2024.11.6-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:8447d2d39b5abe381419319f942de20b7ecd60ce86f16a23b0698f22e1b70008"}, + {file = "regex-2024.11.6-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:da8f5fc57d1933de22a9e23eec290a0d8a5927a5370d24bda9a6abe50683fe62"}, + {file = "regex-2024.11.6-cp310-cp310-win32.whl", hash = "sha256:b489578720afb782f6ccf2840920f3a32e31ba28a4b162e13900c3e6bd3f930e"}, + {file = "regex-2024.11.6-cp310-cp310-win_amd64.whl", hash = "sha256:5071b2093e793357c9d8b2929dfc13ac5f0a6c650559503bb81189d0a3814519"}, + {file = "regex-2024.11.6-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:5478c6962ad548b54a591778e93cd7c456a7a29f8eca9c49e4f9a806dcc5d638"}, + {file = "regex-2024.11.6-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:2c89a8cc122b25ce6945f0423dc1352cb9593c68abd19223eebbd4e56612c5b7"}, + {file = "regex-2024.11.6-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:94d87b689cdd831934fa3ce16cc15cd65748e6d689f5d2b8f4f4df2065c9fa20"}, + {file = "regex-2024.11.6-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1062b39a0a2b75a9c694f7a08e7183a80c63c0d62b301418ffd9c35f55aaa114"}, + {file = "regex-2024.11.6-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:167ed4852351d8a750da48712c3930b031f6efdaa0f22fa1933716bfcd6bf4a3"}, + {file = "regex-2024.11.6-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2d548dafee61f06ebdb584080621f3e0c23fff312f0de1afc776e2a2ba99a74f"}, + {file = "regex-2024.11.6-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f2a19f302cd1ce5dd01a9099aaa19cae6173306d1302a43b627f62e21cf18ac0"}, + {file = "regex-2024.11.6-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bec9931dfb61ddd8ef2ebc05646293812cb6b16b60cf7c9511a832b6f1854b55"}, + {file = "regex-2024.11.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:9714398225f299aa85267fd222f7142fcb5c769e73d7733344efc46f2ef5cf89"}, + {file = "regex-2024.11.6-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:202eb32e89f60fc147a41e55cb086db2a3f8cb82f9a9a88440dcfc5d37faae8d"}, + {file = "regex-2024.11.6-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:4181b814e56078e9b00427ca358ec44333765f5ca1b45597ec7446d3a1ef6e34"}, + {file = "regex-2024.11.6-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:068376da5a7e4da51968ce4c122a7cd31afaaec4fccc7856c92f63876e57b51d"}, + {file = "regex-2024.11.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ac10f2c4184420d881a3475fb2c6f4d95d53a8d50209a2500723d831036f7c45"}, + {file = "regex-2024.11.6-cp311-cp311-win32.whl", hash = "sha256:c36f9b6f5f8649bb251a5f3f66564438977b7ef8386a52460ae77e6070d309d9"}, + {file = "regex-2024.11.6-cp311-cp311-win_amd64.whl", hash = "sha256:02e28184be537f0e75c1f9b2f8847dc51e08e6e171c6bde130b2687e0c33cf60"}, + {file = "regex-2024.11.6-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:52fb28f528778f184f870b7cf8f225f5eef0a8f6e3778529bdd40c7b3920796a"}, + {file = "regex-2024.11.6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:fdd6028445d2460f33136c55eeb1f601ab06d74cb3347132e1c24250187500d9"}, + {file = "regex-2024.11.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:805e6b60c54bf766b251e94526ebad60b7de0c70f70a4e6210ee2891acb70bf2"}, + {file = "regex-2024.11.6-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b85c2530be953a890eaffde05485238f07029600e8f098cdf1848d414a8b45e4"}, + {file = "regex-2024.11.6-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bb26437975da7dc36b7efad18aa9dd4ea569d2357ae6b783bf1118dabd9ea577"}, + {file = "regex-2024.11.6-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:abfa5080c374a76a251ba60683242bc17eeb2c9818d0d30117b4486be10c59d3"}, + {file = "regex-2024.11.6-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:70b7fa6606c2881c1db9479b0eaa11ed5dfa11c8d60a474ff0e095099f39d98e"}, + {file = "regex-2024.11.6-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0c32f75920cf99fe6b6c539c399a4a128452eaf1af27f39bce8909c9a3fd8cbe"}, + {file = "regex-2024.11.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:982e6d21414e78e1f51cf595d7f321dcd14de1f2881c5dc6a6e23bbbbd68435e"}, + {file = "regex-2024.11.6-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:a7c2155f790e2fb448faed6dd241386719802296ec588a8b9051c1f5c481bc29"}, + {file = "regex-2024.11.6-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:149f5008d286636e48cd0b1dd65018548944e495b0265b45e1bffecce1ef7f39"}, + {file = "regex-2024.11.6-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:e5364a4502efca094731680e80009632ad6624084aff9a23ce8c8c6820de3e51"}, + {file = "regex-2024.11.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0a86e7eeca091c09e021db8eb72d54751e527fa47b8d5787caf96d9831bd02ad"}, + {file = "regex-2024.11.6-cp312-cp312-win32.whl", hash = "sha256:32f9a4c643baad4efa81d549c2aadefaeba12249b2adc5af541759237eee1c54"}, + {file = "regex-2024.11.6-cp312-cp312-win_amd64.whl", hash = "sha256:a93c194e2df18f7d264092dc8539b8ffb86b45b899ab976aa15d48214138e81b"}, + {file = "regex-2024.11.6-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:a6ba92c0bcdf96cbf43a12c717eae4bc98325ca3730f6b130ffa2e3c3c723d84"}, + {file = "regex-2024.11.6-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:525eab0b789891ac3be914d36893bdf972d483fe66551f79d3e27146191a37d4"}, + {file = "regex-2024.11.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:086a27a0b4ca227941700e0b31425e7a28ef1ae8e5e05a33826e17e47fbfdba0"}, + {file = "regex-2024.11.6-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bde01f35767c4a7899b7eb6e823b125a64de314a8ee9791367c9a34d56af18d0"}, + {file = "regex-2024.11.6-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b583904576650166b3d920d2bcce13971f6f9e9a396c673187f49811b2769dc7"}, + {file = "regex-2024.11.6-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1c4de13f06a0d54fa0d5ab1b7138bfa0d883220965a29616e3ea61b35d5f5fc7"}, + {file = "regex-2024.11.6-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3cde6e9f2580eb1665965ce9bf17ff4952f34f5b126beb509fee8f4e994f143c"}, + {file = "regex-2024.11.6-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0d7f453dca13f40a02b79636a339c5b62b670141e63efd511d3f8f73fba162b3"}, + {file = "regex-2024.11.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:59dfe1ed21aea057a65c6b586afd2a945de04fc7db3de0a6e3ed5397ad491b07"}, + {file = "regex-2024.11.6-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:b97c1e0bd37c5cd7902e65f410779d39eeda155800b65fc4d04cc432efa9bc6e"}, + {file = "regex-2024.11.6-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:f9d1e379028e0fc2ae3654bac3cbbef81bf3fd571272a42d56c24007979bafb6"}, + {file = "regex-2024.11.6-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:13291b39131e2d002a7940fb176e120bec5145f3aeb7621be6534e46251912c4"}, + {file = "regex-2024.11.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4f51f88c126370dcec4908576c5a627220da6c09d0bff31cfa89f2523843316d"}, + {file = "regex-2024.11.6-cp313-cp313-win32.whl", hash = "sha256:63b13cfd72e9601125027202cad74995ab26921d8cd935c25f09c630436348ff"}, + {file = "regex-2024.11.6-cp313-cp313-win_amd64.whl", hash = "sha256:2b3361af3198667e99927da8b84c1b010752fa4b1115ee30beaa332cabc3ef1a"}, + {file = "regex-2024.11.6-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:3a51ccc315653ba012774efca4f23d1d2a8a8f278a6072e29c7147eee7da446b"}, + {file = "regex-2024.11.6-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:ad182d02e40de7459b73155deb8996bbd8e96852267879396fb274e8700190e3"}, + {file = "regex-2024.11.6-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:ba9b72e5643641b7d41fa1f6d5abda2c9a263ae835b917348fc3c928182ad467"}, + {file = "regex-2024.11.6-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:40291b1b89ca6ad8d3f2b82782cc33807f1406cf68c8d440861da6304d8ffbbd"}, + {file = "regex-2024.11.6-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:cdf58d0e516ee426a48f7b2c03a332a4114420716d55769ff7108c37a09951bf"}, + {file = "regex-2024.11.6-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a36fdf2af13c2b14738f6e973aba563623cb77d753bbbd8d414d18bfaa3105dd"}, + {file = "regex-2024.11.6-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d1cee317bfc014c2419a76bcc87f071405e3966da434e03e13beb45f8aced1a6"}, + {file = "regex-2024.11.6-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:50153825ee016b91549962f970d6a4442fa106832e14c918acd1c8e479916c4f"}, + {file = "regex-2024.11.6-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:ea1bfda2f7162605f6e8178223576856b3d791109f15ea99a9f95c16a7636fb5"}, + {file = "regex-2024.11.6-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:df951c5f4a1b1910f1a99ff42c473ff60f8225baa1cdd3539fe2819d9543e9df"}, + {file = "regex-2024.11.6-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:072623554418a9911446278f16ecb398fb3b540147a7828c06e2011fa531e773"}, + {file = "regex-2024.11.6-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:f654882311409afb1d780b940234208a252322c24a93b442ca714d119e68086c"}, + {file = "regex-2024.11.6-cp38-cp38-musllinux_1_2_s390x.whl", hash = "sha256:89d75e7293d2b3e674db7d4d9b1bee7f8f3d1609428e293771d1a962617150cc"}, + {file = "regex-2024.11.6-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:f65557897fc977a44ab205ea871b690adaef6b9da6afda4790a2484b04293a5f"}, + {file = "regex-2024.11.6-cp38-cp38-win32.whl", hash = "sha256:6f44ec28b1f858c98d3036ad5d7d0bfc568bdd7a74f9c24e25f41ef1ebfd81a4"}, + {file = "regex-2024.11.6-cp38-cp38-win_amd64.whl", hash = "sha256:bb8f74f2f10dbf13a0be8de623ba4f9491faf58c24064f32b65679b021ed0001"}, + {file = "regex-2024.11.6-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:5704e174f8ccab2026bd2f1ab6c510345ae8eac818b613d7d73e785f1310f839"}, + {file = "regex-2024.11.6-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:220902c3c5cc6af55d4fe19ead504de80eb91f786dc102fbd74894b1551f095e"}, + {file = "regex-2024.11.6-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:5e7e351589da0850c125f1600a4c4ba3c722efefe16b297de54300f08d734fbf"}, + {file = "regex-2024.11.6-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5056b185ca113c88e18223183aa1a50e66507769c9640a6ff75859619d73957b"}, + {file = "regex-2024.11.6-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2e34b51b650b23ed3354b5a07aab37034d9f923db2a40519139af34f485f77d0"}, + {file = "regex-2024.11.6-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5670bce7b200273eee1840ef307bfa07cda90b38ae56e9a6ebcc9f50da9c469b"}, + {file = "regex-2024.11.6-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:08986dce1339bc932923e7d1232ce9881499a0e02925f7402fb7c982515419ef"}, + {file = "regex-2024.11.6-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:93c0b12d3d3bc25af4ebbf38f9ee780a487e8bf6954c115b9f015822d3bb8e48"}, + {file = "regex-2024.11.6-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:764e71f22ab3b305e7f4c21f1a97e1526a25ebdd22513e251cf376760213da13"}, + {file = "regex-2024.11.6-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:f056bf21105c2515c32372bbc057f43eb02aae2fda61052e2f7622c801f0b4e2"}, + {file = "regex-2024.11.6-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:69ab78f848845569401469da20df3e081e6b5a11cb086de3eed1d48f5ed57c95"}, + {file = "regex-2024.11.6-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:86fddba590aad9208e2fa8b43b4c098bb0ec74f15718bb6a704e3c63e2cef3e9"}, + {file = "regex-2024.11.6-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:684d7a212682996d21ca12ef3c17353c021fe9de6049e19ac8481ec35574a70f"}, + {file = "regex-2024.11.6-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:a03e02f48cd1abbd9f3b7e3586d97c8f7a9721c436f51a5245b3b9483044480b"}, + {file = "regex-2024.11.6-cp39-cp39-win32.whl", hash = "sha256:41758407fc32d5c3c5de163888068cfee69cb4c2be844e7ac517a52770f9af57"}, + {file = "regex-2024.11.6-cp39-cp39-win_amd64.whl", hash = "sha256:b2837718570f95dd41675328e111345f9b7095d821bac435aac173ac80b19983"}, + {file = "regex-2024.11.6.tar.gz", hash = "sha256:7ab159b063c52a0333c884e4679f8d7a85112ee3078fe3d9004b2dd875585519"}, +] + +[[package]] +name = "requests" +version = "2.32.4" +description = "Python HTTP for Humans." +optional = false +python-versions = ">=3.8" +groups = ["docs", "test"] +files = [ + {file = "requests-2.32.4-py3-none-any.whl", hash = "sha256:27babd3cda2a6d50b30443204ee89830707d396671944c998b5975b031ac2b2c"}, + {file = "requests-2.32.4.tar.gz", hash = "sha256:27d0316682c8a29834d3264820024b62a36942083d52caf2f14c0591336d3422"}, +] + +[package.dependencies] +certifi = ">=2017.4.17" +charset_normalizer = ">=2,<4" +idna = ">=2.5,<4" +urllib3 = ">=1.21.1,<3" + +[package.extras] +socks = ["PySocks (>=1.5.6,!=1.5.7)"] +use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"] + +[[package]] +name = "restructuredtext-lint" +version = "1.4.0" +description = "reStructuredText linter" +optional = false +python-versions = "*" +groups = ["test"] +files = [ + {file = "restructuredtext_lint-1.4.0.tar.gz", hash = "sha256:1b235c0c922341ab6c530390892eb9e92f90b9b75046063e047cacfb0f050c45"}, +] + +[package.dependencies] +docutils = ">=0.11,<1.0" + +[[package]] +name = "rich" +version = "14.0.0" +description = "Render rich text, tables, progress bars, syntax highlighting, markdown and more to the terminal" +optional = false +python-versions = ">=3.8.0" +groups = ["test"] +files = [ + {file = "rich-14.0.0-py3-none-any.whl", hash = "sha256:1c9491e1951aac09caffd42f448ee3d04e58923ffe14993f6e83068dc395d7e0"}, + {file = "rich-14.0.0.tar.gz", hash = "sha256:82f1bc23a6a21ebca4ae0c45af9bdbc492ed20231dcb63f297d6d1021a9d5725"}, +] + +[package.dependencies] +markdown-it-py = ">=2.2.0" +pygments = ">=2.13.0,<3.0.0" +typing-extensions = {version = ">=4.0.0,<5.0", markers = "python_version < \"3.11\""} + +[package.extras] +jupyter = ["ipywidgets (>=7.5.1,<9)"] + +[[package]] +name = "ruamel-yaml" +version = "0.18.12" +description = "ruamel.yaml is a YAML parser/emitter that supports roundtrip preservation of comments, seq/map flow style, and map key order" +optional = false +python-versions = ">=3.7" +groups = ["test"] +files = [ + {file = "ruamel.yaml-0.18.12-py3-none-any.whl", hash = "sha256:790ba4c48b6a6e6b12b532a7308779eb12d2aaab3a80fdb8389216f28ea2b287"}, + {file = "ruamel.yaml-0.18.12.tar.gz", hash = "sha256:5a38fd5ce39d223bebb9e3a6779e86b9427a03fb0bf9f270060f8b149cffe5e2"}, +] + +[package.dependencies] +"ruamel.yaml.clib" = {version = ">=0.2.7", markers = "platform_python_implementation == \"CPython\" and python_version < \"3.14\""} + +[package.extras] +docs = ["mercurial (>5.7)", "ryd"] +jinja2 = ["ruamel.yaml.jinja2 (>=0.2)"] + +[[package]] +name = "ruamel-yaml-clib" +version = "0.2.12" +description = "C version of reader, parser and emitter for ruamel.yaml derived from libyaml" +optional = false +python-versions = ">=3.9" +groups = ["test"] +markers = "platform_python_implementation == \"CPython\" and python_version < \"3.14\"" +files = [ + {file = "ruamel.yaml.clib-0.2.12-cp310-cp310-macosx_13_0_arm64.whl", hash = "sha256:11f891336688faf5156a36293a9c362bdc7c88f03a8a027c2c1d8e0bcde998e5"}, + {file = "ruamel.yaml.clib-0.2.12-cp310-cp310-manylinux2014_aarch64.whl", hash = "sha256:a606ef75a60ecf3d924613892cc603b154178ee25abb3055db5062da811fd969"}, + {file = "ruamel.yaml.clib-0.2.12-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd5415dded15c3822597455bc02bcd66e81ef8b7a48cb71a33628fc9fdde39df"}, + {file = "ruamel.yaml.clib-0.2.12-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f66efbc1caa63c088dead1c4170d148eabc9b80d95fb75b6c92ac0aad2437d76"}, + {file = "ruamel.yaml.clib-0.2.12-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:22353049ba4181685023b25b5b51a574bce33e7f51c759371a7422dcae5402a6"}, + {file = "ruamel.yaml.clib-0.2.12-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:932205970b9f9991b34f55136be327501903f7c66830e9760a8ffb15b07f05cd"}, + {file = "ruamel.yaml.clib-0.2.12-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:a52d48f4e7bf9005e8f0a89209bf9a73f7190ddf0489eee5eb51377385f59f2a"}, + {file = "ruamel.yaml.clib-0.2.12-cp310-cp310-win32.whl", hash = "sha256:3eac5a91891ceb88138c113f9db04f3cebdae277f5d44eaa3651a4f573e6a5da"}, + {file = "ruamel.yaml.clib-0.2.12-cp310-cp310-win_amd64.whl", hash = "sha256:ab007f2f5a87bd08ab1499bdf96f3d5c6ad4dcfa364884cb4549aa0154b13a28"}, + {file = "ruamel.yaml.clib-0.2.12-cp311-cp311-macosx_13_0_arm64.whl", hash = "sha256:4a6679521a58256a90b0d89e03992c15144c5f3858f40d7c18886023d7943db6"}, + {file = "ruamel.yaml.clib-0.2.12-cp311-cp311-manylinux2014_aarch64.whl", hash = "sha256:d84318609196d6bd6da0edfa25cedfbabd8dbde5140a0a23af29ad4b8f91fb1e"}, + {file = "ruamel.yaml.clib-0.2.12-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bb43a269eb827806502c7c8efb7ae7e9e9d0573257a46e8e952f4d4caba4f31e"}, + {file = "ruamel.yaml.clib-0.2.12-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:811ea1594b8a0fb466172c384267a4e5e367298af6b228931f273b111f17ef52"}, + {file = "ruamel.yaml.clib-0.2.12-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:cf12567a7b565cbf65d438dec6cfbe2917d3c1bdddfce84a9930b7d35ea59642"}, + {file = "ruamel.yaml.clib-0.2.12-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:7dd5adc8b930b12c8fc5b99e2d535a09889941aa0d0bd06f4749e9a9397c71d2"}, + {file = "ruamel.yaml.clib-0.2.12-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1492a6051dab8d912fc2adeef0e8c72216b24d57bd896ea607cb90bb0c4981d3"}, + {file = "ruamel.yaml.clib-0.2.12-cp311-cp311-win32.whl", hash = "sha256:bd0a08f0bab19093c54e18a14a10b4322e1eacc5217056f3c063bd2f59853ce4"}, + {file = "ruamel.yaml.clib-0.2.12-cp311-cp311-win_amd64.whl", hash = "sha256:a274fb2cb086c7a3dea4322ec27f4cb5cc4b6298adb583ab0e211a4682f241eb"}, + {file = "ruamel.yaml.clib-0.2.12-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:20b0f8dc160ba83b6dcc0e256846e1a02d044e13f7ea74a3d1d56ede4e48c632"}, + {file = "ruamel.yaml.clib-0.2.12-cp312-cp312-manylinux2014_aarch64.whl", hash = "sha256:943f32bc9dedb3abff9879edc134901df92cfce2c3d5c9348f172f62eb2d771d"}, + {file = "ruamel.yaml.clib-0.2.12-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:95c3829bb364fdb8e0332c9931ecf57d9be3519241323c5274bd82f709cebc0c"}, + {file = "ruamel.yaml.clib-0.2.12-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:749c16fcc4a2b09f28843cda5a193e0283e47454b63ec4b81eaa2242f50e4ccd"}, + {file = "ruamel.yaml.clib-0.2.12-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:bf165fef1f223beae7333275156ab2022cffe255dcc51c27f066b4370da81e31"}, + {file = "ruamel.yaml.clib-0.2.12-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:32621c177bbf782ca5a18ba4d7af0f1082a3f6e517ac2a18b3974d4edf349680"}, + {file = "ruamel.yaml.clib-0.2.12-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:b82a7c94a498853aa0b272fd5bc67f29008da798d4f93a2f9f289feb8426a58d"}, + {file = "ruamel.yaml.clib-0.2.12-cp312-cp312-win32.whl", hash = "sha256:e8c4ebfcfd57177b572e2040777b8abc537cdef58a2120e830124946aa9b42c5"}, + {file = "ruamel.yaml.clib-0.2.12-cp312-cp312-win_amd64.whl", hash = "sha256:0467c5965282c62203273b838ae77c0d29d7638c8a4e3a1c8bdd3602c10904e4"}, + {file = "ruamel.yaml.clib-0.2.12-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:4c8c5d82f50bb53986a5e02d1b3092b03622c02c2eb78e29bec33fd9593bae1a"}, + {file = "ruamel.yaml.clib-0.2.12-cp313-cp313-manylinux2014_aarch64.whl", hash = "sha256:e7e3736715fbf53e9be2a79eb4db68e4ed857017344d697e8b9749444ae57475"}, + {file = "ruamel.yaml.clib-0.2.12-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0b7e75b4965e1d4690e93021adfcecccbca7d61c7bddd8e22406ef2ff20d74ef"}, + {file = "ruamel.yaml.clib-0.2.12-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:96777d473c05ee3e5e3c3e999f5d23c6f4ec5b0c38c098b3a5229085f74236c6"}, + {file = "ruamel.yaml.clib-0.2.12-cp313-cp313-musllinux_1_1_i686.whl", hash = "sha256:3bc2a80e6420ca8b7d3590791e2dfc709c88ab9152c00eeb511c9875ce5778bf"}, + {file = "ruamel.yaml.clib-0.2.12-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:e188d2699864c11c36cdfdada94d781fd5d6b0071cd9c427bceb08ad3d7c70e1"}, + {file = "ruamel.yaml.clib-0.2.12-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4f6f3eac23941b32afccc23081e1f50612bdbe4e982012ef4f5797986828cd01"}, + {file = "ruamel.yaml.clib-0.2.12-cp313-cp313-win32.whl", hash = "sha256:6442cb36270b3afb1b4951f060eccca1ce49f3d087ca1ca4563a6eb479cb3de6"}, + {file = "ruamel.yaml.clib-0.2.12-cp313-cp313-win_amd64.whl", hash = "sha256:e5b8daf27af0b90da7bb903a876477a9e6d7270be6146906b276605997c7e9a3"}, + {file = "ruamel.yaml.clib-0.2.12-cp39-cp39-macosx_12_0_arm64.whl", hash = "sha256:fc4b630cd3fa2cf7fce38afa91d7cfe844a9f75d7f0f36393fa98815e911d987"}, + {file = "ruamel.yaml.clib-0.2.12-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:bc5f1e1c28e966d61d2519f2a3d451ba989f9ea0f2307de7bc45baa526de9e45"}, + {file = "ruamel.yaml.clib-0.2.12-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5a0e060aace4c24dcaf71023bbd7d42674e3b230f7e7b97317baf1e953e5b519"}, + {file = "ruamel.yaml.clib-0.2.12-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e2f1c3765db32be59d18ab3953f43ab62a761327aafc1594a2a1fbe038b8b8a7"}, + {file = "ruamel.yaml.clib-0.2.12-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:d85252669dc32f98ebcd5d36768f5d4faeaeaa2d655ac0473be490ecdae3c285"}, + {file = "ruamel.yaml.clib-0.2.12-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:e143ada795c341b56de9418c58d028989093ee611aa27ffb9b7f609c00d813ed"}, + {file = "ruamel.yaml.clib-0.2.12-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:2c59aa6170b990d8d2719323e628aaf36f3bfbc1c26279c0eeeb24d05d2d11c7"}, + {file = "ruamel.yaml.clib-0.2.12-cp39-cp39-win32.whl", hash = "sha256:beffaed67936fbbeffd10966a4eb53c402fafd3d6833770516bf7314bc6ffa12"}, + {file = "ruamel.yaml.clib-0.2.12-cp39-cp39-win_amd64.whl", hash = "sha256:040ae85536960525ea62868b642bdb0c2cc6021c9f9d507810c0c604e66f5a7b"}, + {file = "ruamel.yaml.clib-0.2.12.tar.gz", hash = "sha256:6c8fbb13ec503f99a91901ab46e0b07ae7941cd527393187039aec586fdfd36f"}, +] + +[[package]] +name = "ruff" +version = "0.12.2" +description = "An extremely fast Python linter and code formatter, written in Rust." +optional = false +python-versions = ">=3.7" +groups = ["test"] +files = [ + {file = "ruff-0.12.2-py3-none-linux_armv6l.whl", hash = "sha256:093ea2b221df1d2b8e7ad92fc6ffdca40a2cb10d8564477a987b44fd4008a7be"}, + {file = "ruff-0.12.2-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:09e4cf27cc10f96b1708100fa851e0daf21767e9709e1649175355280e0d950e"}, + {file = "ruff-0.12.2-py3-none-macosx_11_0_arm64.whl", hash = "sha256:8ae64755b22f4ff85e9c52d1f82644abd0b6b6b6deedceb74bd71f35c24044cc"}, + {file = "ruff-0.12.2-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3eb3a6b2db4d6e2c77e682f0b988d4d61aff06860158fdb413118ca133d57922"}, + {file = "ruff-0.12.2-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:73448de992d05517170fc37169cbca857dfeaeaa8c2b9be494d7bcb0d36c8f4b"}, + {file = "ruff-0.12.2-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3b8b94317cbc2ae4a2771af641739f933934b03555e51515e6e021c64441532d"}, + {file = "ruff-0.12.2-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:45fc42c3bf1d30d2008023a0a9a0cfb06bf9835b147f11fe0679f21ae86d34b1"}, + {file = "ruff-0.12.2-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ce48f675c394c37e958bf229fb5c1e843e20945a6d962cf3ea20b7a107dcd9f4"}, + {file = "ruff-0.12.2-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:793d8859445ea47591272021a81391350205a4af65a9392401f418a95dfb75c9"}, + {file = "ruff-0.12.2-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6932323db80484dda89153da3d8e58164d01d6da86857c79f1961934354992da"}, + {file = "ruff-0.12.2-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:6aa7e623a3a11538108f61e859ebf016c4f14a7e6e4eba1980190cacb57714ce"}, + {file = "ruff-0.12.2-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:2a4a20aeed74671b2def096bdf2eac610c7d8ffcbf4fb0e627c06947a1d7078d"}, + {file = "ruff-0.12.2-py3-none-musllinux_1_2_i686.whl", hash = "sha256:71a4c550195612f486c9d1f2b045a600aeba851b298c667807ae933478fcef04"}, + {file = "ruff-0.12.2-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:4987b8f4ceadf597c927beee65a5eaf994c6e2b631df963f86d8ad1bdea99342"}, + {file = "ruff-0.12.2-py3-none-win32.whl", hash = "sha256:369ffb69b70cd55b6c3fc453b9492d98aed98062db9fec828cdfd069555f5f1a"}, + {file = "ruff-0.12.2-py3-none-win_amd64.whl", hash = "sha256:dca8a3b6d6dc9810ed8f328d406516bf4d660c00caeaef36eb831cf4871b0639"}, + {file = "ruff-0.12.2-py3-none-win_arm64.whl", hash = "sha256:48d6c6bfb4761df68bc05ae630e24f506755e702d4fb08f08460be778c7ccb12"}, + {file = "ruff-0.12.2.tar.gz", hash = "sha256:d7b4f55cd6f325cb7621244f19c873c565a08aff5a4ba9c69aa7355f3f7afd3e"}, +] + +[[package]] +name = "safety" +version = "3.5.2" +description = "Scan dependencies for known vulnerabilities and licenses." +optional = false +python-versions = ">=3.8" +groups = ["test"] +files = [ + {file = "safety-3.5.2-py3-none-any.whl", hash = "sha256:d5baff410c548393e80ba2bd2ab1de2701a690ca3577e457335917b9db4641e1"}, + {file = "safety-3.5.2.tar.gz", hash = "sha256:ecbb2e76f9574284f2c0e168e71ec92b39018ae9516b3b00f132de57ecaa09cb"}, +] + +[package.dependencies] +authlib = ">=1.2.0" +click = ">=8.0.2,<8.2.0" +dparse = ">=0.6.4" +filelock = ">=3.16.1,<3.17.0" +httpx = "*" +jinja2 = ">=3.1.0" +marshmallow = ">=3.15.0" +nltk = ">=3.9" +packaging = ">=21.0" +psutil = ">=6.1.0,<6.2.0" +pydantic = ">=2.6.0,<2.10.0" +requests = "*" +ruamel-yaml = ">=0.17.21" +safety-schemas = "0.0.14" +setuptools = ">=65.5.1" +tenacity = "*" +tomli = {version = "*", markers = "python_version < \"3.11\""} +tomlkit = "*" +typer = ">=0.12.1" +typing-extensions = ">=4.7.1" + +[package.extras] +github = ["pygithub (>=1.43.3)"] +gitlab = ["python-gitlab (>=1.3.0)"] +spdx = ["spdx-tools (>=0.8.2)"] + +[[package]] +name = "safety-schemas" +version = "0.0.14" +description = "Schemas for Safety tools" +optional = false +python-versions = ">=3.7" +groups = ["test"] +files = [ + {file = "safety_schemas-0.0.14-py3-none-any.whl", hash = "sha256:0bf6fc4aa5e474651b714cc9e427c862792946bf052b61d5c7bec4eac4c0f254"}, + {file = "safety_schemas-0.0.14.tar.gz", hash = "sha256:49953f7a59e919572be25595a8946f9cbbcd2066fe3e160c9467d9d1d6d7af6a"}, +] + +[package.dependencies] +dparse = ">=0.6.4" +packaging = ">=21.0" +pydantic = ">=2.6.0,<2.10.0" +ruamel-yaml = ">=0.17.21" +typing-extensions = ">=4.7.1" + +[[package]] +name = "setuptools" +version = "80.9.0" +description = "Easily download, build, install, upgrade, and uninstall Python packages" +optional = false +python-versions = ">=3.9" +groups = ["test"] +files = [ + {file = "setuptools-80.9.0-py3-none-any.whl", hash = "sha256:062d34222ad13e0cc312a4c02d73f059e86a4acbfbdea8f8f76b28c99f306922"}, + {file = "setuptools-80.9.0.tar.gz", hash = "sha256:f36b47402ecde768dbfafc46e8e4207b4360c654f1f3bb84475f0a28628fb19c"}, +] + +[package.extras] +check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1) ; sys_platform != \"cygwin\"", "ruff (>=0.8.0) ; sys_platform != \"cygwin\""] +core = ["importlib_metadata (>=6) ; python_version < \"3.10\"", "jaraco.functools (>=4)", "jaraco.text (>=3.7)", "more_itertools", "more_itertools (>=8.8)", "packaging (>=24.2)", "platformdirs (>=4.2.2)", "tomli (>=2.0.1) ; python_version < \"3.11\"", "wheel (>=0.43.0)"] +cover = ["pytest-cov"] +doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "pygments-github-lexers (==0.0.5)", "pyproject-hooks (!=1.1)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-favicon", "sphinx-inline-tabs", "sphinx-lint", "sphinx-notfound-page (>=1,<2)", "sphinx-reredirects", "sphinxcontrib-towncrier", "towncrier (<24.7)"] +enabler = ["pytest-enabler (>=2.2)"] +test = ["build[virtualenv] (>=1.0.3)", "filelock (>=3.4.0)", "ini2toml[lite] (>=0.14)", "jaraco.develop (>=7.21) ; python_version >= \"3.9\" and sys_platform != \"cygwin\"", "jaraco.envs (>=2.2)", "jaraco.path (>=3.7.2)", "jaraco.test (>=5.5)", "packaging (>=24.2)", "pip (>=19.1)", "pyproject-hooks (!=1.1)", "pytest (>=6,!=8.1.*)", "pytest-home (>=0.5)", "pytest-perf ; sys_platform != \"cygwin\"", "pytest-subprocess", "pytest-timeout", "pytest-xdist (>=3)", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel (>=0.44.0)"] +type = ["importlib_metadata (>=7.0.2) ; python_version < \"3.10\"", "jaraco.develop (>=7.21) ; sys_platform != \"cygwin\"", "mypy (==1.14.*)", "pytest-mypy"] + +[[package]] +name = "shellingham" +version = "1.5.4" +description = "Tool to Detect Surrounding Shell" +optional = false +python-versions = ">=3.7" +groups = ["test"] +files = [ + {file = "shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686"}, + {file = "shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de"}, +] + +[[package]] +name = "sniffio" +version = "1.3.1" +description = "Sniff out which async library your code is running under" +optional = false +python-versions = ">=3.7" +groups = ["test"] +files = [ + {file = "sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2"}, + {file = "sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc"}, +] + +[[package]] +name = "snowballstemmer" +version = "3.0.1" +description = "This package provides 32 stemmers for 30 languages generated from Snowball algorithms." +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*" +groups = ["docs"] +files = [ + {file = "snowballstemmer-3.0.1-py3-none-any.whl", hash = "sha256:6cd7b3897da8d6c9ffb968a6781fa6532dce9c3618a4b127d920dab764a19064"}, + {file = "snowballstemmer-3.0.1.tar.gz", hash = "sha256:6d5eeeec8e9f84d4d56b847692bacf79bc2c8e90c7f80ca4444ff8b6f2e52895"}, +] + +[[package]] +name = "sortedcontainers" +version = "2.4.0" +description = "Sorted Containers -- Sorted List, Sorted Dict, Sorted Set" +optional = false +python-versions = "*" +groups = ["test"] +files = [ + {file = "sortedcontainers-2.4.0-py2.py3-none-any.whl", hash = "sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0"}, + {file = "sortedcontainers-2.4.0.tar.gz", hash = "sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88"}, +] + +[[package]] +name = "sphinx" +version = "7.4.7" +description = "Python documentation generator" +optional = false +python-versions = ">=3.9" +groups = ["docs"] +files = [ + {file = "sphinx-7.4.7-py3-none-any.whl", hash = "sha256:c2419e2135d11f1951cd994d6eb18a1835bd8fdd8429f9ca375dc1f3281bd239"}, + {file = "sphinx-7.4.7.tar.gz", hash = "sha256:242f92a7ea7e6c5b406fdc2615413890ba9f699114a9c09192d7dfead2ee9cfe"}, +] + +[package.dependencies] +alabaster = ">=0.7.14,<0.8.0" +babel = ">=2.13" +colorama = {version = ">=0.4.6", markers = "sys_platform == \"win32\""} +docutils = ">=0.20,<0.22" +imagesize = ">=1.3" +importlib-metadata = {version = ">=6.0", markers = "python_version < \"3.10\""} +Jinja2 = ">=3.1" +packaging = ">=23.0" +Pygments = ">=2.17" +requests = ">=2.30.0" +snowballstemmer = ">=2.2" +sphinxcontrib-applehelp = "*" +sphinxcontrib-devhelp = "*" +sphinxcontrib-htmlhelp = ">=2.0.0" +sphinxcontrib-jsmath = "*" +sphinxcontrib-qthelp = "*" +sphinxcontrib-serializinghtml = ">=1.1.9" +tomli = {version = ">=2", markers = "python_version < \"3.11\""} + +[package.extras] +docs = ["sphinxcontrib-websupport"] +lint = ["flake8 (>=6.0)", "importlib-metadata (>=6.0)", "mypy (==1.10.1)", "pytest (>=6.0)", "ruff (==0.5.2)", "sphinx-lint (>=0.9)", "tomli (>=2)", "types-docutils (==0.21.0.20240711)", "types-requests (>=2.30.0)"] +test = ["cython (>=3.0)", "defusedxml (>=0.7.1)", "pytest (>=8.0)", "setuptools (>=70.0)", "typing_extensions (>=4.9)"] + +[[package]] +name = "sphinx-autodoc-typehints" +version = "2.3.0" +description = "Type hints (PEP 484) support for the Sphinx autodoc extension" +optional = false +python-versions = ">=3.9" +groups = ["docs"] +files = [ + {file = "sphinx_autodoc_typehints-2.3.0-py3-none-any.whl", hash = "sha256:3098e2c6d0ba99eacd013eb06861acc9b51c6e595be86ab05c08ee5506ac0c67"}, + {file = "sphinx_autodoc_typehints-2.3.0.tar.gz", hash = "sha256:535c78ed2d6a1bad393ba9f3dfa2602cf424e2631ee207263e07874c38fde084"}, +] + +[package.dependencies] +sphinx = ">=7.3.5" + +[package.extras] +docs = ["furo (>=2024.1.29)"] +numpy = ["nptyping (>=2.5)"] +testing = ["covdefaults (>=2.3)", "coverage (>=7.4.4)", "defusedxml (>=0.7.1)", "diff-cover (>=9)", "pytest (>=8.1.1)", "pytest-cov (>=5)", "sphobjinv (>=2.3.1)", "typing-extensions (>=4.11)"] + +[[package]] +name = "sphinx-rtd-theme" +version = "3.0.2" +description = "Read the Docs theme for Sphinx" +optional = false +python-versions = ">=3.8" +groups = ["docs"] +files = [ + {file = "sphinx_rtd_theme-3.0.2-py2.py3-none-any.whl", hash = "sha256:422ccc750c3a3a311de4ae327e82affdaf59eb695ba4936538552f3b00f4ee13"}, + {file = "sphinx_rtd_theme-3.0.2.tar.gz", hash = "sha256:b7457bc25dda723b20b086a670b9953c859eab60a2a03ee8eb2bb23e176e5f85"}, +] + +[package.dependencies] +docutils = ">0.18,<0.22" +sphinx = ">=6,<9" +sphinxcontrib-jquery = ">=4,<5" + +[package.extras] +dev = ["bump2version", "transifex-client", "twine", "wheel"] + +[[package]] +name = "sphinxcontrib-applehelp" +version = "2.0.0" +description = "sphinxcontrib-applehelp is a Sphinx extension which outputs Apple help books" +optional = false +python-versions = ">=3.9" +groups = ["docs"] +files = [ + {file = "sphinxcontrib_applehelp-2.0.0-py3-none-any.whl", hash = "sha256:4cd3f0ec4ac5dd9c17ec65e9ab272c9b867ea77425228e68ecf08d6b28ddbdb5"}, + {file = "sphinxcontrib_applehelp-2.0.0.tar.gz", hash = "sha256:2f29ef331735ce958efa4734873f084941970894c6090408b079c61b2e1c06d1"}, +] + +[package.extras] +lint = ["mypy", "ruff (==0.5.5)", "types-docutils"] +standalone = ["Sphinx (>=5)"] +test = ["pytest"] + +[[package]] +name = "sphinxcontrib-devhelp" +version = "2.0.0" +description = "sphinxcontrib-devhelp is a sphinx extension which outputs Devhelp documents" +optional = false +python-versions = ">=3.9" +groups = ["docs"] +files = [ + {file = "sphinxcontrib_devhelp-2.0.0-py3-none-any.whl", hash = "sha256:aefb8b83854e4b0998877524d1029fd3e6879210422ee3780459e28a1f03a8a2"}, + {file = "sphinxcontrib_devhelp-2.0.0.tar.gz", hash = "sha256:411f5d96d445d1d73bb5d52133377b4248ec79db5c793ce7dbe59e074b4dd1ad"}, +] + +[package.extras] +lint = ["mypy", "ruff (==0.5.5)", "types-docutils"] +standalone = ["Sphinx (>=5)"] +test = ["pytest"] + +[[package]] +name = "sphinxcontrib-htmlhelp" +version = "2.1.0" +description = "sphinxcontrib-htmlhelp is a sphinx extension which renders HTML help files" +optional = false +python-versions = ">=3.9" +groups = ["docs"] +files = [ + {file = "sphinxcontrib_htmlhelp-2.1.0-py3-none-any.whl", hash = "sha256:166759820b47002d22914d64a075ce08f4c46818e17cfc9470a9786b759b19f8"}, + {file = "sphinxcontrib_htmlhelp-2.1.0.tar.gz", hash = "sha256:c9e2916ace8aad64cc13a0d233ee22317f2b9025b9cf3295249fa985cc7082e9"}, +] + +[package.extras] +lint = ["mypy", "ruff (==0.5.5)", "types-docutils"] +standalone = ["Sphinx (>=5)"] +test = ["html5lib", "pytest"] + +[[package]] +name = "sphinxcontrib-jquery" +version = "4.1" +description = "Extension to include jQuery on newer Sphinx releases" +optional = false +python-versions = ">=2.7" +groups = ["docs"] +files = [ + {file = "sphinxcontrib-jquery-4.1.tar.gz", hash = "sha256:1620739f04e36a2c779f1a131a2dfd49b2fd07351bf1968ced074365933abc7a"}, + {file = "sphinxcontrib_jquery-4.1-py2.py3-none-any.whl", hash = "sha256:f936030d7d0147dd026a4f2b5a57343d233f1fc7b363f68b3d4f1cb0993878ae"}, +] + +[package.dependencies] +Sphinx = ">=1.8" + +[[package]] +name = "sphinxcontrib-jsmath" +version = "1.0.1" +description = "A sphinx extension which renders display math in HTML via JavaScript" +optional = false +python-versions = ">=3.5" +groups = ["docs"] +files = [ + {file = "sphinxcontrib-jsmath-1.0.1.tar.gz", hash = "sha256:a9925e4a4587247ed2191a22df5f6970656cb8ca2bd6284309578f2153e0c4b8"}, + {file = "sphinxcontrib_jsmath-1.0.1-py2.py3-none-any.whl", hash = "sha256:2ec2eaebfb78f3f2078e73666b1415417a116cc848b72e5172e596c871103178"}, +] + +[package.extras] +test = ["flake8", "mypy", "pytest"] + +[[package]] +name = "sphinxcontrib-qthelp" +version = "2.0.0" +description = "sphinxcontrib-qthelp is a sphinx extension which outputs QtHelp documents" +optional = false +python-versions = ">=3.9" +groups = ["docs"] +files = [ + {file = "sphinxcontrib_qthelp-2.0.0-py3-none-any.whl", hash = "sha256:b18a828cdba941ccd6ee8445dbe72ffa3ef8cbe7505d8cd1fa0d42d3f2d5f3eb"}, + {file = "sphinxcontrib_qthelp-2.0.0.tar.gz", hash = "sha256:4fe7d0ac8fc171045be623aba3e2a8f613f8682731f9153bb2e40ece16b9bbab"}, +] + +[package.extras] +lint = ["mypy", "ruff (==0.5.5)", "types-docutils"] +standalone = ["Sphinx (>=5)"] +test = ["defusedxml (>=0.7.1)", "pytest"] + +[[package]] +name = "sphinxcontrib-serializinghtml" +version = "2.0.0" +description = "sphinxcontrib-serializinghtml is a sphinx extension which outputs \"serialized\" HTML files (json and pickle)" +optional = false +python-versions = ">=3.9" +groups = ["docs"] +files = [ + {file = "sphinxcontrib_serializinghtml-2.0.0-py3-none-any.whl", hash = "sha256:6e2cb0eef194e10c27ec0023bfeb25badbbb5868244cf5bc5bdc04e4464bf331"}, + {file = "sphinxcontrib_serializinghtml-2.0.0.tar.gz", hash = "sha256:e9d912827f872c029017a53f0ef2180b327c3f7fd23c87229f7a8e8b70031d4d"}, +] + +[package.extras] +lint = ["mypy", "ruff (==0.5.5)", "types-docutils"] +standalone = ["Sphinx (>=5)"] +test = ["pytest"] + +[[package]] +name = "sqlparse" +version = "0.5.3" +description = "A non-validating SQL parser." +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "sqlparse-0.5.3-py3-none-any.whl", hash = "sha256:cf2196ed3418f3ba5de6af7e82c694a9fbdbfecccdfc72e281548517081f16ca"}, + {file = "sqlparse-0.5.3.tar.gz", hash = "sha256:09f67787f56a0b16ecdbde1bfc7f5d9c3371ca683cfeaa8e6ff60b4807ec9272"}, +] + +[package.extras] +dev = ["build", "hatch"] +doc = ["sphinx"] + +[[package]] +name = "stevedore" +version = "5.4.1" +description = "Manage dynamic plugins for Python applications" +optional = false +python-versions = ">=3.9" +groups = ["test"] +files = [ + {file = "stevedore-5.4.1-py3-none-any.whl", hash = "sha256:d10a31c7b86cba16c1f6e8d15416955fc797052351a56af15e608ad20811fcfe"}, + {file = "stevedore-5.4.1.tar.gz", hash = "sha256:3135b5ae50fe12816ef291baff420acb727fcd356106e3e9cbfa9e5985cd6f4b"}, +] + +[package.dependencies] +pbr = ">=2.0.0" + +[[package]] +name = "tenacity" +version = "9.1.2" +description = "Retry code until it succeeds" +optional = false +python-versions = ">=3.9" +groups = ["test"] +files = [ + {file = "tenacity-9.1.2-py3-none-any.whl", hash = "sha256:f77bf36710d8b73a50b2dd155c97b870017ad21afe6ab300326b0371b3b05138"}, + {file = "tenacity-9.1.2.tar.gz", hash = "sha256:1169d376c297e7de388d18b4481760d478b0e99a777cad3a9c86e556f4b697cb"}, +] + +[package.extras] +doc = ["reno", "sphinx"] +test = ["pytest", "tornado (>=4.5)", "typeguard"] + +[[package]] +name = "tomli" +version = "2.2.1" +description = "A lil' TOML parser" +optional = false +python-versions = ">=3.8" +groups = ["docs", "test"] +files = [ + {file = "tomli-2.2.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:678e4fa69e4575eb77d103de3df8a895e1591b48e740211bd1067378c69e8249"}, + {file = "tomli-2.2.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:023aa114dd824ade0100497eb2318602af309e5a55595f76b626d6d9f3b7b0a6"}, + {file = "tomli-2.2.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ece47d672db52ac607a3d9599a9d48dcb2f2f735c6c2d1f34130085bb12b112a"}, + {file = "tomli-2.2.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6972ca9c9cc9f0acaa56a8ca1ff51e7af152a9f87fb64623e31d5c83700080ee"}, + {file = "tomli-2.2.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c954d2250168d28797dd4e3ac5cf812a406cd5a92674ee4c8f123c889786aa8e"}, + {file = "tomli-2.2.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8dd28b3e155b80f4d54beb40a441d366adcfe740969820caf156c019fb5c7ec4"}, + {file = "tomli-2.2.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:e59e304978767a54663af13c07b3d1af22ddee3bb2fb0618ca1593e4f593a106"}, + {file = "tomli-2.2.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:33580bccab0338d00994d7f16f4c4ec25b776af3ffaac1ed74e0b3fc95e885a8"}, + {file = "tomli-2.2.1-cp311-cp311-win32.whl", hash = "sha256:465af0e0875402f1d226519c9904f37254b3045fc5084697cefb9bdde1ff99ff"}, + {file = "tomli-2.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:2d0f2fdd22b02c6d81637a3c95f8cd77f995846af7414c5c4b8d0545afa1bc4b"}, + {file = "tomli-2.2.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4a8f6e44de52d5e6c657c9fe83b562f5f4256d8ebbfe4ff922c495620a7f6cea"}, + {file = "tomli-2.2.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8d57ca8095a641b8237d5b079147646153d22552f1c637fd3ba7f4b0b29167a8"}, + {file = "tomli-2.2.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e340144ad7ae1533cb897d406382b4b6fede8890a03738ff1683af800d54192"}, + {file = "tomli-2.2.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:db2b95f9de79181805df90bedc5a5ab4c165e6ec3fe99f970d0e302f384ad222"}, + {file = "tomli-2.2.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:40741994320b232529c802f8bc86da4e1aa9f413db394617b9a256ae0f9a7f77"}, + {file = "tomli-2.2.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:400e720fe168c0f8521520190686ef8ef033fb19fc493da09779e592861b78c6"}, + {file = "tomli-2.2.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:02abe224de6ae62c19f090f68da4e27b10af2b93213d36cf44e6e1c5abd19fdd"}, + {file = "tomli-2.2.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b82ebccc8c8a36f2094e969560a1b836758481f3dc360ce9a3277c65f374285e"}, + {file = "tomli-2.2.1-cp312-cp312-win32.whl", hash = "sha256:889f80ef92701b9dbb224e49ec87c645ce5df3fa2cc548664eb8a25e03127a98"}, + {file = "tomli-2.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:7fc04e92e1d624a4a63c76474610238576942d6b8950a2d7f908a340494e67e4"}, + {file = "tomli-2.2.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f4039b9cbc3048b2416cc57ab3bda989a6fcf9b36cf8937f01a6e731b64f80d7"}, + {file = "tomli-2.2.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:286f0ca2ffeeb5b9bd4fcc8d6c330534323ec51b2f52da063b11c502da16f30c"}, + {file = "tomli-2.2.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a92ef1a44547e894e2a17d24e7557a5e85a9e1d0048b0b5e7541f76c5032cb13"}, + {file = "tomli-2.2.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9316dc65bed1684c9a98ee68759ceaed29d229e985297003e494aa825ebb0281"}, + {file = "tomli-2.2.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e85e99945e688e32d5a35c1ff38ed0b3f41f43fad8df0bdf79f72b2ba7bc5272"}, + {file = "tomli-2.2.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ac065718db92ca818f8d6141b5f66369833d4a80a9d74435a268c52bdfa73140"}, + {file = "tomli-2.2.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:d920f33822747519673ee656a4b6ac33e382eca9d331c87770faa3eef562aeb2"}, + {file = "tomli-2.2.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a198f10c4d1b1375d7687bc25294306e551bf1abfa4eace6650070a5c1ae2744"}, + {file = "tomli-2.2.1-cp313-cp313-win32.whl", hash = "sha256:d3f5614314d758649ab2ab3a62d4f2004c825922f9e370b29416484086b264ec"}, + {file = "tomli-2.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:a38aa0308e754b0e3c67e344754dff64999ff9b513e691d0e786265c93583c69"}, + {file = "tomli-2.2.1-py3-none-any.whl", hash = "sha256:cb55c73c5f4408779d0cf3eef9f762b9c9f147a77de7b258bef0a5628adc85cc"}, + {file = "tomli-2.2.1.tar.gz", hash = "sha256:cd45e1dc79c835ce60f7404ec8119f2eb06d38b1deba146f07ced3bbc44505ff"}, +] +markers = {docs = "python_version < \"3.11\"", test = "python_full_version <= \"3.11.0a6\""} + +[[package]] +name = "tomlkit" +version = "0.13.3" +description = "Style preserving TOML library" +optional = false +python-versions = ">=3.8" +groups = ["docs", "test"] +files = [ + {file = "tomlkit-0.13.3-py3-none-any.whl", hash = "sha256:c89c649d79ee40629a9fda55f8ace8c6a1b42deb912b2a8fd8d942ddadb606b0"}, + {file = "tomlkit-0.13.3.tar.gz", hash = "sha256:430cf247ee57df2b94ee3fbe588e71d362a941ebb545dec29b53961d61add2a1"}, +] + +[[package]] +name = "tqdm" +version = "4.67.1" +description = "Fast, Extensible Progress Meter" +optional = false +python-versions = ">=3.7" +groups = ["test"] +files = [ + {file = "tqdm-4.67.1-py3-none-any.whl", hash = "sha256:26445eca388f82e72884e0d580d5464cd801a3ea01e63e5601bdff9ba6a48de2"}, + {file = "tqdm-4.67.1.tar.gz", hash = "sha256:f8aef9c52c08c13a65f30ea34f4e5aac3fd1a34959879d7e59e63027286627f2"}, +] + +[package.dependencies] +colorama = {version = "*", markers = "platform_system == \"Windows\""} + +[package.extras] +dev = ["nbval", "pytest (>=6)", "pytest-asyncio (>=0.24)", "pytest-cov", "pytest-timeout"] +discord = ["requests"] +notebook = ["ipywidgets (>=6)"] +slack = ["slack-sdk"] +telegram = ["requests"] + +[[package]] +name = "typer" +version = "0.16.0" +description = "Typer, build great CLIs. Easy to code. Based on Python type hints." +optional = false +python-versions = ">=3.7" +groups = ["test"] +files = [ + {file = "typer-0.16.0-py3-none-any.whl", hash = "sha256:1f79bed11d4d02d4310e3c1b7ba594183bcedb0ac73b27a9e5f28f6fb5b98855"}, + {file = "typer-0.16.0.tar.gz", hash = "sha256:af377ffaee1dbe37ae9440cb4e8f11686ea5ce4e9bae01b84ae7c63b87f1dd3b"}, +] + +[package.dependencies] +click = ">=8.0.0" +rich = ">=10.11.0" +shellingham = ">=1.3.0" +typing-extensions = ">=3.7.4.3" + +[[package]] +name = "typing-extensions" +version = "4.14.0" +description = "Backported and Experimental Type Hints for Python 3.9+" +optional = false +python-versions = ">=3.9" +groups = ["main", "test"] +files = [ + {file = "typing_extensions-4.14.0-py3-none-any.whl", hash = "sha256:a1514509136dd0b477638fc68d6a91497af5076466ad0fa6c338e44e359944af"}, + {file = "typing_extensions-4.14.0.tar.gz", hash = "sha256:8676b788e32f02ab42d9e7c61324048ae4c6d844a399eebace3d4979d75ceef4"}, +] +markers = {main = "python_version < \"3.11\""} + +[[package]] +name = "tzdata" +version = "2025.2" +description = "Provider of IANA time zone data" +optional = false +python-versions = ">=2" +groups = ["main"] +markers = "sys_platform == \"win32\"" +files = [ + {file = "tzdata-2025.2-py2.py3-none-any.whl", hash = "sha256:1a403fada01ff9221ca8044d701868fa132215d84beb92242d9acd2147f667a8"}, + {file = "tzdata-2025.2.tar.gz", hash = "sha256:b60a638fcc0daffadf82fe0f57e53d06bdec2f36c4df66280ae79bce6bd6f2b9"}, +] + +[[package]] +name = "urllib3" +version = "2.5.0" +description = "HTTP library with thread-safe connection pooling, file post, and more." +optional = false +python-versions = ">=3.9" +groups = ["docs", "test"] +files = [ + {file = "urllib3-2.5.0-py3-none-any.whl", hash = "sha256:e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc"}, + {file = "urllib3-2.5.0.tar.gz", hash = "sha256:3fc47733c7e419d4bc3f6b3dc2b4f890bb743906a30d56ba4a5bfa4bbff92760"}, +] + +[package.extras] +brotli = ["brotli (>=1.0.9) ; platform_python_implementation == \"CPython\"", "brotlicffi (>=0.8.0) ; platform_python_implementation != \"CPython\""] +h2 = ["h2 (>=4,<5)"] +socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"] +zstd = ["zstandard (>=0.18.0)"] + +[[package]] +name = "zipp" +version = "3.22.0" +description = "Backport of pathlib-compatible object wrapper for zip files" +optional = false +python-versions = ">=3.9" +groups = ["docs", "test"] +markers = "python_version < \"3.10\"" +files = [ + {file = "zipp-3.22.0-py3-none-any.whl", hash = "sha256:fe208f65f2aca48b81f9e6fd8cf7b8b32c26375266b009b413d45306b6148343"}, + {file = "zipp-3.22.0.tar.gz", hash = "sha256:dd2f28c3ce4bc67507bfd3781d21b7bb2be31103b51a4553ad7d90b84e57ace5"}, +] + +[package.extras] +check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1) ; sys_platform != \"cygwin\""] +cover = ["pytest-cov"] +doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] +enabler = ["pytest-enabler (>=2.2)"] +test = ["big-O", "importlib_resources ; python_version < \"3.9\"", "jaraco.functools", "jaraco.itertools", "jaraco.test", "more_itertools", "pytest (>=6,!=8.1.*)", "pytest-ignore-flaky"] +type = ["pytest-mypy"] + +[metadata] +lock-version = "2.1" +python-versions = "^3.9" +content-hash = "846cf15b8de2eba66370c4627abf2bcc7bbe4e94be0dc1ae5ea577954cd88ec5" diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 00000000..da0dca36 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,134 @@ +[build-system] +requires = ["poetry-core>=1.9"] +build-backend = "poetry.core.masonry.api" + + +[tool.poetry] +name = "django-eav2" +description = "Entity-Attribute-Value storage for Django" +version = "1.8.1" +license = "GNU Lesser General Public License (LGPL), Version 3" +packages = [ + { include = "eav" } +] + + +authors = [ + "Mauro Lizaur ", +] + +readme = "README.md" + +repository = "https://github.com/jazzband/django-eav2" + +keywords = [ + "django", + "django-eav2", + "database", + "eav", + "sql", +] + +classifiers = [ + "Development Status :: 3 - Alpha", + "Environment :: Web Environment", + "Framework :: Django", + "Intended Audience :: Developers", + "License :: OSI Approved :: GNU Library or Lesser General Public License (LGPL)", + "Programming Language :: Python", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Topic :: Database", + "Topic :: Software Development :: Libraries :: Python Modules", + "Framework :: Django", + "Framework :: Django :: 4.2", + "Framework :: Django :: 5.1", + "Framework :: Django :: 5.2", +] + +[tool.semantic_release] +version_variable = [ + "pyproject.toml:version" +] +branch = "master" +upload_to_pypi = false +upload_to_release = false +build_command = "pip install poetry && poetry build" + +[tool.poetry.dependencies] +python = "^3.9" +django = ">=4.2,<5.3" + +[tool.poetry.group.test.dependencies] +mypy = "^1.6" +ruff = ">=0.6.3,<0.13.0" + +safety = ">=2.3,<4.0" + +pytest = ">=7.4.3,<9.0.0" +pytest-cov = ">=4.1,<7.0" +pytest-randomly = "^3.15" +pytest-django = "^4.5.2" +hypothesis = "^6.87.1" + +doc8 = ">=0.11.2,<1.2.0" + +[tool.poetry.group.docs] +optional = true + +[tool.poetry.group.docs.dependencies] +sphinx = ">=5.0,<8.0" +sphinx-rtd-theme = ">=1.3,<4.0" +sphinx-autodoc-typehints = ">=1.19.5,<3.0.0" +m2r2 = "^0.3" +tomlkit = ">=0.13.0,<0.14" + + +[tool.ruff] +line-length = 88 +target-version = "py38" + +[tool.ruff.lint] +select = ["ALL"] +ignore = [ + "ANN", # Type hints related, let mypy handle these. + "ARG", # Unused arguments + "D", # Docstrings related + "EM101", # "Exception must not use a string literal, assign to variable first" + "EM102", # "Exception must not use an f-string literal, assign to variable first" + "PD", # Pandas related + "Q000", # For now + "SIM105", # "Use contextlib.suppress({exception}) instead of try-except-pass" + "TRY003", # "Avoid specifying long messages outside the exception class" + ] + +[tool.ruff.lint.flake8-implicit-str-concat] +allow-multiline = false + +[tool.ruff.lint.per-file-ignores] +# Allow private member access for Registry +"eav/registry.py" = ["SLF001"] + +# Migrations are special +"**/migrations/*" = ["RUF012"] + +# Sphinx specific +"docs/source/conf.py" = ["INP001"] + +# pytest is even more special +"tests/*" = [ + "INP001", # "Add an __init__.py" + "PLR2004", # "Magic value used in comparison" + "PT009", # "Use a regular assert instead of unittest-style" + "PT027", # "Use pytest.raises instead of unittest-style" + "S101", # "Use of assert detected" + "SLF001" # "Private member accessed" + ] + +[tool.ruff.lint.pydocstyle] +# Use Google-style docstrings. +convention = "google" diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index e97c9bd1..00000000 --- a/requirements.txt +++ /dev/null @@ -1 +0,0 @@ -Django>=1.11 diff --git a/runtests b/runtests deleted file mode 100755 index c71a783f..00000000 --- a/runtests +++ /dev/null @@ -1,29 +0,0 @@ -#!/usr/bin/env python -import os -import sys - -import django -from django.conf import settings -from django.test.utils import get_runner - -if __name__ == "__main__": - os.environ['DJANGO_SETTINGS_MODULE'] = 'tests.test_settings' - django.setup() - TestRunner = get_runner(settings) - test_runner = TestRunner() - - if len(sys.argv) == 1 or sys.argv[1] in ['-a', '--all']: - tests = [ - 'tests.queries', - 'tests.registry', - 'tests.data_validation', - 'tests.attributes', - 'tests.misc_models', - 'tests.set_and_get', - 'tests.forms' - ] - else: - tests = ['tests.{}'.format(arg) for arg in sys.argv[1:]] - - result = test_runner.run_tests(tests) - sys.exit(bool(result)) diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 00000000..d19478ef --- /dev/null +++ b/setup.cfg @@ -0,0 +1,75 @@ +# All configuration for plugins and other utils is defined here. +# Read more about `setup.cfg`: +# https://docs.python.org/3/distutils/configfile.html + + +[tool:pytest] +# Django options: +# https://pytest-django.readthedocs.io/en/latest/ +DJANGO_SETTINGS_MODULE = test_project.settings + +# PYTHONPATH configuration: +# https://docs.pytest.org/en/7.0.x/reference/reference.html#confval-pythonpath +pythonpath = ./eav + +# py.test options: +norecursedirs = + *.egg + .eggs + dist + build + docs + .tox + .git + __pycache__ + +# You will need to measure your tests speed with `-n auto` and without it, +# so you can see whether it gives you any performance gain, or just gives +# you an overhead. See `docs/template/development-process.rst`. +addopts = + -p no:randomly + --strict-markers + --strict-config + --doctest-modules + --cov=eav + --cov-report=term-missing:skip-covered + --cov-report=html + --cov-report=xml + --cov-branch + --cov-fail-under=90 + + +[coverage:run] +# Exclude tox output from coverage calculation +omit = */.tox/* + +[coverage:report] +skip_covered = True +show_missing = True +sort = Cover +exclude_lines = + pragma: no cover + # type hinting related code + if TYPE_CHECKING: + + +[mypy] +# mypy configurations: https://bit.ly/2zEl9WI + +allow_redefinition = False +check_untyped_defs = True +disallow_any_explicit = True +disallow_any_generics = True +disallow_untyped_calls = True +ignore_errors = False +ignore_missing_imports = True +implicit_reexport = False +strict_optional = True +strict_equality = True +local_partial_types = True +no_implicit_optional = True +warn_no_return = True +warn_unused_ignores = True +warn_redundant_casts = True +warn_unused_configs = True +warn_unreachable = True diff --git a/setup.py b/setup.py deleted file mode 100755 index 8d06df0c..00000000 --- a/setup.py +++ /dev/null @@ -1,25 +0,0 @@ -from setuptools import setup, find_packages - -setup( - name = 'django-eav2', - version = __import__('eav').__version__, - license = 'GNU Lesser General Public License (LGPL), Version 3', - requires = ['python (>= 3.5)', 'django (>= 1.11.14)'], - provides = ['eav'], - description = 'Entity-Attribute-Value storage for Django', - url = 'http://github.com/makimo/django-eav2', - packages = find_packages(), - maintainer = 'Iwo Herka', - maintainer_email = 'hi@iwoherka.eu', - - classifiers = [ - 'Development Status :: 3 - Alpha', - 'Environment :: Web Environment', - 'Framework :: Django', - 'Intended Audience :: Developers', - 'License :: OSI Approved :: GNU Library or Lesser General Public License (LGPL)', - 'Programming Language :: Python', - 'Topic :: Database', - 'Topic :: Software Development :: Libraries :: Python Modules', - ], -) diff --git a/test_project/__init__.py b/test_project/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/test_project/apps.py b/test_project/apps.py new file mode 100644 index 00000000..caf81c52 --- /dev/null +++ b/test_project/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class TestAppConfig(AppConfig): + name = "test_project" diff --git a/test_project/migrations/0001_initial.py b/test_project/migrations/0001_initial.py new file mode 100644 index 00000000..a81045eb --- /dev/null +++ b/test_project/migrations/0001_initial.py @@ -0,0 +1,160 @@ +import uuid + +from django.db import migrations, models + +from test_project.models import MAX_CHARFIELD_LEN + + +class Migration(migrations.Migration): + """Initial migration for test_project.""" + + initial = True + + dependencies = [] + + operations = [ + migrations.CreateModel( + name="ExampleMetaclassModel", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("name", models.CharField(max_length=MAX_CHARFIELD_LEN)), + ], + options={ + "abstract": False, + }, + ), + migrations.CreateModel( + name="ExampleModel", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("name", models.CharField(max_length=MAX_CHARFIELD_LEN)), + ], + options={ + "abstract": False, + }, + ), + migrations.CreateModel( + name="RegisterTestModel", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("name", models.CharField(max_length=MAX_CHARFIELD_LEN)), + ], + options={ + "abstract": False, + }, + ), + migrations.CreateModel( + name="Patient", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("name", models.CharField(max_length=MAX_CHARFIELD_LEN)), + ("email", models.EmailField(blank=True, max_length=MAX_CHARFIELD_LEN)), + ( + "example", + models.ForeignKey( + blank=True, + null=True, + on_delete=models.deletion.PROTECT, + to="test_project.examplemodel", + ), + ), + ], + options={ + "abstract": False, + }, + ), + migrations.CreateModel( + name="M2MModel", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("name", models.CharField(max_length=MAX_CHARFIELD_LEN)), + ("models", models.ManyToManyField(to="test_project.ExampleModel")), + ], + options={ + "abstract": False, + }, + ), + migrations.CreateModel( + name="Encounter", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("num", models.PositiveSmallIntegerField()), + ( + "patient", + models.ForeignKey( + on_delete=models.deletion.PROTECT, + to="test_project.patient", + ), + ), + ], + options={ + "abstract": False, + }, + ), + migrations.CreateModel( + name="Doctor", + fields=[ + ( + "id", + models.UUIDField( + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + ), + ), + ("name", models.CharField(max_length=MAX_CHARFIELD_LEN)), + ], + options={ + "abstract": False, + }, + ), + ] diff --git a/test_project/migrations/__init__.py b/test_project/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/test_project/models.py b/test_project/models.py new file mode 100644 index 00000000..7bd7720b --- /dev/null +++ b/test_project/models.py @@ -0,0 +1,142 @@ +import uuid +from typing import Final, final + +from django.db import models + +from eav.decorators import register_eav +from eav.managers import EntityManager +from eav.models import EAVModelMeta + +#: Constants +MAX_CHARFIELD_LEN: Final = 254 + + +class TestBase(models.Model): + """Base class for test models.""" + + class Meta: + """Define common options.""" + + app_label = "test_project" + abstract = True + + +class DoctorManager(EntityManager): + """ + Custom manager for the Doctor model. + + This manager extends the EntityManager and provides additional + methods specific to the Doctor model, and is expected to be the + default manager on the model. + """ + + def get_by_name(self, name: str) -> models.QuerySet: + """Returns a QuerySet of doctors with the given name. + + Args: + name (str): The name of the doctor to search for. + + Returns: + models.QuerySet: A QuerySet of doctors with the specified name. + """ + return self.filter(name=name) + + +class DoctorSubstringManager(models.Manager): + """ + Custom manager for the Doctor model. + + This is a second manager used to ensure during testing that it's not replaced + as the default manager after eav.register(). + """ + + def get_by_name_contains(self, substring: str) -> models.QuerySet: + """Returns a QuerySet of doctors whose names contain the given substring. + + Args: + substring (str): The substring to search for in the doctor's name. + + Returns: + models.QuerySet: A QuerySet of doctors whose names contain the + specified substring. + """ + return self.filter(name__icontains=substring) + + +@final +@register_eav() +class Doctor(TestBase): + """Test model using UUID as primary key.""" + + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + name = models.CharField(max_length=MAX_CHARFIELD_LEN) + + objects = DoctorManager() + substrings = DoctorSubstringManager() + + def __str__(self): + return self.name + + +@final +class Patient(TestBase): + name = models.CharField(max_length=MAX_CHARFIELD_LEN) + email = models.EmailField(max_length=MAX_CHARFIELD_LEN, blank=True) + example = models.ForeignKey( + "ExampleModel", + null=True, + blank=True, + on_delete=models.PROTECT, + ) + + def __str__(self): + return self.name + + def __repr__(self): + return self.name + + +class Encounter(TestBase): + num = models.PositiveSmallIntegerField() + patient = models.ForeignKey(Patient, on_delete=models.PROTECT) + + def __str__(self): + return f"{self.patient}: encounter num {self.num}" + + def __repr__(self): + return self.name + + +@register_eav() +@final +class ExampleModel(TestBase): + name = models.CharField(max_length=MAX_CHARFIELD_LEN) + + def __str__(self): + return self.name + + +@register_eav() +@final +class M2MModel(TestBase): + name = models.CharField(max_length=MAX_CHARFIELD_LEN) + models = models.ManyToManyField(ExampleModel) + + def __str__(self): + return self.name + + +@final +class ExampleMetaclassModel(TestBase, metaclass=EAVModelMeta): + name = models.CharField(max_length=MAX_CHARFIELD_LEN) + + def __str__(self): + return self.name + + +@final +class RegisterTestModel(TestBase, metaclass=EAVModelMeta): + name = models.CharField(max_length=MAX_CHARFIELD_LEN) + + def __str__(self): + return self.name diff --git a/test_project/settings.py b/test_project/settings.py new file mode 100644 index 00000000..47cb3b24 --- /dev/null +++ b/test_project/settings.py @@ -0,0 +1,102 @@ +from __future__ import annotations + +from pathlib import Path + +# Build paths inside the project like this: BASE_DIR / 'subdir'. +BASE_DIR = Path(__file__).parent.parent + + +# Quick-start development settings - unsuitable for production +# See https://docs.djangoproject.com/en/3.1/howto/deployment/checklist/ + +# SECURITY WARNING: keep the secret key used in production secret! +SECRET_KEY = "secret!" # noqa: S105 + +# SECURITY WARNING: don't run with debug turned on in production! +DEBUG = True + +ALLOWED_HOSTS: list[str] = [] + + +# Application definition + +INSTALLED_APPS = [ + "django.contrib.admin", + "django.contrib.auth", + "django.contrib.contenttypes", + "django.contrib.sessions", + "django.contrib.messages", + "django.contrib.staticfiles", + # Test Project: + "test_project.apps.TestAppConfig", + # Our app: + "eav", +] + + +MIDDLEWARE = [ + "django.middleware.security.SecurityMiddleware", + "django.contrib.sessions.middleware.SessionMiddleware", + "django.middleware.common.CommonMiddleware", + "django.middleware.csrf.CsrfViewMiddleware", + "django.contrib.auth.middleware.AuthenticationMiddleware", + "django.contrib.messages.middleware.MessageMiddleware", + "django.middleware.clickjacking.XFrameOptionsMiddleware", +] + +TEMPLATES = [ + { + "BACKEND": "django.template.backends.django.DjangoTemplates", + "DIRS": [], + "APP_DIRS": True, + "OPTIONS": { + "context_processors": [ + "django.template.context_processors.debug", + "django.template.context_processors.request", + "django.contrib.auth.context_processors.auth", + "django.contrib.messages.context_processors.messages", + ], + }, + }, +] + + +# Database +# https://docs.djangoproject.com/en/3.1/ref/settings/#databases + +DATABASES = { + "default": { + "ENGINE": "django.db.backends.sqlite3", + "NAME": ":memory:", + }, +} + + +DEFAULT_AUTO_FIELD = "django.db.models.AutoField" +EAV2_PRIMARY_KEY_FIELD = "django.db.models.AutoField" + + +# Password validation +# https://docs.djangoproject.com/en/3.1/ref/settings/#auth-password-validators + +AUTH_PASSWORD_VALIDATORS = [] + + +# Internationalization +# https://docs.djangoproject.com/en/3.1/topics/i18n/ + +LANGUAGE_CODE = "en-us" + +TIME_ZONE = "UTC" + +USE_I18N = True + +USE_L10N = True + +USE_TZ = False + + +# Static files (CSS, JavaScript, Images) +# https://docs.djangoproject.com/en/3.1/howto/static-files/ + +STATIC_URL = "/static/" diff --git a/tests/attributes.py b/tests/attributes.py deleted file mode 100644 index 74674dfe..00000000 --- a/tests/attributes.py +++ /dev/null @@ -1,90 +0,0 @@ -from django.core.exceptions import ValidationError -from django.test import TestCase - -import sys -import eav -from eav.exceptions import IllegalAssignmentException -from eav.models import Attribute, Value -from eav.registry import EavConfig - -from .models import Encounter, Patient - -if sys.version_info[0] > 2: - from .metaclass_models3 import RegisterTestModel -else: - from .metaclass_models2 import RegisterTestModel - - -class Attributes(TestCase): - def setUp(self): - class EncounterEavConfig(EavConfig): - manager_attr = 'eav_objects' - eav_attr = 'eav_field' - generic_relation_attr = 'encounter_eav_values' - generic_relation_related_name = 'encounters' - - @classmethod - def get_attributes(cls, instance=None): - return Attribute.objects.filter(slug__contains='a') - - eav.register(Encounter, EncounterEavConfig) - eav.register(Patient) - - Attribute.objects.create(name='age', datatype=Attribute.TYPE_INT) - Attribute.objects.create(name='height', datatype=Attribute.TYPE_FLOAT) - Attribute.objects.create(name='weight', datatype=Attribute.TYPE_FLOAT) - Attribute.objects.create(name='color', datatype=Attribute.TYPE_TEXT) - - def tearDown(self): - eav.unregister(Encounter) - eav.unregister(Patient) - - def test_get_attribute_querysets(self): - self.assertEqual(Patient._eav_config_cls.get_attributes().count(), 4) - self.assertEqual(Encounter._eav_config_cls.get_attributes().count(), 1) - - def test_duplicate_attributs(self): - ''' - Ensure that no two Attributes with the same slug can exist. - ''' - with self.assertRaises(ValidationError): - Attribute.objects.create(name='height', datatype=Attribute.TYPE_FLOAT) - - def test_setting_attributes(self): - p = Patient.objects.create(name='Jon') - e = Encounter.objects.create(patient=p, num=1) - - p.eav.age = 3 - p.eav.height = 2.3 - p.save() - e.eav_field.age = 4 - e.save() - self.assertEqual(Value.objects.count(), 3) - t = RegisterTestModel.objects.create(name="test") - t.eav.age = 6 - t.eav.height = 10 - t.save() - p = Patient.objects.get(name='Jon') - self.assertEqual(p.eav.age, 3) - self.assertEqual(p.eav.height, 2.3) - e = Encounter.objects.get(num=1) - self.assertEqual(e.eav_field.age, 4) - t = RegisterTestModel.objects.get(name="test") - self.assertEqual(t.eav.age, 6) - self.assertEqual(t.eav.height, 10) - - def test_illegal_assignemnt(self): - class EncounterEavConfig(EavConfig): - @classmethod - def get_attributes(cls, instance=None): - return Attribute.objects.filter(datatype=Attribute.TYPE_INT) - - eav.unregister(Encounter) - eav.register(Encounter, EncounterEavConfig) - - p = Patient.objects.create(name='Jon') - e = Encounter.objects.create(patient=p, num=1) - - with self.assertRaises(IllegalAssignmentException): - e.eav.color = 'red' - e.save() diff --git a/tests/forms.py b/tests/forms.py deleted file mode 100644 index 9fa6f04b..00000000 --- a/tests/forms.py +++ /dev/null @@ -1,113 +0,0 @@ -from django.test import TestCase -from django.contrib.admin.sites import AdminSite - -import eav -import sys -from eav.admin import * -from .models import Patient, M2MModel, ExampleModel -from eav.models import Attribute -from eav.forms import BaseDynamicEntityForm -from django.contrib import admin -from django.core.handlers.base import BaseHandler -from django.test.client import RequestFactory -from django.forms import ModelForm - - -class MockRequest(RequestFactory): - def request(self, **request): - "Construct a generic request object." - request = RequestFactory.request(self, **request) - handler = BaseHandler() - handler.load_middleware() - # BaseHandler_request_middleware is not set in Django2.0 - # and removed in Django2.1 - if sys.version_info[0] < 2: - for middleware_method in handler._request_middleware: - if middleware_method(request): - raise Exception("Couldn't create request mock object - " - "request middleware returned a response") - return request - - -class MockSuperUser: - def __init__(self): - self.is_active = True - self.is_staff = True - - def has_perm(self, perm): - return True - - -request = MockRequest().request() -request.user = MockSuperUser() - - -class PatientForm(ModelForm): - class Meta: - model = Patient - fields = '__all__' - - -class M2MModelForm(ModelForm): - class Meta: - model = M2MModel - fields = '__all__' - - -class Forms(TestCase): - def setUp(self): - eav.register(Patient) - Attribute.objects.create(name='weight', datatype=Attribute.TYPE_FLOAT) - Attribute.objects.create(name='color', datatype=Attribute.TYPE_TEXT) - - self.female = EnumValue.objects.create(value='Female') - self.male = EnumValue.objects.create(value='Male') - gender_group = EnumGroup.objects.create(name='Gender') - gender_group.values.add(self.female, self.male) - - Attribute.objects.create( - name='gender', - datatype=Attribute.TYPE_ENUM, - enum_group=gender_group - ) - - self.instance = Patient.objects.create(name='Jim Morrison') - self.site = AdminSite() - - def test_fields(self): - admin = BaseEntityAdmin(Patient, self.site) - admin.form = BaseDynamicEntityForm - view = admin.change_view(request, str(self.instance.pk)) - - own_fields = 2 - adminform = view.context_data['adminform'] - - self.assertEqual( - len(adminform.form.fields), Attribute.objects.count() + own_fields - ) - - def test_valid_submit(self): - self.instance.eav.color = 'Blue' - form = PatientForm(self.instance.__dict__, instance=self.instance) - jim = form.save() - - self.assertEqual(jim.eav.color, 'Blue') - - def test_invalid_submit(self): - form = PatientForm(dict(color='Blue'), instance=self.instance) - with self.assertRaises(ValueError): - jim = form.save() - - def test_valid_enums(self): - self.instance.eav.gender = self.female - form = PatientForm(self.instance.__dict__, instance=self.instance) - rose = form.save() - - self.assertEqual(rose.eav.gender, self.female) - - def test_m2m(self): - m2mmodel = M2MModel.objects.create(name='name') - model = ExampleModel.objects.create(name='name') - form = M2MModelForm(dict(name='Lorem', models=[model.pk]), instance=m2mmodel) - form.save() - self.assertEqual(len(m2mmodel.models.all()), 1) diff --git a/tests/metaclass_models2.py b/tests/metaclass_models2.py deleted file mode 100644 index 10245a36..00000000 --- a/tests/metaclass_models2.py +++ /dev/null @@ -1,18 +0,0 @@ -from django.db import models -from eav.models import EAVModelMeta - - -class ExampleMetaclassModel(models.Model): - __metaclass__ = EAVModelMeta - name = models.CharField(max_length=12) - - def __unicode__(self): - return self.name - - -class RegisterTestModel(models.Model): - __metaclass__ = EAVModelMeta - name = models.CharField(max_length=12) - - def __unicode__(self): - return self.name diff --git a/tests/metaclass_models3.py b/tests/metaclass_models3.py deleted file mode 100644 index 3ace3d5f..00000000 --- a/tests/metaclass_models3.py +++ /dev/null @@ -1,16 +0,0 @@ -from django.db import models -from eav.models import EAVModelMeta - - -class ExampleMetaclassModel(models.Model, metaclass=EAVModelMeta): - name = models.CharField(max_length=12) - - def __str__(self): - return self.name - - -class RegisterTestModel(models.Model, metaclass=EAVModelMeta): - name = models.CharField(max_length=12) - - def __str__(self): - return self.name diff --git a/tests/misc_models.py b/tests/misc_models.py deleted file mode 100644 index 2a78fb9e..00000000 --- a/tests/misc_models.py +++ /dev/null @@ -1,41 +0,0 @@ -from django.test import TestCase - -from eav.models import EnumGroup, Attribute, Value, EnumValue - -import eav -from .models import Patient - - -class MiscModels(TestCase): - def test_enumgroup_str(self): - name = 'Yes / No' - e = EnumGroup.objects.create(name=name) - self.assertEqual('', str(e)) - - def test_attribute_help_text(self): - desc = 'Patient Age' - a = Attribute.objects.create(name='age', description=desc, datatype=Attribute.TYPE_INT) - self.assertEqual(a.help_text, desc) - - def test_setting_to_none_deletes_value(self): - eav.register(Patient) - Attribute.objects.create(name='age', datatype=Attribute.TYPE_INT) - p = Patient.objects.create(name='Bob', eav__age=5) - self.assertEqual(Value.objects.count(), 1) - p.eav.age = None - p.save() - self.assertEqual(Value.objects.count(), 0) - - def test_string_enum_value_assignment(self): - yes = EnumValue.objects.create(value='yes') - no = EnumValue.objects.create(value='no') - ynu = EnumGroup.objects.create(name='Yes / No / Unknown') - ynu.values.add(yes) - ynu.values.add(no) - Attribute.objects.create(name='is_patient', datatype=Attribute.TYPE_ENUM, enum_group=ynu) - eav.register(Patient) - p = Patient.objects.create(name='Joe') - p.eav.is_patient = 'yes' - p.save() - p = Patient.objects.get(name='Joe') # get from DB again - self.assertEqual(p.eav.is_patient, yes) diff --git a/tests/models.py b/tests/models.py deleted file mode 100644 index 03752129..00000000 --- a/tests/models.py +++ /dev/null @@ -1,42 +0,0 @@ -from django.db import models -from eav.decorators import register_eav - - -class Patient(models.Model): - name = models.CharField(max_length=12) - example = models.ForeignKey( - 'ExampleModel', null=True, blank=True, on_delete=models.PROTECT) - - def __str__(self): - return self.name - - def __repr__(self): - return self.name - - -class Encounter(models.Model): - num = models.PositiveSmallIntegerField() - patient = models.ForeignKey(Patient, on_delete=models.PROTECT) - - def __str__(self): - return '%s: encounter num %d' % (self.patient, self.num) - - def __repr__(self): - return self.name - - -@register_eav() -class ExampleModel(models.Model): - name = models.CharField(max_length=12) - - def __unicode__(self): - return self.name - - -@register_eav() -class M2MModel(models.Model): - name = models.CharField(max_length=12) - models = models.ManyToManyField(ExampleModel) - - def __unicode__(self): - return self.name diff --git a/tests/queries.py b/tests/queries.py deleted file mode 100644 index cd5c54b8..00000000 --- a/tests/queries.py +++ /dev/null @@ -1,206 +0,0 @@ -from django.core.exceptions import MultipleObjectsReturned, ObjectDoesNotExist -from django.db.models import Q -from django.db.utils import NotSupportedError -from django.test import TestCase - -import eav -from eav.models import Attribute, EnumGroup, EnumValue, Value -from eav.registry import EavConfig - -from .models import Encounter, Patient - - -class Queries(TestCase): - def setUp(self): - eav.register(Encounter) - eav.register(Patient) - - Attribute.objects.create(name='age', datatype=Attribute.TYPE_INT) - Attribute.objects.create(name='height', datatype=Attribute.TYPE_FLOAT) - Attribute.objects.create(name='weight', datatype=Attribute.TYPE_FLOAT) - Attribute.objects.create(name='city', datatype=Attribute.TYPE_TEXT) - Attribute.objects.create(name='country', datatype=Attribute.TYPE_TEXT) - - self.yes = EnumValue.objects.create(value='yes') - self.no = EnumValue.objects.create(value='no') - self.unknown = EnumValue.objects.create(value='unknown') - - ynu = EnumGroup.objects.create(name='Yes / No / Unknown') - ynu.values.add(self.yes) - ynu.values.add(self.no) - ynu.values.add(self.unknown) - - Attribute.objects.create(name='fever', datatype=Attribute.TYPE_ENUM, enum_group=ynu) - - def tearDown(self): - eav.unregister(Encounter) - eav.unregister(Patient) - - def init_data(self): - yes = self.yes - no = self.no - - data = [ - # Name, age, fever, city, country. - ['Anne', 3, no, 'New York', 'USA'], - ['Bob', 15, no, 'Bamako', 'Mali'], - ['Cyrill', 15, yes, 'Kisumu', 'Kenya'], - ['Daniel', 3, no, 'Nice', 'France'], - ['Eugene', 2, yes, 'France', 'Nice'] - ] - - for row in data: - Patient.objects.create( - name=row[0], - eav__age=row[1], - eav__fever=row[2], - eav__city=row[3], - eav__country=row[4] - ) - - def test_get_or_create_with_eav(self): - Patient.objects.get_or_create(name='Bob', eav__age=5) - self.assertEqual(Patient.objects.count(), 1) - self.assertEqual(Value.objects.count(), 1) - Patient.objects.get_or_create(name='Bob', eav__age=5) - self.assertEqual(Patient.objects.count(), 1) - self.assertEqual(Value.objects.count(), 1) - Patient.objects.get_or_create(name='Bob', eav__age=6) - self.assertEqual(Patient.objects.count(), 2) - self.assertEqual(Value.objects.count(), 2) - - def test_get_with_eav(self): - p1, _ = Patient.objects.get_or_create(name='Bob', eav__age=6) - self.assertEqual(Patient.objects.get(eav__age=6), p1) - - Patient.objects.create(name='Fred', eav__age=6) - self.assertRaises(MultipleObjectsReturned, lambda: Patient.objects.get(eav__age=6)) - - def test_filtering_on_normal_and_eav_fields(self): - self.init_data() - - # Check number of objects in DB. - self.assertEqual(Patient.objects.count(), 5) - self.assertEqual(Value.objects.count(), 20) - - # Nobody - q1 = Q(eav__fever=self.yes) & Q(eav__fever=self.no) - p = Patient.objects.filter(q1) - self.assertEqual(p.count(), 0) - - # Anne, Daniel - q1 = Q(eav__age__gte=3) # Everyone except Eugene - q2 = Q(eav__age__lt=15) # Anne, Daniel, Eugene - p = Patient.objects.filter(q2 & q1) - self.assertEqual(p.count(), 2) - - # Anne - q1 = Q(eav__city__contains='Y') & Q(eav__fever='no') - q2 = Q(eav__age=3) - p = Patient.objects.filter(q1 & q2) - self.assertEqual(p.count(), 1) - - # Anne - q1 = Q(eav__city__contains='Y') & Q(eav__fever=self.no) - q2 = Q(eav__age=3) - p = Patient.objects.filter(q1 & q2) - self.assertEqual(p.count(), 1) - - # Anne, Daniel - q1 = Q(eav__city__contains='Y', eav__fever=self.no) - q2 = Q(eav__city='Nice') - q3 = Q(eav__age=3) - p = Patient.objects.filter((q1 | q2) & q3) - self.assertEqual(p.count(), 2) - - # Everyone - q1 = Q(eav__fever=self.no) | Q(eav__fever=self.yes) - p = Patient.objects.filter(q1) - self.assertEqual(p.count(), 5) - - # Anne, Bob, Daniel - q1 = Q(eav__fever=self.no) # Anne, Bob, Daniel - q2 = Q(eav__fever=self.yes) # Cyrill, Eugene - q3 = Q(eav__country__contains='e') # Cyrill, Daniel, Eugene - q4 = q2 & q3 # Cyrill, Daniel, Eugene - q5 = (q1 | q4) & q1 # Anne, Bob, Daniel - p = Patient.objects.filter(q5) - self.assertEqual(p.count(), 3) - - # Everyone except Anne - q1 = Q(eav__city__contains='Y') - p = Patient.objects.exclude(q1) - self.assertEqual(p.count(), 4) - - # Anne, Bob, Daniel - q1 = Q(eav__city__contains='Y') - q2 = Q(eav__fever=self.no) - q3 = q1 | q2 - p = Patient.objects.filter(q3) - self.assertEqual(p.count(), 3) - - # Anne, Daniel - q1 = Q(eav__age=3) - p = Patient.objects.filter(q1) - self.assertEqual(p.count(), 2) - - # Eugene - q1 = Q(name__contains='E', eav__fever=self.yes) - p = Patient.objects.filter(q1) - self.assertEqual(p.count(), 1) - - def _order(self, ordering): - query = Patient.objects.all().order_by(*ordering) - return list(query.values_list('name', flat=True)) - - def assert_order_by_results(self, eav_attr='eav'): - self.assertEqual( - ['Bob', 'Eugene', 'Cyrill', 'Anne', 'Daniel'], - self._order(['%s__city' % eav_attr]) - ) - - self.assertEqual( - ['Eugene', 'Anne', 'Daniel', 'Bob', 'Cyrill'], - self._order(['%s__age' % eav_attr, '%s__city' % eav_attr]) - ) - - self.assertEqual( - ['Eugene', 'Cyrill', 'Anne', 'Daniel', 'Bob'], - self._order(['%s__fever' % eav_attr, '%s__age' % eav_attr]) - ) - - self.assertEqual( - ['Eugene', 'Cyrill', 'Daniel', 'Bob', 'Anne'], - self._order(['%s__fever' % eav_attr, '-name']) - ) - - self.assertEqual( - ['Eugene', 'Daniel', 'Cyrill', 'Bob', 'Anne'], - self._order(['-name', '%s__age' % eav_attr]) - ) - - self.assertEqual( - ['Anne', 'Bob', 'Cyrill', 'Daniel', 'Eugene'], - self._order(['example__name']) - ) - - with self.assertRaises(NotSupportedError): - Patient.objects.all().order_by('%s__first__second' % eav_attr) - - with self.assertRaises(ObjectDoesNotExist): - Patient.objects.all().order_by('%s__nonsense' % eav_attr) - - def test_order_by(self): - self.init_data() - self.assert_order_by_results() - - def test_order_by_with_custom_config(self): - - class CustomConfig(EavConfig): - eav_attr = "data" - generic_relation_attr = "data_values" - - self.init_data() - eav.unregister(Patient) - eav.register(Patient, config_cls=CustomConfig) - self.assert_order_by_results(eav_attr='data') diff --git a/tests/registry.py b/tests/registry.py deleted file mode 100644 index c042ca8d..00000000 --- a/tests/registry.py +++ /dev/null @@ -1,99 +0,0 @@ -from django.test import TestCase - -import sys -import eav -from eav.registry import EavConfig - -from .models import Encounter, ExampleModel, Patient - -if sys.version_info[0] > 2: - from .metaclass_models3 import ExampleMetaclassModel -else: - from .metaclass_models2 import ExampleMetaclassModel - - -class RegistryTests(TestCase): - def setUp(self): - pass - - def tearDown(self): - pass - - def register_encounter(self): - class EncounterEav(EavConfig): - manager_attr = 'eav_objects' - eav_attr = 'eav_field' - generic_relation_attr = 'encounter_eav_values' - generic_relation_related_name = 'encounters' - - @classmethod - def get_attributes(cls): - return 'testing' - - eav.register(Encounter, EncounterEav) - - def test_registering_with_defaults(self): - eav.register(Patient) - self.assertTrue(hasattr(Patient, '_eav_config_cls')) - self.assertEqual(Patient._eav_config_cls.manager_attr, 'objects') - self.assertFalse(Patient._eav_config_cls.manager_only) - self.assertEqual(Patient._eav_config_cls.eav_attr, 'eav') - self.assertEqual(Patient._eav_config_cls.generic_relation_attr, 'eav_values') - self.assertEqual(Patient._eav_config_cls.generic_relation_related_name, None) - eav.unregister(Patient) - - def test_registering_overriding_defaults(self): - eav.register(Patient) - self.register_encounter() - self.assertTrue(hasattr(Patient, '_eav_config_cls')) - self.assertEqual(Patient._eav_config_cls.manager_attr, 'objects') - self.assertEqual(Patient._eav_config_cls.eav_attr, 'eav') - - self.assertTrue(hasattr(Encounter, '_eav_config_cls')) - self.assertEqual(Encounter._eav_config_cls.get_attributes(), 'testing') - self.assertEqual(Encounter._eav_config_cls.manager_attr, 'eav_objects') - self.assertEqual(Encounter._eav_config_cls.eav_attr, 'eav_field') - eav.unregister(Patient) - eav.unregister(Encounter) - - def test_registering_via_decorator_with_defaults(self): - self.assertTrue(hasattr(ExampleModel, '_eav_config_cls')) - self.assertEqual(ExampleModel._eav_config_cls.manager_attr, 'objects') - self.assertEqual(ExampleModel._eav_config_cls.eav_attr, 'eav') - - def test_register_via_metaclass_with_defaults(self): - self.assertTrue(hasattr(ExampleMetaclassModel, '_eav_config_cls')) - self.assertEqual(ExampleMetaclassModel._eav_config_cls.manager_attr, 'objects') - self.assertEqual(ExampleMetaclassModel._eav_config_cls.eav_attr, 'eav') - - def test_unregistering(self): - old_mgr = Patient.objects - eav.register(Patient) - self.assertTrue(Patient.objects.__class__.__name__ == 'EntityManager') - eav.unregister(Patient) - self.assertFalse(Patient.objects.__class__.__name__ == 'EntityManager') - self.assertEqual(Patient.objects, old_mgr) - self.assertFalse(hasattr(Patient, '_eav_config_cls')) - - def test_unregistering_via_decorator(self): - self.assertTrue(ExampleModel.objects.__class__.__name__ == 'EntityManager') - eav.unregister(ExampleModel) - self.assertFalse(ExampleModel.objects.__class__.__name__ == 'EntityManager') - - def test_unregistering_via_metaclass(self): - self.assertTrue(ExampleMetaclassModel.objects.__class__.__name__ == 'EntityManager') - eav.unregister(ExampleMetaclassModel) - self.assertFalse(ExampleMetaclassModel.objects.__class__.__name__ == 'EntityManager') - - def test_unregistering_unregistered_model_proceeds_silently(self): - eav.unregister(Patient) - - def test_double_registering_model_is_harmless(self): - eav.register(Patient) - eav.register(Patient) - - def test_doesnt_register_nonmodel(self): - with self.assertRaises(ValueError): - @eav.decorators.register_eav() - class Foo(object): - pass diff --git a/tests/test_attributes.py b/tests/test_attributes.py new file mode 100644 index 00000000..8ad4999c --- /dev/null +++ b/tests/test_attributes.py @@ -0,0 +1,183 @@ +import string +import uuid + +import pytest +from django.conf import settings as django_settings +from django.core.exceptions import ValidationError +from django.test import TestCase +from hypothesis import given, settings +from hypothesis import strategies as st +from hypothesis.extra import django +from hypothesis.strategies import just + +import eav +from eav.exceptions import IllegalAssignmentException +from eav.models import Attribute, Value +from eav.registry import EavConfig +from test_project.models import Doctor, Encounter, Patient, RegisterTestModel + +if django_settings.EAV2_PRIMARY_KEY_FIELD == "django.db.models.UUIDField": + auto_field_strategy = st.builds(uuid.uuid4, version=4, max_length=32) +elif django_settings.EAV2_PRIMARY_KEY_FIELD == "django.db.models.CharField": + auto_field_strategy = st.text(min_size=1, max_size=255) +else: + auto_field_strategy = st.integers(min_value=1, max_value=32) + + +class Attributes(TestCase): + def setUp(self): + class EncounterEavConfig(EavConfig): + manager_attr = "eav_objects" + eav_attr = "eav_field" + generic_relation_attr = "encounter_eav_values" + generic_relation_related_name = "encounters" + + @classmethod + def get_attributes(cls, instance=None): + return Attribute.objects.filter(slug__contains="a") + + eav.register(Encounter, EncounterEavConfig) + eav.register(Patient) + + Attribute.objects.create(name="age", datatype=Attribute.TYPE_INT) + Attribute.objects.create(name="height", datatype=Attribute.TYPE_FLOAT) + Attribute.objects.create(name="weight", datatype=Attribute.TYPE_FLOAT) + Attribute.objects.create(name="color", datatype=Attribute.TYPE_TEXT) + + def tearDown(self): + eav.unregister(Encounter) + eav.unregister(Patient) + + def test_get_attribute_querysets(self): + self.assertEqual(Patient._eav_config_cls.get_attributes().count(), 4) + self.assertEqual(Encounter._eav_config_cls.get_attributes().count(), 1) + + def test_duplicate_attributs(self): + """ + Ensure that no two Attributes with the same slug can exist. + """ + with self.assertRaises(ValidationError): + Attribute.objects.create(name="height", datatype=Attribute.TYPE_FLOAT) + + def test_setting_attributes(self): + p = Patient.objects.create(name="Jon") + e = Encounter.objects.create(patient=p, num=1) + + p.eav.age = 3 + p.eav.height = 2.3 + p.save() + e.eav_field.age = 4 + e.save() + self.assertEqual(Value.objects.count(), 3) + t = RegisterTestModel.objects.create(name="test") + t.eav.age = 6 + t.eav.height = 10 + t.save() + p = Patient.objects.get(name="Jon") + self.assertEqual(p.eav.age, 3) + self.assertEqual(p.eav.height, 2.3) + e = Encounter.objects.get(num=1) + self.assertEqual(e.eav_field.age, 4) + t = RegisterTestModel.objects.get(name="test") + self.assertEqual(t.eav.age, 6) + self.assertEqual(t.eav.height, 10) + + # Validate repr of Value for an entity with an INT PK + v1 = Value.objects.filter(entity_id=p.pk).first() + assert isinstance(repr(v1), str) + assert isinstance(str(v1), str) + + def test_illegal_assignemnt(self): + class EncounterEavConfig(EavConfig): + @classmethod + def get_attributes(cls, instance=None): + return Attribute.objects.filter(datatype=Attribute.TYPE_INT) + + eav.unregister(Encounter) + eav.register(Encounter, EncounterEavConfig) + + p = Patient.objects.create(name="Jon") + e = Encounter.objects.create(patient=p, num=1) + + with self.assertRaises(IllegalAssignmentException): + e.eav.color = "red" + e.save() + + def test_uuid_pk(self): + """Tests for when model pk is UUID.""" + expected_age = 10 + d1 = Doctor.objects.create(name="Lu") + d1.eav.age = expected_age + d1.save() + + assert d1.eav.age == expected_age + + # Validate repr of Value for an entity with a UUID PK + v1 = Value.objects.filter(entity_uuid=d1.pk).first() + assert isinstance(repr(v1), str) + assert isinstance(str(v1), str) + + def test_big_integer(self): + """Tests an integer larger than 32-bit a value.""" + big_num = 3147483647 + patient = Patient.objects.create(name="Jon") + patient.eav.age = big_num + + patient.save() + + assert patient.eav.age == big_num + + +class TestAttributeModel(django.TestCase): + """This is a property-based test that ensures model correctness.""" + + @given( + django.from_model( + Attribute, + id=auto_field_strategy, + datatype=just(Attribute.TYPE_TEXT), + enum_group=just(None), + slug=just(None), # Let Attribute.save() handle + ), + ) + @settings(deadline=None) + def test_model_properties(self, attribute: Attribute) -> None: + """Tests that instance can be saved and has correct representation.""" + attribute.full_clean() + attribute.save() + + assert attribute + + @given( + st.text( + alphabet=st.sampled_from(string.ascii_letters + string.digits), + min_size=50, + max_size=100, + ), + ) + def test_large_name_input(self, name_value) -> None: + """Ensure proper slug is generated from large name fields.""" + instance = Attribute.objects.create( + name=name_value, + datatype=Attribute.TYPE_TEXT, + enum_group=None, + ) + + assert isinstance(instance, Attribute) + + +@pytest.mark.django_db +def test_attribute_create_with_invalid_slug() -> None: + """ + Test that creating an Attribute with an invalid slug raises a UserWarning. + + This test ensures that when an Attribute is created with a slug that is not + a valid Python identifier, a UserWarning is raised. The warning should + indicate that the slug is invalid and suggest updating it. + """ + with pytest.warns(UserWarning): + Attribute.objects.create( + name="Test Attribute", + slug="123-invalid", + datatype=Attribute.TYPE_TEXT, + ) diff --git a/tests/data_validation.py b/tests/test_data_validation.py similarity index 50% rename from tests/data_validation.py rename to tests/test_data_validation.py index f179519f..8859bdc6 100644 --- a/tests/data_validation.py +++ b/tests/test_data_validation.py @@ -1,92 +1,96 @@ -from django.utils import timezone - -from django.test import TestCase -from django.core.exceptions import ValidationError from django.contrib.auth.models import User +from django.core.exceptions import ValidationError +from django.test import TestCase +from django.utils import timezone import eav -from eav.models import Attribute, Value, EnumValue, EnumGroup - -from .models import Patient +from eav.models import Attribute, EnumGroup, EnumValue, Value +from test_project.models import Patient class DataValidation(TestCase): - def setUp(self): eav.register(Patient) - Attribute.objects.create(name='Age', datatype=Attribute.TYPE_INT) - Attribute.objects.create(name='DoB', datatype=Attribute.TYPE_DATE) - Attribute.objects.create(name='Height', datatype=Attribute.TYPE_FLOAT) - Attribute.objects.create(name='City', datatype=Attribute.TYPE_TEXT) - Attribute.objects.create(name='Pregnant?', datatype=Attribute.TYPE_BOOLEAN) - Attribute.objects.create(name='User', datatype=Attribute.TYPE_OBJECT) + Attribute.objects.create(name="Age", datatype=Attribute.TYPE_INT) + Attribute.objects.create(name="DoB", datatype=Attribute.TYPE_DATE) + Attribute.objects.create(name="Height", datatype=Attribute.TYPE_FLOAT) + Attribute.objects.create(name="City", datatype=Attribute.TYPE_TEXT) + Attribute.objects.create(name="Pregnant", datatype=Attribute.TYPE_BOOLEAN) + Attribute.objects.create(name="User", datatype=Attribute.TYPE_OBJECT) + Attribute.objects.create(name="Extra", datatype=Attribute.TYPE_JSON) + Attribute.objects.create(name="Multi", datatype=Attribute.TYPE_CSV) def tearDown(self): eav.unregister(Patient) def test_required_field(self): - p = Patient(name='Bob') + p = Patient(name="Bob") p.eav.age = 5 p.save() - Attribute.objects.create(name='Weight', datatype=Attribute.TYPE_INT, required=True) + Attribute.objects.create( + name="Weight", + datatype=Attribute.TYPE_INT, + required=True, + ) p.eav.age = 6 self.assertRaises(ValidationError, p.save) - p = Patient.objects.get(name='Bob') + p = Patient.objects.get(name="Bob") self.assertEqual(p.eav.age, 5) p.eav.weight = 23 p.save() - p = Patient.objects.get(name='Bob') + p = Patient.objects.get(name="Bob") self.assertEqual(p.eav.weight, 23) def test_create_required_field(self): - Attribute.objects.create(name='Weight', datatype=Attribute.TYPE_INT, required=True) - self.assertRaises(ValidationError, - Patient.objects.create, - name='Joe', eav__age=5) + Attribute.objects.create( + name="Weight", + datatype=Attribute.TYPE_INT, + required=True, + ) + self.assertRaises( + ValidationError, + Patient.objects.create, + name="Joe", + eav__age=5, + ) self.assertEqual(Patient.objects.count(), 0) self.assertEqual(Value.objects.count(), 0) - Patient.objects.create(name='Joe', eav__weight=2, eav__age=5) + Patient.objects.create(name="Joe", eav__weight=2, eav__age=5) self.assertEqual(Patient.objects.count(), 1) self.assertEqual(Value.objects.count(), 2) def test_validation_error_create(self): - self.assertRaises(ValidationError, - Patient.objects.create, - name='Joe', eav__age='df') + self.assertRaises( + ValidationError, + Patient.objects.create, + name="Joe", + eav__age="df", + ) self.assertEqual(Patient.objects.count(), 0) self.assertEqual(Value.objects.count(), 0) - def test_bad_slug(self): - a = Attribute.objects.create(name='color', datatype=Attribute.TYPE_TEXT) - a.slug = 'Color' - self.assertRaises(ValidationError, a.save) - a.slug = '1st' - self.assertRaises(ValidationError, a.save) - a.slug = '_st' - self.assertRaises(ValidationError, a.save) - def test_changing_datatypes(self): - a = Attribute.objects.create(name='Color', datatype=Attribute.TYPE_INT) + a = Attribute.objects.create(name="Color", datatype=Attribute.TYPE_INT) a.datatype = Attribute.TYPE_TEXT a.save() - Patient.objects.create(name='Bob', eav__color='brown') + Patient.objects.create(name="Bob", eav__color="brown") a.datatype = Attribute.TYPE_INT self.assertRaises(ValidationError, a.save) def test_int_validation(self): - p = Patient.objects.create(name='Joe') - p.eav.age = 'bad' + p = Patient.objects.create(name="Joe") + p.eav.age = "bad" self.assertRaises(ValidationError, p.save) p.eav.age = 15 p.save() self.assertEqual(Patient.objects.get(pk=p.pk).eav.age, 15) def test_date_validation(self): - p = Patient.objects.create(name='Joe') - p.eav.dob = '12' + p = Patient.objects.create(name="Joe") + p.eav.dob = "12" self.assertRaises(ValidationError, lambda: p.save()) p.eav.dob = 15 self.assertRaises(ValidationError, lambda: p.save()) @@ -100,26 +104,26 @@ def test_date_validation(self): self.assertEqual(Patient.objects.get(pk=p.pk).eav.dob.date(), today) def test_float_validation(self): - p = Patient.objects.create(name='Joe') - p.eav.height = 'bad' + p = Patient.objects.create(name="Joe") + p.eav.height = "bad" self.assertRaises(ValidationError, p.save) p.eav.height = 15 p.save() self.assertEqual(Patient.objects.get(pk=p.pk).eav.height, 15) - p.eav.height='2.3' + p.eav.height = "2.3" p.save() self.assertEqual(Patient.objects.get(pk=p.pk).eav.height, 2.3) def test_text_validation(self): - p = Patient.objects.create(name='Joe') + p = Patient.objects.create(name="Joe") p.eav.city = 5 self.assertRaises(ValidationError, p.save) - p.eav.city = 'El Dorado' + p.eav.city = "El Dorado" p.save() - self.assertEqual(Patient.objects.get(pk=p.pk).eav.city, 'El Dorado') + self.assertEqual(Patient.objects.get(pk=p.pk).eav.city, "El Dorado") def test_bool_validation(self): - p = Patient.objects.create(name='Joe') + p = Patient.objects.create(name="Joe") p.eav.pregnant = 5 self.assertRaises(ValidationError, p.save) p.eav.pregnant = True @@ -127,62 +131,86 @@ def test_bool_validation(self): self.assertEqual(Patient.objects.get(pk=p.pk).eav.pregnant, True) def test_object_validation(self): - p = Patient.objects.create(name='Joe') + p = Patient.objects.create(name="Joe") p.eav.user = 5 self.assertRaises(ValidationError, p.save) p.eav.user = object self.assertRaises(ValidationError, p.save) - p.eav.user = User(username='joe') + p.eav.user = User(username="joe") self.assertRaises(ValidationError, p.save) - u = User.objects.create(username='joe') + u = User.objects.create(username="joe") p.eav.user = u p.save() self.assertEqual(Patient.objects.get(pk=p.pk).eav.user, u) def test_enum_validation(self): - yes = EnumValue.objects.create(value='yes') - no = EnumValue.objects.create(value='no') - unkown = EnumValue.objects.create(value='unkown') - green = EnumValue.objects.create(value='green') - ynu = EnumGroup.objects.create(name='Yes / No / Unknown') + yes = EnumValue.objects.create(value="yes") + no = EnumValue.objects.create(value="no") + unkown = EnumValue.objects.create(value="unkown") + green = EnumValue.objects.create(value="green") + ynu = EnumGroup.objects.create(name="Yes / No / Unknown") ynu.values.add(yes) ynu.values.add(no) ynu.values.add(unkown) - Attribute.objects.create(name='Fever?', datatype=Attribute.TYPE_ENUM, enum_group=ynu) + Attribute.objects.create( + name="Fever", + datatype=Attribute.TYPE_ENUM, + enum_group=ynu, + ) - p = Patient.objects.create(name='Joe') + p = Patient.objects.create(name="Joe") p.eav.fever = 5 self.assertRaises(ValidationError, p.save) p.eav.fever = object self.assertRaises(ValidationError, p.save) p.eav.fever = green self.assertRaises(ValidationError, p.save) - p.eav.fever = EnumValue(value='yes') + p.eav.fever = EnumValue(value="yes") self.assertRaises(ValidationError, p.save) p.eav.fever = no p.save() self.assertEqual(Patient.objects.get(pk=p.pk).eav.fever, no) def test_enum_datatype_without_enum_group(self): - a = Attribute(name='Age Bracket', datatype=Attribute.TYPE_ENUM) + a = Attribute(name="Age Bracket", datatype=Attribute.TYPE_ENUM) self.assertRaises(ValidationError, a.save) - yes = EnumValue.objects.create(value='yes') - no = EnumValue.objects.create(value='no') - unkown = EnumValue.objects.create(value='unkown') - ynu = EnumGroup.objects.create(name='Yes / No / Unknown') + yes = EnumValue.objects.create(value="yes") + no = EnumValue.objects.create(value="no") + unkown = EnumValue.objects.create(value="unkown") + ynu = EnumGroup.objects.create(name="Yes / No / Unknown") ynu.values.add(yes) ynu.values.add(no) ynu.values.add(unkown) - a = Attribute(name='Age Bracket', datatype=Attribute.TYPE_ENUM, enum_group=ynu) + a = Attribute(name="Age Bracket", datatype=Attribute.TYPE_ENUM, enum_group=ynu) a.save() def test_enum_group_on_other_datatype(self): - yes = EnumValue.objects.create(value='yes') - no = EnumValue.objects.create(value='no') - unkown = EnumValue.objects.create(value='unkown') - ynu = EnumGroup.objects.create(name='Yes / No / Unknown') + yes = EnumValue.objects.create(value="yes") + no = EnumValue.objects.create(value="no") + unkown = EnumValue.objects.create(value="unkown") + ynu = EnumGroup.objects.create(name="Yes / No / Unknown") ynu.values.add(yes) ynu.values.add(no) ynu.values.add(unkown) - a = Attribute(name='color', datatype=Attribute.TYPE_TEXT, enum_group=ynu) + a = Attribute(name="color", datatype=Attribute.TYPE_TEXT, enum_group=ynu) self.assertRaises(ValidationError, a.save) + + def test_json_validation(self): + p = Patient.objects.create(name="Joe") + p.eav.extra = 5 + self.assertRaises(ValidationError, p.save) + p.eav.extra = {"eyes": "blue", "hair": "brown"} + p.save() + self.assertEqual(Patient.objects.get(pk=p.pk).eav.extra.get("eyes", ""), "blue") + + def test_csv_validation(self): + yes = EnumValue.objects.create(value="yes") + p = Patient.objects.create(name="Mike") + p.eav.multi = yes + self.assertRaises(ValidationError, p.save) + p.eav.multi = "one;two;three" + p.save() + self.assertEqual( + Patient.objects.get(pk=p.pk).eav.multi, + ["one", "two", "three"], + ) diff --git a/tests/test_forms.py b/tests/test_forms.py new file mode 100644 index 00000000..e1606e1f --- /dev/null +++ b/tests/test_forms.py @@ -0,0 +1,251 @@ +import pytest +from django.contrib.admin.sites import AdminSite +from django.core.handlers.base import BaseHandler +from django.forms import ModelForm +from django.test import TestCase +from django.test.client import RequestFactory + +import eav +from eav.admin import BaseEntityAdmin +from eav.forms import BaseDynamicEntityForm +from eav.models import Attribute, EnumGroup, EnumValue +from test_project.models import ExampleModel, M2MModel, Patient + + +class MockRequest(RequestFactory): + def request(self, **request): + "Construct a generic request object." + request = RequestFactory.request(self, **request) + handler = BaseHandler() + handler.load_middleware() + + return request + + +class MockSuperUser: + def __init__(self): + self.is_active = True + self.is_staff = True + + def has_perm(self, perm): + return True + + +request = MockRequest().request() +request.user = MockSuperUser() + + +class PatientForm(ModelForm): + class Meta: + model = Patient + fields = ("name", "email", "example") + + +class PatientDynamicForm(BaseDynamicEntityForm): + class Meta: + model = Patient + fields = ("name", "email", "example") + + +class M2MModelForm(ModelForm): + class Meta: + model = M2MModel + fields = ("name", "models") + + +class Forms(TestCase): + def setUp(self): + eav.register(Patient) + Attribute.objects.create(name="weight", datatype=Attribute.TYPE_FLOAT) + Attribute.objects.create(name="color", datatype=Attribute.TYPE_TEXT) + + self.female = EnumValue.objects.create(value="Female") + self.male = EnumValue.objects.create(value="Male") + gender_group = EnumGroup.objects.create(name="Gender") + gender_group.values.add(self.female, self.male) + + Attribute.objects.create( + name="gender", + datatype=Attribute.TYPE_ENUM, + enum_group=gender_group, + ) + + self.instance = Patient.objects.create(name="Jim Morrison") + + def test_valid_submit(self): + self.instance.eav.color = "Blue" + form = PatientForm(self.instance.__dict__, instance=self.instance) + jim = form.save() + + self.assertEqual(jim.eav.color, "Blue") + + def test_invalid_submit(self): + form = PatientForm({"color": "Blue"}, instance=self.instance) + with self.assertRaises(ValueError): + form.save() + + def test_valid_enums(self): + self.instance.eav.gender = self.female + form = PatientForm(self.instance.__dict__, instance=self.instance) + rose = form.save() + + self.assertEqual(rose.eav.gender, self.female) + + def test_m2m(self): + m2mmodel = M2MModel.objects.create(name="name") + model = ExampleModel.objects.create(name="name") + form = M2MModelForm({"name": "Lorem", "models": [model.pk]}, instance=m2mmodel) + form.save() + self.assertEqual(len(m2mmodel.models.all()), 1) + + +@pytest.fixture +def patient() -> Patient: + """Return an eav enabled Patient instance.""" + eav.register(Patient) + return Patient.objects.create(name="Jim Morrison") + + +@pytest.fixture +def create_attributes() -> None: + """Create some Attributes to use for testing.""" + Attribute.objects.create(name="weight", datatype=Attribute.TYPE_FLOAT) + Attribute.objects.create(name="color", datatype=Attribute.TYPE_TEXT) + + +@pytest.mark.django_db +@pytest.mark.parametrize( + ("csv_data", "separator"), + [ + ("", ";"), + ("justone", ","), + ("one;two;three", ";"), + ("alpha,beta,gamma", ","), + (None, ","), + ], +) +def test_csvdynamicform(patient, csv_data, separator) -> None: + """Ensure that a TYPE_CSV field works correctly with forms.""" + Attribute.objects.create(name="csv", datatype=Attribute.TYPE_CSV) + patient.eav.csv = csv_data + patient.save() + patient.refresh_from_db() + + form = PatientDynamicForm( + patient.__dict__, + instance=patient, + ) + form.fields["csv"].separator = separator + assert form.is_valid() + jim = form.save() + + expected_result = str(csv_data).split(separator) if csv_data else [] + assert jim.eav.csv == expected_result + + +@pytest.mark.django_db +def test_csvdynamicform_empty(patient) -> None: + """Test to ensure an instance with no eav values is correct.""" + form = PatientDynamicForm( + patient.__dict__, + instance=patient, + ) + assert form.is_valid() + assert form.save() + + +@pytest.mark.django_db +@pytest.mark.usefixtures("create_attributes") +@pytest.mark.parametrize("define_fieldsets", [True, False]) +def test_entity_admin_form(patient, define_fieldsets): + """Test the BaseEntityAdmin form setup and dynamic fieldsets handling.""" + admin = BaseEntityAdmin(Patient, AdminSite()) + admin.readonly_fields = ("email",) + admin.form = BaseDynamicEntityForm + expected_fieldsets = 2 + + if define_fieldsets: + # Use all fields in Patient model + admin.fieldsets = ( + (None, {"fields": ["name", "example"]}), + ("Contact Info", {"fields": ["email"]}), + ) + expected_fieldsets = 3 + + view = admin.change_view(request, str(patient.pk)) + + adminform = view.context_data["adminform"] + + # Count the total fields in fieldsets + total_fields = sum( + len(fields_info["fields"]) for _, fields_info in adminform.fieldsets + ) + + # 3 for 'name', 'email', 'example' + expected_fields_count = Attribute.objects.count() + 3 + + assert total_fields == expected_fields_count + + # Ensure our fieldset count is correct + assert len(adminform.fieldsets) == expected_fieldsets + + +@pytest.mark.django_db +def test_entity_admin_form_no_attributes(patient): + """Test the BaseEntityAdmin form with no Attributes created.""" + admin = BaseEntityAdmin(Patient, AdminSite()) + admin.readonly_fields = ("email",) + admin.form = BaseDynamicEntityForm + + # Only fields defined in Patient model + expected_fields = 3 + + view = admin.change_view(request, str(patient.pk)) + + adminform = view.context_data["adminform"] + + # Count the total fields in fieldsets + total_fields = sum( + len(fields_info["fields"]) for _, fields_info in adminform.fieldsets + ) + + # 3 for 'name', 'email', 'example' + assert total_fields == expected_fields + + +@pytest.mark.django_db +def test_dynamic_form_renders_enum_choices(): + """ + Test that enum choices render correctly in BaseDynamicEntityForm. + + This test verifies the fix for issue #648 where enum choices weren't + rendering correctly in Django 4.2.17 due to QuerySet unpacking issues. + """ + # Setup + eav.register(Patient) + + # Create enum values and group + female = EnumValue.objects.create(value="Female") + male = EnumValue.objects.create(value="Male") + gender_group = EnumGroup.objects.create(name="Gender") + gender_group.values.add(female, male) + + Attribute.objects.create( + name="gender", + datatype=Attribute.TYPE_ENUM, + enum_group=gender_group, + ) + + # Create a patient + patient = Patient.objects.create(name="Test Patient") + + # Initialize the dynamic form + form = PatientDynamicForm(instance=patient) + + # Test rendering - should not raise any exceptions + rendered_form = form.as_p() + + # Verify the form rendered and contains the enum choices + assert 'name="gender"' in rendered_form + assert f'value="{female.pk}">{female.value}' in rendered_form + assert f'value="{male.pk}">{male.value}' in rendered_form diff --git a/tests/test_logic.py b/tests/test_logic.py new file mode 100644 index 00000000..7dcf0c9b --- /dev/null +++ b/tests/test_logic.py @@ -0,0 +1,76 @@ +import pytest +from hypothesis import given +from hypothesis import strategies as st + +from eav.logic.slug import SLUGFIELD_MAX_LENGTH, generate_slug + + +@given(st.text()) +def test_generate_slug(name: str) -> None: + """Ensures slug generation works properly.""" + slug = generate_slug(name) + + assert slug + + +@given(st.text(min_size=SLUGFIELD_MAX_LENGTH)) +def test_generate_long_slug_text(name: str) -> None: + """Ensures a slug isn't generated longer than maximum allowed length.""" + slug = generate_slug(name) + + assert len(slug) <= SLUGFIELD_MAX_LENGTH + + +def test_generate_slug_uniqueness() -> None: + """Test that generate_slug() produces unique slugs for different inputs. + + This test ensures that even similar inputs result in unique slugs, + and that the number of unique slugs matches the number of inputs. + """ + inputs = ["age #", "age %", "age $", "age @", "age!", "age?", "age 😊"] + + generated_slugs: dict[str, str] = {} + for input_str in inputs: + slug = generate_slug(input_str) + assert slug not in generated_slugs.values(), ( + f"Duplicate slug '{slug}' generated for input '{input_str}'" + ) + generated_slugs[input_str] = slug + + assert len(generated_slugs) == len( + inputs, + ), "Number of unique slugs doesn't match number of inputs" + + +@pytest.mark.parametrize( + "input_str", + [ + "01 age", + "? age", + "age 😊", + "class", + "def function", + "2nd place", + "@username", + "user-name", + "first.last", + "snake_case", + "CamelCase", + " ", # Empty + ], +) +def test_generate_slug_valid_identifier(input_str: str) -> None: + """Test that generate_slug() produces valid Python identifiers. + + This test ensures that the generated slugs are valid Python identifiers + for a variety of input strings, including those with numbers, special + characters, emojis, and different naming conventions. + + Args: + input_str (str): The input string to test. + """ + slug = generate_slug(input_str) + assert slug.isidentifier(), ( + f"Generated slug '{slug}' for input '{input_str}' " + + "is not a valid Python identifier" + ) diff --git a/tests/test_misc_models.py b/tests/test_misc_models.py new file mode 100644 index 00000000..fc11da23 --- /dev/null +++ b/tests/test_misc_models.py @@ -0,0 +1,70 @@ +import pytest +from django.test import TestCase + +import eav +from eav.models import Attribute, EnumGroup, EnumValue, Value +from test_project.models import Patient + + +@pytest.fixture +def enumgroup(db): + """Sample `EnumGroup` object for testing.""" + test_group = EnumGroup.objects.create(name="Yes / No") + value_yes = EnumValue.objects.create(value="Yes") + value_no = EnumValue.objects.create(value="No") + test_group.values.add(value_yes) + test_group.values.add(value_no) + return test_group + + +def test_enumgroup_display(enumgroup): + """Test repr() and str() of EnumGroup.""" + assert f"" == repr(enumgroup) + assert str(enumgroup) == str(enumgroup.name) + + +def test_enumvalue_display(enumgroup): + """Test repr() and str() of EnumValue.""" + test_value = enumgroup.values.first() + assert f"" == repr(test_value) + assert str(test_value) == test_value.value + + +class MiscModels(TestCase): + """Miscellaneous tests on models.""" + + def test_attribute_help_text(self): + desc = "Patient Age" + a = Attribute.objects.create( + name="age", + description=desc, + datatype=Attribute.TYPE_INT, + ) + self.assertEqual(a.help_text, desc) + + def test_setting_to_none_deletes_value(self): + eav.register(Patient) + Attribute.objects.create(name="age", datatype=Attribute.TYPE_INT) + p = Patient.objects.create(name="Bob", eav__age=5) + self.assertEqual(Value.objects.count(), 1) + p.eav.age = None + p.save() + self.assertEqual(Value.objects.count(), 0) + + def test_string_enum_value_assignment(self): + yes = EnumValue.objects.create(value="yes") + no = EnumValue.objects.create(value="no") + ynu = EnumGroup.objects.create(name="Yes / No / Unknown") + ynu.values.add(yes) + ynu.values.add(no) + Attribute.objects.create( + name="is_patient", + datatype=Attribute.TYPE_ENUM, + enum_group=ynu, + ) + eav.register(Patient) + p = Patient.objects.create(name="Joe") + p.eav.is_patient = "yes" + p.save() + p = Patient.objects.get(name="Joe") # get from DB again + self.assertEqual(p.eav.is_patient, yes) diff --git a/tests/test_natural_keys.py b/tests/test_natural_keys.py new file mode 100644 index 00000000..79d0a7c9 --- /dev/null +++ b/tests/test_natural_keys.py @@ -0,0 +1,52 @@ +from django.test import TestCase + +import eav +from eav.models import Attribute, EnumGroup, EnumValue, Value +from test_project.models import Patient + + +class ModelTest(TestCase): + def setUp(self): + eav.register(Patient) + Attribute.objects.create(name="age", datatype=Attribute.TYPE_INT) + Attribute.objects.create(name="height", datatype=Attribute.TYPE_FLOAT) + Attribute.objects.create(name="weight", datatype=Attribute.TYPE_FLOAT) + Attribute.objects.create(name="color", datatype=Attribute.TYPE_TEXT) + + EnumGroup.objects.create(name="Yes / No") + EnumValue.objects.create(value="yes") + EnumValue.objects.create(value="no") + EnumValue.objects.create(value="unknown") + + def test_attr_natural_keys(self): + attr = Attribute.objects.get(name="age") + attr_natural_key = attr.natural_key() + attr_retrieved_model = Attribute.objects.get_by_natural_key(*attr_natural_key) + self.assertEqual(attr_retrieved_model, attr) + + def test_value_natural_keys(self): + p = Patient.objects.create(name="Jon") + p.eav.age = 5 + p.save() + + val = p.eav_values.first() + + value_natural_key = val.natural_key() + value_retrieved_model = Value.objects.get_by_natural_key(*value_natural_key) + self.assertEqual(value_retrieved_model, val) + + def test_enum_group_natural_keys(self): + enum_group = EnumGroup.objects.first() + enum_group_natural_key = enum_group.natural_key() + enum_group_retrieved_model = EnumGroup.objects.get_by_natural_key( + *enum_group_natural_key, + ) + self.assertEqual(enum_group_retrieved_model, enum_group) + + def test_enum_value_natural_keys(self): + enum_value = EnumValue.objects.first() + enum_value_natural_key = enum_value.natural_key() + enum_value_retrieved_model = EnumValue.objects.get_by_natural_key( + *enum_value_natural_key, + ) + self.assertEqual(enum_value_retrieved_model, enum_value) diff --git a/tests/test_primary_key_format.py b/tests/test_primary_key_format.py new file mode 100644 index 00000000..43f60006 --- /dev/null +++ b/tests/test_primary_key_format.py @@ -0,0 +1,32 @@ +import uuid + +from django.db import models + +from eav.logic.object_pk import _DEFAULT_CHARFIELD_LEN, get_pk_format + + +def test_get_uuid_primary_key(settings) -> None: + settings.EAV2_PRIMARY_KEY_FIELD = "django.db.models.UUIDField" + primary_field = get_pk_format() + assert isinstance(primary_field, models.UUIDField) + assert primary_field.primary_key + assert not primary_field.editable + assert primary_field.default == uuid.uuid4 + + +def test_get_char_primary_key(settings) -> None: + settings.EAV2_PRIMARY_KEY_FIELD = "django.db.models.CharField" + primary_field = get_pk_format() + assert isinstance(primary_field, models.CharField) + assert primary_field.primary_key + assert not primary_field.editable + assert primary_field.max_length == _DEFAULT_CHARFIELD_LEN + + +def test_get_default_primary_key(settings) -> None: + # This test covers the default case for "BigAutoField" + settings.EAV2_PRIMARY_KEY_FIELD = "AnyOtherField" + primary_field = get_pk_format() + assert isinstance(primary_field, models.BigAutoField) + assert primary_field.primary_key + assert not primary_field.editable diff --git a/tests/test_queries.py b/tests/test_queries.py new file mode 100644 index 00000000..5a627578 --- /dev/null +++ b/tests/test_queries.py @@ -0,0 +1,409 @@ +from __future__ import annotations + +import pytest +from django.core.exceptions import MultipleObjectsReturned, ObjectDoesNotExist +from django.db.models import Q +from django.db.utils import NotSupportedError +from django.test import TestCase + +import eav +from eav.models import Attribute, EnumGroup, EnumValue, Value +from eav.registry import EavConfig +from test_project.models import Encounter, ExampleModel, Patient + + +class Queries(TestCase): + def setUp(self): + eav.register(Encounter) + eav.register(Patient) + + Attribute.objects.create(name="age", datatype=Attribute.TYPE_INT) + Attribute.objects.create(name="height", datatype=Attribute.TYPE_FLOAT) + Attribute.objects.create(name="weight", datatype=Attribute.TYPE_FLOAT) + Attribute.objects.create(name="city", datatype=Attribute.TYPE_TEXT) + Attribute.objects.create(name="country", datatype=Attribute.TYPE_TEXT) + Attribute.objects.create(name="extras", datatype=Attribute.TYPE_JSON) + Attribute.objects.create(name="illness", datatype=Attribute.TYPE_CSV) + + self.yes = EnumValue.objects.create(value="yes") + self.no = EnumValue.objects.create(value="no") + self.unknown = EnumValue.objects.create(value="unknown") + + ynu = EnumGroup.objects.create(name="Yes / No / Unknown") + ynu.values.add(self.yes) + ynu.values.add(self.no) + ynu.values.add(self.unknown) + + Attribute.objects.create( + name="fever", + datatype=Attribute.TYPE_ENUM, + enum_group=ynu, + ) + + def tearDown(self): + eav.unregister(Encounter) + eav.unregister(Patient) + + def init_data(self) -> None: + yes = self.yes + no = self.no + + data = [ + # Name, age, fever, + # city, country, extras + # possible illness + ["Anne", 3, no, "New York", "USA", {"chills": "yes"}, "cold"], + ["Bob", 15, no, "Bamako", "Mali", {}, ""], + [ + "Cyrill", + 15, + yes, + "Kisumu", + "Kenya", + {"chills": "yes", "headache": "no"}, + "flu", + ], + ["Daniel", 3, no, "Nice", "France", {"headache": "yes"}, "cold"], + [ + "Eugene", + 2, + yes, + "France", + "Nice", + {"chills": "no", "headache": "yes"}, + "flu;cold", + ], + ] + + for row in data: + Patient.objects.create( + name=row[0], + eav__age=row[1], + eav__fever=row[2], + eav__city=row[3], + eav__country=row[4], + eav__extras=row[5], + eav__illness=row[6], + ) + + def test_get_or_create_with_eav(self): + Patient.objects.get_or_create(name="Bob", eav__age=5) + self.assertEqual(Patient.objects.count(), 1) + self.assertEqual(Value.objects.count(), 1) + Patient.objects.get_or_create(name="Bob", eav__age=5) + self.assertEqual(Patient.objects.count(), 1) + self.assertEqual(Value.objects.count(), 1) + Patient.objects.get_or_create(name="Bob", eav__age=6) + self.assertEqual(Patient.objects.count(), 2) + self.assertEqual(Value.objects.count(), 2) + + def test_get_or_create_with_defaults(self): + """Tests EntityManager.get_or_create() with defaults keyword.""" + city_name = "Tokyo" + email = "mari@test.com" + p1, _ = Patient.objects.get_or_create( + name="Mari", + eav__age=27, + defaults={ + "email": email, + "eav__city": city_name, + }, + ) + assert Patient.objects.count() == 1 + assert p1.email == email + assert p1.eav.city == city_name + + def test_get_with_eav(self): + p1, _ = Patient.objects.get_or_create(name="Bob", eav__age=6) + self.assertEqual(Patient.objects.get(eav__age=6), p1) + + Patient.objects.create(name="Fred", eav__age=6) + self.assertRaises( + MultipleObjectsReturned, + lambda: Patient.objects.get(eav__age=6), + ) + + def test_no_results_for_contradictory_conditions(self) -> None: + """Test that contradictory conditions return no results.""" + self.init_data() + q1 = Q(eav__fever=self.yes) & Q(eav__fever=self.no) + p = Patient.objects.filter(q1) + + # Should return no patients due to contradictory conditions + assert p.count() == 0 + + def test_filtering_on_numeric_eav_fields(self) -> None: + """Test filtering on numeric EAV fields.""" + self.init_data() + q1 = Q(eav__age__gte=3) # Everyone except Eugene + q2 = Q(eav__age__lt=15) # Anne, Daniel, Eugene + p = Patient.objects.filter(q2 & q1) + + # Should return Anne and Daniel + assert p.count() == 2 + + def test_filtering_on_text_and_boolean_eav_fields(self) -> None: + """Test filtering on text and boolean EAV fields.""" + self.init_data() + q1 = Q(eav__city__contains="Y") & Q(eav__fever="no") + q2 = Q(eav__age=3) + p = Patient.objects.filter(q1 & q2) + + # Should return only Anne + assert p.count() == 1 + + def test_filtering_with_enum_eav_fields(self) -> None: + """Test filtering with enum EAV fields.""" + self.init_data() + q1 = Q(eav__city__contains="Y") & Q(eav__fever=self.no) + q2 = Q(eav__age=3) + p = Patient.objects.filter(q1 & q2) + + # Should return only Anne + assert p.count() == 1 + + def test_complex_query_with_or_conditions(self) -> None: + """Test complex query with OR conditions.""" + self.init_data() + q1 = Q(eav__city__contains="Y", eav__fever=self.no) + q2 = Q(eav__city="Nice") + q3 = Q(eav__age=3) + p = Patient.objects.filter((q1 | q2) & q3) + + # Should return Anne and Daniel + assert p.count() == 2 + + def test_filtering_with_multiple_enum_values(self) -> None: + """Test filtering with multiple enum values.""" + self.init_data() + q1 = Q(eav__fever=self.no) | Q(eav__fever=self.yes) + p = Patient.objects.filter(q1) + + # Should return all patients + assert p.count() == 5 + + def test_complex_query_with_multiple_conditions(self) -> None: + """Test complex query with multiple conditions.""" + self.init_data() + q1 = Q(eav__fever=self.no) # Anne, Bob, Daniel + q2 = Q(eav__fever=self.yes) # Cyrill, Eugene + q3 = Q(eav__country__contains="e") # Cyrill, Daniel, Eugene + q4 = q2 & q3 # Cyrill, Daniel, Eugene + q5 = (q1 | q4) & q1 # Anne, Bob, Daniel + p = Patient.objects.filter(q5) + + # Should return Anne, Bob, and Daniel + assert p.count() == 3 + + def test_excluding_with_eav_fields(self) -> None: + """Test excluding with EAV fields.""" + self.init_data() + q1 = Q(eav__city__contains="Y") + p = Patient.objects.exclude(q1) + + # Should return all patients except Anne + assert p.count() == 4 + + def test_filtering_with_or_conditions(self) -> None: + """Test filtering with OR conditions.""" + self.init_data() + q1 = Q(eav__city__contains="Y") + q2 = Q(eav__fever=self.no) + q3 = q1 | q2 + p = Patient.objects.filter(q3) + + # Should return Anne, Bob, and Daniel + assert p.count() == 3 + + def test_filtering_on_single_eav_field(self) -> None: + """Test filtering on a single EAV field.""" + self.init_data() + q1 = Q(eav__age=3) + p = Patient.objects.filter(q1) + + # Should return Anne and Daniel + assert p.count() == 2 + + def test_combining_normal_and_eav_fields(self) -> None: + """Test combining normal and EAV fields in a query.""" + self.init_data() + q1 = Q(name__contains="E", eav__fever=self.yes) + p = Patient.objects.filter(q1) + + # Should return only Eugene + assert p.count() == 1 + + def test_filtering_on_json_eav_field(self) -> None: + """Test filtering on JSON EAV field.""" + self.init_data() + q1 = Q(eav__extras__has_key="chills") + p = Patient.objects.exclude(q1) + + # Should return patients without 'chills' in extras + assert p.count() == 2 + + q1 = Q(eav__extras__has_key="chills") + p = Patient.objects.filter(q1) + + # Should return patients with 'chills' in extras + assert p.count() == 3 + + q1 = Q(eav__extras__chills="no") + p = Patient.objects.filter(q1) + + # Should return patients with 'chills' set to 'no' + assert p.count() == 1 + + q1 = Q(eav__extras__chills="yes") + p = Patient.objects.filter(q1) + + # Should return patients with 'chills' set to 'yes' + assert p.count() == 2 + + def test_filtering_on_empty_json_eav_field(self) -> None: + """Test filtering on empty JSON EAV field.""" + self.init_data() + q1 = Q(eav__extras={}) + p = Patient.objects.filter(q1) + + # Should return patients with empty extras + assert p.count() == 1 + + q1 = Q(eav__extras={}) + p = Patient.objects.exclude(q1) + + # Should return patients with non-empty extras + assert p.count() == 4 + + def test_filtering_on_text_eav_field_with_icontains(self) -> None: + """Test filtering on text EAV field with icontains.""" + self.init_data() + q1 = Q(eav__illness__icontains="cold") + p = Patient.objects.exclude(q1) + + # Should return patients without 'cold' in illness + assert p.count() == 2 + + q1 = Q(eav__illness__icontains="flu") + p = Patient.objects.exclude(q1) + + # Should return patients without 'flu' in illness + assert p.count() == 3 + + def test_filtering_on_null_eav_field(self) -> None: + """Test filtering on null EAV field.""" + self.init_data() + q1 = Q(eav__illness__isnull=False) + p = Patient.objects.filter(~q1) + + # Should return patients with null illness + assert p.count() == 1 + + def _order(self, ordering) -> list[str]: + query = Patient.objects.all().order_by(*ordering) + return list(query.values_list("name", flat=True)) + + def assert_order_by_results(self, eav_attr="eav") -> None: + """Test the ordering functionality of EAV attributes.""" + # Ordering by a single EAV attribute + assert self._order([f"{eav_attr}__city"]) == [ + "Bob", + "Eugene", + "Cyrill", + "Anne", + "Daniel", + ] + + # Ordering by multiple EAV attributes + assert self._order([f"{eav_attr}__age", f"{eav_attr}__city"]) == [ + "Eugene", + "Anne", + "Daniel", + "Bob", + "Cyrill", + ] + + # Ordering by EAV attributes with different data types + assert self._order([f"{eav_attr}__fever", f"{eav_attr}__age"]) == [ + "Eugene", + "Cyrill", + "Anne", + "Daniel", + "Bob", + ] + + # Combining EAV and regular model field ordering + assert self._order([f"{eav_attr}__fever", "-name"]) == [ + "Eugene", + "Cyrill", + "Daniel", + "Bob", + "Anne", + ] + + # Mixing regular and EAV field ordering + assert self._order(["-name", f"{eav_attr}__age"]) == [ + "Eugene", + "Daniel", + "Cyrill", + "Bob", + "Anne", + ] + + # Ordering by a related model field + assert self._order(["example__name"]) == [ + "Anne", + "Bob", + "Cyrill", + "Daniel", + "Eugene", + ] + + # Error handling for unsupported nested EAV attributes + with pytest.raises(NotSupportedError): + Patient.objects.all().order_by(f"{eav_attr}__first__second") + + # Error handling for non-existent EAV attributes + with pytest.raises(ObjectDoesNotExist): + Patient.objects.all().order_by(f"{eav_attr}__nonsense") + + def test_order_by(self): + self.init_data() + self.assert_order_by_results() + + def test_order_by_with_custom_config(self): + class CustomConfig(EavConfig): + eav_attr = "data" + generic_relation_attr = "data_values" + + self.init_data() + eav.unregister(Patient) + eav.register(Patient, config_cls=CustomConfig) + self.assert_order_by_results(eav_attr="data") + + def test_fk_filter(self): + e = ExampleModel.objects.create(name="test1") + p = Patient.objects.get_or_create(name="Beth", example=e)[0] + c = ExampleModel.objects.filter(patient=p) + self.assertEqual(c.count(), 1) + + def test_filter_with_multiple_eav_attributes(self): + """ + Test filtering a model using both regular and multiple EAV attributes. + + This test initializes test data and then filters the Patient test model + based on a combination of a regular attribute and multiple EAV attributes. + """ + self.init_data() + + # Use the filter method with 3 EAV attribute conditions + patients = Patient.objects.filter( + name="Anne", + eav__age=3, + eav__illness="cold", + eav__fever="no", + ) + + # Assert that the expected patient is returned + self.assertEqual(len(patients), 1) + self.assertEqual(patients[0].name, "Anne") diff --git a/tests/test_registry.py b/tests/test_registry.py new file mode 100644 index 00000000..b7e39b5e --- /dev/null +++ b/tests/test_registry.py @@ -0,0 +1,136 @@ +from django.contrib.auth.models import User +from django.test import TestCase + +import eav +from eav.managers import EntityManager +from eav.registry import EavConfig +from test_project.models import ( + Doctor, + Encounter, + ExampleMetaclassModel, + ExampleModel, + Patient, +) + + +class RegistryTests(TestCase): + def setUp(self): + pass + + def tearDown(self): + pass + + def register_encounter(self): + class EncounterEav(EavConfig): + manager_attr = "eav_objects" + eav_attr = "eav_field" + generic_relation_attr = "encounter_eav_values" + generic_relation_related_name = "encounters" + + @classmethod + def get_attributes(cls): + return "testing" + + eav.register(Encounter, EncounterEav) + + def test_registering_with_defaults(self): + eav.register(Patient) + self.assertTrue(hasattr(Patient, "_eav_config_cls")) + self.assertEqual(Patient._eav_config_cls.manager_attr, "objects") + self.assertFalse(Patient._eav_config_cls.manager_only) + self.assertEqual(Patient._eav_config_cls.eav_attr, "eav") + self.assertEqual(Patient._eav_config_cls.generic_relation_attr, "eav_values") + self.assertEqual(Patient._eav_config_cls.generic_relation_related_name, None) + eav.unregister(Patient) + + def test_registering_overriding_defaults(self): + eav.register(Patient) + self.register_encounter() + self.assertTrue(hasattr(Patient, "_eav_config_cls")) + self.assertEqual(Patient._eav_config_cls.manager_attr, "objects") + self.assertEqual(Patient._eav_config_cls.eav_attr, "eav") + + self.assertTrue(hasattr(Encounter, "_eav_config_cls")) + self.assertEqual(Encounter._eav_config_cls.get_attributes(), "testing") + self.assertEqual(Encounter._eav_config_cls.manager_attr, "eav_objects") + self.assertEqual(Encounter._eav_config_cls.eav_attr, "eav_field") + eav.unregister(Patient) + eav.unregister(Encounter) + + def test_registering_via_decorator_with_defaults(self): + self.assertTrue(hasattr(ExampleModel, "_eav_config_cls")) + self.assertEqual(ExampleModel._eav_config_cls.manager_attr, "objects") + self.assertEqual(ExampleModel._eav_config_cls.eav_attr, "eav") + + def test_register_via_metaclass_with_defaults(self): + self.assertTrue(hasattr(ExampleMetaclassModel, "_eav_config_cls")) + self.assertEqual(ExampleMetaclassModel._eav_config_cls.manager_attr, "objects") + self.assertEqual(ExampleMetaclassModel._eav_config_cls.eav_attr, "eav") + + def test_unregistering(self): + old_mgr = Patient.objects + eav.register(Patient) + self.assertTrue(Patient.objects.__class__.__name__ == "EntityManager") + eav.unregister(Patient) + self.assertFalse(Patient.objects.__class__.__name__ == "EntityManager") + self.assertEqual(Patient.objects, old_mgr) + self.assertFalse(hasattr(Patient, "_eav_config_cls")) + + def test_unregistering_via_decorator(self): + self.assertTrue(ExampleModel.objects.__class__.__name__ == "EntityManager") + eav.unregister(ExampleModel) + self.assertFalse(ExampleModel.objects.__class__.__name__ == "EntityManager") + + def test_unregistering_via_metaclass(self): + self.assertTrue( + ExampleMetaclassModel.objects.__class__.__name__ == "EntityManager", + ) + eav.unregister(ExampleMetaclassModel) + self.assertFalse( + ExampleMetaclassModel.objects.__class__.__name__ == "EntityManager", + ) + + def test_unregistering_unregistered_model_proceeds_silently(self): + eav.unregister(Patient) + + def test_double_registering_model_is_harmless(self): + eav.register(Patient) + eav.register(Patient) + + def test_doesnt_register_nonmodel(self): + with self.assertRaises(TypeError): + + @eav.decorators.register_eav() + class Foo: + pass + + def test_model_without_local_managers(self): + """Test when a model doesn't have local_managers.""" + # Check just in case test model changes in the future + assert bool(User._meta.local_managers) is False + eav.register(User) + assert isinstance(User.objects, eav.managers.EntityManager) + + # Reverse check: managers should be empty again + eav.unregister(User) + assert bool(User._meta.local_managers) is False + + +def test_default_manager_stays() -> None: + """ + Test to ensure default manager remains after registration. + + This test verifies that the default manager of the Doctor model is correctly + replaced or maintained after registering a new EntityManager. Specifically, + if the model's Meta default_manager_name isn't set, the test ensures that + the default manager remains as 'objects' or the first manager declared in + the class. + """ + instance_meta = Doctor._meta + assert instance_meta.default_manager_name is None + assert isinstance(instance_meta.default_manager, EntityManager) + + # Explicity test this as for our test setup, we want to have a state where + # the default manager is 'objects' + assert instance_meta.default_manager.name == "objects" + assert len(instance_meta.managers) == 2 diff --git a/tests/set_and_get.py b/tests/test_set_and_get.py similarity index 53% rename from tests/set_and_get.py rename to tests/test_set_and_get.py index 986f4c90..5ac9db86 100644 --- a/tests/set_and_get.py +++ b/tests/test_set_and_get.py @@ -2,8 +2,7 @@ import eav from eav.registry import EavConfig - -from .models import Patient, Encounter +from test_project.models import Encounter, Patient class RegistryTests(TestCase): @@ -15,46 +14,44 @@ def tearDown(self): def register_encounter(self): class EncounterEav(EavConfig): - manager_attr = 'eav_objects' - eav_attr = 'eav_field' - generic_relation_attr = 'encounter_eav_values' - generic_relation_related_name = 'encounters' - eav.register(Encounter, EncounterEav) + manager_attr = "eav_objects" + eav_attr = "eav_field" + generic_relation_attr = "encounter_eav_values" + generic_relation_related_name = "encounters" + eav.register(Encounter, EncounterEav) def test_registering_with_defaults(self): eav.register(Patient) - self.assertTrue(hasattr(Patient, '_eav_config_cls')) - self.assertEqual(Patient._eav_config_cls.manager_attr, 'objects') + self.assertTrue(hasattr(Patient, "_eav_config_cls")) + self.assertEqual(Patient._eav_config_cls.manager_attr, "objects") self.assertFalse(Patient._eav_config_cls.manager_only) - self.assertEqual(Patient._eav_config_cls.eav_attr, 'eav') - self.assertEqual(Patient._eav_config_cls.generic_relation_attr, - 'eav_values') - self.assertEqual(Patient._eav_config_cls.generic_relation_related_name, - None) + self.assertEqual(Patient._eav_config_cls.eav_attr, "eav") + self.assertEqual(Patient._eav_config_cls.generic_relation_attr, "eav_values") + self.assertEqual(Patient._eav_config_cls.generic_relation_related_name, None) eav.unregister(Patient) def test_registering_overriding_defaults(self): eav.register(Patient) self.register_encounter() - self.assertTrue(hasattr(Patient, '_eav_config_cls')) - self.assertEqual(Patient._eav_config_cls.manager_attr, 'objects') - self.assertEqual(Patient._eav_config_cls.eav_attr, 'eav') + self.assertTrue(hasattr(Patient, "_eav_config_cls")) + self.assertEqual(Patient._eav_config_cls.manager_attr, "objects") + self.assertEqual(Patient._eav_config_cls.eav_attr, "eav") - self.assertTrue(hasattr(Encounter, '_eav_config_cls')) - self.assertEqual(Encounter._eav_config_cls.manager_attr, 'eav_objects') - self.assertEqual(Encounter._eav_config_cls.eav_attr, 'eav_field') + self.assertTrue(hasattr(Encounter, "_eav_config_cls")) + self.assertEqual(Encounter._eav_config_cls.manager_attr, "eav_objects") + self.assertEqual(Encounter._eav_config_cls.eav_attr, "eav_field") eav.unregister(Patient) eav.unregister(Encounter) def test_unregistering(self): old_mgr = Patient.objects eav.register(Patient) - self.assertTrue(Patient.objects.__class__.__name__ == 'EntityManager') + self.assertTrue(Patient.objects.__class__.__name__ == "EntityManager") eav.unregister(Patient) - self.assertFalse(Patient.objects.__class__.__name__ == 'EntityManager') + self.assertFalse(Patient.objects.__class__.__name__ == "EntityManager") self.assertEqual(Patient.objects, old_mgr) - self.assertFalse(hasattr(Patient, '_eav_config_cls')) + self.assertFalse(hasattr(Patient, "_eav_config_cls")) def test_unregistering_unregistered_model_proceeds_silently(self): eav.unregister(Patient) diff --git a/tests/test_settings.py b/tests/test_settings.py deleted file mode 100644 index 95905321..00000000 --- a/tests/test_settings.py +++ /dev/null @@ -1,49 +0,0 @@ -import os - -BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) - -SECRET_KEY = 'fake-key' - -SITE_ID = 1 - -INSTALLED_APPS = [ - 'django.contrib.auth', - 'django.contrib.sites', - 'django.contrib.admin', - 'django.contrib.messages', # Required for admin app. - 'django.contrib.contenttypes', - 'tests', - 'eav' -] - -MIDDLEWARE = [ - # Following 3 middleware required for admin app. - 'django.contrib.auth.middleware.AuthenticationMiddleware', - 'django.contrib.messages.middleware.MessageMiddleware', - 'django.contrib.sessions.middleware.SessionMiddleware' -] - -TEMPLATES = [ - { - 'BACKEND': 'django.template.backends.django.DjangoTemplates', - 'DIRS': [], - 'APP_DIRS': True, - 'OPTIONS': { - 'context_processors': [ - 'django.template.context_processors.debug', - 'django.template.context_processors.request', - 'django.contrib.auth.context_processors.auth', - 'django.contrib.messages.context_processors.messages', - ], - }, - }, -] - - -DATABASES = { - 'default': { - 'ENGINE': 'django.db.backends.sqlite3', - 'NAME': os.path.join(BASE_DIR, 'db.sqlite3'), - 'TEST_NAME': os.path.join(BASE_DIR, 'test_db.sqlite3'), - } -} diff --git a/tests/test_value.py b/tests/test_value.py new file mode 100644 index 00000000..0741fb28 --- /dev/null +++ b/tests/test_value.py @@ -0,0 +1,319 @@ +import pytest +from django.contrib.contenttypes.models import ContentType +from django.core.exceptions import ValidationError +from django.db import IntegrityError + +from eav.models import Attribute, Value +from test_project.models import Doctor, Patient + + +@pytest.fixture +def patient_ct() -> ContentType: + """Return the content type for the Patient model.""" + return ContentType.objects.get_for_model(Patient) + + +@pytest.fixture +def doctor_ct() -> ContentType: + """Return the content type for the Doctor model.""" + # We use Doctor model for UUID tests since it already uses UUID as primary key + return ContentType.objects.get_for_model(Doctor) + + +@pytest.fixture +def attribute() -> Attribute: + """Create and return a test attribute.""" + return Attribute.objects.create( + name="test_attribute", + datatype="text", + ) + + +@pytest.fixture +def patient() -> Patient: + """Create and return a patient with integer PK.""" + # Patient model uses auto-incrementing integer primary keys + return Patient.objects.create(name="Patient with Int PK") + + +@pytest.fixture +def doctor() -> Doctor: + """Create and return a doctor with UUID PK.""" + # Doctor model uses UUID primary keys, ideal for testing entity_uuid constraints + return Doctor.objects.create(name="Doctor with UUID PK") + + +class TestValueModelValidation: + """Test Value model Python-level validation (via full_clean in save).""" + + @pytest.mark.django_db + def test_unique_entity_id_validation( + self, + patient_ct: ContentType, + attribute: Attribute, + patient: Patient, + ) -> None: + """ + Test that model validation prevents duplicate entity_id values. + + The model's save() method calls full_clean() which should detect the + duplicate before it hits the database constraint. + """ + # Create first value - this should succeed + Value.objects.create( + entity_ct=patient_ct, + entity_id=patient.id, + attribute=attribute, + value_text="First value", + ) + + # Try to create a second value with the same entity_ct, attribute, and entity_id + # This should fail with ValidationError from full_clean() + with pytest.raises(ValidationError) as excinfo: + Value.objects.create( + entity_ct=patient_ct, + entity_id=patient.id, + attribute=attribute, + value_text="Second value", + ) + + # Verify the error message indicates uniqueness violation + assert "already exists" in str(excinfo.value) + + @pytest.mark.django_db + def test_unique_entity_uuid_validation( + self, + doctor_ct: ContentType, + attribute: Attribute, + doctor: Doctor, + ) -> None: + """ + Test that model validation prevents duplicate entity_uuid values. + + The model's full_clean() should detect the duplicate before it hits + the database constraint. + """ + # Create first value with UUID - this should succeed + Value.objects.create( + entity_ct=doctor_ct, + entity_uuid=doctor.id, + attribute=attribute, + value_text="First UUID value", + ) + + # Try to create a second value with the same entity_ct, + # attribute, and entity_uuid + with pytest.raises(ValidationError) as excinfo: + Value.objects.create( + entity_ct=doctor_ct, + entity_uuid=doctor.id, + attribute=attribute, + value_text="Second UUID value", + ) + + # Verify the error message indicates uniqueness violation + assert "already exists" in str(excinfo.value) + + @pytest.mark.django_db + def test_entity_id_xor_entity_uuid_validation( + self, + patient_ct: ContentType, + attribute: Attribute, + patient: Patient, + doctor: Doctor, + ) -> None: + """ + Test that model validation enforces XOR between entity_id and entity_uuid. + + The model's full_clean() should detect if both or neither field is provided. + """ + # Try to create with both ID types + with pytest.raises(ValidationError): + Value.objects.create( + entity_ct=patient_ct, + entity_id=patient.id, + entity_uuid=doctor.id, + attribute=attribute, + value_text="Both IDs provided", + ) + + # Try to create with neither ID type + with pytest.raises(ValidationError): + Value.objects.create( + entity_ct=patient_ct, + entity_id=None, + entity_uuid=None, + attribute=attribute, + value_text="No IDs provided", + ) + + +class TestValueDatabaseConstraints: + """ + Test Value model database constraints when bypassing model validation. + + These tests use bulk_create() which bypasses the save() method and its + full_clean() validation, allowing us to test the database constraints directly. + """ + + @pytest.mark.django_db + def test_unique_entity_id_constraint( + self, + patient_ct: ContentType, + attribute: Attribute, + patient: Patient, + ) -> None: + """ + Test that database constraints prevent duplicate entity_id values. + + Even when bypassing model validation with bulk_create, the database + constraint should still prevent duplicates. + """ + # Create first value - this should succeed + Value.objects.create( + entity_ct=patient_ct, + entity_id=patient.id, + attribute=attribute, + value_text="First value", + ) + + # Try to bulk create a duplicate value, bypassing model validation + with pytest.raises(IntegrityError): + Value.objects.bulk_create( + [ + Value( + entity_ct=patient_ct, + entity_id=patient.id, + attribute=attribute, + value_text="Second value", + ), + ], + ) + + @pytest.mark.django_db + def test_unique_entity_uuid_constraint( + self, + doctor_ct: ContentType, + attribute: Attribute, + doctor: Doctor, + ) -> None: + """ + Test that database constraints prevent duplicate entity_uuid values. + + Even when bypassing model validation, the database constraint should + still prevent duplicates. + """ + # Create first value with UUID - this should succeed + Value.objects.create( + entity_ct=doctor_ct, + entity_uuid=doctor.id, + attribute=attribute, + value_text="First UUID value", + ) + + # Try to bulk create a duplicate value, bypassing model validation + with pytest.raises(IntegrityError): + Value.objects.bulk_create( + [ + Value( + entity_ct=doctor_ct, + entity_uuid=doctor.id, + attribute=attribute, + value_text="Second UUID value", + ), + ], + ) + + @pytest.mark.django_db + def test_entity_id_and_entity_uuid_constraint( + self, + patient_ct: ContentType, + attribute: Attribute, + patient: Patient, + doctor: Doctor, + ) -> None: + """ + Test that database constraints prevent having both entity_id and entity_uuid. + + Even when bypassing model validation, the database constraint should + prevent having both fields set. + """ + # Try to bulk create with both ID types + with pytest.raises(IntegrityError): + Value.objects.bulk_create( + [ + Value( + entity_ct=patient_ct, + entity_id=patient.id, + entity_uuid=doctor.id, + attribute=attribute, + value_text="Both IDs provided", + ), + ], + ) + + @pytest.mark.django_db + def test_neither_entity_id_nor_entity_uuid_constraint( + self, + patient_ct: ContentType, + attribute: Attribute, + ) -> None: + """ + Test that database constraints prevent having neither entity_id nor entity_uuid. + + Even when bypassing model validation, the database constraint should + prevent having neither field set. + """ + # Try to bulk create with neither ID type + with pytest.raises(IntegrityError): + Value.objects.bulk_create( + [ + Value( + entity_ct=patient_ct, + entity_id=None, + entity_uuid=None, + attribute=attribute, + value_text="No IDs provided", + ), + ], + ) + + @pytest.mark.django_db + def test_happy_path_constraints( + self, + patient_ct: ContentType, + doctor_ct: ContentType, + attribute: Attribute, + patient: Patient, + doctor: Doctor, + ) -> None: + """ + Test that valid values pass both database constraints. + + Values with either entity_id or entity_uuid (but not both) should be accepted. + """ + # Test with entity_id using bulk_create + values = Value.objects.bulk_create( + [ + Value( + entity_ct=patient_ct, + entity_id=patient.id, + attribute=attribute, + value_text="Integer ID bulk created", + ), + ], + ) + assert len(values) == 1 + + # Test with entity_uuid using bulk_create + values = Value.objects.bulk_create( + [ + Value( + entity_ct=doctor_ct, + entity_uuid=doctor.id, + attribute=attribute, + value_text="UUID bulk created", + ), + ], + ) + assert len(values) == 1 diff --git a/tox.ini b/tox.ini deleted file mode 100644 index e56279bd..00000000 --- a/tox.ini +++ /dev/null @@ -1,36 +0,0 @@ -[tox] -envlist = - py27-django{111}, - py35-django{111,20,30,tip}, - py36-django{111,20,30,tip}, - py37-django{111,20,21,22,30,tip}, - py38-django{111,20,21,22,30,tip} - migrationscheck - -[testenv] -pip_pre=True - -deps = - django111: Django >=1.11, <2.0 - django20: Django >= 2.0, <2.1 - django21: Django >= 2.3, <2.2 - django22: Django >= 2.2, <2.3 - django30: Django >= 3.0, <3.1 - djangotip: https://github.com/django/django/archive/master.tar.gz - -commands = - ./runtests - -[testenv:migrationscheck] -pip_pre=True - -deps = - Django - -setenv = - DJANGO_SETTINGS_MODULE=tests.test_settings - -# make test fail if missing migrations -commands = - django-admin makemigrations --check --dry-run -