pax_global_header00006660000000000000000000000064151534770710014524gustar00rootroot0000000000000052 comment=10256aff840030e13a791cc1ff84b8190aaf2604 django-commons-django-fsm-2-10256af/000077500000000000000000000000001515347707100171475ustar00rootroot00000000000000django-commons-django-fsm-2-10256af/.github/000077500000000000000000000000001515347707100205075ustar00rootroot00000000000000django-commons-django-fsm-2-10256af/.github/dependabot.yml000066400000000000000000000001621515347707100233360ustar00rootroot00000000000000version: 2 updates: - package-ecosystem: github-actions directory: / schedule: interval: monthly django-commons-django-fsm-2-10256af/.github/pull_request_template.md000066400000000000000000000015701515347707100254530ustar00rootroot00000000000000## Description ## Type of Change - [ ] Bug fix (non-breaking change that fixes an issue) - [ ] New feature (non-breaking change that adds functionality) - [ ] Breaking change (fix or feature that would cause existing functionality to change) - [ ] Documentation update - [ ] Other (no functional changes) ## Checklist - [ ] I have read the [CONTRIBUTING.md](CONTRIBUTING.md) guide - [ ] Tests added/updated for the changes - [ ] All tests pass locally (`uv run pytest` or `pytest`) - [ ] Linting passes (`uv run ruff check .`) - [ ] Documentation updated (if applicable) - [ ] CHANGELOG.rst updated (for user-facing changes) ## Testing ```bash # Commands used to test uv run pytest tests/test_xxx.py -v ``` ## Related Issues django-commons-django-fsm-2-10256af/.github/workflows/000077500000000000000000000000001515347707100225445ustar00rootroot00000000000000django-commons-django-fsm-2-10256af/.github/workflows/coverage.yml000066400000000000000000000015321515347707100250630ustar00rootroot00000000000000name: Coverage on: pull_request: push: branches: - main jobs: coverage: name: Check coverage runs-on: ubuntu-latest steps: - name: Check out uses: actions/checkout@v6 - name: Set up Python uses: actions/setup-python@v6 with: python-version: "3.x" - name: Install graphviz run: | sudo apt-get update sudo apt-get install -y graphviz - name: Install uv uses: astral-sh/setup-uv@v7 with: enable-cache: true - name: Install requirements run: uv sync - name: Run tests run: uv run coverage run -m pytest --cov=django_fsm --cov-report=xml - name: Upload coverage reports to Codecov uses: codecov/codecov-action@v5 with: token: ${{ secrets.CODECOV_TOKEN }} django-commons-django-fsm-2-10256af/.github/workflows/lint.yml000066400000000000000000000004261515347707100242370ustar00rootroot00000000000000name: django-fsm linting on: pull_request: push: branches: - main jobs: build: runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 - uses: actions/setup-python@v6 with: python-version: "3.x" - uses: j178/prek-action@v1 django-commons-django-fsm-2-10256af/.github/workflows/release.yml000066400000000000000000000063761515347707100247230ustar00rootroot00000000000000name: Release on: push: tags: - '*.*.*' env: # Change these for your project's URLs PYPI_URL: https://pypi.org/p/django-fsm-2 PYPI_TEST_URL: https://test.pypi.org/p/django-fsm-2 jobs: build: name: Build distribution 📦 runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 - name: Set up Python uses: actions/setup-python@v6 with: python-version: "3.x" - name: Install pypa/build run: python3 -m pip install build --user - name: Build a binary wheel and a source tarball run: python3 -m build - name: Store the distribution packages uses: actions/upload-artifact@v7 with: name: python-package-distributions path: dist/ publish-to-pypi: name: >- Publish Python 🐍 distribution 📦 to PyPI if: startsWith(github.ref, 'refs/tags/') # only publish to PyPI on tag pushes needs: - build runs-on: ubuntu-latest environment: name: pypi url: ${{ env.PYPI_URL }} permissions: id-token: write # IMPORTANT: mandatory for trusted publishing steps: - name: Download all the dists uses: actions/download-artifact@v8 with: name: python-package-distributions path: dist/ - name: Publish distribution 📦 to PyPI uses: pypa/gh-action-pypi-publish@release/v1 github-release: name: >- Sign the Python 🐍 distribution 📦 with Sigstore and upload them to GitHub Release needs: - publish-to-pypi runs-on: ubuntu-latest permissions: contents: write # IMPORTANT: mandatory for making GitHub Releases id-token: write # IMPORTANT: mandatory for sigstore steps: - name: Download all the dists uses: actions/download-artifact@v8 with: name: python-package-distributions path: dist/ - name: Sign the dists with Sigstore uses: sigstore/gh-action-sigstore-python@v3.2.0 with: inputs: >- ./dist/*.tar.gz ./dist/*.whl - name: Create GitHub Release env: GITHUB_TOKEN: ${{ github.token }} run: >- gh release create '${{ github.ref_name }}' --repo '${{ github.repository }}' --notes "" - name: Upload artifact signatures to GitHub Release env: GITHUB_TOKEN: ${{ github.token }} # Upload to GitHub Release using the `gh` CLI. # `dist/` contains the built packages, and the # sigstore-produced signatures and certificates. run: >- gh release upload '${{ github.ref_name }}' dist/** --repo '${{ github.repository }}' publish-to-testpypi: name: Publish Python 🐍 distribution 📦 to TestPyPI needs: - build runs-on: ubuntu-latest environment: name: testpypi url: ${{ env.PYPI_TEST_URL }} permissions: id-token: write # IMPORTANT: mandatory for trusted publishing steps: - name: Download all the dists uses: actions/download-artifact@v8 with: name: python-package-distributions path: dist/ - name: Publish distribution 📦 to TestPyPI uses: pypa/gh-action-pypi-publish@release/v1 with: repository-url: https://test.pypi.org/legacy/ skip-existing: true django-commons-django-fsm-2-10256af/.github/workflows/test.yml000066400000000000000000000013661515347707100242540ustar00rootroot00000000000000name: django-fsm testing on: pull_request: push: branches: [main] jobs: build: runs-on: ubuntu-latest strategy: fail-fast: false matrix: python-version: ['3.10', '3.11', '3.12', '3.13', '3.14'] steps: - uses: actions/checkout@v6 - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v6 with: python-version: ${{ matrix.python-version }} allow-prereleases: true - name: Install graphviz run: | sudo apt-get update sudo apt-get install -y graphviz - name: Install dependencies run: | python -m pip install --upgrade pip python -m pip install tox tox-gh-actions - name: Test with tox run: tox django-commons-django-fsm-2-10256af/.gitignore000066400000000000000000000035451515347707100211460ustar00rootroot00000000000000# Byte-compiled / optimized / DLL files __pycache__/ *.py[cod] *$py.class # C extensions *.so # Distribution / packaging .Python build/ develop-eggs/ dist/ downloads/ eggs/ .eggs/ lib/ lib64/ parts/ sdist/ var/ wheels/ pip-wheel-metadata/ share/python-wheels/ *.egg-info/ .installed.cfg *.egg MANIFEST # PyInstaller # Usually these files are written by a python script from a template # before PyInstaller builds the exe, so as to inject date/other infos into it. *.manifest *.spec # Installer logs pip-log.txt pip-delete-this-directory.txt # Unit test / coverage reports htmlcov/ .tox/ .nox/ .coverage .coverage.* .cache nosetests.xml coverage.xml *.cover *.py,cover .hypothesis/ .pytest_cache/ # Translations *.mo *.pot # Django stuff: *.log local_settings.py db.sqlite3 db.sqlite3-journal # Flask stuff: instance/ .webassets-cache # Scrapy stuff: .scrapy # Sphinx documentation docs/_build/ # PyBuilder target/ # Jupyter Notebook .ipynb_checkpoints # IPython profile_default/ ipython_config.py # pyenv .python-version # pipenv # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. # However, in case of collaboration, if having platform-specific dependencies or dependencies # having no cross-platform support, pipenv may install dependencies that don't work, or not # install all needed dependencies. #Pipfile.lock # PEP 582; used by e.g. github.com/David-OConnor/pyflow __pypackages__/ # Celery stuff celerybeat-schedule celerybeat.pid # SageMath parsed files *.sage.py # Environments .env .venv env/ venv/ ENV/ env.bak/ venv.bak/ # Spyder project settings .spyderproject .spyproject # Rope project settings .ropeproject # mkdocs documentation /site # mypy .mypy_cache/ .dmypy.json dmypy.json # Pyre type checker .pyre/ # sqlite test.db *.sqlite3 # django fsm exports/* tests/testapp/migrations/ # Codex .codex django-commons-django-fsm-2-10256af/.pre-commit-config.yaml000066400000000000000000000025111515347707100234270ustar00rootroot00000000000000default_language_version: python: python3.12 repos: - repo: https://github.com/pre-commit/pre-commit-hooks rev: v6.0.0 hooks: - id: check-added-large-files - id: check-case-conflict - id: check-executables-have-shebangs - id: check-json - id: check-merge-conflict - id: check-symlinks - id: check-toml - id: check-xml - id: check-yaml - id: debug-statements - id: detect-private-key - id: end-of-file-fixer - id: fix-byte-order-marker - id: mixed-line-ending # - id: no-commit-to-branch - id: trailing-whitespace - repo: https://github.com/crate-ci/typos rev: v1.39.0 hooks: - id: typos - repo: https://github.com/asottile/pyupgrade rev: v3.21.0 hooks: - id: pyupgrade args: - "--py310-plus" - repo: https://github.com/adamchainz/django-upgrade rev: 1.29.1 hooks: - id: django-upgrade args: [--target-version, "4.2"] - repo: https://github.com/astral-sh/ruff-pre-commit rev: v0.14.3 hooks: - id: ruff-format - id: ruff-check - repo: https://github.com/pre-commit/mirrors-mypy rev: v1.19.1 hooks: - id: mypy additional_dependencies: - django-stubs==5.2.9 - django-guardian - django_fsm_log django-commons-django-fsm-2-10256af/CHANGELOG.rst000066400000000000000000000120411515347707100211660ustar00rootroot00000000000000Changelog ========= Unreleased ~~~~~~~~~~ django-fsm-2 4.2.1 2026-03-09 ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - Fix ``django_fsm.admin`` import failures when Django generic types are not runtime-subscriptable django-fsm-2 4.2.0 2026-03-07 ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - Add typing - Improve transition equality (#97) - Drop support for python 3.8 & 3.9 (#86) - Add Admin Integration (with custom form management) (#49) django-fsm-2 4.1.0 2025-11-03 ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - Add support for Django 6.0 - Add support for Django 5.2 - Add support for python 3.14 - Add support for python 3.13 django-fsm-2 4.0.0 2024-09-02 ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - Add support for Django 5.1 - Remove support for Django 3.2 - Remove support for Django 4.0 - Remove support for Django 4.1 - Move the project to ``django-commons`` django-fsm-2 3.0.0 2024-03-26 ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ First release of the forked version of django-fsm - Drop support for Python < 3.8. - Add support for python 3.11 - Add support for python 3.12 - Drop support for django < 3.2 - Add support for django 4.2 - Add support for django 5.0 - Enable Github actions for testing - Remove South support...if exists django-fsm 2.8.1 2022-08-15 ~~~~~~~~~~~~~~~~~~~~~~~~~~~ - Improve fix for get_available_FIELD_transition django-fsm 2.8.0 2021-11-05 ~~~~~~~~~~~~~~~~~~~~~~~~~~ - Fix get_available_FIELD_transition on django>=3.2 - Fix refresh_from_db for ConcurrentTransitionMixin django-fsm 2.7.1 2020-10-13 ~~~~~~~~~~~~~~~~~~~~~~~~~~ - Fix warnings on Django 3.1+ django-fsm 2.7.0 2019-12-03 ~~~~~~~~~~~~~~~~~~~~~~~~~~~ - Django 3.0 support - Test on Python 3.8 django-fsm 2.6.1 2019-04-19 ~~~~~~~~~~~~~~~~~~~~~~~~~~~ - Update pypi classifiers to latest django/python supported versions - Several fixes for graph_transition command django-fsm 2.6.0 2017-06-08 ~~~~~~~~~~~~~~~~~~~~~~~~~~~ - Fix django 1.11 compatibility - Fix TypeError in `graph_transitions` command when using django's lazy translations django-fsm 2.5.0 2017-03-04 ~~~~~~~~~~~~~~~~~~~~~~~~~~~ - graph_transition command fix for django 1.10 - graph_transition command supports GET_STATE targets - signal data extended with method args/kwargs and field - sets allowed to be passed to the transition decorator django-fsm 2.4.0 2016-05-14 ~~~~~~~~~~~~~~~~~~~~~~~~~~~ - graph_transition command now works with multiple FSM's per model - Add ability to set target state from transition return value or callable django-fsm 2.3.0 2015-10-15 ~~~~~~~~~~~~~~~~~~~~~~~~~~~ - Add source state shortcut '+' to specify transitions from all states except the target - Add object-level permission checks - Fix translated labels for graph of FSMIntegerField - Fix multiple signals for several transition decorators django-fsm 2.2.1 2015-04-27 ~~~~~~~~~~~~~~~~~~~~~~~~~~~ - Improved exception message for unmet transition conditions. - Don't send post transition signal in case of no state changes on exception - Allow empty string as correct state value - Improved graphviz fsm visualisation - Clean django 1.8 warnings django-fsm 2.2.0 2014-09-03 ~~~~~~~~~~~~~~~~~~~~~~~~~~~ - Support for `class substitution `__ to proxy classes depending on the state - Added ConcurrentTransitionMixin with optimistic locking support - Default db\_index=True for FSMIntegerField removed - Graph transition code migrated to new graphviz library with python 3 support - Ability to change state on transition exception django-fsm 2.1.0 2014-05-15 ~~~~~~~~~~~~~~~~~~~~~~~~~~~ - Support for attaching permission checks on model transitions django-fsm 2.0.0 2014-03-15 ~~~~~~~~~~~~~~~~~~~~~~~~~~~ - Backward incompatible release - All public code import moved directly to django\_fsm package - Correct support for several @transitions decorator with different source states and conditions on same method - save parameter from transition decorator removed - get\_available\_FIELD\_transitions return Transition data object instead of tuple - Models got get\_available\_FIELD\_transitions, even if field specified as string reference - New get\_all\_FIELD\_transitions method contributed to class django-fsm 1.6.0 2014-03-15 ~~~~~~~~~~~~~~~~~~~~~~~~~~~ - FSMIntegerField and FSMKeyField support django-fsm 1.5.1 2014-01-04 ~~~~~~~~~~~~~~~~~~~~~~~~~~~ - Ad-hoc support for state fields from proxy and inherited models django-fsm 1.5.0 2013-09-17 ~~~~~~~~~~~~~~~~~~~~~~~~~~~ - Python 3 compatibility django-fsm 1.4.0 2011-12-21 ~~~~~~~~~~~~~~~~~~~~~~~~~~~ - Add graph\_transition command for drawing state transition picture django-fsm 1.3.0 2011-07-28 ~~~~~~~~~~~~~~~~~~~~~~~~~~~ - Add direct field modification protection django-fsm 1.2.0 2011-03-23 ~~~~~~~~~~~~~~~~~~~~~~~~~~~ - Add pre\_transition and post\_transition signals django-fsm 1.1.0 2011-02-22 ~~~~~~~~~~~~~~~~~~~~~~~~~~~ - Add support for transition conditions - Allow multiple FSMField in one model - Contribute get\_available\_FIELD\_transitions for model class django-fsm 1.0.0 2010-10-12 ~~~~~~~~~~~~~~~~~~~~~~~~~~~ - Initial public release django-commons-django-fsm-2-10256af/CODE_OF_CONDUCT.md000066400000000000000000000002631515347707100217470ustar00rootroot00000000000000# Django FSM 2 Code of Conduct The django-fsm-2 project utilizes the [Django Commons Code of Conduct](https://github.com/django-commons/membership/blob/main/CODE_OF_CONDUCT.md). django-commons-django-fsm-2-10256af/CONTRIBUTING.md000066400000000000000000000067571515347707100214170ustar00rootroot00000000000000# Contributing to django-fsm-2 Thank you for your interest in contributing to django-fsm-2! This document provides guidelines and instructions for contributing. ## Prerequisites - **uv** (recommended) or pip for package management - **graphviz** system package (for graph visualization tests) ### Installing graphviz ```bash # macOS brew install graphviz # Ubuntu/Debian sudo apt-get install graphviz # Fedora sudo dnf install graphviz # Windows (with chocolatey) choco install graphviz ``` ## Development Setup ### Quick Start with uv (Recommended) [uv](https://github.com/astral-sh/uv) is a fast Python package manager that handles virtual environments automatically. ```bash # Clone the repository git clone https://github.com/django-commons/django-fsm-2.git cd django-fsm # Install dependencies (creates .venv automatically) uv sync # Run tests uv run pytest # Run tests with coverage uv run pytest --cov=django_fsm --cov-report=term-missing ``` ### Alternative: pip with venv ```bash # Clone the repository git clone https://github.com/django-commons/django-fsm-2.git cd django-fsm # Create virtual environment python -m venv .venv source .venv/bin/activate # On Windows: .venv\Scripts\activate # Install in development mode with dev dependencies pip install -e ".[graphviz]" pip install pytest pytest-django pytest-cov django-guardian prek # Run tests pytest ``` ## Running Tests ### Basic test run ```bash uv run pytest ``` ### With verbose output ```bash uv run pytest -v ``` ### Run specific test file ```bash uv run pytest tests/test_basic_transitions.py -v ``` ### Run specific test ```bash uv run pytest tests/test_basic_transitions.py::test_initial_state -v ``` ### With coverage report ```bash uv run pytest --cov=django_fsm --cov-report=term-missing --cov-report=html ``` ### Multi-version testing with tox To test against multiple Python and Django versions: ```bash # Install tox pip install tox # Run all environments tox # Run specific environment tox -e py312-dj52 ``` ## Code Style We use [ruff](https://github.com/astral-sh/ruff) for linting and formatting. ### Check for issues ```bash uv run ruff check . ``` ### Auto-fix issues ```bash uv run ruff check --fix . ``` ### Format code ```bash uv run ruff format . ``` ## Pre-commit Hooks We use prek to ensure code quality before commits. ### Setup ```bash # Install prek hooks uv run prek install # Run manually on all files uv run prek run --all-files ``` ## Type Checking We use mypy for type checking: ```bash uv run mypy django_fsm ``` ## Pull Request Guidelines ### Before submitting 1. **Write tests** for any new functionality 2. **Run the full test suite** and ensure all tests pass 3. **Run linting** and fix any issues 4. **Update documentation** if you're adding/changing features ### PR Checklist - [ ] Tests added/updated for the changes - [ ] All tests pass (`uv run pytest`) - [ ] Linting passes (`uv run ruff check .`) - [ ] Documentation updated (if applicable) - [ ] CHANGELOG.rst updated (for user-facing changes) ### Commit Messages - Use clear, descriptive commit messages - Start with a verb (Add, Fix, Update, Remove, etc.) - Reference issues when applicable: "Fix #123: Handle edge case in transition" ## Getting Help - **Issues**: Open a [GitHub issue](https://github.com/django-commons/django-fsm-2/issues) for bugs or feature requests - **Discussions**: Use GitHub Discussions for questions and ideas ## License By contributing, you agree that your contributions will be licensed under the MIT License. django-commons-django-fsm-2-10256af/LICENSE000066400000000000000000000020751515347707100201600ustar00rootroot00000000000000The MIT License (MIT) Copyright (c) 2010 Mikhail Podgurskiy Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. django-commons-django-fsm-2-10256af/README.md000066400000000000000000000417511515347707100204360ustar00rootroot00000000000000# Django friendly finite state machine support [![CI tests](https://github.com/django-commons/django-fsm-2/actions/workflows/test.yml/badge.svg)](https://github.com/django-commons/django-fsm-2/actions/workflows/test.yml) [![codecov](https://codecov.io/github/django-commons/django-fsm-2/graph/badge.svg?token=gxsNL3cBl3)](https://codecov.io/github/django-commons/django-fsm-2) [![Documentation](https://img.shields.io/static/v1?label=Docs&message=READ&color=informational&style=plastic)](https://github.com/django-commons/django-fsm-2#usage) [![MIT License](https://img.shields.io/static/v1?label=License&message=MIT&color=informational&style=plastic)](https://github.com/django-commons/anymail-history/LICENSE) Django FSM-2 adds simple, declarative state management to Django models. ## Introduction FSM really helps to structure the code, and centralize the lifecycle of your Models. Instead of adding a CharField field to a django model and manage its values by hand everywhere, FSMFields offer the ability to declare your transitions once with the decorator. These methods could contain side-effects, permissions, or logic to make the lifecycle management easier. Nice introduction is available here: https://gist.github.com/Nagyman/9502133 > [!IMPORTANT] > Django FSM-2 is a maintained fork of [Django FSM](https://github.com/viewflow/django-fsm). > > Big thanks to Mikhail Podgurskiy for starting this project and maintaining it for so many years. > > Unfortunately, after 2 years without any releases, the project was brutally archived. Viewflow is presented as an alternative but the transition is not that easy. > > If what you need is just a simple state machine, tailor-made for Django, Django FSM-2 is the successor of Django FSM, with dependencies updates, typing (planned) ## Quick start ```python from django.db import models from django_fsm import FSMField, FSMModelMixin, transition class BlogPost(FSMModelMixin, models.Model): state = FSMField(default='new') @transition(field=state, source='new', target='published') def publish(self, **kwargs): pass ``` ```python from django_fsm import can_proceed post = BlogPost.objects.get(pk=1) if can_proceed(post.publish): post.publish() post.save() ``` ## Installation Install the package: ```bash uv pip install django-fsm-2 ``` Or install from git: ```bash uv pip install -e git://github.com/django-commons/django-fsm-2.git#egg=django-fsm ``` Add `django_fsm` to your Django apps (required to [graph transitions](#drawing-transitions) or use [Admin integration](#admin-integration)): ```python INSTALLED_APPS = ( ..., 'django_fsm', ..., ) ``` > [!IMPORTANT] Migration from django-fsm Django FSM-2 is a drop-in replacement. Update your dependency from `django-fsm` to `django-fsm-2` and keep your existing code. ```bash uv pip install django-fsm-2 ``` ## Usage ### Core ideas - Store a state in an `FSMField` (or `FSMIntegerField`/`FSMKeyField`). - Declare transitions once with the `@transition` decorator. - Transition methods can contain business logic and side effects. - The in-memory state changes on success; `save()` persists it. ### Adding an FSM field ```python from django_fsm import FSMField, FSMModelMixin class BlogPost(FSMModelMixin, models.Model): state = FSMField(default='new') ``` ### Declaring a transition ```python from django_fsm import transition @transition(field=state, source='new', target='published') def publish(self, **kwargs): """ This function may contain side effects, like updating caches, notifying users, etc. The return value will be discarded. """ ``` The `field` parameter accepts a string attribute name or a field instance. If calling `publish()` succeeds without raising an exception, the state changes in memory. **You must call `save()` to persist it**. ```python from django_fsm import can_proceed def publish_view(request, post_id, **kwargs): post = get_object_or_404(BlogPost, pk=post_id) if not can_proceed(post.publish): raise PermissionDenied post.publish() post.save() return redirect('/') ``` ### Preconditions (conditions) Use `conditions` to restrict transitions. Each function receives the instance and must return truthy/falsey. The functions should not have side effects. ```python def can_publish(instance): # No publishing after 17 hours return datetime.datetime.now().hour <= 17 class XXX(FSMModelMixin, models.Model): @transition( field=state, source='new', target='published', conditions=[can_publish] ) def publish(self, **kwargs): pass ``` You can also use model methods: ```python class XXX(FSMModelMixin, models.Model): def can_destroy(self): return self.is_under_investigation() @transition( field=state, source='*', target='destroyed', conditions=[can_destroy] ) def destroy(self, **kwargs): pass ``` ### Protected state fields Use `protected=True` to prevent direct assignment. Only transitions may change the state. Because `refresh_from_db` assigns to the field, protected fields raise there as well unless you use `FSMModelMixin`. Use `FSMModelMixin` by default to allow refresh without enabling arbitrary writes elsewhere. ```python from django_fsm import FSMModelMixin class BlogPost(FSMModelMixin, models.Model): state = FSMField(default='new', protected=True) model = BlogPost() model.state = 'invalid' # Raises AttributeError model.refresh_from_db() # Works ``` ### Source and target states `source` accepts a list of states, a single state, or a `django_fsm.State` implementation. - `source='*'` allows switching to `target` from any state. - `source='+'` allows switching to `target` from any state except `target`. `target` can be a specific state or a `django_fsm.State` implementation. ```python from django_fsm import FSMField, transition, RETURN_VALUE, GET_STATE @transition( field=state, source='*', target=RETURN_VALUE('for_moderators', 'published'), ) def publish(self, is_public=False, **kwargs): return 'for_moderators' if is_public else 'published' @transition( field=state, source='for_moderators', target=GET_STATE( lambda self, allowed: 'published' if allowed else 'rejected', states=['published', 'rejected'], ), ) def moderate(self, allowed, **kwargs): pass @transition( field=state, source='for_moderators', target=GET_STATE( lambda self, **kwargs: 'published' if kwargs.get('allowed', True) else 'rejected', states=['published', 'rejected'], ), ) def moderate(self, allowed=True, **kwargs): pass ``` ### Custom transition metadata Use `custom` to attach arbitrary data to a transition. ```python @transition( field=state, source='*', target='onhold', custom=dict(verbose='Hold for legal reasons'), ) def legal_hold(self, **kwargs): pass ``` ### Error target state If a transition method raises an exception, you can specify an `on_error` state. ```python @transition( field=state, source='new', target='published', on_error='failed' ) def publish(self, **kwargs): """ Some exception could happen here """ ``` ### Permissions Attach permissions to transitions with the `permission` argument. It accepts a permission string or a callable that receives `(instance, user)`. ```python @transition( field=state, source='*', target='published', permission=lambda instance, user: not user.has_perm('myapp.can_make_mistakes'), ) def publish(self, **kwargs): pass @transition( field=state, source='*', target='removed', permission='myapp.can_remove_post', ) def remove(self, **kwargs): pass ``` Check permission with `has_transition_perm`: ```python from django_fsm import has_transition_perm def publish_view(request, post_id): post = get_object_or_404(BlogPost, pk=post_id) if not has_transition_perm(post.publish, request.user): raise PermissionDenied post.publish() post.save() return redirect('/') ``` ### Model helpers Considering a model with a state field called "FIELD" - `get_all_FIELD_transitions` enumerates all declared transitions. - `get_available_FIELD_transitions` returns transitions available in the current state. - `get_available_user_FIELD_transitions` returns transitions available in the current state for a given user. Example: If your state field is called `status` ```python my_model_instance.get_all_status_transitions() my_model_instance.get_available_status_transitions() my_model_instance.get_available_user_status_transitions() ``` ### FSMKeyField (foreign key support) Use `FSMKeyField` to store state values in a table and maintain FK integrity. ```python class DbState(FSMModelMixin, models.Model): id = models.CharField(primary_key=True) label = models.CharField() def __str__(self): return self.label class BlogPost(FSMModelMixin, models.Model): state = FSMKeyField(DbState, default='new') @transition(field=state, source='new', target='published') def publish(self, **kwargs): pass ``` In your fixtures/initial_data.json: ```json [ { "pk": "new", "model": "myapp.dbstate", "fields": { "label": "_NEW_" } }, { "pk": "published", "model": "myapp.dbstate", "fields": { "label": "_PUBLISHED_" } } ] ``` Note: `source` and `target` use the PK values of the `DbState` model as names, even if the field is accessed without the `_id` postfix. ### FSMIntegerField (enum-style states) ```python class BlogPostStateEnum(object): NEW = 10 PUBLISHED = 20 HIDDEN = 30 class BlogPostWithIntegerField(FSMModelMixin, models.Model): state = FSMIntegerField(default=BlogPostStateEnum.NEW) @transition( field=state, source=BlogPostStateEnum.NEW, target=BlogPostStateEnum.PUBLISHED, ) def publish(self, **kwargs): pass ``` ### Signals `django_fsm.signals.pre_transition` and `django_fsm.signals.post_transition` fire before and after an allowed transition. No signals fire for invalid transitions. Arguments sent with these signals: - `sender` The model class. - `instance` The actual instance being processed. - `name` Transition name. - `source` Source model state. - `target` Target model state. ## Optimistic locking Use `ConcurrentTransitionMixin` to avoid concurrent state changes. If the state changed in the database, `django_fsm.ConcurrentTransition` is raised on `save()`. ```python from django_fsm import FSMField, ConcurrentTransitionMixin, FSMModelMixin class BlogPost(ConcurrentTransitionMixin, FSMModelMixin, models.Model): state = FSMField(default='new') ``` For guaranteed protection against race conditions caused by concurrently executed transitions, make sure: - Your transitions do not have side effects except for database changes. - You always call `save()` within a `django.db.transaction.atomic()` block. Following these recommendations, `ConcurrentTransitionMixin` will cause a rollback of all changes executed in an inconsistent state. ## Admin Integration > NB: If you're migrating from [django-fsm-admin](https://github.com/gadventures/django-fsm-admin) (or any alternative), make sure it's not installed anymore to avoid installing the old django-fsm. Update import path: ``` python - from django_fsm_admin.mixins import FSMTransitionMixin + from django_fsm.admin import FSMAdminMixin ``` 1. In your admin.py file, use FSMAdminMixin to add behaviour to your ModelAdmin. FSMAdminMixin should be before ModelAdmin, the order is important. ``` python from django_fsm.admin import FSMAdminMixin @admin.register(AdminBlogPost) class MyAdmin(FSMAdminMixin, admin.ModelAdmin): # Declare the fsm fields you want to manage fsm_fields = ['my_fsm_field'] ... ``` 2. You can customize the buttons by adding `label` and `help_text` to the `custom` attribute of the transition decorator ``` python @transition( field='state', source=['startstate'], target='finalstate', custom={ "label": "My awesome transition", # this "help_text": "Rename blog post", # and this }, ) def do_something(self, **kwargs): ... ``` or by overriding some methods in FSMAdminMixin ``` python @admin.register(AdminBlogPost) class MyAdmin(FSMAdminMixin, admin.ModelAdmin): ... def get_fsm_label(self, transition): # this method if transition.name == "do_something": return "My awesome transition" return super().get_fsm_label(transition) def get_help_text(self, transition): # and this method if transition.name == "do_something": return "Rename blog post" return super().get_help_text(transition) ``` 3. For forms in the admin transition flow, see the Custom Forms section below. 4. Hiding a transition is possible by adding ``custom={"admin": False}`` to the transition decorator: ``` python @transition( field='state', source=['startstate'], target='finalstate', custom={ "admin": False, # this }, ) def do_something(self, **kwargs): # will not add a button "Do Something" to your admin model interface ``` or from the admin: ``` python @admin.register(AdminBlogPost) class MyAdmin(FSMAdminMixin, admin.ModelAdmin): ... def is_fsm_transition_visible(self, transition: fsm.Transition) -> bool: if transition.name == "do_something": return False return super().is_fsm_transition_visible(transition) ``` NB: By adding `FSM_ADMIN_FORCE_PERMIT = True` to your configuration settings (or `fsm_default_disallow_transition = False` to your admin), the above restriction becomes the default. Then one must explicitly allow that a transition method shows up in the admin interface using `custom={"admin": True}` ``` python @admin.register(AdminBlogPost) class MyAdmin(FSMAdminMixin, admin.ModelAdmin): fsm_default_disallow_transition = False ... ``` ### Custom Forms You can attach a custom form to a transition so the admin prompts for input before the transition runs. Add a `form` entry to `custom` on the transition, or define an admin-level mapping via `fsm_forms`. Both accept a `forms.Form`/ `forms.ModelForm` class or a dotted import path. ```python from django import forms from django_fsm import FSMModelMixin, transition class RenameForm(forms.Form): new_title = forms.CharField(max_length=255) # it's also possible to declare fsm log description description = forms.CharField(max_length=255) class BlogPost(FSMModelMixin, models.Model): title = models.CharField(max_length=255) state = FSMField(default="created") @transition( field=state, source="*", target="created", custom={ "label": "Rename", "help_text": "Rename blog post", "form": "path.to.RenameForm", }, ) def rename(self, new_title, **kwargs): self.title = new_title ``` You can also define forms directly on your `ModelAdmin` without touching the transition definition: ```python from django_fsm.admin import FSMAdminMixin from .admin_forms import RenameForm @admin.register(AdminBlogPost) class MyAdmin(FSMAdminMixin, admin.ModelAdmin): fsm_fields = ["state"] fsm_forms = { "rename": "path.to.RenameForm", # use import path "rename": RenameForm, # or FormClass } ``` Behavior details: - When `form` is set, the transition button redirects to a form view instead of executing immediately. - If both are defined, `fsm_forms` on the admin takes precedence over `custom["form"]` on the transition. - On submit, `cleaned_data` is passed to the transition method as keyword arguments and the object is saved. - `RenameForm` receives the current instance automatically. - You can override the transition form template by setting `fsm_transition_form_template` on your `ModelAdmin` (or override globally `templates/django_fsm/fsm_admin_transition_form.html`). ## Drawing transitions Render a graphical overview of your model transitions. 1. Install graphviz support: ```bash uv pip install django-fsm-2[graphviz] ``` or ```bash uv pip install "graphviz>=0.4" ``` 2. Ensure `django_fsm` is in `INSTALLED_APPS`: ```python INSTALLED_APPS = ( ..., 'django_fsm', ..., ) ``` 3. Run the management command: ```bash # Create a dot file ./manage.py graph_transitions > transitions.dot # Create a PNG image file for a specific model ./manage.py graph_transitions -o blog_transitions.png myapp.Blog # Exclude some transitions ./manage.py graph_transitions -e transition_1,transition_2 myapp.Blog ``` ## Extensions Transition logging support could be achieved with help of django-fsm-log package : ## Contributing We welcome contributions. See `CONTRIBUTING.md` for detailed setup instructions. ### Quick Development Setup ```bash # Clone and setup git clone https://github.com/django-commons/django-fsm-2.git cd django-fsm uv sync # Run tests uv run pytest -v # or uv run tox # Run linting uv run ruff format . uv run ruff check . ``` django-commons-django-fsm-2-10256af/django_fsm/000077500000000000000000000000001515347707100212565ustar00rootroot00000000000000django-commons-django-fsm-2-10256af/django_fsm/__init__.py000066400000000000000000000655611515347707100234040ustar00rootroot00000000000000""" State tracking functionality for django models """ from __future__ import annotations import inspect import typing from functools import partialmethod from functools import wraps from django import VERSION as DJANGO_VERSION from django.apps import apps as django_apps from django.db import models from django.db.models import Field from django.db.models import QuerySet from django.db.models.query_utils import DeferredAttribute from django.db.models.signals import class_prepared from django_fsm.signals import post_transition from django_fsm.signals import pre_transition if typing.TYPE_CHECKING: # pragma: no cover from typing import Self from django.contrib.auth.models import AbstractUser from django.contrib.auth.models import AnonymousUser from django.contrib.auth.models import PermissionsMixin UserWithPermissions: typing.TypeAlias = AbstractUser | AnonymousUser | PermissionsMixin _Field: typing.TypeAlias = models.Field[typing.Any, typing.Any] CharField: typing.TypeAlias = models.CharField[str, str] IntegerField: typing.TypeAlias = models.IntegerField[int, int] ForeignKey: typing.TypeAlias = models.ForeignKey[typing.Any, typing.Any] _FSMModel: typing.TypeAlias = models.Model _StateValue: typing.TypeAlias = str | int | models.Choices _Permission: typing.TypeAlias = str | typing.Callable[[_FSMModel, UserWithPermissions], bool] _Condition: typing.TypeAlias = typing.Callable[[_FSMModel], bool] _TransitionFunc: typing.TypeAlias = typing.Callable[..., _StateValue | typing.Any | None] else: _FSMModel = object _Field = object CharField = models.CharField IntegerField = models.IntegerField ForeignKey = models.ForeignKey Self = typing.Any try: from typing import override except ImportError: # pragma: no cover # Py<3.12 from typing_extensions import override __all__ = [ "GET_STATE", "RETURN_VALUE", "ConcurrentTransition", "ConcurrentTransitionMixin", "FSMField", "FSMFieldMixin", "FSMIntegerField", "FSMKeyField", "TransitionNotAllowed", "can_proceed", "has_transition_perm", "transition", ] class TransitionNotAllowed(Exception): # noqa: N818 """Raised when a transition is not allowed""" @override def __init__(self, *args: typing.Any, **kwargs: typing.Any) -> None: self.object = kwargs.pop("object", None) self.method = kwargs.pop("method", None) super().__init__(*args, **kwargs) class InvalidResultState(Exception): # noqa: N818 """Raised when we got invalid result state""" class ConcurrentTransition(Exception): # noqa: N818 """ Raised when the transition cannot be executed because the object has become stale (state has been changed since it was fetched from the database). """ class Transition: def __init__( self, method: _TransitionFunc, source: _StateValue, target: _StateValue, on_error: _StateValue | None, conditions: list[_Condition] | None, permission: _Permission | None, custom: dict[str, typing.Any] | None, ) -> None: self.method = method self.source = source self.target = target self.on_error = on_error self.conditions = conditions self.permission = permission self.custom = custom or {} @property def name(self) -> str: return self.method.__name__ @property def qualname(self) -> str: return self.method.__qualname__ def has_perm(self, instance: _FSMModel, user: UserWithPermissions) -> bool: if not self.permission: return True if callable(self.permission): return bool(self.permission(instance, user)) if user.has_perm(self.permission, instance): return True if user.has_perm(self.permission): return True return False def __hash__(self) -> int: return hash(self.qualname) def __eq__(self, other: object) -> bool: if isinstance(other, Transition): return hash(other) == hash(self) if isinstance(other, str): return other == self.name return False def get_available_FIELD_transitions( # noqa: N802 instance: _FSMModel, field: FSMFieldMixin ) -> typing.Generator[Transition]: """ List of transitions available in current model state with all conditions met """ curr_state = field.get_state(instance) transitions = field.transitions[instance.__class__] for transition in transitions.values(): meta = transition._django_fsm if meta.has_transition(curr_state) and meta.conditions_met(instance, curr_state): yield meta.get_transition(curr_state) def get_all_FIELD_transitions( # noqa: N802 instance: _FSMModel, field: FSMFieldMixin ) -> typing.Generator[Transition]: """ List of all transitions available in current model state """ return field.get_all_transitions(instance.__class__) def get_available_user_FIELD_transitions( # noqa: N802 instance: _FSMModel, user: UserWithPermissions, field: FSMFieldMixin ) -> typing.Generator[Transition]: """ List of transitions available in current model state with all conditions met and user have rights on it """ for transition in get_available_FIELD_transitions(instance, field): if transition.has_perm(instance, user): yield transition class FSMMeta: """ Models methods transitions meta information """ def __init__(self, field: FSMFieldMixin | str, method: bool) -> None: # noqa: FBT001 self.field = field self.transitions: dict[_StateValue, Transition] = {} # source -> Transition def get_transition(self, source: _StateValue) -> Transition | None: transition = self.transitions.get(source, None) if transition is None: transition = self.transitions.get("*", None) if transition is None: transition = self.transitions.get("+", None) return transition def add_transition( self, method: _TransitionFunc, source: _StateValue, target: _StateValue, on_error: _StateValue | None = None, conditions: list[_Condition] | None = None, permission: _Permission | None = None, custom: dict[str, typing.Any] | None = None, ) -> None: if source in self.transitions: raise AssertionError(f"Duplicate transition for {source} state") self.transitions[source] = Transition( method=method, source=source, target=target, on_error=on_error, conditions=conditions, permission=permission, custom=custom, ) def has_transition(self, state: _StateValue) -> bool: """ Lookup if any transition exists from current model state using current method """ if state in self.transitions: return True if "*" in self.transitions: return True if "+" in self.transitions and self.transitions["+"].target != state: return True return False def conditions_met(self, instance: _FSMModel, state: _StateValue) -> bool: """ Check if all conditions have been met """ transition = self.get_transition(state) if transition is None: return False if transition.conditions is None: return True return all(condition(instance) for condition in transition.conditions) def has_transition_perm( self, instance: _FSMModel, state: _StateValue, user: UserWithPermissions ) -> bool: transition = self.get_transition(state) if not transition: return False return transition.has_perm(instance, user) def next_state(self, current_state: _StateValue) -> _StateValue: transition = self.get_transition(current_state) if transition is None: raise TransitionNotAllowed(f"No transition from {current_state}") return transition.target def exception_state(self, current_state: _StateValue) -> _StateValue | None: transition = self.get_transition(current_state) if transition is None: raise TransitionNotAllowed(f"No transition from {current_state}") return transition.on_error class FSMFieldDescriptor: def __init__(self, field: FSMFieldMixin) -> None: self.field = field def __get__(self, instance: _FSMModel, type: typing.Any | None = None) -> typing.Any: # noqa: A002 if instance is None: return self return self.field.get_state(instance) def __set__(self, instance: _FSMModel, value: typing.Any) -> None: if self.field.protected and self.field.name in instance.__dict__: raise AttributeError(f"Direct {self.field.name} modification is not allowed") # Update state self.field.set_proxy(instance, value) self.field.set_state(instance, value) class FSMFieldMixin(_Field): descriptor_class = FSMFieldDescriptor @override def __init__(self, *args: typing.Any, **kwargs: typing.Any) -> None: self.protected = kwargs.pop("protected", False) self.transitions: dict[ type[_FSMModel], dict[str, typing.Any] ] = {} # cls -> (transitions name -> method) self.state_proxy: dict[_StateValue, str] = {} # state -> ProxyClsRef state_choices = kwargs.pop("state_choices", None) choices = kwargs.get("choices") if state_choices is not None and choices is not None: raise ValueError("Use one of choices or state_choices value") if state_choices is not None: choices = [] for state, title, proxy_cls_ref in state_choices: choices.append((state, title)) self.state_proxy[state] = proxy_cls_ref kwargs["choices"] = choices super().__init__(*args, **kwargs) @override def deconstruct(self) -> tuple[str, str, typing.Sequence[typing.Any], dict[str, typing.Any]]: name, path, args, kwargs = super().deconstruct() if self.protected: kwargs["protected"] = self.protected return name, path, args, kwargs def get_state(self, instance: _FSMModel) -> typing.Any: # The state field may be deferred. We delegate the logic of figuring this out # and loading the deferred field on-demand to Django's built-in DeferredAttribute class. return DeferredAttribute(self).__get__(instance) def set_state(self, instance: _FSMModel, state: _StateValue) -> None: instance.__dict__[self.name] = state def set_proxy(self, instance: _FSMModel, state: _StateValue) -> None: """ Change class """ if state in self.state_proxy: state_proxy = self.state_proxy[state] try: app_label, model_name = state_proxy.split(".") except ValueError: # If we can't split, assume a model in current app app_label = instance._meta.app_label model_name = state_proxy model = django_apps.get_app_config(app_label).get_model(model_name) if model is None: raise ValueError(f"No model found {state_proxy}") instance.__class__ = model def change_state( self, instance: _FSMModel, method: typing.Any, *args: typing.Any, **kwargs: typing.Any ) -> typing.Any: meta: FSMMeta = method._django_fsm method_name: str = method.__name__ current_state = self.get_state(instance) if not meta.has_transition(current_state): raise TransitionNotAllowed( f"Can't switch from state '{current_state}' using method '{method_name}'", object=instance, method=method, ) if not meta.conditions_met(instance, current_state): raise TransitionNotAllowed( f"Transition conditions have not been met for method '{method_name}'", object=instance, method=method, ) next_state = meta.next_state(current_state) signal_kwargs = { "sender": instance.__class__, "instance": instance, "name": method_name, "field": meta.field, "source": current_state, "target": next_state, "method_args": args, "method_kwargs": kwargs, } pre_transition.send(**signal_kwargs) try: result = method(instance, *args, **kwargs) if next_state is not None: if hasattr(next_state, "get_state"): next_state = next_state.get_state( instance, transition, result, args=args, kwargs=kwargs ) signal_kwargs["target"] = next_state self.set_proxy(instance, next_state) self.set_state(instance, next_state) except Exception as exc: exception_state = meta.exception_state(current_state) if exception_state: self.set_proxy(instance, exception_state) self.set_state(instance, exception_state) signal_kwargs["target"] = exception_state signal_kwargs["exception"] = exc post_transition.send(**signal_kwargs) raise else: post_transition.send(**signal_kwargs) return result def get_all_transitions(self, instance_cls: type[_FSMModel]) -> typing.Generator[Transition]: """ Returns [(source, target, name, method)] for all field transitions """ for transition in self.transitions[instance_cls].values(): yield from transition._django_fsm.transitions.values() @override def contribute_to_class( self, cls: type[_FSMModel], name: str, private_only: bool = False, **kwargs: typing.Any, ) -> None: self.base_cls = cls super().contribute_to_class(cls, name, private_only=private_only, **kwargs) setattr(cls, self.name, self.descriptor_class(self)) setattr( cls, f"get_all_{self.name}_transitions", partialmethod(get_all_FIELD_transitions, field=self), ) setattr( cls, f"get_available_{self.name}_transitions", partialmethod(get_available_FIELD_transitions, field=self), ) setattr( cls, f"get_available_user_{self.name}_transitions", partialmethod(get_available_user_FIELD_transitions, field=self), ) class_prepared.connect(self._collect_transitions) def _collect_transitions(self, *args: typing.Any, **kwargs: typing.Any) -> None: sender = kwargs["sender"] if not issubclass(sender, self.base_cls): return def is_field_transition_method(attr: _TransitionFunc) -> bool: return ( (inspect.ismethod(attr) or inspect.isfunction(attr)) and hasattr(attr, "_django_fsm") and ( attr._django_fsm.field in [self, self.name] or ( isinstance(attr._django_fsm.field, Field) and attr._django_fsm.field.name == self.name and attr._django_fsm.field.creation_counter == self.creation_counter ) ) ) sender_transitions: dict[str, typing.Any] = {} transitions = inspect.getmembers(sender, predicate=is_field_transition_method) for method_name, method in transitions: method._django_fsm.field = self sender_transitions[method_name] = method self.transitions[sender] = sender_transitions class FSMField(FSMFieldMixin, CharField): """ State Machine support for Django model as CharField """ @override def __init__(self, *args: typing.Any, **kwargs: typing.Any) -> None: kwargs.setdefault("max_length", 50) super().__init__(*args, **kwargs) class FSMIntegerField(FSMFieldMixin, IntegerField): """ Same as FSMField, but stores the state value in an IntegerField. """ class FSMKeyField(FSMFieldMixin, ForeignKey): """ State Machine support for Django model """ def get_state(self, instance: _FSMModel) -> typing.Any: return instance.__dict__[self.attname] def set_state(self, instance: _FSMModel, state: _StateValue) -> None: instance.__dict__[self.attname] = self.to_python(state) class FSMModelMixin(_FSMModel): """ Mixin that allows refresh_from_db for models with fsm protected fields """ def _get_protected_fsm_fields(self) -> set[str]: def is_fsm_and_protected(f: object) -> bool: return isinstance(f, FSMFieldMixin) and f.protected protected_fields = filter(is_fsm_and_protected, self._meta.concrete_fields) return {f.attname for f in protected_fields} @override def refresh_from_db(self, *args: typing.Any, **kwargs: typing.Any) -> None: protected_fields = self._get_protected_fsm_fields() for f in protected_fields: setattr(self._meta.get_field(f), "protected", False) super().refresh_from_db(*args, **kwargs) for f in protected_fields: setattr(self._meta.get_field(f), "protected", True) class ConcurrentTransitionMixin(FSMModelMixin): """ Protects a Model from undesirable effects caused by concurrently executed transitions, e.g. running the same transition multiple times at the same time, or running different transitions with the same SOURCE state at the same time. This behavior is achieved using an idea based on optimistic locking. No additional version field is required though; only the state field(s) is/are used for the tracking. This scheme is not that strict as true *optimistic locking* mechanism, it is however more lightweight - leveraging the specifics of FSM models. Instance of a model based on this Mixin will be prevented from saving into DB if any of its state fields (instances of FSMFieldMixin) has been changed since the object was fetched from the database. *ConcurrentTransition* exception will be raised in such cases. For guaranteed protection against such race conditions, make sure: * Your transitions do not have any side effects except for changes in the database, * You always run the save() method on the object within django.db.transaction.atomic() block. Following these recommendations, you can rely on ConcurrentTransitionMixin to cause a rollback of all the changes that have been executed in an inconsistent (out of sync) state, thus practically negating their effect. """ @override def __init__(self, *args: typing.Any, **kwargs: typing.Any) -> None: super().__init__(*args, **kwargs) self._update_initial_state() @property def state_fields(self) -> typing.Iterable[FSMFieldMixin]: return filter(lambda field: isinstance(field, FSMFieldMixin), self._meta.fields) # type: ignore[arg-type] @override def _do_update( self, base_qs: QuerySet[Self], using: str | None, pk_val: typing.Any, values: typing.Collection[tuple[_Field, type[models.Model] | None, typing.Any]], update_fields: typing.Iterable[str] | None, forced_update: bool, returning_fields: bool | None = None, ) -> bool: # _do_update is called once for each model class in the inheritance hierarchy. We can only # filter the base_qs on state fields (can be more than one!) present in this specific model. # Select state fields to filter on filter_on = filter(lambda field: field.model == base_qs.model, self.state_fields) # state filter will be used to narrow down the standard filter checking only PK state_filter = {field.attname: self.__initial_states[field.attname] for field in filter_on} # Django 6.0+ added returning_fields parameter to _do_update if DJANGO_VERSION >= (6, 0): updated = super()._do_update( # type: ignore[call-arg] base_qs=base_qs.filter(**state_filter), using=using, pk_val=pk_val, values=values, update_fields=update_fields, forced_update=forced_update, returning_fields=returning_fields, ) else: updated = super()._do_update( base_qs=base_qs.filter(**state_filter), using=using, pk_val=pk_val, values=values, update_fields=update_fields, forced_update=forced_update, ) # It may happen that nothing was updated in the original _do_update method not because of # unmatching state, but because of missing PK. This codepath is possible when saving a new # model instance with *preset PK*. # In this case Django does not know it has to do INSERT operation, # so it tries UPDATE first and falls back to INSERT if UPDATE fails. # Thus, we need to make sure we only catch the case when the object *is* in the DB, # but with changed state; and mimic standard _do_update behavior otherwise. # Django will pick it up and execute _do_insert. if not updated and base_qs.filter(pk=pk_val).using(using).exists(): raise ConcurrentTransition( "Cannot save object! The state has been changed since fetched from the database!" ) return updated def _update_initial_state(self) -> None: self.__initial_states = { field.attname: field.value_from_object(self) for field in self.state_fields } @override def refresh_from_db(self, *args: typing.Any, **kwargs: typing.Any) -> None: super().refresh_from_db(*args, **kwargs) self._update_initial_state() @override def save(self, *args: typing.Any, **kwargs: typing.Any) -> None: super().save(*args, **kwargs) self._update_initial_state() def transition( field: FSMFieldMixin | str, source: _StateValue | typing.Sequence[_StateValue] = "*", target: _StateValue | State | None = None, on_error: _StateValue | None = None, conditions: list[_Condition] | None = None, permission: _Permission | None = None, custom: dict[str, typing.Any] | None = None, ) -> typing.Callable[[typing.Any], typing.Any]: """ Method decorator to mark allowed transitions. Set target to None if current state needs to be validated and has not changed after the function call. """ def inner_transition(func: typing.Any) -> typing.Any: wrapper_installed, fsm_meta = True, getattr(func, "_django_fsm", None) if not fsm_meta: wrapper_installed = False fsm_meta = FSMMeta(field=field, method=func) setattr(func, "_django_fsm", fsm_meta) if isinstance(source, list | tuple | set): for state in source: func._django_fsm.add_transition( func, state, target, on_error, conditions, permission, custom ) else: func._django_fsm.add_transition( func, source, target, on_error, conditions, permission, custom ) @wraps(func) def _change_state( instance: _FSMModel, *args: typing.Any, **kwargs: typing.Any ) -> typing.Any: assert isinstance(fsm_meta.field, FSMFieldMixin) return fsm_meta.field.change_state(instance, func, *args, **kwargs) if not wrapper_installed: return _change_state return func return inner_transition def can_proceed(bound_method: typing.Any, check_conditions: bool = True) -> bool: # noqa: FBT001, FBT002 """ Returns True if model in state allows to call bound_method Set ``check_conditions`` argument to ``False`` to skip checking conditions. """ if not hasattr(bound_method, "_django_fsm"): raise TypeError(f"{bound_method.__func__.__name__} method is not transition") meta = bound_method._django_fsm self = bound_method.__self__ current_state = meta.field.get_state(self) return meta.has_transition(current_state) and ( not check_conditions or meta.conditions_met(self, current_state) ) def has_transition_perm(bound_method: typing.Any, user: UserWithPermissions) -> bool: """ Returns True if model in state allows to call bound_method and user have rights on it """ if not hasattr(bound_method, "_django_fsm"): raise TypeError(f"{bound_method.__func__.__name__} method is not transition") meta = bound_method._django_fsm self = bound_method.__self__ current_state = meta.field.get_state(self) return bool( meta.has_transition(current_state) and meta.conditions_met(self, current_state) and meta.has_transition_perm(self, current_state, user) ) class State: allowed_states: typing.Sequence[_StateValue] def get_state( self, model: _FSMModel, transition: Transition, result: typing.Any, args: typing.Sequence[typing.Any] | None = None, kwargs: dict[str, typing.Any] | None = None, ) -> typing.Any: raise NotImplementedError class RETURN_VALUE(State): # noqa: N801 def __init__(self, *allowed_states: _StateValue) -> None: self.allowed_states = allowed_states or [] def get_state( self, model: _FSMModel, transition: Transition, result: typing.Any, args: typing.Sequence[typing.Any] | None = None, kwargs: dict[str, typing.Any] | None = None, ) -> typing.Any: if self.allowed_states is not None and result not in self.allowed_states: raise InvalidResultState( f"{result} is not in list of allowed states\n{self.allowed_states}" ) return result class GET_STATE(State): # noqa: N801 def __init__( self, func: typing.Callable[..., _StateValue], states: typing.Sequence[_StateValue] | None = None, ) -> None: self.func = func self.allowed_states = states or [] def get_state( self, model: _FSMModel, transition: Transition, result: _StateValue, args: typing.Sequence[typing.Any] | None = None, kwargs: dict[str, typing.Any] | None = None, ) -> typing.Any: if args is None: args = () if kwargs is None: kwargs = {} result_state = self.func(model, *args, **kwargs) if self.allowed_states is not None and result_state not in self.allowed_states: raise InvalidResultState( f"{result_state} is not in list of allowed states\n{self.allowed_states}" ) return result_state django-commons-django-fsm-2-10256af/django_fsm/admin.py000066400000000000000000000351771515347707100227350ustar00rootroot00000000000000from __future__ import annotations import logging import typing from dataclasses import dataclass from django import http from django.apps import apps from django.conf import settings from django.contrib import admin from django.contrib import messages from django.contrib.admin.templatetags.admin_urls import add_preserved_filters from django.core.exceptions import AppRegistryNotReady from django.core.exceptions import ImproperlyConfigured from django.forms import Form from django.forms import ModelForm from django.shortcuts import redirect from django.shortcuts import render from django.urls import URLPattern from django.urls import path from django.urls import reverse from django.utils.module_loading import import_string from django.utils.translation import gettext_lazy as _ import django_fsm as fsm logger = logging.getLogger(__name__) try: from typing import override except ImportError: # pragma: no cover # Py<3.12 from typing_extensions import override if typing.TYPE_CHECKING: # pragma: no cover _ModelAdmin: typing.TypeAlias = admin.ModelAdmin[fsm._FSMModel] _FormType: typing.TypeAlias = type[Form | ModelForm[fsm._FSMModel]] else: _ModelAdmin = admin.ModelAdmin _FormType = type[Form | ModelForm] @dataclass class FSMTransitionContext: name: str label: str help_text: str | None = None @dataclass class FSMObjectTransition: fsm_field: str block_label: str available_transitions: list[FSMTransitionContext] class FSMAdminMixin(_ModelAdmin): change_form_template = "django_fsm/fsm_admin_change_form.html" fsm_fields: list[str] = [] fsm_transition_success_msg = _("FSM transition '{transition_name}' succeeded.") fsm_transition_error_msg = _("FSM transition '{transition_name}' failed: {error}.") fsm_transition_not_allowed_msg = _("FSM transition '{transition_name}' is not allowed.") fsm_context_key = "fsm_object_transitions" fsm_post_param = "_fsm_transition_to" fsm_default_disallow_transition = not getattr(settings, "FSM_ADMIN_FORCE_PERMIT", False) fsm_transition_form_template = "django_fsm/fsm_admin_transition_form.html" fsm_forms: dict[str, str | _FormType | None] = {} # Admin hooks @override def __init__(self, model: type[fsm._FSMModel], admin_site: admin.AdminSite) -> None: if not self.fsm_fields: # pragma: no cover # django-fsm-admin retro compatibility if hasattr(self, "fsm_field"): logger.warning( "'fsm_field' declaration is deprecated, please update to 'fsm_fields'" ) self.fsm_fields = self.fsm_field else: raise ImproperlyConfigured("'fsm_fields' is not declared") super().__init__(model, admin_site) @override def get_readonly_fields( self, request: http.HttpRequest, obj: fsm._FSMModel | None = None ) -> tuple[str, ...]: """Ensures 'protected' fields are 'readonly'""" read_only_fields = list(super().get_readonly_fields(request, obj)) for fsm_field_name in self.fsm_fields: if fsm_field_name in read_only_fields: # pragma: no cover continue field = self.model._meta.get_field(fsm_field_name) if not isinstance(field, fsm.FSMField): # pragma: no cover raise ImproperlyConfigured(f"'{fsm_field_name}' is not an FSMField") if getattr(field, "protected", False): read_only_fields.append(fsm_field_name) return tuple(read_only_fields) @override def get_urls(self) -> list[URLPattern]: meta = self.model._meta return [ path( "/transition//", self.admin_site.admin_view(self.fsm_transition_view), name=f"{meta.app_label}_{meta.model_name}_transition", ), *super().get_urls(), ] @override def change_view( self, request: http.HttpRequest, object_id: str, form_url: str = "", extra_context: dict[str, typing.Any] | None = None, ) -> http.HttpResponse: """Override the change view to add FSM transitions to the context.""" _context = extra_context or {} _context[self.fsm_context_key] = self._get_fsm_extra_context( request=request, obj=self.get_object(request=request, object_id=object_id), ) return super().change_view( request=request, object_id=object_id, form_url=form_url, extra_context=_context, ) @override def response_change(self, request: http.HttpRequest, obj: fsm._FSMModel) -> http.HttpResponse: transition_name = request.POST.get(self.fsm_post_param) if not transition_name: return super().response_change(request=request, obj=obj) if self.get_fsm_transition_form( transition=self._get_fsm_transition_by_name(obj=obj, transition_name=transition_name) ): return redirect( reverse( f"admin:{self.model._meta.app_label}_{self.model._meta.model_name}_transition", kwargs={ "object_id": obj.pk, "transition_name": transition_name, }, ) ) if self._apply_fsm_transition( obj=obj, transition_name=transition_name, request=request, ): logger.info("FSM transition %s completed successfully", transition_name) return http.HttpResponseRedirect( redirect_to=add_preserved_filters( context={ "preserved_filters": self.get_preserved_filters(request), "opts": self.model._meta, }, url=request.path, ) ) # Public extension points @staticmethod def get_fsm_block_label(fsm_field_name: str) -> str: return f"Transition ({fsm_field_name})" def is_fsm_transition_visible(self, transition: fsm.Transition) -> bool: return bool(transition.custom.get("admin", self.fsm_default_disallow_transition)) def get_fsm_label(self, transition: fsm.Transition) -> str: return transition.custom.get("label") or transition.name def get_help_text(self, transition: fsm.Transition) -> str | None: return transition.custom.get("help_text") def get_fsm_transition_form(self, transition: fsm.Transition) -> _FormType | None: """Get transition form class with error handling.""" form = self.fsm_forms.get(transition.name, transition.custom.get("form")) if isinstance(form, str): try: form = import_string(form) except (ImportError, AttributeError): raise ImproperlyConfigured(f"Failed to import form {form}") if isinstance(form, type) and issubclass(form, (ModelForm, Form)): return form return None # Transition helpers def _get_fsm_extra_context( self, *, request: http.HttpRequest, obj: fsm._FSMModel | None ) -> typing.Generator[FSMObjectTransition]: for field_name in sorted(self.fsm_fields): transitions_func = getattr(obj, f"get_available_user_{field_name}_transitions", None) if callable(transitions_func): available_transitions = transitions_func(user=request.user) if admin_allowed_transitions := [ FSMTransitionContext( name=t.name, label=self.get_fsm_label(t), help_text=self.get_help_text(t), ) for t in available_transitions if self.is_fsm_transition_visible(t) ]: yield FSMObjectTransition( fsm_field=field_name, block_label=self.get_fsm_block_label(fsm_field_name=field_name), available_transitions=admin_allowed_transitions, ) def _get_fsm_transition_func( self, *, obj: fsm._FSMModel, transition_name: str ) -> fsm._TransitionFunc: try: transition_func: fsm._TransitionFunc = getattr(obj, transition_name) except AttributeError: raise AttributeError( f"{obj.__class__.__name__} has no transition method '{transition_name}'." ) if not callable(transition_func): raise TypeError(f"Attribute '{transition_name}' is not callable.") # Security: Only allow FSM transition methods if not hasattr(transition_func, "_django_fsm"): raise ValueError(f"Method '{transition_name}' is not an FSM transition.") return transition_func def _get_fsm_transition_by_name( self, *, obj: fsm._FSMModel, transition_name: str ) -> fsm.Transition: transition_func = self._get_fsm_transition_func(obj=obj, transition_name=transition_name) transitions = transition_func._django_fsm.transitions # type: ignore[attr-defined] if isinstance(transitions, dict): transitions = list(transitions.values()) # Each transition method stores one transition per target field; first entry is sufficient. return transitions[0] # type: ignore[no-any-return] @staticmethod def _is_fsm_log_enabled() -> bool: try: return apps.is_installed("django_fsm_log") except AppRegistryNotReady: # pragma: no cover return "django_fsm_log" in settings.INSTALLED_APPS def _execute_fsm_transition( self, *, transition_func: fsm._TransitionFunc, request: http.HttpRequest, kwargs: typing.Mapping[str, typing.Any] | None = None, ) -> None: kwargs = kwargs or {} if self._is_fsm_log_enabled(): try: transition_func(by=request.user, **kwargs) except TypeError: transition_func(**kwargs) else: transition_func(**kwargs) def _apply_fsm_transition( self, *, obj: fsm._FSMModel, transition_name: str, request: http.HttpRequest, kwargs: typing.Mapping[str, typing.Any] | None = None, ) -> bool: try: self._execute_fsm_transition( transition_func=self._get_fsm_transition_func( obj=obj, transition_name=transition_name ), request=request, kwargs=kwargs, ) obj.save() except fsm.TransitionNotAllowed: self.message_user( request=request, message=self.fsm_transition_not_allowed_msg.format( transition_name=transition_name, ), level=messages.ERROR, ) return False except fsm.ConcurrentTransition as err: self.message_user( request=request, message=self.fsm_transition_error_msg.format( transition_name=transition_name, error=str(err), ), level=messages.ERROR, ) return False except Exception as err: logger.exception("Unexpected error during FSM transition %s", transition_name) self.message_user( request=request, message=self.fsm_transition_error_msg.format( transition_name=transition_name, error=str(err), ), level=messages.ERROR, ) return False else: self.message_user( request=request, message=self.fsm_transition_success_msg.format( transition_name=transition_name, ), level=messages.SUCCESS, ) return True # Form handling def fsm_transition_view( self, request: http.HttpRequest, object_id: str, transition_name: str, **kwargs: typing.Any ) -> http.HttpResponse: """Handle FSM transition form view with enhanced validation.""" obj = self.get_object(request, object_id) if obj is None: return self._get_obj_does_not_exist_redirect(request, self.opts, object_id) # type: ignore[no-any-return, attr-defined] transition = self._get_fsm_transition_by_name(obj=obj, transition_name=transition_name) if not transition.has_perm(obj, user=request.user): self.message_user( request=request, message=self.fsm_transition_not_allowed_msg.format( transition_name=transition_name, ), level=messages.ERROR, ) return redirect( f"admin:{self.model._meta.app_label}_{self.model._meta.model_name}_change", object_id=obj.pk, ) form_class = self.get_fsm_transition_form(transition) if not form_class: logger.warning("No form configured for transition %s", transition_name) return http.HttpResponseBadRequest(f"No form configuration found for {transition_name}") data = request.POST if request.method == "POST" else None transition_form: Form | ModelForm[fsm._FSMModel] if issubclass(form_class, ModelForm): transition_form = form_class(data=data, instance=obj) else: transition_form = form_class(data=data) if request.method == "POST" and transition_form.is_valid(): # noqa: SIM102 if self._apply_fsm_transition( obj=obj, transition_name=transition_name, request=request, kwargs=transition_form.cleaned_data, ): return redirect( f"admin:{self.model._meta.app_label}_{self.model._meta.model_name}_change", object_id=obj.pk, ) return render( request, template_name=self.fsm_transition_form_template, context=( admin.site.each_context(request) | { "opts": self.model._meta, "original": obj, "transition": FSMTransitionContext( name=transition_name, label=self.get_fsm_label(transition), help_text=self.get_help_text(transition), ), "transition_form": transition_form, } ), ) django-commons-django-fsm-2-10256af/django_fsm/management/000077500000000000000000000000001515347707100233725ustar00rootroot00000000000000django-commons-django-fsm-2-10256af/django_fsm/management/__init__.py000066400000000000000000000000001515347707100254710ustar00rootroot00000000000000django-commons-django-fsm-2-10256af/django_fsm/management/commands/000077500000000000000000000000001515347707100251735ustar00rootroot00000000000000django-commons-django-fsm-2-10256af/django_fsm/management/commands/__init__.py000066400000000000000000000000001515347707100272720ustar00rootroot00000000000000django-commons-django-fsm-2-10256af/django_fsm/management/commands/graph_transitions.py000066400000000000000000000176151515347707100313150ustar00rootroot00000000000000from __future__ import annotations import typing from itertools import chain import graphviz from django.apps import apps from django.core.management.base import BaseCommand from django.utils.encoding import force_str from django_fsm import GET_STATE from django_fsm import RETURN_VALUE from django_fsm import FSMFieldMixin if typing.TYPE_CHECKING: # pragma: no cover from argparse import ArgumentParser from collections.abc import Sequence from django.db import models from django_fsm import _StateValue def all_fsm_fields_data( model: type[models.Model], ) -> list[tuple[FSMFieldMixin, type[models.Model]]]: return [ (field, model) for field in model._meta.get_fields() if isinstance(field, FSMFieldMixin) ] def one_fsm_fields_data( model: type[models.Model], field_name: str ) -> tuple[FSMFieldMixin, type[models.Model]]: field = model._meta.get_field(field_name) if not isinstance(field, FSMFieldMixin): raise LookupError(f"{field_name} is not an FSMField") # noqa: TRY004 return (field, model) def node_name(field: FSMFieldMixin, state: _StateValue) -> str: opts = field.model._meta assert opts.verbose_name return "{}.{}.{}.{}".format( opts.app_label, opts.verbose_name.replace(" ", "_"), field.name, state ) def node_label(field: FSMFieldMixin, state: _StateValue | None) -> str: if hasattr(field, "choices") and field.choices: state = dict(field.choices).get(state) return force_str(state) def generate_dot( # noqa: C901, PLR0912 fields_data: Sequence[tuple[FSMFieldMixin, type[models.Model]]], ignore_transitions: Sequence[str] | None = None, ) -> graphviz.Digraph: ignore_transitions = ignore_transitions or [] result = graphviz.Digraph() for field, model in fields_data: sources: set[tuple[(str, str)]] = set() targets: set[tuple[str, str]] = set() edges: set[tuple[str, str, tuple[tuple[str, str]]]] = set() any_targets: set[tuple[_StateValue, str]] = set() any_except_targets: set[tuple[_StateValue, str]] = set() # dump nodes and edges for transition in field.get_all_transitions(model): if transition.name in ignore_transitions: continue _targets = list( (state for state in transition.target.allowed_states) if isinstance(transition.target, GET_STATE | RETURN_VALUE) else (transition.target,) ) source_name_pair = ( ((state, node_name(field, state)) for state in transition.source.allowed_states) if isinstance(transition.source, GET_STATE | RETURN_VALUE) else ((transition.source, node_name(field, transition.source)),) ) for source, source_name in source_name_pair: if transition.on_error: on_error_name = node_name(field, transition.on_error) targets.add((on_error_name, node_label(field, transition.on_error))) edges.add((source_name, on_error_name, (("style", "dotted"),))) for target in _targets: if transition.source == "*": any_targets.add((target, transition.name)) elif transition.source == "+": any_except_targets.add((target, transition.name)) else: target_name = node_name(field, target) sources.add((source_name, node_label(field, source))) targets.add((target_name, node_label(field, target))) edges.add((source_name, target_name, (("label", transition.name),))) targets.update( { (node_name(field, target), node_label(field, target)) for target, _ in chain(any_targets, any_except_targets) } ) for target, name in any_targets: target_name = node_name(field, target) all_nodes = sources | targets for source_name, label in all_nodes: sources.add((source_name, label)) edges.add((source_name, target_name, (("label", name),))) for target, name in any_except_targets: target_name = node_name(field, target) all_nodes = sources | targets all_nodes.remove((target_name, node_label(field, target))) for source_name, label in all_nodes: sources.add((source_name, label)) edges.add((source_name, target_name, (("label", name),))) # construct subgraph opts = field.model._meta subgraph = graphviz.Digraph( name=f"cluster_{opts.app_label}_{opts.object_name}_{field.name}", graph_attr={"label": f"{opts.app_label}.{opts.object_name}.{field.name}"}, ) final_states = targets - sources for name, label in final_states: subgraph.node(name, label=label, shape="doublecircle") for name, label in (sources | targets) - final_states: subgraph.node(name, label=label, shape="circle") # Adding initial state notation if field.default and label == field.default: initial_name = node_name(field, "_initial") subgraph.node(name=initial_name, label="", shape="point") subgraph.edge(tail_name=initial_name, head_name=name) for source_name, target_name, attrs in edges: subgraph.edge(tail_name=source_name, head_name=target_name, **dict(attrs)) result.subgraph(subgraph) return result class Command(BaseCommand): help = "Creates a GraphViz dot file with transitions for selected fields" def add_arguments(self, parser: ArgumentParser) -> None: parser.add_argument( "--output", "-o", action="store", dest="outputfile", help="Render output file. Type of output depends on file extensions." "Use png or jpg to render graph to image.", ) parser.add_argument( "--layout", "-l", action="store", dest="layout", default="dot", help=f"Layout to be used by GraphViz for visualization: {graphviz.ENGINES}.", ) parser.add_argument( "--exclude", "-e", action="store", dest="exclude", default="", help="Ignore transitions with this name.", ) parser.add_argument("args", nargs="*", help=("[appname[.model[.field]]]")) def handle(self, *args: str, **options: typing.Any) -> None: fields_data: list[tuple[FSMFieldMixin, type[models.Model]]] = [] if args: for arg in args: match arg.split("."): case [app_label]: app = apps.get_app_config(app_label) for model in app.get_models(): fields_data += all_fsm_fields_data(model) case [app_label, model_name]: model = apps.get_model(app_label, model_name) fields_data += all_fsm_fields_data(model) case [app_label, model_name, field_name]: model = apps.get_model(app_label, model_name) fields_data += [one_fsm_fields_data(model, field_name)] else: for model in apps.get_models(): fields_data += all_fsm_fields_data(model) dotdata = generate_dot(fields_data, ignore_transitions=options["exclude"].split(",")) if outputfile := options["outputfile"]: filename, graph_format = outputfile.rsplit(".", 1) dotdata.engine = options["layout"] dotdata.format = graph_format dotdata.render(filename) else: self.stdout.write(str(dotdata)) django-commons-django-fsm-2-10256af/django_fsm/py.typed000066400000000000000000000000521515347707100227520ustar00rootroot00000000000000# Marker file for PEP 561 typed packages. django-commons-django-fsm-2-10256af/django_fsm/signals.py000066400000000000000000000002571515347707100232740ustar00rootroot00000000000000from __future__ import annotations from django.db.models.signals import ModelSignal pre_transition: ModelSignal = ModelSignal() post_transition: ModelSignal = ModelSignal() django-commons-django-fsm-2-10256af/django_fsm/templates/000077500000000000000000000000001515347707100232545ustar00rootroot00000000000000django-commons-django-fsm-2-10256af/django_fsm/templates/django_fsm/000077500000000000000000000000001515347707100253635ustar00rootroot00000000000000django-commons-django-fsm-2-10256af/django_fsm/templates/django_fsm/fsm_admin_change_form.html000066400000000000000000000007411515347707100325400ustar00rootroot00000000000000{% extends 'admin/change_form.html' %} {% block submit_buttons_bottom %} {% for fsm_object_transition in fsm_object_transitions %}
{% for transition in fsm_object_transition.available_transitions %} {% include "django_fsm/fsm_transition_button.html" with transition=transition %} {% endfor %}
{% endfor %} {{ block.super }} {% endblock %} django-commons-django-fsm-2-10256af/django_fsm/templates/django_fsm/fsm_admin_transition_form.html000066400000000000000000000014771515347707100335140ustar00rootroot00000000000000{% extends 'admin/change_form.html' %} {% load i18n admin_urls static admin_modify %} {% block breadcrumbs %} {% endblock %} {% block content %}

{{ transition.label }}

{{ transition.help_text }}

{% csrf_token %} {{ transition_form.as_div }}
{% endblock %} django-commons-django-fsm-2-10256af/django_fsm/templates/django_fsm/fsm_transition_button.html000066400000000000000000000002571515347707100327070ustar00rootroot00000000000000 django-commons-django-fsm-2-10256af/fsm_admin/000077500000000000000000000000001515347707100211045ustar00rootroot00000000000000django-commons-django-fsm-2-10256af/fsm_admin/__init__.py000066400000000000000000000000001515347707100232030ustar00rootroot00000000000000django-commons-django-fsm-2-10256af/fsm_admin/mixins.py000066400000000000000000000013461515347707100227710ustar00rootroot00000000000000from __future__ import annotations import typing from django.core import checks from django_fsm.admin import FSMAdminMixin as FSMTransitionMixin __all__ = ["FSMTransitionMixin"] @checks.register(checks.Tags.compatibility) def check_deprecated_mixin_import( app_configs: typing.Any, **kwargs: typing.Any ) -> list[checks.CheckMessage]: """ Check to warn users that they are still using the legacy import path. """ return [ checks.Warning( "'fsm_admin.mixins' is deprecated, Update your imports:", hint="Replace 'from fsm_admin.mixins import FSMTransitionMixin' " "with 'from django_fsm.admin import FSMAdminMixin'.", id="django_fsm.admin.W001", ) ] django-commons-django-fsm-2-10256af/mypy_stubs/000077500000000000000000000000001515347707100213655ustar00rootroot00000000000000django-commons-django-fsm-2-10256af/mypy_stubs/django_fsm_log/000077500000000000000000000000001515347707100243355ustar00rootroot00000000000000django-commons-django-fsm-2-10256af/mypy_stubs/django_fsm_log/__init__.pyi000066400000000000000000000000001515347707100266050ustar00rootroot00000000000000django-commons-django-fsm-2-10256af/mypy_stubs/django_fsm_log/decorators.pyi000066400000000000000000000004201515347707100272210ustar00rootroot00000000000000from collections.abc import Callable from typing import ParamSpec from typing import TypeVar _P = ParamSpec("_P") _R = TypeVar("_R") def fsm_log_by(func: Callable[_P, _R]) -> Callable[_P, _R]: ... def fsm_log_description(func: Callable[_P, _R]) -> Callable[_P, _R]: ... django-commons-django-fsm-2-10256af/pyproject.toml000066400000000000000000000106271515347707100220710ustar00rootroot00000000000000[project] name = "django-fsm-2" version = "4.2.1" description = "Django friendly finite state machine support." authors = [{ name = "Mikhail Podgurskiy", email = "kmmbvnr@gmail.com" }] requires-python = ">=3.10" readme = "README.md" license = "MIT" classifiers = [ "Development Status :: 5 - Production/Stable", "Environment :: Web Environment", "Intended Audience :: Developers", "License :: OSI Approved :: MIT License", "Operating System :: OS Independent", "Framework :: Django", "Framework :: Django :: 4.2", "Framework :: Django :: 5.0", "Framework :: Django :: 5.1", "Framework :: Django :: 5.2", "Framework :: Django :: 6.0", "Programming Language :: Python", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Programming Language :: Python :: 3.14", "Typing :: Typed", "Topic :: Software Development :: Libraries :: Python Modules", ] dependencies = [ "django>=4.2.29", "django-stubs-ext", "typing-extensions; python_version < '3.12'", ] [project.urls] Homepage = "http://github.com/django-commons/django-fsm-2" Repository = "http://github.com/django-commons/django-fsm-2" Documentation = "http://github.com/django-commons/django-fsm-2" [project.optional-dependencies] graphviz = [ "graphviz", ] [dependency-groups] dev = [ "coverage", "django_fsm_log", "django-guardian", "graphviz", "mypy", "prek", "pytest", "pytest-cov", "pytest-django", "pytest-randomly", "tox", ] [tool.uv] default-groups = [ "dev" ] [tool.hatch.build.targets.sdist] include = ["django_fsm", "fsm_admin", "django_fsm/py.typed"] [tool.hatch.build.targets.wheel] include = ["django_fsm", "fsm_admin", "django_fsm/py.typed"] [build-system] requires = ["hatchling"] build-backend = "hatchling.build" [tool.pytest.ini_options] DJANGO_SETTINGS_MODULE = "tests.settings" [tool.ruff] line-length = 100 target-version = "py310" fix = true [tool.ruff.lint] select = ["ALL"] extend-ignore = [ "COM812", # This rule may cause conflicts when used with the formatter "D", # pydocstyle "DOC", # pydoclint "B", "PTH", "ANN", # Missing type annotation "S101", # Use of `assert` detected "RUF012", # Mutable class attributes should be annotated with `typing.ClassVar` "ARG001", # Unused function argument "ARG002", # Unused method argument "TRY002", # Create your own exception "TRY003", # Avoid specifying long messages outside the exception class "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 "SLF001", # Private member accessed "SIM103", # Return the condition directly "PLC0415", # `import` should be at the top-level of a file "PLR0913", # Too many arguments in function definition ] fixable = [ "I", # isort "RUF100", # Unused `noqa` directive "E501", ] [tool.ruff.lint.extend-per-file-ignores] "tests/*" = [ "DJ008", # Model does not define `__str__` method ] [tool.ruff.lint.isort] force-single-line = true required-imports = ["from __future__ import annotations"] [tool.django-stubs] django_settings_module = "tests.settings" [tool.mypy] python_version = 3.12 plugins = ["mypy_django_plugin.main"] mypy_path = "mypy_stubs" # Start off with these warn_unused_configs = true warn_redundant_casts = true warn_unused_ignores = true # Getting these passing should be easy strict_equality = true extra_checks = true # Strongly recommend enabling this one as soon as you can check_untyped_defs = true # These shouldn't be too much additional work, but may be tricky to # get passing if you use a lot of untyped libraries disallow_subclassing_any = true disallow_untyped_decorators = true disallow_any_generics = true # These next few are various gradations of forcing use of type annotations disallow_untyped_calls = true disallow_incomplete_defs = true disallow_untyped_defs = true # This one isn't too hard to get passing, but return on investment is lower no_implicit_reexport = true # This one can be tricky to get passing if you use a lot of untyped libraries warn_return_any = true [[tool.mypy.overrides]] module = [ "tests.*", "django_fsm.tests.*" ] disallow_untyped_defs = false django-commons-django-fsm-2-10256af/tests/000077500000000000000000000000001515347707100203115ustar00rootroot00000000000000django-commons-django-fsm-2-10256af/tests/__init__.py000066400000000000000000000000001515347707100224100ustar00rootroot00000000000000django-commons-django-fsm-2-10256af/tests/manage.py000077500000000000000000000013021515347707100221120ustar00rootroot00000000000000#!/usr/bin/env python """Django's command-line utility for administrative tasks.""" from __future__ import annotations import os import sys def main() -> None: """Run administrative tasks.""" os.environ.setdefault("DJANGO_SETTINGS_MODULE", "tests.settings") try: from django.core.management import execute_from_command_line except ImportError as exc: 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 exc execute_from_command_line(sys.argv) if __name__ == "__main__": main() django-commons-django-fsm-2-10256af/tests/settings.py000066400000000000000000000125741515347707100225340ustar00rootroot00000000000000""" Django settings for tests project. Generated by 'django-admin startproject' using Django 4.2. For more information on this file, see https://docs.djangoproject.com/en/4.2/topics/settings/ For the full list of settings and their values, see https://docs.djangoproject.com/en/4.2/ref/settings/ """ from __future__ import annotations import sys from pathlib import Path import django_stubs_ext django_stubs_ext.monkeypatch() # Enforce local path resolution to avoid using version declared as a dependency by django-fsm-log project_root = Path(__file__).resolve().parent.parent sys.path.insert(0, str(project_root)) # Build paths inside the project like this: BASE_DIR / 'subdir'. BASE_DIR = Path(__file__).resolve().parent.parent # Quick-start development settings - unsuitable for production # See https://docs.djangoproject.com/en/4.2/howto/deployment/checklist/ # SECURITY WARNING: keep the secret key used in production secret! SECRET_KEY = "nokey" # noqa: S105 # SECURITY WARNING: don't run with debug turned on in production! DEBUG = True ALLOWED_HOSTS: list[str] = [] # Application definition PROJECT_APPS = ( "django_fsm", "tests.testapp", ) INSTALLED_APPS = [ "django.contrib.admin", "django.contrib.auth", "django.contrib.contenttypes", "django.contrib.sessions", "django.contrib.messages", "django.contrib.staticfiles", "django_fsm_log", "guardian", *PROJECT_APPS, ] 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", ] ROOT_URLCONF = "tests.urls" TEMPLATES = [ { "BACKEND": "django.template.backends.django.DjangoTemplates", "DIRS": [], "APP_DIRS": True, "OPTIONS": { "context_processors": [ "django.template.context_processors.request", "django.contrib.auth.context_processors.auth", "django.contrib.messages.context_processors.messages", ], }, }, ] WSGI_APPLICATION = "tests.wsgi.application" # Database # https://docs.djangoproject.com/en/4.2/ref/settings/#databases DATABASES = { "default": { "ENGINE": "django.db.backends.sqlite3", "NAME": BASE_DIR / "db.sqlite3", } } # Authentication # https://docs.djangoproject.com/en/4.2/topics/auth/ AUTHENTICATION_BACKENDS = ( "django.contrib.auth.backends.ModelBackend", # this is default "guardian.backends.ObjectPermissionBackend", ) # Password validation # https://docs.djangoproject.com/en/4.2/ref/settings/#auth-password-validators AUTH_PASSWORD_VALIDATORS = [ { "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator", }, { "NAME": "django.contrib.auth.password_validation.MinimumLengthValidator", }, { "NAME": "django.contrib.auth.password_validation.CommonPasswordValidator", }, { "NAME": "django.contrib.auth.password_validation.NumericPasswordValidator", }, ] # Internationalization # https://docs.djangoproject.com/en/4.2/topics/i18n/ LANGUAGE_CODE = "en-us" TIME_ZONE = "UTC" USE_I18N = True USE_TZ = True # Static files (CSS, JavaScript, Images) # https://docs.djangoproject.com/en/4.2/howto/static-files/ STATIC_URL = "static/" # Default primary key field type # https://docs.djangoproject.com/en/4.2/ref/settings/#default-auto-field DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" # Django FSM-log settings DJANGO_FSM_LOG_IGNORED_MODELS = ( # "tests.testapp.models.AdminBlogPost", "tests.testapp.models.Application", "tests.testapp.models.BlogPost", "tests.testapp.models.DbState", "tests.testapp.models.FKApplication", "tests.testapp.tests.SimpleBlogPost", "tests.testapp.tests.test_abstract_inheritance.BaseAbstractModel", "tests.testapp.tests.test_abstract_inheritance.InheritedFromAbstractModel", "tests.testapp.tests.test_access_deferred_fsm_field.DeferrableModel", "tests.testapp.tests.test_basic_transitions.SimpleBlogPost", "tests.testapp.tests.test_conditions.BlogPostWithConditions", "tests.testapp.tests.test_custom_data.BlogPostWithCustomData", "tests.testapp.tests.test_exception_transitions.ExceptionalBlogPost", "tests.testapp.tests.test_graph_transitions.VisualBlogPost", "tests.testapp.tests.test_integer_field.BlogPostWithIntegerField", "tests.testapp.tests.test_lock_mixin.ExtendedBlogPost", "tests.testapp.tests.test_lock_mixin.LockedBlogPost", "tests.testapp.tests.test_mixin_support.MixinSupportTestModel", "tests.testapp.tests.test_multi_resultstate.MultiResultTest", "tests.testapp.tests.test_multidecorators.MultiDecoratedModel", "tests.testapp.tests.test_protected_field.ProtectedAccessModel", "tests.testapp.tests.test_protected_fields.RefreshableProtectedAccessModel", "tests.testapp.tests.test_proxy_inheritance.InheritedModel", "tests.testapp.tests.test_state_transitions.Caterpillar", "tests.testapp.tests.test_string_field_parameter.BlogPostWithStringField", "tests.testapp.tests.test_transition_all_except_target.ExceptTargetTransition", "tests.testapp.tests.test_key_field.FKBlogPost", ) django-commons-django-fsm-2-10256af/tests/testapp/000077500000000000000000000000001515347707100217715ustar00rootroot00000000000000django-commons-django-fsm-2-10256af/tests/testapp/__init__.py000066400000000000000000000000001515347707100240700ustar00rootroot00000000000000django-commons-django-fsm-2-10256af/tests/testapp/admin.py000066400000000000000000000035651515347707100234440ustar00rootroot00000000000000from __future__ import annotations import typing from django.contrib import admin from django_fsm_log.admin import StateLogInline import django_fsm as fsm from django_fsm.admin import FSMAdminMixin from .admin_forms import ForceStateForm from .admin_forms import FSMLogDescriptionForm from .models import AdminBlogPost if typing.TYPE_CHECKING: from django.db.models import QuerySet from django.http import HttpRequest @admin.register(AdminBlogPost) class AdminBlogPostAdmin(FSMAdminMixin, admin.ModelAdmin[AdminBlogPost]): list_display = ( "id", "title", "state", "step", ) actions = ["step_reset_action"] fsm_fields = [ "state", "step", ] fsm_forms = { "complex_transition": "tests.testapp.admin_forms.AdminBlogPostRenameModelForm", "invalid": FSMLogDescriptionForm, "force_state": ForceStateForm, } inlines = [StateLogInline] # Override label def get_fsm_label(self, transition): if transition.name == "do_something": return "My awesome transition" return super().get_fsm_label(transition) # Override help_text def get_help_text(self, transition): if transition.name == "do_something": return "Rename blog post" return super().get_help_text(transition) # Use a Transition as a Django admin action @admin.action(description="Reset step") def step_reset_action(self, request: HttpRequest, queryset: QuerySet[AdminBlogPost]) -> None: for obj in queryset: if fsm.can_proceed(obj.step_reset): self._apply_fsm_transition( obj=obj, transition_name="step_reset", request=request, kwargs={ "description": "Reset from admin", }, ) django-commons-django-fsm-2-10256af/tests/testapp/admin_forms.py000066400000000000000000000032021515347707100246360ustar00rootroot00000000000000from __future__ import annotations from django import forms from .models import AdminBlogPost from .models import AdminBlogPostState class FSMLogDescriptionForm(forms.Form): # fsm log field description = forms.CharField( label="Comment", widget=forms.Textarea, required=True, help_text="Why are you updating the title", ) class ForceStateForm(FSMLogDescriptionForm): state = forms.ChoiceField( choices=AdminBlogPostState.choices, required=True, ) class AdminBlogPostRenameForm(forms.Form): """ This form is used to test the admin form renaming functionality. """ title = forms.CharField( label="New Title", max_length=255, required=True, ) comment = forms.CharField( label="Comment", widget=forms.Textarea, required=True, help_text="Why are you updating the title", ) # fsm log field description = forms.CharField( label="Comment", widget=forms.Textarea, required=True, help_text="Why are you updating the title", ) class AdminBlogPostRenameModelForm(forms.ModelForm[AdminBlogPost]): """ This form is used to test the admin form renaming functionality. """ title = forms.CharField( label="New Title", max_length=255, required=True, ) # fsm log field description = forms.CharField( label="Comment", widget=forms.Textarea, required=True, help_text="Why are you updating the title", ) class Meta: model = AdminBlogPost fields: list[str] = ["title"] django-commons-django-fsm-2-10256af/tests/testapp/apps.py000066400000000000000000000002031515347707100233010ustar00rootroot00000000000000from __future__ import annotations from django.apps import AppConfig class TestAppConfig(AppConfig): name = "tests.testapp" django-commons-django-fsm-2-10256af/tests/testapp/fixtures/000077500000000000000000000000001515347707100236425ustar00rootroot00000000000000django-commons-django-fsm-2-10256af/tests/testapp/fixtures/test_states_data.json000066400000000000000000000010561515347707100300720ustar00rootroot00000000000000[ { "model": "testapp.dbstate", "pk": "new", "fields": { "label": "_New"} }, { "model": "testapp.dbstate", "pk": "draft", "fields": { "label": "_Draft"} }, { "model": "testapp.dbstate", "pk": "dept", "fields": { "label": "_Dept"} }, { "model": "testapp.dbstate", "pk": "dean", "fields": { "label": "_Dean"} }, { "model": "testapp.dbstate", "pk": "done", "fields": { "label": "_Done"} } ] django-commons-django-fsm-2-10256af/tests/testapp/models.py000066400000000000000000000311351515347707100236310ustar00rootroot00000000000000from __future__ import annotations from django.contrib.auth.models import AbstractUser from django.db import models from django_fsm_log.decorators import fsm_log_by from django_fsm_log.decorators import fsm_log_description import django_fsm as fsm from django_fsm import GET_STATE from django_fsm import RETURN_VALUE from django_fsm import FSMField from django_fsm import FSMKeyField from django_fsm import transition class Application(models.Model): """ Student application need to be approved by dept chair and dean. Test workflow """ state = FSMField(default="new") @transition(field=state, source="new", target="published", on_error="failed") def standard(self) -> None: pass @transition(field=state, source="published") def no_target(self) -> None: pass @transition(field=state, source="*", target="blocked") def any_source(self) -> None: pass @transition(field=state, source="+", target="hidden") def any_source_except_target(self) -> None: pass @transition( field=state, source="new", target=GET_STATE( lambda _, allowed: "published" if allowed else "rejected", states=["published", "rejected"], ), ) def get_state(self, *, allowed: bool) -> None: pass @transition( field=state, source="*", target=GET_STATE( lambda _, allowed: "published" if allowed else "rejected", states=["published", "rejected"], ), ) def get_state_any_source(self, *, allowed: bool) -> None: pass @transition( field=state, source="+", target=GET_STATE( lambda _, allowed: "published" if allowed else "rejected", states=["published", "rejected"], ), ) def get_state_any_source_except_target(self, *, allowed: bool) -> None: pass @transition(field=state, source="new", target=RETURN_VALUE("moderated", "blocked")) def return_value(self) -> str: return "published" @transition(field=state, source="*", target=RETURN_VALUE("moderated", "blocked")) def return_value_any_source(self) -> str: return "published" @transition(field=state, source="+", target=RETURN_VALUE("moderated", "blocked")) def return_value_any_source_except_target(self) -> str: return "published" @transition(field=state, source="new", target="published", on_error="failed") def on_error(self) -> None: pass class DbState(models.Model): """ States in DB """ id = models.CharField(primary_key=True, max_length=50) label = models.CharField(max_length=255) def __str__(self): return self.label class FKApplication(models.Model): """ Student application need to be approved by dept chair and dean. Test workflow for FSMKeyField """ state = FSMKeyField(DbState, default="new", on_delete=models.CASCADE) @transition(field=state, source="new", target="published") def standard(self) -> None: pass @transition(field=state, source="published") def no_target(self) -> None: pass @transition(field=state, source="*", target="blocked") def any_source(self) -> None: pass @transition(field=state, source="+", target="hidden") def any_source_except_target(self) -> None: pass @transition( field=state, source="new", target=GET_STATE( lambda _, allowed: "published" if allowed else "rejected", states=["published", "rejected"], ), ) def get_state(self, *, allowed: bool) -> None: pass @transition( field=state, source="*", target=GET_STATE( lambda _, allowed: "published" if allowed else "rejected", states=["published", "rejected"], ), ) def get_state_any_source(self, *, allowed: bool) -> None: pass @transition( field=state, source="+", target=GET_STATE( lambda _, allowed: "published" if allowed else "rejected", states=["published", "rejected"], ), ) def get_state_any_source_except_target(self, *, allowed: bool) -> None: pass @transition(field=state, source="new", target=RETURN_VALUE("moderated", "blocked")) def return_value(self) -> str: return "published" @transition(field=state, source="*", target=RETURN_VALUE("moderated", "blocked")) def return_value_any_source(self) -> str: return "published" @transition(field=state, source="+", target=RETURN_VALUE("moderated", "blocked")) def return_value_any_source_except_target(self) -> str: return "published" @transition(field=state, source="new", target="published", on_error="failed") def on_error(self) -> None: pass class MultiStateApplication(Application): another_state = FSMKeyField(DbState, default="new", on_delete=models.CASCADE) @transition(field=another_state, source="new", target="published") def another_state_standard(self) -> None: pass class BlogPostState(models.IntegerChoices): NEW = 0, "New" PUBLISHED = 1, "Published" HIDDEN = 2, "Hidden" REMOVED = 3, "Removed" RESTORED = 4, "Restored" MODERATED = 5, "Moderated" STOLEN = 6, "Stolen" FAILED = 7, "Failed" class BlogPost(models.Model): """ Test workflow """ state = FSMField(choices=BlogPostState.choices, default=BlogPostState.NEW, protected=True) class Meta: permissions = [ ("can_publish_post", "Can publish post"), ("can_remove_post", "Can remove post"), ] def can_restore(self: models.Model, user: fsm.UserWithPermissions) -> bool: if isinstance(user, AbstractUser): return bool(user.is_superuser or user.is_staff) return False @transition( field=state, source=BlogPostState.NEW, target=BlogPostState.PUBLISHED, on_error=BlogPostState.FAILED, permission="testapp.can_publish_post", ) def publish(self) -> None: pass @transition(field=state, source=BlogPostState.PUBLISHED) def notify_all(self) -> None: pass @transition( field=state, source=BlogPostState.PUBLISHED, target=BlogPostState.HIDDEN, on_error=BlogPostState.FAILED, ) def hide(self) -> None: pass @transition( field=state, source=BlogPostState.NEW, target=BlogPostState.REMOVED, on_error=BlogPostState.FAILED, permission=lambda _, u: u.has_perm("testapp.can_remove_post"), ) def remove(self) -> None: raise Exception(f"No rights to delete {self}") @transition( field=state, source=BlogPostState.NEW, target=BlogPostState.RESTORED, on_error=BlogPostState.FAILED, permission=can_restore, ) def restore(self) -> None: pass @transition( field=state, source=[BlogPostState.PUBLISHED, BlogPostState.HIDDEN], target=BlogPostState.STOLEN, ) def steal(self) -> None: pass @transition(field=state, source="*", target=BlogPostState.MODERATED) def moderate(self) -> None: pass class AdminBlogPostState(models.TextChoices): CREATED = "created", "Created" REVIEWED = "reviewed", "Reviewed" PUBLISHED = "published", "Published" HIDDEN = "hidden", "Hidden" class AdminBlogPostStep(models.TextChoices): STEP_1 = "step1", "Step one" STEP_2 = "step2", "Step two" STEP_3 = "step3", "Step three" class AdminBlogPost(fsm.FSMModelMixin, models.Model): title = models.CharField(max_length=50) state = FSMField( choices=AdminBlogPostState.choices, default=AdminBlogPostState.CREATED, protected=True, ) step = FSMField( choices=AdminBlogPostStep.choices, default=AdminBlogPostStep.STEP_1, protected=False, ) # state transitions def __str__(self) -> str: return f"{self.title} ({self.state})" @fsm_log_by @fsm_log_description @transition( field=state, source="*", target=RETURN_VALUE(*AdminBlogPostState), ) def force_state( self, state: AdminBlogPostState, by: AbstractUser | None = None, description: str | None = None, ) -> AdminBlogPostState: return state @fsm_log_by @fsm_log_description @transition( field=state, source="*", target=AdminBlogPostState.HIDDEN, custom={ "admin": False, }, ) def secret_transition( self, by: AbstractUser | None = None, description: str | None = None ) -> None: pass @fsm_log_by @fsm_log_description @transition( field=state, source=AdminBlogPostState.CREATED, target=AdminBlogPostState.REVIEWED, ) def moderate(self, by: AbstractUser | None = None, description: str | None = None) -> None: pass @fsm_log_by @fsm_log_description @transition( field=state, source=[ AdminBlogPostState.REVIEWED, AdminBlogPostState.HIDDEN, ], target=AdminBlogPostState.PUBLISHED, ) def publish(self, by: AbstractUser | None = None, description: str | None = None) -> None: pass @fsm_log_by @fsm_log_description @transition( field=state, source=[ AdminBlogPostState.REVIEWED, AdminBlogPostState.PUBLISHED, ], target=AdminBlogPostState.HIDDEN, ) def hide(self, by: AbstractUser | None = None, description: str | None = None) -> None: pass @fsm_log_by @fsm_log_description @transition( field=state, source="*", target=None, ) def invalid(self, by: AbstractUser | None = None, description: str | None = None) -> None: raise Exception("You shall not pass!") @transition( field=state, source="*", target=None, ) def non_fsm_log_invalid(self) -> None: raise Exception("Domain-raised exception") @fsm_log_by @fsm_log_description @transition( field=state, source="*", target=None, ) def invalid_without_forms( self, by: AbstractUser | None = None, description: str | None = None ) -> None: raise Exception("You shall not pass!") @fsm_log_by @fsm_log_description @transition( field=state, source="*", target=AdminBlogPostState.CREATED, custom={ "label": "Rename *", "form": "tests.testapp.admin_forms.AdminBlogPostRenameForm", "help_text": "Do it wisely!", }, ) def complex_transition( self, *, title: str, comment: str | None = None, by: AbstractUser | None = None, description: str | None = None, ) -> None: self.title = title if comment: ... # Do something with the comment @fsm_log_by @fsm_log_description @transition(field=state, source="*", target=None, conditions=[lambda _obj: False]) def conditions_unmet( self, by: AbstractUser | None = None, description: str | None = None ) -> None: pass @fsm_log_by @fsm_log_description @transition(field=state, source="*", target=None, permission=lambda _obj, _user: False) def permission_denied( self, by: AbstractUser | None = None, description: str | None = None ) -> None: pass # step transitions @fsm_log_by @fsm_log_description @transition( field=step, source=[AdminBlogPostStep.STEP_1], target=AdminBlogPostStep.STEP_2, custom={ "label": "Go to Step 2", }, ) def step_two(self, by: AbstractUser | None = None, description: str | None = None) -> None: pass @fsm_log_by @fsm_log_description @transition( field=step, source=[AdminBlogPostStep.STEP_2], target=AdminBlogPostStep.STEP_3, ) def step_three(self, by: AbstractUser | None = None, description: str | None = None) -> None: pass @fsm_log_by @fsm_log_description @transition( field=step, source=[ AdminBlogPostStep.STEP_2, AdminBlogPostStep.STEP_3, ], target=AdminBlogPostStep.STEP_1, ) def step_reset(self, by: AbstractUser | None = None, description: str | None = None) -> None: pass def normal_function(self): raise NotImplementedError @property def name_property(self): return "name" django-commons-django-fsm-2-10256af/tests/testapp/tests/000077500000000000000000000000001515347707100231335ustar00rootroot00000000000000django-commons-django-fsm-2-10256af/tests/testapp/tests/__init__.py000066400000000000000000000000001515347707100252320ustar00rootroot00000000000000django-commons-django-fsm-2-10256af/tests/testapp/tests/test_abstract_inheritance.py000066400000000000000000000035621515347707100307260ustar00rootroot00000000000000from __future__ import annotations from django.db import models from django.test import TestCase from django_fsm import FSMField from django_fsm import can_proceed from django_fsm import transition class BaseAbstractModel(models.Model): state = FSMField(default="new") class Meta: abstract = True @transition(field=state, source="new", target="published") def publish(self): pass class AnotherFromAbstractModel(BaseAbstractModel): """ This class exists to trigger a regression when multiple concrete classes inherit from a shared abstract class (example: BaseAbstractModel). Don't try to remove it. """ @transition(field="state", source="published", target="sticked") def stick(self): pass class InheritedFromAbstractModel(BaseAbstractModel): @transition(field="state", source="published", target="sticked") def stick(self): pass class TestinheritedModel(TestCase): def setUp(self): self.model = InheritedFromAbstractModel() def test_known_transition_should_succeed(self): assert can_proceed(self.model.publish) self.model.publish() assert self.model.state == "published" assert can_proceed(self.model.stick) self.model.stick() assert self.model.state == "sticked" def test_field_available_transitions_works(self): self.model.publish() assert self.model.state == "published" transitions = self.model.get_available_state_transitions() # type: ignore[attr-defined] assert [data.target for data in transitions] == ["sticked"] def test_field_all_transitions_works(self): transitions = self.model.get_all_state_transitions() # type: ignore[attr-defined] assert {("new", "published"), ("published", "sticked")} == { (data.source, data.target) for data in transitions } django-commons-django-fsm-2-10256af/tests/testapp/tests/test_access_deferred_fsm_field.py000066400000000000000000000016331515347707100316600ustar00rootroot00000000000000from __future__ import annotations from django.db import models from django.test import TestCase from django_fsm import FSMField from django_fsm import can_proceed from django_fsm import transition class DeferrableModel(models.Model): state = FSMField(default="new") objects: models.Manager[DeferrableModel] = models.Manager() @transition(field=state, source="new", target="published") def publish(self): pass @transition(field=state, source="+", target="removed") def remove(self): pass class Test(TestCase): def setUp(self): DeferrableModel.objects.create() self.model = DeferrableModel.objects.only("id").get() def test_usecase(self): assert self.model.state == "new" assert can_proceed(self.model.remove) self.model.remove() assert self.model.state == "removed" assert not can_proceed(self.model.remove) django-commons-django-fsm-2-10256af/tests/testapp/tests/test_admin.py000066400000000000000000000724461515347707100256510ustar00rootroot00000000000000from __future__ import annotations import contextlib import typing from http import HTTPStatus from unittest import mock from unittest.mock import patch import pytest from django.contrib import admin from django.contrib import messages from django.contrib.admin.sites import AdminSite from django.contrib.auth import get_user_model from django.core.exceptions import ImproperlyConfigured from django.http import HttpResponse from django.http import HttpResponseBadRequest from django.http import HttpResponseRedirect from django.test import TestCase from django.test.client import RequestFactory from django.test.utils import modify_settings from django.urls import reverse from django_fsm_log.models import StateLog import django_fsm as fsm from django_fsm.admin import FSMAdminMixin from tests.testapp.admin import AdminBlogPostAdmin from tests.testapp.admin_forms import AdminBlogPostRenameModelForm from tests.testapp.admin_forms import FSMLogDescriptionForm from tests.testapp.models import AdminBlogPost from tests.testapp.models import AdminBlogPostState if typing.TYPE_CHECKING: from django.contrib.auth.models import AnonymousUser from django.contrib.auth.models import User from django.core.handlers.wsgi import WSGIRequest disable_fsm_log = modify_settings( DJANGO_FSM_LOG_IGNORED_MODELS={ "append": "tests.testapp.models.AdminBlogPost", } ) class BaseAdminTestCase(TestCase): model = AdminBlogPost admin_class = AdminBlogPostAdmin user: User | AnonymousUser blog_post: AdminBlogPost fsm_log_enabled = True @classmethod def setUpTestData(cls): cls.user = get_user_model().objects.create_user( username="jacob", password="password", # noqa: S106 is_staff=True, ) cls.blog_post = cls.model.objects.create( title="Article name", state=AdminBlogPostState.PUBLISHED ) def setUp(self) -> None: self.model_admin = self.admin_class(self.model, AdminSite()) def make_request(self, *, data: typing.Any | None = None) -> WSGIRequest: factory = RequestFactory() request = factory.post(path="/", data=data or {}) request.user = self.user return request def assert_state_log_empty(self) -> None: assert StateLog.objects.count() == 0 def assert_state_log_for_user( self, *, description: str | None = None, source_state: AdminBlogPostState | None = None, state: AdminBlogPostState | None = None, transition: str | None = None, ) -> None: if self.fsm_log_enabled: statelog = StateLog.objects.get() assert statelog.by == self.user if description is not None: assert statelog.description == description if source_state is not None: assert statelog.source_state == source_state if state is not None: assert statelog.state == state if transition is not None: assert statelog.transition == transition else: self.assert_state_log_empty() class EmptyFieldAdmin(FSMAdminMixin, admin.ModelAdmin[AdminBlogPost]): fsm_fields = [] class InvalidFieldAdmin(FSMAdminMixin, admin.ModelAdmin[AdminBlogPost]): fsm_fields = ["title"] class InvalidFormPathAdmin(FSMAdminMixin, admin.ModelAdmin[AdminBlogPost]): fsm_fields = ["state"] fsm_forms = { "complex_transition": "invalid.path", } class ModelAdminMisconfigurationTestCase(TestCase): user: User | AnonymousUser blog_post: AdminBlogPost request: WSGIRequest def setUp(self) -> None: with contextlib.suppress(admin.sites.NotRegistered): # type: ignore[attr-defined] admin.site.unregister(AdminBlogPost) def tearDown(self) -> None: """Reconnect original admin""" with contextlib.suppress(admin.sites.NotRegistered): # type: ignore[attr-defined] admin.site.unregister(AdminBlogPost) admin.site.register(AdminBlogPost, AdminBlogPostAdmin) @classmethod def setUpTestData(cls): cls.user = get_user_model().objects.create_user( username="jacob", password="password", # noqa: S106 is_staff=True, ) cls.blog_post = AdminBlogPost.objects.create( title="Article name", state=AdminBlogPostState.PUBLISHED ) cls.request = RequestFactory().post(path="/") cls.request.user = cls.user def test_empty_fsm_field(self): with pytest.raises(ImproperlyConfigured): admin.site.register(AdminBlogPost, EmptyFieldAdmin) def test_invalid_fsm_field(self): admin.site.register(AdminBlogPost, InvalidFieldAdmin) with pytest.raises(ImproperlyConfigured, match="'title' is not an FSMField"): InvalidFieldAdmin(AdminBlogPost, AdminSite()).get_readonly_fields( request=self.request, obj=self.blog_post, ) def test_invalid_form_path(self): admin.site.register(AdminBlogPost, InvalidFormPathAdmin) blog_admin = InvalidFormPathAdmin(AdminBlogPost, AdminSite()) transition = blog_admin._get_fsm_transition_by_name( obj=self.blog_post, transition_name="complex_transition" ) with pytest.raises( ImproperlyConfigured, match=r"Failed to import form invalid\.path", ): blog_admin.get_fsm_transition_form(transition) class ModelAdminTestCase(TestCase): blog_post: AdminBlogPost request: WSGIRequest @classmethod def setUpTestData(cls): blog_post = AdminBlogPost.objects.create(title="Article name") blog_post.moderate() blog_post.save() cls.blog_post = blog_post cls.request = RequestFactory().get(path="/path") cls.request.user = get_user_model().objects.create_user( username="jacob", password="password", # noqa: S106 is_staff=True, ) def setUp(self): self.model_admin = AdminBlogPostAdmin(AdminBlogPost, AdminSite()) # Basics def test_protected_fields_are_readonly(self): assert self.model_admin.get_readonly_fields(request=self.request) == ("state",) # Execution def test_execute_fsm_transition_falls_back_to_plain_call(self) -> None: called: dict[str, str] = {} def transition_method(*, comment: str) -> None: called["comment"] = comment with mock.patch.object(self.model_admin, "_is_fsm_log_enabled", return_value=False): self.model_admin._execute_fsm_transition( transition_func=transition_method, request=self.request, kwargs={"comment": "Because"}, ) assert called["comment"] == "Because" # Context def test_get_fsm_extra_context_filters_admin_hidden( self, ) -> None: blog_post = AdminBlogPost.objects.create( title="Article name", state=AdminBlogPostState.REVIEWED, ) transitions = list( self.model_admin._get_fsm_extra_context(request=self.request, obj=blog_post) ) assert len(transitions) == 2 # noqa: PLR2004 transitions_by_field = { item.fsm_field: { transition_context.name for transition_context in item.available_transitions } for item in transitions } # Only available transitions assert transitions_by_field["step"] == {"step_two"} # but not if custom.admin is False assert "secret_transition" in blog_post.get_available_user_state_transitions( # type: ignore[attr-defined] user=self.request.user ) assert "secret_transition" not in transitions_by_field["state"] assert "conditions_unmet" not in transitions_by_field["state"] assert "permission_denied" not in transitions_by_field["state"] @mock.patch("django.contrib.admin.ModelAdmin.change_view") @mock.patch("django_fsm.admin.FSMAdminMixin._get_fsm_extra_context") def test_change_view_context( self, mock_get_fsm_extra_context: mock.Mock, mock_super_change_view: mock.Mock, ) -> None: mock_get_fsm_extra_context.return_value = ["object transitions"] self.model_admin.change_view( request=self.request, form_url="/test", object_id=str(self.blog_post.pk), extra_context={ "existing_context": "existing context", }, ) mock_get_fsm_extra_context.assert_called_once_with( request=self.request, obj=self.blog_post, ) mock_super_change_view.assert_called_once_with( request=self.request, object_id=str(self.blog_post.pk), form_url="/test", extra_context={ "existing_context": "existing context", "fsm_object_transitions": ["object transitions"], }, ) @patch("django.contrib.admin.options.ModelAdmin.message_user") class ResponseChangeViewTestCase(BaseAdminTestCase): # Classic updates @mock.patch("django.contrib.admin.ModelAdmin.response_change") def test_classic_update_keep_working( self, mock_response_change: mock.Mock, mock_message_user: mock.Mock ) -> None: self.assert_state_log_empty() blog_post = AdminBlogPost.objects.create(title="Article name") assert blog_post.state == AdminBlogPostState.CREATED self.model_admin.response_change( request=self.make_request( data={"title": "New Name"}, ), obj=blog_post, ) mock_response_change.assert_called_once() self.assert_state_log_empty() # Errors def test_unknown_transition(self, mock_message_user: mock.Mock) -> None: self.assert_state_log_empty() blog_post = AdminBlogPost.objects.create(title="Article name") assert blog_post.state == AdminBlogPostState.CREATED with pytest.raises(AttributeError): self.model_admin.response_change( request=self.make_request( data={"_fsm_transition_to": "unknown_transition"}, ), obj=blog_post, ) self.assert_state_log_empty() def test_non_transition_function(self, mock_message_user: mock.Mock) -> None: self.assert_state_log_empty() with pytest.raises(ValueError, match=r"not an FSM transition"): self.model_admin.response_change( request=self.make_request( data={"_fsm_transition_to": "normal_function"}, ), obj=self.blog_post, ) self.assert_state_log_empty() def test_non_callable_function(self, mock_message_user: mock.Mock) -> None: self.assert_state_log_empty() with pytest.raises(TypeError, match=r"Attribute 'name_property' is not callable."): self.model_admin.response_change( request=self.make_request( data={"_fsm_transition_to": "name_property"}, ), obj=self.blog_post, ) self.assert_state_log_empty() def test_transition_raised_exception(self, mock_message_user: mock.Mock) -> None: self.assert_state_log_empty() self.model_admin.response_change( request=self.make_request( data={"_fsm_transition_to": "invalid_without_forms"}, ), obj=self.blog_post, ) mock_message_user.assert_called_once_with( request=mock.ANY, message="FSM transition 'invalid_without_forms' failed: You shall not pass!.", level=messages.ERROR, ) self.assert_state_log_empty() def test_execute_fsm_transition_falls_back_to_plain_call( self, mock_message_user: mock.Mock ) -> None: self.assert_state_log_empty() self.model_admin.response_change( request=self.make_request( data={"_fsm_transition_to": "non_fsm_log_invalid"}, ), obj=self.blog_post, ) mock_message_user.assert_called_once_with( request=mock.ANY, message="FSM transition 'non_fsm_log_invalid' failed: Domain-raised exception.", level=messages.ERROR, ) self.assert_state_log_empty() def test_response_change_transition_not_allowed(self, mock_message_user: mock.Mock) -> None: self.assert_state_log_empty() blog_post = AdminBlogPost.objects.create(title="Article name") assert blog_post.state == AdminBlogPostState.CREATED with mock.patch.object( self.model_admin, "_execute_fsm_transition", side_effect=fsm.TransitionNotAllowed, ): self.model_admin.response_change( request=self.make_request( data={"_fsm_transition_to": "moderate"}, ), obj=blog_post, ) mock_message_user.assert_called_once_with( request=mock.ANY, message="FSM transition 'moderate' is not allowed.", level=messages.ERROR, ) blog_post.refresh_from_db() assert blog_post.state == AdminBlogPostState.CREATED self.assert_state_log_empty() def test_response_change_concurrent_transition(self, mock_message_user: mock.Mock) -> None: self.assert_state_log_empty() blog_post = AdminBlogPost.objects.create(title="Article name") assert blog_post.state == AdminBlogPostState.CREATED with mock.patch.object( self.model_admin, "_execute_fsm_transition", side_effect=fsm.ConcurrentTransition("error message"), ): self.model_admin.response_change( request=self.make_request( data={"_fsm_transition_to": "moderate"}, ), obj=blog_post, ) mock_message_user.assert_called_once_with( request=mock.ANY, message="FSM transition 'moderate' failed: error message.", level=messages.ERROR, ) blog_post.refresh_from_db() assert blog_post.state == AdminBlogPostState.CREATED self.assert_state_log_empty() # Transitions def test_transition_applied(self, mock_message_user: mock.Mock) -> None: self.assert_state_log_empty() blog_post = AdminBlogPost.objects.create(title="Article name") assert blog_post.state == AdminBlogPostState.CREATED self.model_admin.response_change( request=self.make_request( data={"_fsm_transition_to": "moderate"}, ), obj=blog_post, ) mock_message_user.assert_called_once_with( request=mock.ANY, message="FSM transition 'moderate' succeeded.", level=messages.SUCCESS, ) blog_post.refresh_from_db() assert blog_post.state == AdminBlogPostState.REVIEWED self.assert_state_log_for_user() def test_transition_not_allowed_exception(self, mock_message_user: mock.Mock) -> None: self.assert_state_log_empty() blog_post = AdminBlogPost.objects.create(title="Article name") assert blog_post.state == AdminBlogPostState.CREATED self.model_admin.response_change( request=self.make_request( data={"_fsm_transition_to": "publish"}, ), obj=blog_post, ) mock_message_user.assert_called_once_with( request=mock.ANY, message="FSM transition 'publish' is not allowed.", level=messages.ERROR, ) blog_post.refresh_from_db() assert blog_post.state == AdminBlogPostState.CREATED self.assert_state_log_empty() def test_concurrent_transition_exception(self, mock_message_user: mock.Mock) -> None: self.assert_state_log_empty() blog_post = AdminBlogPost.objects.create(title="Article name") assert blog_post.state == AdminBlogPostState.CREATED with mock.patch( "tests.testapp.models.AdminBlogPost.moderate", side_effect=fsm.ConcurrentTransition("error message"), ): self.model_admin.response_change( request=self.make_request( data={"_fsm_transition_to": "moderate"}, ), obj=blog_post, ) mock_message_user.assert_called_once_with( request=mock.ANY, message="FSM transition 'moderate' failed: error message.", level=messages.ERROR, ) blog_post.refresh_from_db() assert blog_post.state == AdminBlogPostState.CREATED self.assert_state_log_empty() def test_unknown_transition_raise_error(self, mock_message_user: mock.Mock) -> None: request = self.make_request( data={"_fsm_transition_to": "unknown_transition"}, ) with pytest.raises(AttributeError): self.model_admin.response_change( request=request, obj=self.blog_post, ) def test_transition_without_form_execute_fsm_transition( self, mock_message_user: mock.Mock ) -> None: self.assert_state_log_empty() res = self.model_admin.response_change( request=self.make_request( data={"_fsm_transition_to": "hide"}, ), obj=self.blog_post, ) assert isinstance(res, HttpResponseRedirect) assert res.status_code == HTTPStatus.FOUND mock_message_user.assert_called_once_with( request=mock.ANY, message="FSM transition 'hide' succeeded.", level=messages.SUCCESS, ) self.blog_post.refresh_from_db() assert self.blog_post.state == AdminBlogPostState.HIDDEN self.assert_state_log_for_user() def test_transition_with_form_redirects_properly(self, mock_message_user: mock.Mock) -> None: self.assert_state_log_empty() res = self.model_admin.response_change( request=self.make_request( data={"_fsm_transition_to": "complex_transition"}, ), obj=self.blog_post, ) assert isinstance(res, HttpResponseRedirect) assert res.status_code == HTTPStatus.FOUND assert res.url == reverse( f"admin:{self.model_admin.opts.app_label}_{self.model_admin.opts.model_name}_transition", kwargs={ "object_id": self.blog_post.pk, "transition_name": "complex_transition", }, ) self.assert_state_log_empty() self.blog_post.refresh_from_db() assert self.blog_post.state == AdminBlogPostState.PUBLISHED assert self.blog_post.title == "Article name" mock_message_user.assert_not_called() @disable_fsm_log class ResponseChangeViewWithoutFsmLogTestCase(ResponseChangeViewTestCase): fsm_log_enabled = False @mock.patch("tests.testapp.admin.AdminBlogPostAdmin.message_user") class TransitionViewTestCase(BaseAdminTestCase): # Errors def test_non_transition_function(self, mock_message_user: mock.Mock) -> None: self.assert_state_log_empty() with pytest.raises(ValueError, match=r"not an FSM transition"): self.model_admin.fsm_transition_view( request=self.make_request(), object_id=str(self.blog_post.pk), transition_name="normal_function", ) self.assert_state_log_empty() def test_non_callable_function(self, mock_message_user: mock.Mock) -> None: self.assert_state_log_empty() with pytest.raises(TypeError, match=r"Attribute 'name_property' is not callable."): self.model_admin.fsm_transition_view( request=self.make_request(), object_id=str(self.blog_post.pk), transition_name="name_property", ) self.assert_state_log_empty() def test_transition_raised_exception(self, mock_message_user: mock.Mock) -> None: self.assert_state_log_empty() self.model_admin.fsm_transition_view( request=self.make_request( data={ "description": "because", }, ), object_id=str(self.blog_post.pk), transition_name="invalid", ) mock_message_user.assert_called_once_with( request=mock.ANY, message="FSM transition 'invalid' failed: You shall not pass!.", level=messages.ERROR, ) self.assert_state_log_empty() def test_transition_form_not_allowed(self, mock_message_user: mock.Mock) -> None: self.assert_state_log_empty() with mock.patch.object( self.model_admin, "_execute_fsm_transition", side_effect=fsm.TransitionNotAllowed ): self.model_admin.fsm_transition_view( request=self.make_request( data={ "title": "New Title", "comment": "Because", "description": "Because", }, ), object_id=str(self.blog_post.pk), transition_name="complex_transition", ) mock_message_user.assert_called_once_with( request=mock.ANY, message="FSM transition 'complex_transition' is not allowed.", level=messages.ERROR, ) self.blog_post.refresh_from_db() assert self.blog_post.state == AdminBlogPostState.PUBLISHED self.assert_state_log_empty() def test_transition_form_concurrent_exception(self, mock_message_user: mock.Mock) -> None: self.assert_state_log_empty() with mock.patch.object( self.model_admin, "_execute_fsm_transition", side_effect=fsm.ConcurrentTransition("error message"), ): self.model_admin.fsm_transition_view( request=self.make_request( data={ "title": "New Title", "comment": "Because", "description": "Because", }, ), object_id=str(self.blog_post.pk), transition_name="complex_transition", ) mock_message_user.assert_called_once_with( request=mock.ANY, message="FSM transition 'complex_transition' failed: error message.", level=messages.ERROR, ) self.blog_post.refresh_from_db() assert self.blog_post.state == AdminBlogPostState.PUBLISHED self.assert_state_log_empty() def test_permission_denied(self, mock_message_user: mock.Mock) -> None: self.assert_state_log_empty() with mock.patch.object( self.model_admin, "fsm_forms", { "permission_denied": FSMLogDescriptionForm, }, ): self.model_admin.fsm_transition_view( request=self.make_request( data={ "description": "Because", }, ), object_id=str(self.blog_post.pk), transition_name="permission_denied", ) mock_message_user.assert_called_once_with( request=mock.ANY, message="FSM transition 'permission_denied' is not allowed.", level=messages.ERROR, ) self.blog_post.refresh_from_db() assert self.blog_post.state == AdminBlogPostState.PUBLISHED self.assert_state_log_empty() def test_conditions_denied(self, mock_message_user: mock.Mock) -> None: self.assert_state_log_empty() with mock.patch.object( self.model_admin, "fsm_forms", { "conditions_unmet": FSMLogDescriptionForm, }, ): self.model_admin.fsm_transition_view( request=self.make_request( data={ "description": "Because", }, ), object_id=str(self.blog_post.pk), transition_name="conditions_unmet", ) mock_message_user.assert_called_once_with( request=mock.ANY, message="FSM transition 'conditions_unmet' is not allowed.", level=messages.ERROR, ) self.blog_post.refresh_from_db() assert self.blog_post.state == AdminBlogPostState.PUBLISHED self.assert_state_log_empty() def test_invalid_object_id(self, mock_message_user: mock.Mock) -> None: self.assert_state_log_empty() invalid_object_id = "123456" assert not AdminBlogPost.objects.filter(pk=invalid_object_id).exists() res = self.model_admin.fsm_transition_view( request=self.make_request( data={ "state": AdminBlogPostState.CREATED, "description": "because", }, ), object_id=invalid_object_id, transition_name="force_state", ) assert isinstance(res, HttpResponseRedirect) assert res.status_code == HTTPStatus.FOUND assert res["Location"] == reverse("admin:index") mock_message_user.assert_called_once_with( mock.ANY, f"{self.model._meta.verbose_name} with ID “{invalid_object_id}” doesn’t exist. Perhaps it was deleted?", # noqa: RUF001, E501 messages.WARNING, ) self.assert_state_log_empty() def test_transition_without_form_(self, mock_message_user: mock.Mock) -> None: self.assert_state_log_empty() res = self.model_admin.fsm_transition_view( request=self.make_request(), transition_name="hide", object_id=str(self.blog_post.pk), ) assert isinstance(res, HttpResponseBadRequest) assert res.status_code == HTTPStatus.BAD_REQUEST self.assert_state_log_empty() # Form submissions def test_transition_form_submission_executes(self, mock_message_user: mock.Mock) -> None: assert self.blog_post.state == AdminBlogPostState.PUBLISHED self.assert_state_log_empty() res = self.model_admin.fsm_transition_view( request=self.make_request( data={ "title": "New Title", "comment": "Because", "description": "Because", }, ), object_id=str(self.blog_post.pk), transition_name="complex_transition", ) assert isinstance(res, HttpResponseRedirect) assert res.status_code == HTTPStatus.FOUND mock_message_user.assert_called_once_with( request=mock.ANY, message="FSM transition 'complex_transition' succeeded.", level=messages.SUCCESS, ) self.blog_post.refresh_from_db() assert self.blog_post.state == AdminBlogPostState.CREATED assert self.blog_post.title == "New Title" self.assert_state_log_for_user( description="Because", source_state=AdminBlogPostState.PUBLISHED, state=AdminBlogPostState.CREATED, transition="complex_transition", ) # Rendering def test_transition_form_rendered(self, mock_message_user: mock.Mock) -> None: request = RequestFactory().get(path="/") request.user = self.user mock_response = HttpResponse("ok") with mock.patch("django_fsm.admin.render", return_value=mock_response) as mock_render: res = self.model_admin.fsm_transition_view( request=request, object_id=str(self.blog_post.pk), transition_name="complex_transition", ) assert res is mock_response mock_render.assert_called_once() args, kwargs = mock_render.call_args assert args[0] is request assert kwargs["template_name"] == self.model_admin.fsm_transition_form_template context = kwargs["context"] assert "transition_form" in context assert context["transition_form"].is_bound is False class ModelFormTransitionViewTestCase(TransitionViewTestCase): """ Runs TransitionViewTestCase but with **class-based forms** (instead of import path) """ def setUp(self) -> None: self.fsm_forms_patcher = mock.patch.dict( AdminBlogPostAdmin.fsm_forms, { "complex_transition": AdminBlogPostRenameModelForm, "invalid": FSMLogDescriptionForm, }, clear=False, ) self.fsm_forms_patcher.start() super().setUp() def tearDown(self) -> None: self.fsm_forms_patcher.stop() super().tearDown() @disable_fsm_log class NoFsmLogTransitionViewTestCase(TransitionViewTestCase): """ Runs TransitionViewTestCase but with **FSM log disabled** """ fsm_log_enabled = False @disable_fsm_log class NoFsmLogModelFormTransitionViewTestCase(ModelFormTransitionViewTestCase): """ Runs TransitionViewTestCase but with **class-based forms** and **FSM log disabled** """ fsm_log_enabled = False django-commons-django-fsm-2-10256af/tests/testapp/tests/test_basic_transitions.py000066400000000000000000000260741515347707100302730ustar00rootroot00000000000000from __future__ import annotations import pytest from django.db import models from django.test import TestCase from django_fsm import FSMField from django_fsm import Transition from django_fsm import TransitionNotAllowed from django_fsm import can_proceed from django_fsm import transition from django_fsm.signals import post_transition from django_fsm.signals import pre_transition class SimpleBlogPost(models.Model): state = FSMField(default="new") @transition(field=state, source="new", target="published") def publish(self): pass @transition(source="published", field=state) def notify_all(self): pass @transition(source="published", target="hidden", field=state) def hide(self): pass @transition(source="new", target="removed", field=state) def remove(self): raise Exception("Upss") @transition(source=["published", "hidden"], target="stolen", field=state) def steal(self): pass @transition(source="*", target="moderated", field=state) def moderate(self): pass @transition(source="+", target="blocked", field=state) def block(self): pass @transition(source="*", target="", field=state) def empty(self): pass class AdvancedBlogPost(SimpleBlogPost): @transition(field="state", source="new", target="published") def publish(self): pass class FSMFieldTest(TestCase): def setUp(self): self.model = SimpleBlogPost() def test_initial_state_instantiated(self): assert self.model.state == "new" def test_known_transition_should_succeed(self): assert can_proceed(self.model.publish) self.model.publish() assert self.model.state == "published" assert can_proceed(self.model.hide) self.model.hide() assert self.model.state == "hidden" def test_unknown_transition_fails(self): assert not can_proceed(self.model.hide) with pytest.raises(TransitionNotAllowed): self.model.hide() def test_state_non_changed_after_fail(self): assert can_proceed(self.model.remove) with pytest.raises(Exception, match="Upss"): self.model.remove() assert self.model.state == "new" def test_allowed_null_transition_should_succeed(self): self.model.publish() self.model.notify_all() assert self.model.state == "published" def test_unknown_null_transition_should_fail(self): with pytest.raises(TransitionNotAllowed): self.model.notify_all() assert self.model.state == "new" def test_multiple_source_support_path_1_works(self): self.model.publish() self.model.steal() assert self.model.state == "stolen" def test_multiple_source_support_path_2_works(self): self.model.publish() self.model.hide() self.model.steal() assert self.model.state == "stolen" def test_star_shortcut_succeed(self): assert can_proceed(self.model.moderate) self.model.moderate() assert self.model.state == "moderated" def test_plus_shortcut_succeeds_for_other_source(self): """Tests that the '+' shortcut succeeds for a source other than the target. """ assert can_proceed(self.model.block) self.model.block() assert self.model.state == "blocked" def test_plus_shortcut_fails_for_same_source(self): """Tests that the '+' shortcut fails if the source equals the target. """ self.model.block() assert not can_proceed(self.model.block) with pytest.raises(TransitionNotAllowed): self.model.block() def test_empty_string_target(self): self.model.empty() assert self.model.state == "" class StateSignalsTests(TestCase): def setUp(self): self.model = SimpleBlogPost() self.pre_transition_called = False self.post_transition_called = False pre_transition.connect(self.on_pre_transition, sender=SimpleBlogPost) post_transition.connect(self.on_post_transition, sender=SimpleBlogPost) def tearDown(self): pre_transition.disconnect(self.on_pre_transition, sender=SimpleBlogPost) post_transition.disconnect(self.on_post_transition, sender=SimpleBlogPost) def on_pre_transition(self, sender, instance, name, source, target, **kwargs): assert instance.state == source self.pre_transition_called = True def on_post_transition(self, sender, instance, name, source, target, **kwargs): assert instance.state == target self.post_transition_called = True def test_signals_called_on_valid_transition(self): self.model.publish() assert self.pre_transition_called assert self.post_transition_called def test_signals_not_called_on_invalid_transition(self): with pytest.raises(TransitionNotAllowed): self.model.hide() assert not self.pre_transition_called assert not self.post_transition_called class LazySenderTests(StateSignalsTests): def setUp(self): self.model = SimpleBlogPost() self.pre_transition_called = False self.post_transition_called = False pre_transition.connect(self.on_pre_transition, sender="testapp.SimpleBlogPost") post_transition.connect(self.on_post_transition, sender="testapp.SimpleBlogPost") def tearDown(self): pre_transition.disconnect(self.on_pre_transition, sender="testapp.SimpleBlogPost") post_transition.disconnect(self.on_post_transition, sender="testapp.SimpleBlogPost") class TestFieldTransitionsInspect(TestCase): def setUp(self): self.model = SimpleBlogPost() def test_transition_are_hashable(self) -> None: transition = Transition( method=self.model.publish, source="new", target="published", on_error=None, conditions=[], permission=None, custom={}, ) assert hash(transition) is not None def test_transition_equality(self) -> None: for wrong_value in [0, 1, True, False, None]: assert ( Transition( method=AdvancedBlogPost.publish, source="new", target="published", on_error=None, conditions=[], permission=None, custom={}, ) != wrong_value ) assert Transition( method=AdvancedBlogPost.publish, source="new", target="published", on_error=None, conditions=[], permission=None, custom={}, ) != Transition( method=SimpleBlogPost.publish, source="new", target="published", on_error=None, conditions=[], permission=None, custom={}, ) assert Transition( method=AdvancedBlogPost.empty, source="*", target="", on_error=None, conditions=[], permission=None, custom={}, ) == Transition( method=SimpleBlogPost.empty, source="*", target="", on_error=None, conditions=[], permission=None, custom={}, ) def test_in_operator_for_available_transitions(self): # store the generator in a list, so we can reuse the generator and do multiple asserts transitions = list(self.model.get_available_state_transitions()) # type: ignore[attr-defined] assert "publish" in transitions assert "xyz" not in transitions # inline method for faking the name of the transition def publish(): pass obj = Transition( method=publish, source="", target="", on_error="", conditions=None, permission="", custom=None, ) assert obj not in transitions def test_available_conditions_from_new(self): transitions = self.model.get_available_state_transitions() # type: ignore[attr-defined] actual = {(transition.source, transition.target) for transition in transitions} expected = { ("*", "moderated"), ("new", "published"), ("new", "removed"), ("*", ""), ("+", "blocked"), } assert actual == expected def test_available_conditions_from_published(self): self.model.publish() transitions = self.model.get_available_state_transitions() # type: ignore[attr-defined] actual = {(transition.source, transition.target) for transition in transitions} expected = { ("*", "moderated"), ("published", None), ("published", "hidden"), ("published", "stolen"), ("*", ""), ("+", "blocked"), } assert actual == expected def test_available_conditions_from_hidden(self): self.model.publish() self.model.hide() transitions = self.model.get_available_state_transitions() # type: ignore[attr-defined] actual = {(transition.source, transition.target) for transition in transitions} expected = {("*", "moderated"), ("hidden", "stolen"), ("*", ""), ("+", "blocked")} assert actual == expected def test_available_conditions_from_stolen(self): self.model.publish() self.model.steal() transitions = self.model.get_available_state_transitions() # type: ignore[attr-defined] actual = {(transition.source, transition.target) for transition in transitions} expected = {("*", "moderated"), ("*", ""), ("+", "blocked")} assert actual == expected def test_available_conditions_from_blocked(self): self.model.block() transitions = self.model.get_available_state_transitions() # type: ignore[attr-defined] actual = {(transition.source, transition.target) for transition in transitions} expected = {("*", "moderated"), ("*", "")} assert actual == expected def test_available_conditions_from_empty(self): self.model.empty() transitions = self.model.get_available_state_transitions() # type: ignore[attr-defined] actual = {(transition.source, transition.target) for transition in transitions} expected = {("*", "moderated"), ("*", ""), ("+", "blocked")} assert actual == expected def test_all_conditions(self): transitions = self.model.get_all_state_transitions() # type: ignore[attr-defined] actual = {(transition.source, transition.target) for transition in transitions} expected = { ("*", "moderated"), ("new", "published"), ("new", "removed"), ("published", None), ("published", "hidden"), ("published", "stolen"), ("hidden", "stolen"), ("*", ""), ("+", "blocked"), } assert actual == expected django-commons-django-fsm-2-10256af/tests/testapp/tests/test_conditions.py000066400000000000000000000030571515347707100267220ustar00rootroot00000000000000from __future__ import annotations import pytest from django.db import models from django.test import TestCase from django_fsm import FSMField from django_fsm import TransitionNotAllowed from django_fsm import can_proceed from django_fsm import transition def condition_func(instance: models.Model) -> bool: return True class BlogPostWithConditions(models.Model): state = FSMField(default="new") def model_condition(self: models.Model) -> bool: return True def unmet_condition(self: models.Model) -> bool: return False @transition( field=state, source="new", target="published", conditions=[condition_func, model_condition] ) def publish(self): pass @transition( field=state, source="published", target="destroyed", conditions=[condition_func, unmet_condition], ) def destroy(self): pass class ConditionalTest(TestCase): def setUp(self): self.model = BlogPostWithConditions() def test_initial_staet(self): assert self.model.state == "new" def test_known_transition_should_succeed(self): assert can_proceed(self.model.publish) self.model.publish() assert self.model.state == "published" def test_unmet_condition(self): self.model.publish() assert self.model.state == "published" assert not can_proceed(self.model.destroy) with pytest.raises(TransitionNotAllowed): self.model.destroy() assert can_proceed(self.model.destroy, check_conditions=False) django-commons-django-fsm-2-10256af/tests/testapp/tests/test_custom_data.py000066400000000000000000000030371515347707100270520ustar00rootroot00000000000000from __future__ import annotations from django.db import models from django.test import TestCase from django_fsm import FSMField from django_fsm import transition class BlogPostWithCustomData(models.Model): state = FSMField(default="new") @transition( field=state, source="new", target="published", conditions=[], custom={"label": "Publish", "type": "*"}, ) def publish(self): pass @transition( field=state, source="published", target="destroyed", custom={"label": "Destroy", "type": "manual"}, ) def destroy(self): pass @transition( field=state, source="published", target="review", custom={"label": "Periodic review", "type": "automated"}, ) def review(self): pass class CustomTransitionDataTest(TestCase): def setUp(self): self.model = BlogPostWithCustomData() def test_initial_state(self): assert self.model.state == "new" transitions = list(self.model.get_available_state_transitions()) # type: ignore[attr-defined] assert len(transitions) == 1 assert transitions[0].target == "published" assert transitions[0].custom == {"label": "Publish", "type": "*"} def test_all_transitions_have_custom_data(self): transitions = self.model.get_all_state_transitions() # type: ignore[attr-defined] for t in transitions: assert t.custom["label"] is not None assert t.custom["type"] is not None django-commons-django-fsm-2-10256af/tests/testapp/tests/test_exception_transitions.py000066400000000000000000000031461515347707100312030ustar00rootroot00000000000000from __future__ import annotations import pytest from django.db import models from django.test import TestCase from django_fsm import FSMField from django_fsm import can_proceed from django_fsm import transition from django_fsm.signals import post_transition class ExceptionalBlogPost(models.Model): state = FSMField(default="new") @transition(field=state, source="new", target="published", on_error="crashed") def publish(self): raise Exception("Upss") @transition(field=state, source="new", target="deleted") def delete(self): raise Exception("Upss") class FSMFieldExceptionTest(TestCase): def setUp(self): self.model = ExceptionalBlogPost() post_transition.connect(self.on_post_transition, sender=ExceptionalBlogPost) self.post_transition_data = {} def tearDown(self): post_transition.disconnect(self.on_post_transition, sender=ExceptionalBlogPost) def on_post_transition(self, **kwargs): self.post_transition_data = kwargs def test_state_changed_after_fail(self): assert can_proceed(self.model.publish) with pytest.raises(Exception, match="Upss"): self.model.publish() assert self.model.state == "crashed" assert self.post_transition_data["target"] == "crashed" assert "exception" in self.post_transition_data def test_state_not_changed_after_fail(self): assert can_proceed(self.model.delete) with pytest.raises(Exception, match="Upss"): self.model.delete() assert self.model.state == "new" assert self.post_transition_data == {} django-commons-django-fsm-2-10256af/tests/testapp/tests/test_graph_transitions.py000066400000000000000000000507111515347707100303060ustar00rootroot00000000000000from __future__ import annotations import os import tempfile import typing from io import StringIO from pathlib import Path import graphviz import pytest from django.core.exceptions import FieldDoesNotExist from django.core.management import call_command from django.test import TestCase from django_fsm.management.commands.graph_transitions import node_label from django_fsm.management.commands.graph_transitions import node_name from tests.testapp.models import Application from tests.testapp.models import BlogPost from tests.testapp.models import BlogPostState from tests.testapp.tests.test_model_create_with_generic import Task from tests.testapp.tests.test_model_create_with_generic import TaskState class GraphTransitionsCommandTest(TestCase): MODELS_TO_TEST = [ "testapp.Application", "testapp.FKApplication", ] EXTENSIONS_TO_TEST = ["png", "jpg", "jpeg"] def test_node_name(self): assert node_name(Task.state.field, TaskState.DONE) == "testapp.task.state.done" assert node_name(BlogPost.state.field, BlogPostState.NEW) == "testapp.blog_post.state.0" def test_node_label(self): assert node_label(Application.state.field, "new") == "new" assert ( node_label(BlogPost.state.field, BlogPostState.PUBLISHED.value) == BlogPostState.PUBLISHED.label ) # choices is not declared, fallbacking to the value instead assert node_label(Task.state.field, TaskState.DONE.value) == TaskState.DONE.value def _call_command(self, *args: typing.Any, **kwargs: typing.Any) -> str: out = StringIO() call_command("graph_transitions", *args, **kwargs, stdout=out) return out.getvalue() def test_all_models(self): self._call_command() def test_app(self): self._call_command("testapp") def test_app_fail(self): with pytest.raises(LookupError): self._call_command("unknown_app") def test_single_model(self): for model in self.MODELS_TO_TEST: output = self._call_command(model) assert model in output for excluded_model in self.MODELS_TO_TEST: if model != excluded_model: assert excluded_model not in output def test_single_model_fail(self): with pytest.raises(LookupError): self._call_command("testapp.UnknownModel") def test_single_model_with_layouts(self): for model in self.MODELS_TO_TEST: for layout in graphviz.ENGINES: self._call_command("-l", layout, model) def test_single_model_with_output(self): with tempfile.TemporaryDirectory() as tmp_dir: previous_cwd = os.getcwd() try: # The command writes relative paths, so isolate it in a temp dir. os.chdir(tmp_dir) export_dir = Path("exports") export_dir.mkdir() for model in self.MODELS_TO_TEST: for extension in self.EXTENSIONS_TO_TEST: my_file = export_dir / f"{model}.{extension}" self._call_command("-o", my_file, model) assert my_file.exists() finally: os.chdir(previous_cwd) def test_single_model_exclude(self): excluded_transitions = ["standard", "no_target"] for model in self.MODELS_TO_TEST: output = self._call_command("-e", ",".join(excluded_transitions), model) for excluded_t in excluded_transitions: assert excluded_t not in output def test_single_field(self): """Test that specifying app.model.field filters to only that field.""" output = self._call_command("testapp.MultiStateApplication.another_state") assert "testapp.multi_state_application.another_state" in output assert "testapp.application.state" not in output def test_single_field_fail(self): with pytest.raises((LookupError, FieldDoesNotExist)): self._call_command("testapp.MultiStateApplication.unknown_field") with pytest.raises(LookupError): self._call_command("testapp.MultiStateApplication.id") def test_output_contains_subgraph_label(self): # noqa: PLR0915 output = self._call_command("testapp.Application") assert "subgraph cluster_testapp_Application_state {" in output assert 'graph [label="testapp.Application.state"]' in output assert '"testapp.application.state.new" [label=new shape=circle]' in output assert '"testapp.application.state._initial" [label="" shape=point]' in output assert '"testapp.application.state._initial" -> "testapp.application.state.new"' in output assert '"testapp.application.state.failed" [label=failed shape=circle]' in output assert '"testapp.application.state.None" [label=None shape=circle]' in output assert '"testapp.application.state.blocked" [label=blocked shape=circle]' in output assert '"testapp.application.state.hidden" [label=hidden shape=circle]' in output assert '"testapp.application.state.rejected" [label=rejected shape=circle]' in output assert '"testapp.application.state.moderated" [label=moderated shape=circle]' in output assert '"testapp.application.state.published" [label=published shape=circle]' in output assert ( '"testapp.application.state.new" -> "testapp.application.state.rejected" [label=get_state]' # noqa: E501 in output ) assert ( '"testapp.application.state.hidden" -> "testapp.application.state.blocked" [label=return_value_any_source]' # noqa: E501 in output ) assert ( '"testapp.application.state.published" -> "testapp.application.state.rejected" [label=get_state_any_source_except_target]' # noqa: E501 in output ) assert ( '"testapp.application.state.hidden" -> "testapp.application.state.published" [label=get_state_any_source_except_target]' # noqa: E501 in output ) assert ( '"testapp.application.state.new" -> "testapp.application.state.published" [label=on_error]' # noqa: E501 in output ) assert ( '"testapp.application.state.new" -> "testapp.application.state.published" [label=get_state_any_source_except_target]' # noqa: E501 in output ) assert ( '"testapp.application.state.rejected" -> "testapp.application.state.rejected" [label=get_state_any_source]' # noqa: E501 in output ) assert ( '"testapp.application.state.None" -> "testapp.application.state.published" [label=get_state_any_source_except_target]' # noqa: E501 in output ) assert ( '"testapp.application.state.moderated" -> "testapp.application.state.hidden" [label=any_source_except_target]' # noqa: E501 in output ) assert ( '"testapp.application.state.published" -> "testapp.application.state.blocked" [label=return_value_any_source]' # noqa: E501 in output ) assert ( '"testapp.application.state.moderated" -> "testapp.application.state.moderated" [label=return_value_any_source]' # noqa: E501 in output ) assert ( '"testapp.application.state.moderated" -> "testapp.application.state.rejected" [label=get_state_any_source_except_target]' # noqa: E501 in output ) assert ( '"testapp.application.state.failed" -> "testapp.application.state.blocked" [label=return_value_any_source_except_target]' # noqa: E501 in output ) assert ( '"testapp.application.state.rejected" -> "testapp.application.state.published" [label=get_state_any_source_except_target]' # noqa: E501 in output ) assert ( '"testapp.application.state.failed" -> "testapp.application.state.moderated" [label=return_value_any_source]' # noqa: E501 in output ) assert ( '"testapp.application.state.hidden" -> "testapp.application.state.rejected" [label=get_state_any_source_except_target]' # noqa: E501 in output ) assert ( '"testapp.application.state.new" -> "testapp.application.state.hidden" [label=any_source_except_target]' # noqa: E501 in output ) assert ( '"testapp.application.state.new" -> "testapp.application.state.moderated" [label=return_value]' # noqa: E501 in output ) assert ( '"testapp.application.state.blocked" -> "testapp.application.state.published" [label=get_state_any_source_except_target]' # noqa: E501 in output ) assert ( '"testapp.application.state.moderated" -> "testapp.application.state.published" [label=get_state_any_source]' # noqa: E501 in output ) assert ( '"testapp.application.state.new" -> "testapp.application.state.moderated" [label=return_value_any_source]' # noqa: E501 in output ) assert ( '"testapp.application.state.published" -> "testapp.application.state.moderated" [label=return_value_any_source]' # noqa: E501 in output ) assert ( '"testapp.application.state.new" -> "testapp.application.state.blocked" [label=return_value]' # noqa: E501 in output ) assert ( '"testapp.application.state.None" -> "testapp.application.state.hidden" [label=any_source_except_target]' # noqa: E501 in output ) assert ( '"testapp.application.state.failed" -> "testapp.application.state.blocked" [label=any_source]' # noqa: E501 in output ) assert ( '"testapp.application.state.new" -> "testapp.application.state.blocked" [label=return_value_any_source]' # noqa: E501 in output ) assert ( '"testapp.application.state.failed" -> "testapp.application.state.published" [label=get_state_any_source]' # noqa: E501 in output ) assert ( '"testapp.application.state.None" -> "testapp.application.state.moderated" [label=return_value_any_source]' # noqa: E501 in output ) assert ( '"testapp.application.state.published" -> "testapp.application.state.blocked" [label=return_value_any_source_except_target]' # noqa: E501 in output ) assert ( '"testapp.application.state.published" -> "testapp.application.state.None" [label=no_target]' # noqa: E501 in output ) assert ( '"testapp.application.state.blocked" -> "testapp.application.state.rejected" [label=get_state_any_source]' # noqa: E501 in output ) assert ( '"testapp.application.state.None" -> "testapp.application.state.blocked" [label=return_value_any_source]' # noqa: E501 in output ) assert ( '"testapp.application.state.rejected" -> "testapp.application.state.hidden" [label=any_source_except_target]' # noqa: E501 in output ) assert ( '"testapp.application.state.new" -> "testapp.application.state.published" [label=get_state_any_source]' # noqa: E501 in output ) assert ( '"testapp.application.state.failed" -> "testapp.application.state.moderated" [label=return_value_any_source_except_target]' # noqa: E501 in output ) assert ( '"testapp.application.state.published" -> "testapp.application.state.published" [label=get_state_any_source]' # noqa: E501 in output ) assert ( '"testapp.application.state.moderated" -> "testapp.application.state.blocked" [label=return_value_any_source_except_target]' # noqa: E501 in output ) assert ( '"testapp.application.state.None" -> "testapp.application.state.published" [label=get_state_any_source]' # noqa: E501 in output ) assert ( '"testapp.application.state.blocked" -> "testapp.application.state.hidden" [label=any_source_except_target]' # noqa: E501 in output ) assert ( '"testapp.application.state.published" -> "testapp.application.state.blocked" [label=any_source]' # noqa: E501 in output ) assert ( '"testapp.application.state.new" -> "testapp.application.state.failed" [style=dotted]' in output ) assert ( '"testapp.application.state.blocked" -> "testapp.application.state.moderated" [label=return_value_any_source]' # noqa: E501 in output ) assert ( '"testapp.application.state.failed" -> "testapp.application.state.rejected" [label=get_state_any_source]' # noqa: E501 in output ) assert ( '"testapp.application.state.rejected" -> "testapp.application.state.blocked" [label=return_value_any_source]' # noqa: E501 in output ) assert ( '"testapp.application.state.published" -> "testapp.application.state.moderated" [label=return_value_any_source_except_target]' # noqa: E501 in output ) assert ( '"testapp.application.state.new" -> "testapp.application.state.rejected" [label=get_state_any_source_except_target]' # noqa: E501 in output ) assert ( '"testapp.application.state.hidden" -> "testapp.application.state.blocked" [label=return_value_any_source_except_target]' # noqa: E501 in output ) assert ( '"testapp.application.state.hidden" -> "testapp.application.state.moderated" [label=return_value_any_source]' # noqa: E501 in output ) assert ( '"testapp.application.state.new" -> "testapp.application.state.published" [label=get_state]' # noqa: E501 in output ) assert ( '"testapp.application.state.None" -> "testapp.application.state.rejected" [label=get_state_any_source_except_target]' # noqa: E501 in output ) assert ( '"testapp.application.state.blocked" -> "testapp.application.state.blocked" [label=return_value_any_source]' # noqa: E501 in output ) assert ( '"testapp.application.state.moderated" -> "testapp.application.state.blocked" [label=any_source]' # noqa: E501 in output ) assert ( '"testapp.application.state.hidden" -> "testapp.application.state.blocked" [label=any_source]' # noqa: E501 in output ) assert ( '"testapp.application.state.published" -> "testapp.application.state.rejected" [label=get_state_any_source]' # noqa: E501 in output ) assert ( '"testapp.application.state.hidden" -> "testapp.application.state.published" [label=get_state_any_source]' # noqa: E501 in output ) assert ( '"testapp.application.state.rejected" -> "testapp.application.state.moderated" [label=return_value_any_source]' # noqa: E501 in output ) assert ( '"testapp.application.state.new" -> "testapp.application.state.blocked" [label=any_source]' # noqa: E501 in output ) assert ( '"testapp.application.state.new" -> "testapp.application.state.failed" [style=dotted]' in output ) assert ( '"testapp.application.state.failed" -> "testapp.application.state.published" [label=get_state_any_source_except_target]' # noqa: E501 in output ) assert ( '"testapp.application.state.rejected" -> "testapp.application.state.blocked" [label=return_value_any_source_except_target]' # noqa: E501 in output ) assert ( '"testapp.application.state.blocked" -> "testapp.application.state.rejected" [label=get_state_any_source_except_target]' # noqa: E501 in output ) assert ( '"testapp.application.state.None" -> "testapp.application.state.blocked" [label=any_source]' # noqa: E501 in output ) assert ( '"testapp.application.state.hidden" -> "testapp.application.state.moderated" [label=return_value_any_source_except_target]' # noqa: E501 in output ) assert ( '"testapp.application.state.new" -> "testapp.application.state.moderated" [label=return_value_any_source_except_target]' # noqa: E501 in output ) assert ( '"testapp.application.state.moderated" -> "testapp.application.state.rejected" [label=get_state_any_source]' # noqa: E501 in output ) assert ( '"testapp.application.state.new" -> "testapp.application.state.blocked" [label=return_value_any_source_except_target]' # noqa: E501 in output ) assert ( '"testapp.application.state.None" -> "testapp.application.state.moderated" [label=return_value_any_source_except_target]' # noqa: E501 in output ) assert ( '"testapp.application.state.rejected" -> "testapp.application.state.published" [label=get_state_any_source]' # noqa: E501 in output ) assert ( '"testapp.application.state.new" -> "testapp.application.state.published" [label=standard]' # noqa: E501 in output ) assert ( '"testapp.application.state.hidden" -> "testapp.application.state.rejected" [label=get_state_any_source]' # noqa: E501 in output ) assert ( '"testapp.application.state.None" -> "testapp.application.state.blocked" [label=return_value_any_source_except_target]' # noqa: E501 in output ) assert ( '"testapp.application.state.rejected" -> "testapp.application.state.blocked" [label=any_source]' # noqa: E501 in output ) assert ( '"testapp.application.state.new" -> "testapp.application.state.rejected" [label=get_state_any_source]' # noqa: E501 in output ) assert ( '"testapp.application.state.failed" -> "testapp.application.state.rejected" [label=get_state_any_source_except_target]' # noqa: E501 in output ) assert ( '"testapp.application.state.blocked" -> "testapp.application.state.published" [label=get_state_any_source]' # noqa: E501 in output ) assert ( '"testapp.application.state.rejected" -> "testapp.application.state.moderated" [label=return_value_any_source_except_target]' # noqa: E501 in output ) assert ( '"testapp.application.state.moderated" -> "testapp.application.state.published" [label=get_state_any_source_except_target]' # noqa: E501 in output ) assert ( '"testapp.application.state.failed" -> "testapp.application.state.hidden" [label=any_source_except_target]' # noqa: E501 in output ) assert ( '"testapp.application.state.None" -> "testapp.application.state.rejected" [label=get_state_any_source]' # noqa: E501 in output ) assert ( '"testapp.application.state.blocked" -> "testapp.application.state.blocked" [label=any_source]' # noqa: E501 in output ) assert ( '"testapp.application.state.failed" -> "testapp.application.state.blocked" [label=return_value_any_source]' # noqa: E501 in output ) assert ( '"testapp.application.state.moderated" -> "testapp.application.state.blocked" [label=return_value_any_source]' # noqa: E501 in output ) assert ( '"testapp.application.state.blocked" -> "testapp.application.state.moderated" [label=return_value_any_source_except_target]' # noqa: E501 in output ) assert ( '"testapp.application.state.published" -> "testapp.application.state.hidden" [label=any_source_except_target]' # noqa: E501 in output ) django-commons-django-fsm-2-10256af/tests/testapp/tests/test_integer_field.py000066400000000000000000000021771515347707100273530ustar00rootroot00000000000000from __future__ import annotations import pytest from django.db import models from django.test import TestCase from django_fsm import FSMIntegerField from django_fsm import TransitionNotAllowed from django_fsm import transition class BlogPostStateEnum: NEW = 10 PUBLISHED = 20 HIDDEN = 30 class BlogPostWithIntegerField(models.Model): state = FSMIntegerField(default=BlogPostStateEnum.NEW) @transition(field=state, source=BlogPostStateEnum.NEW, target=BlogPostStateEnum.PUBLISHED) def publish(self): pass @transition(field=state, source=BlogPostStateEnum.PUBLISHED, target=BlogPostStateEnum.HIDDEN) def hide(self): pass class BlogPostWithIntegerFieldTest(TestCase): def setUp(self): self.model = BlogPostWithIntegerField() def test_known_transition_should_succeed(self): self.model.publish() assert self.model.state == BlogPostStateEnum.PUBLISHED self.model.hide() assert self.model.state == BlogPostStateEnum.HIDDEN def test_unknown_transition_fails(self): with pytest.raises(TransitionNotAllowed): self.model.hide() django-commons-django-fsm-2-10256af/tests/testapp/tests/test_key_field.py000066400000000000000000000102561515347707100265030ustar00rootroot00000000000000from __future__ import annotations import pytest from django.db import models from django.test import TestCase from django_fsm import FSMKeyField from django_fsm import TransitionNotAllowed from django_fsm import can_proceed from django_fsm import transition from tests.testapp.models import DbState FK_AVAILABLE_STATES = ( ("New", "_NEW_"), ("Published", "_PUBLISHED_"), ("Hidden", "_HIDDEN_"), ("Removed", "_REMOVED_"), ("Stolen", "_STOLEN_"), ("Moderated", "_MODERATED_"), ) class FKBlogPost(models.Model): state = FSMKeyField(DbState, default="new", protected=True, on_delete=models.CASCADE) @transition(field=state, source="new", target="published") def publish(self): pass @transition(field=state, source="published") def notify_all(self): pass @transition(field=state, source="published", target="hidden") def hide(self): pass @transition(field=state, source="new", target="removed") def remove(self): raise Exception("Upss") @transition(field=state, source=["published", "hidden"], target="stolen") def steal(self): pass @transition(field=state, source="*", target="moderated") def moderate(self): pass class FSMKeyFieldTest(TestCase): def setUp(self): DbState.objects.bulk_create( DbState(pk=item[0], label=item[1]) for item in FK_AVAILABLE_STATES ) self.model = FKBlogPost() def test_initial_state_instantiated(self): assert self.model.state == "new" def test_known_transition_should_succeed(self): assert can_proceed(self.model.publish) self.model.publish() assert self.model.state == "published" assert can_proceed(self.model.hide) self.model.hide() assert self.model.state == "hidden" def test_unknown_transition_fails(self): assert not can_proceed(self.model.hide) with pytest.raises(TransitionNotAllowed): self.model.hide() def test_state_non_changed_after_fail(self): assert can_proceed(self.model.remove) with pytest.raises(Exception, match="Upss"): self.model.remove() assert self.model.state == "new" def test_allowed_null_transition_should_succeed(self): assert can_proceed(self.model.publish) self.model.publish() self.model.notify_all() assert self.model.state == "published" def test_unknown_null_transition_should_fail(self): with pytest.raises(TransitionNotAllowed): self.model.notify_all() assert self.model.state == "new" def test_multiple_source_support_path_1_works(self): self.model.publish() self.model.steal() assert self.model.state == "stolen" def test_multiple_source_support_path_2_works(self): self.model.publish() self.model.hide() self.model.steal() assert self.model.state == "stolen" def test_star_shortcut_succeed(self): assert can_proceed(self.model.moderate) self.model.moderate() assert self.model.state == "moderated" """ # TODO: FIX it class BlogPostStatus(models.Model): name = models.CharField(unique=True, max_length=10) objects = models.Manager() class BlogPostWithFKState(models.Model): status = FSMKeyField(BlogPostStatus, default=lambda: BlogPostStatus.objects.get(name="new")) @transition(field=status, source='new', target='published') def publish(self): pass @transition(field=status, source='published', target='hidden') def hide(self): pass class BlogPostWithFKStateTest(TestCase): def setUp(self): BlogPostStatus.objects.bulk_create([ BlogPostStatus(name="new") BlogPostStatus(name="published") BlogPostStatus(name="hidden") ]) self.model = BlogPostWithFKState() def test_known_transition_should_succeed(self): self.model.publish() self.assertEqual(self.model.state, 'published') self.model.hide() self.assertEqual(self.model.state, 'hidden') def test_unknown_transition_fails(self): with pytest.raises(TransitionNotAllowed): self.model.hide() """ django-commons-django-fsm-2-10256af/tests/testapp/tests/test_lock_mixin.py000066400000000000000000000056101515347707100267020ustar00rootroot00000000000000from __future__ import annotations import pytest from django.db import models from django.test import TestCase from django_fsm import ConcurrentTransition from django_fsm import ConcurrentTransitionMixin from django_fsm import FSMField from django_fsm import transition class LockedBlogPost(ConcurrentTransitionMixin, models.Model): state = FSMField(default="new") text = models.CharField(max_length=50) objects: models.Manager[LockedBlogPost] = models.Manager() @transition(field=state, source="new", target="published") def publish(self): pass @transition(field=state, source="published", target="removed") def remove(self): pass class ExtendedBlogPost(LockedBlogPost): review_state = FSMField(default="waiting", protected=True) notes = models.CharField(max_length=50) objects: models.Manager[ExtendedBlogPost] = models.Manager() @transition(field=review_state, source="waiting", target="rejected") def reject(self): pass class TestLockMixin(TestCase): def test_create_succeed(self): LockedBlogPost.objects.create(text="test_create_succeed") def test_crud_succeed(self): post = LockedBlogPost(text="test_crud_succeed") post.publish() post.save() post = LockedBlogPost.objects.get(pk=post.pk) assert post.state == "published" post.text = "test_crud_succeed2" post.save() post = LockedBlogPost.objects.get(pk=post.pk) assert post.text == "test_crud_succeed2" post.delete() def test_save_and_change_succeed(self): post = LockedBlogPost(text="test_crud_succeed") post.publish() post.save() post.remove() post.save() post.delete() def test_concurrent_modifications_raise_exception(self): post1 = LockedBlogPost.objects.create() post2 = LockedBlogPost.objects.get(pk=post1.pk) post1.publish() post1.save() post2.text = "aaa" post2.publish() with pytest.raises(ConcurrentTransition): post2.save() def test_inheritance_crud_succeed(self): post = ExtendedBlogPost(text="test_inheritance_crud_succeed", notes="reject me") post.publish() post.save() post = ExtendedBlogPost.objects.get(pk=post.pk) assert post.state == "published" post.text = "test_inheritance_crud_succeed2" post.reject() post.save() post = ExtendedBlogPost.objects.get(pk=post.pk) assert post.review_state == "rejected" assert post.text == "test_inheritance_crud_succeed2" def test_concurrent_modifications_after_refresh_db_succeed(self): # bug 255 post1 = LockedBlogPost.objects.create() post2 = LockedBlogPost.objects.get(pk=post1.pk) post1.publish() post1.save() post2.refresh_from_db() post2.remove() post2.save() django-commons-django-fsm-2-10256af/tests/testapp/tests/test_mixin_support.py000066400000000000000000000012751515347707100274710ustar00rootroot00000000000000from __future__ import annotations from django.db import models from django.test import TestCase from django_fsm import FSMField from django_fsm import transition class WorkflowMixin: @transition(field="state", source="*", target="draft") def draft(self): pass @transition(field="state", source="draft", target="published") def publish(self): pass class MixinSupportTestModel(WorkflowMixin, models.Model): state = FSMField(default="new") class Test(TestCase): def test_usecase(self): model = MixinSupportTestModel() model.draft() assert model.state == "draft" model.publish() assert model.state == "published" django-commons-django-fsm-2-10256af/tests/testapp/tests/test_model_create_with_generic.py000066400000000000000000000022611515347707100317170ustar00rootroot00000000000000from __future__ import annotations from django.contrib.contenttypes.fields import GenericForeignKey from django.contrib.contenttypes.models import ContentType from django.db import models from django.test import TestCase from django_fsm import FSMField from django_fsm import transition class Ticket(models.Model): objects: models.Manager[Ticket] = models.Manager() class TaskState(models.TextChoices): NEW = "new", "New" DONE = "done", "Done" class Task(models.Model): content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE) object_id = models.PositiveIntegerField() causality = GenericForeignKey("content_type", "object_id") state = FSMField(default=TaskState.NEW) objects: models.Manager[Task] = models.Manager() @transition(field=state, source=TaskState.NEW, target=TaskState.DONE) def do(self): pass class Test(TestCase): def setUp(self): self.ticket = Ticket.objects.create() def test_model_objects_create(self): """Check a model with state field can be created if one of the other fields is a property or a virtual field. """ Task.objects.create(causality=self.ticket) django-commons-django-fsm-2-10256af/tests/testapp/tests/test_multi_resultstate.py000066400000000000000000000047411515347707100303430ustar00rootroot00000000000000from __future__ import annotations from django.db import models from django.test import TestCase from django_fsm import GET_STATE from django_fsm import RETURN_VALUE from django_fsm import FSMField from django_fsm import transition from django_fsm.signals import post_transition from django_fsm.signals import pre_transition class MultiResultTest(models.Model): state = FSMField(default="new") @transition(field=state, source="new", target=RETURN_VALUE("for_moderators", "published")) def publish(self, *, is_public=False): return "published" if is_public else "for_moderators" @transition( field=state, source="for_moderators", target=GET_STATE( lambda _, allowed: "published" if allowed else "rejected", states=["published", "rejected"], ), ) def moderate(self, allowed): pass class Test(TestCase): def test_return_state_succeed(self): instance = MultiResultTest() instance.publish(is_public=True) assert instance.state == "published" def test_get_state_succeed(self): instance = MultiResultTest(state="for_moderators") instance.moderate(allowed=False) assert instance.state == "rejected" class TestSignals(TestCase): def setUp(self): self.pre_transition_called = False self.post_transition_called = False pre_transition.connect(self.on_pre_transition, sender=MultiResultTest) post_transition.connect(self.on_post_transition, sender=MultiResultTest) def tearDown(self): pre_transition.disconnect(self.on_pre_transition, sender=MultiResultTest) post_transition.disconnect(self.on_post_transition, sender=MultiResultTest) def on_pre_transition(self, sender, instance, name, source, target, **kwargs): assert instance.state == source self.pre_transition_called = True def on_post_transition(self, sender, instance, name, source, target, **kwargs): assert instance.state == target self.post_transition_called = True def test_signals_called_with_get_state(self): instance = MultiResultTest(state="for_moderators") instance.moderate(allowed=False) assert self.pre_transition_called assert self.post_transition_called def test_signals_called_with_return_value(self): instance = MultiResultTest() instance.publish(is_public=True) assert self.pre_transition_called assert self.post_transition_called django-commons-django-fsm-2-10256af/tests/testapp/tests/test_multidecorators.py000066400000000000000000000021011515347707100277560ustar00rootroot00000000000000from __future__ import annotations from django.db import models from django.test import TestCase from django_fsm import FSMField from django_fsm import transition from django_fsm.signals import post_transition class MultiDecoratedModel(models.Model): counter = models.IntegerField(default=0) signal_counter = models.IntegerField(default=0) state = FSMField(default="SUBMITTED_BY_USER") @transition(field=state, source="SUBMITTED_BY_USER", target="REVIEW_USER") @transition(field=state, source="SUBMITTED_BY_ADMIN", target="REVIEW_ADMIN") @transition(field=state, source="SUBMITTED_BY_ANONYMOUS", target="REVIEW_ANONYMOUS") def review(self): self.counter += 1 def count_calls(sender, instance, name, source, target, **kwargs): instance.signal_counter += 1 post_transition.connect(count_calls, sender=MultiDecoratedModel) class TestStateProxy(TestCase): def test_transition_method_called_once(self): model = MultiDecoratedModel() model.review() assert model.counter == 1 assert model.signal_counter == 1 django-commons-django-fsm-2-10256af/tests/testapp/tests/test_object_permissions.py000066400000000000000000000033061515347707100304470ustar00rootroot00000000000000from __future__ import annotations from django.contrib.auth.models import User from django.db import models from django.test import TestCase from django.test.utils import override_settings from guardian.shortcuts import assign_perm from django_fsm import FSMField from django_fsm import has_transition_perm from django_fsm import transition class ObjectPermissionTestModel(models.Model): state = FSMField(default="new") objects: models.Manager[ObjectPermissionTestModel] = models.Manager() class Meta: permissions = [ ("can_publish_objectpermissiontestmodel", "Can publish ObjectPermissionTestModel"), ] @transition( field=state, source="new", target="published", on_error="failed", permission="testapp.can_publish_objectpermissiontestmodel", ) def publish(self) -> None: pass @override_settings( AUTHENTICATION_BACKENDS=( "django.contrib.auth.backends.ModelBackend", "guardian.backends.ObjectPermissionBackend", ) ) class ObjectPermissionFSMFieldTest(TestCase): def setUp(self): super().setUp() self.model = ObjectPermissionTestModel.objects.create() self.unprivileged = User.objects.create(username="unprivileged") self.privileged = User.objects.create(username="object_only_privileged") assign_perm("can_publish_objectpermissiontestmodel", self.privileged, self.model) def test_object_only_access_success(self): assert has_transition_perm(self.model.publish, self.privileged) self.model.publish() def test_object_only_other_access_prohibited(self): assert not has_transition_perm(self.model.publish, self.unprivileged) django-commons-django-fsm-2-10256af/tests/testapp/tests/test_permissions.py000066400000000000000000000035111515347707100271170ustar00rootroot00000000000000from __future__ import annotations from django.contrib.auth.models import Permission from django.contrib.auth.models import User from django.test import TestCase from django_fsm import has_transition_perm from tests.testapp.models import BlogPost class PermissionFSMFieldTest(TestCase): def setUp(self): self.model = BlogPost() self.unprivileged = User.objects.create(username="unprivileged") self.privileged = User.objects.create(username="privileged") self.staff = User.objects.create(username="staff", is_staff=True) self.privileged.user_permissions.add( Permission.objects.get_by_natural_key("can_publish_post", "testapp", "blogpost") ) self.privileged.user_permissions.add( Permission.objects.get_by_natural_key("can_remove_post", "testapp", "blogpost") ) def test_privileged_access_succeed(self): assert has_transition_perm(self.model.publish, self.privileged) assert has_transition_perm(self.model.remove, self.privileged) transitions = self.model.get_available_user_state_transitions(self.privileged) # type: ignore[attr-defined] assert {"publish", "remove", "moderate"} == {transition.name for transition in transitions} def test_unprivileged_access_prohibited(self): assert not has_transition_perm(self.model.publish, self.unprivileged) assert not has_transition_perm(self.model.remove, self.unprivileged) transitions = self.model.get_available_user_state_transitions(self.unprivileged) # type: ignore[attr-defined] assert {"moderate"} == {transition.name for transition in transitions} def test_permission_instance_method(self): assert not has_transition_perm(self.model.restore, self.unprivileged) assert has_transition_perm(self.model.restore, self.staff) django-commons-django-fsm-2-10256af/tests/testapp/tests/test_protected_field.py000066400000000000000000000023551515347707100277050ustar00rootroot00000000000000from __future__ import annotations import pytest from django.db import models from django.test import TestCase from django_fsm import FSMField from django_fsm import transition class ProtectedAccessModel(models.Model): status = FSMField(default="new", protected=True) objects: models.Manager[ProtectedAccessModel] = models.Manager() @transition(field=status, source="new", target="published") def publish(self): pass class MultiProtectedAccessModel(models.Model): status1 = FSMField(default="new", protected=True) status2 = FSMField(default="new", protected=True) objects: models.Manager[MultiProtectedAccessModel] = models.Manager() class TestDirectAccessModels(TestCase): def test_multi_protected_field_create(self): obj = MultiProtectedAccessModel.objects.create() assert obj.status1 == "new" assert obj.status2 == "new" def test_no_direct_access(self): instance = ProtectedAccessModel() assert instance.status == "new" def try_change() -> None: instance.status = "change" with pytest.raises(AttributeError): try_change() instance.publish() instance.save() assert instance.status == "published" django-commons-django-fsm-2-10256af/tests/testapp/tests/test_protected_fields.py000066400000000000000000000033461515347707100300710ustar00rootroot00000000000000from __future__ import annotations import pytest from django.db import models from django.test import TestCase from django_fsm import FSMField from django_fsm import FSMModelMixin from django_fsm import transition class RefreshableProtectedAccessModel(models.Model): status = FSMField(default="new", protected=True) objects: models.Manager[RefreshableProtectedAccessModel] = models.Manager() @transition(field=status, source="new", target="published") def publish(self): pass class RefreshableModel(FSMModelMixin, RefreshableProtectedAccessModel): pass class TestDirectAccessModels(TestCase): def test_no_direct_access(self): instance = RefreshableProtectedAccessModel() assert instance.status == "new" with pytest.raises(AttributeError): instance.status = "change" instance.publish() instance.save() assert instance.status == "published" def test_refresh_from_db(self): instance = RefreshableModel() assert instance.status == "new" instance.save() instance.refresh_from_db() assert instance.status == "new" def test_concurrent_refresh_from_db(self): instance = RefreshableModel() assert instance.status == "new" instance.save() # NOTE: This simulates a concurrent update scenario concurrent_instance = RefreshableModel.objects.get(pk=instance.pk) assert concurrent_instance.status == instance.status == "new" concurrent_instance.publish() assert concurrent_instance.status == "published" concurrent_instance.save() assert instance.status == "new" instance.refresh_from_db() assert instance.status == "published" django-commons-django-fsm-2-10256af/tests/testapp/tests/test_proxy_inheritance.py000066400000000000000000000033321515347707100302770ustar00rootroot00000000000000from __future__ import annotations from django.db import models from django.test import TestCase from django_fsm import FSMField from django_fsm import can_proceed from django_fsm import transition class BaseModel(models.Model): state = FSMField(default="new") @transition(field=state, source="new", target="published") def publish(self): pass class InheritedModel(BaseModel): class Meta: proxy = True @transition(field="state", source="published", target="sticked") def stick(self): pass class TestinheritedModel(TestCase): def setUp(self): self.model = InheritedModel() def test_known_transition_should_succeed(self): assert can_proceed(self.model.publish) self.model.publish() assert self.model.state == "published" assert can_proceed(self.model.stick) self.model.stick() assert self.model.state == "sticked" def test_field_available_transitions_works(self): self.model.publish() assert self.model.state == "published" transitions = self.model.get_available_state_transitions() # type: ignore[attr-defined] assert [data.target for data in transitions] == ["sticked"] def test_field_all_transitions_base_model(self): transitions = BaseModel().get_all_state_transitions() # type: ignore[attr-defined] assert {("new", "published")} == {(data.source, data.target) for data in transitions} def test_field_all_transitions_works(self): transitions = self.model.get_all_state_transitions() # type: ignore[attr-defined] assert {("new", "published"), ("published", "sticked")} == { (data.source, data.target) for data in transitions } django-commons-django-fsm-2-10256af/tests/testapp/tests/test_state_transitions.py000066400000000000000000000031741515347707100303260ustar00rootroot00000000000000from __future__ import annotations from django.db import models from django.test import TestCase from django_fsm import FSMField from django_fsm import transition class Insect(models.Model): class STATE: CATERPILLAR = "CTR" BUTTERFLY = "BTF" STATE_CHOICES = ( (STATE.CATERPILLAR, "Caterpillar", "Caterpillar"), (STATE.BUTTERFLY, "Butterfly", "Butterfly"), ) state = FSMField(default=STATE.CATERPILLAR, state_choices=STATE_CHOICES) objects: models.Manager[Insect] = models.Manager() @transition(field=state, source=STATE.CATERPILLAR, target=STATE.BUTTERFLY) def cocoon(self): pass def fly(self): raise NotImplementedError def crawl(self): raise NotImplementedError class Caterpillar(Insect): class Meta: proxy = True def crawl(self): """ Do crawl """ class Butterfly(Insect): class Meta: proxy = True def fly(self): """ Do fly """ class TestStateProxy(TestCase): def test_initial_proxy_set_succeed(self): insect = Insect() assert isinstance(insect, Caterpillar) def test_transition_proxy_set_succeed(self): insect = Insect() insect.cocoon() assert isinstance(insect, Butterfly) def test_load_proxy_set(self): Insect.objects.bulk_create( [ Insect(state=Insect.STATE.CATERPILLAR), Insect(state=Insect.STATE.BUTTERFLY), ] ) insects = Insect.objects.all() assert {Caterpillar, Butterfly} == {insect.__class__ for insect in insects} django-commons-django-fsm-2-10256af/tests/testapp/tests/test_string_field_parameter.py000066400000000000000000000015041515347707100312550ustar00rootroot00000000000000from __future__ import annotations from django.db import models from django.test import TestCase from django_fsm import FSMField from django_fsm import transition class BlogPostWithStringField(models.Model): state = FSMField(default="new") @transition(field="state", source="new", target="published", conditions=[]) def publish(self): pass @transition(field="state", source="published", target="destroyed") def destroy(self): pass @transition(field="state", source="published", target="review") def review(self): pass class StringFieldTestCase(TestCase): def setUp(self): self.model = BlogPostWithStringField() def test_initial_state(self): assert self.model.state == "new" self.model.publish() assert self.model.state == "published" django-commons-django-fsm-2-10256af/tests/testapp/tests/test_transition_all_except_target.py000066400000000000000000000014761515347707100325140ustar00rootroot00000000000000from __future__ import annotations from django.db import models from django.test import TestCase from django_fsm import FSMField from django_fsm import can_proceed from django_fsm import transition class ExceptTargetTransition(models.Model): state = FSMField(default="new") @transition(field=state, source="new", target="published") def publish(self): pass @transition(field=state, source="+", target="removed") def remove(self): pass class TestExceptTargetTransition(TestCase): def setUp(self): self.model = ExceptTargetTransition() def test_usecase(self): assert self.model.state == "new" assert can_proceed(self.model.remove) self.model.remove() assert self.model.state == "removed" assert not can_proceed(self.model.remove) django-commons-django-fsm-2-10256af/tests/testapp/tests/test_transition_hash_eq.py000066400000000000000000000030751515347707100304330ustar00rootroot00000000000000from __future__ import annotations from django.db import models from django_fsm import Transition def test_transition_eq_matches_name_and_transition() -> None: def publish() -> None: pass transition = Transition( method=publish, source="new", target="published", on_error=None, conditions=[], permission=None, custom={}, ) def other() -> None: pass other.__name__ = "publish" other_transition = Transition( method=other, source="new", target="published", on_error=None, conditions=[], permission=None, custom={}, ) assert transition == "publish" assert transition != other_transition assert transition != "other" assert transition != object() def test_transition_same_name_different_models_not_equal() -> None: class First(models.Model): def publish(self) -> None: pass class Second(models.Model): def publish(self) -> None: pass first_transition = Transition( method=First.publish, source="new", target="published", on_error=None, conditions=[], permission=None, custom={}, ) second_transition = Transition( method=Second.publish, source="new", target="published", on_error=None, conditions=[], permission=None, custom={}, ) assert first_transition != second_transition assert hash(first_transition) != hash(second_transition) django-commons-django-fsm-2-10256af/tests/testapp/tests/test_typing.py000066400000000000000000000035421515347707100260620ustar00rootroot00000000000000from __future__ import annotations import os import subprocess import sys from pathlib import Path from django.test import TestCase class BaseAdminTestCase(TestCase): project_root: Path env: dict[str, str] def setUp(self) -> None: self.project_root = Path(__file__).resolve().parents[3] self.env = os.environ.copy() self.env.pop("DJANGO_SETTINGS_MODULE", None) python_path = self.env.get("PYTHONPATH") self.env["PYTHONPATH"] = ( f"{self.project_root}{os.pathsep}{python_path}" if python_path else str(self.project_root) ) def test_admin_module_imports_without_django_stubs_monkeypatch(self) -> None: result = subprocess.run( # noqa: S603 [ sys.executable, "-c", ( "from django.conf import settings; " "settings.configure(SECRET_KEY='test', USE_I18N=False); " "import django_fsm.admin" ), ], capture_output=True, check=False, cwd=self.project_root, env=self.env, text=True, ) assert result.returncode == 0, result.stderr def test_main_module_imports_without_django_stubs_monkeypatch(self) -> None: result = subprocess.run( # noqa: S603 [ sys.executable, "-c", ( "from django.conf import settings; " "settings.configure(SECRET_KEY='test', USE_I18N=False); " "import django_fsm" ), ], capture_output=True, check=False, cwd=self.project_root, env=self.env, text=True, ) assert result.returncode == 0, result.stderr django-commons-django-fsm-2-10256af/tests/urls.py000066400000000000000000000002501515347707100216450ustar00rootroot00000000000000from __future__ import annotations from django.contrib import admin from django.urls import path urlpatterns = [ path("admin/", admin.site.urls, name="admin"), ] django-commons-django-fsm-2-10256af/tests/wsgi.py000066400000000000000000000006461515347707100216420ustar00rootroot00000000000000"""WSGI config for silvr project. It exposes the WSGI callable as a module-level variable named ``application``. For more information on this file, see https://docs.djangoproject.com/en/3.0/howto/deployment/wsgi/ """ from __future__ import annotations import os from django.core.wsgi import get_wsgi_application os.environ.setdefault("DJANGO_SETTINGS_MODULE", "tests.settings") application = get_wsgi_application() django-commons-django-fsm-2-10256af/tox.ini000066400000000000000000000012221515347707100204570ustar00rootroot00000000000000[tox] envlist = py{310,311}-dj42 py{310,311,312}-dj50 py{310,311,312}-dj51 py{310,311,312,313,314}-dj52 py{312,313,314}-dj60 py{312,313,314}-djmain skipsdist = True [testenv] deps = dj42: Django==4.2 dj50: Django==5.0 dj51: Django==5.1 dj52: Django==5.2 dj60: Django==6.0 djmain: https://github.com/django/django/tarball/main django-fsm-log django-guardian django-stubs-ext graphviz pep8 pyflakes pytest pytest-django pytest-cov commands = {posargs:python -m pytest} [gh-actions] python = 3.10: py310 3.11: py311 3.12: py312 3.13: py313 3.14: py314 django-commons-django-fsm-2-10256af/uv.lock000066400000000000000000003462001515347707100204600ustar00rootroot00000000000000version = 1 revision = 3 requires-python = ">=3.10" resolution-markers = [ "python_full_version >= '3.12'", "python_full_version < '3.12'", ] [[package]] name = "asgiref" version = "3.11.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/63/40/f03da1264ae8f7cfdbf9146542e5e7e8100a4c66ab48e791df9a03d3f6c0/asgiref-3.11.1.tar.gz", hash = "sha256:5f184dc43b7e763efe848065441eac62229c9f7b0475f41f80e207a114eda4ce", size = 38550, upload-time = "2026-02-03T13:30:14.33Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/5c/0a/a72d10ed65068e115044937873362e6e32fab1b7dce0046aeb224682c989/asgiref-3.11.1-py3-none-any.whl", hash = "sha256:e8667a091e69529631969fd45dc268fa79b99c92c5fcdda727757e52146ec133", size = 24345, upload-time = "2026-02-03T13:30:13.039Z" }, ] [[package]] name = "cachetools" version = "7.0.1" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/d4/07/56595285564e90777d758ebd383d6b0b971b87729bbe2184a849932a3736/cachetools-7.0.1.tar.gz", hash = "sha256:e31e579d2c5b6e2944177a0397150d312888ddf4e16e12f1016068f0c03b8341", size = 36126, upload-time = "2026-02-10T22:24:05.03Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/ed/9e/5faefbf9db1db466d633735faceda1f94aa99ce506ac450d232536266b32/cachetools-7.0.1-py3-none-any.whl", hash = "sha256:8f086515c254d5664ae2146d14fc7f65c9a4bce75152eb247e5a9c5e6d7b2ecf", size = 13484, upload-time = "2026-02-10T22:24:03.741Z" }, ] [[package]] name = "colorama" version = "0.4.6" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, ] [[package]] name = "coverage" version = "7.13.4" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/24/56/95b7e30fa389756cb56630faa728da46a27b8c6eb46f9d557c68fff12b65/coverage-7.13.4.tar.gz", hash = "sha256:e5c8f6ed1e61a8b2dcdf31eb0b9bbf0130750ca79c1c49eb898e2ad86f5ccc91", size = 827239, upload-time = "2026-02-09T12:59:03.86Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/44/d4/7827d9ffa34d5d4d752eec907022aa417120936282fc488306f5da08c292/coverage-7.13.4-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0fc31c787a84f8cd6027eba44010517020e0d18487064cd3d8968941856d1415", size = 219152, upload-time = "2026-02-09T12:56:11.974Z" }, { url = "https://files.pythonhosted.org/packages/35/b0/d69df26607c64043292644dbb9dc54b0856fabaa2cbb1eeee3331cc9e280/coverage-7.13.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a32ebc02a1805adf637fc8dec324b5cdacd2e493515424f70ee33799573d661b", size = 219667, upload-time = "2026-02-09T12:56:13.33Z" }, { url = "https://files.pythonhosted.org/packages/82/a4/c1523f7c9e47b2271dbf8c2a097e7a1f89ef0d66f5840bb59b7e8814157b/coverage-7.13.4-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:e24f9156097ff9dc286f2f913df3a7f63c0e333dcafa3c196f2c18b4175ca09a", size = 246425, upload-time = "2026-02-09T12:56:14.552Z" }, { url = "https://files.pythonhosted.org/packages/f8/02/aa7ec01d1a5023c4b680ab7257f9bfde9defe8fdddfe40be096ac19e8177/coverage-7.13.4-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8041b6c5bfdc03257666e9881d33b1abc88daccaf73f7b6340fb7946655cd10f", size = 248229, upload-time = "2026-02-09T12:56:16.31Z" }, { url = "https://files.pythonhosted.org/packages/35/98/85aba0aed5126d896162087ef3f0e789a225697245256fc6181b95f47207/coverage-7.13.4-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2a09cfa6a5862bc2fc6ca7c3def5b2926194a56b8ab78ffcf617d28911123012", size = 250106, upload-time = "2026-02-09T12:56:18.024Z" }, { url = "https://files.pythonhosted.org/packages/96/72/1db59bd67494bc162e3e4cd5fbc7edba2c7026b22f7c8ef1496d58c2b94c/coverage-7.13.4-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:296f8b0af861d3970c2a4d8c91d48eb4dd4771bcef9baedec6a9b515d7de3def", size = 252021, upload-time = "2026-02-09T12:56:19.272Z" }, { url = "https://files.pythonhosted.org/packages/9d/97/72899c59c7066961de6e3daa142d459d47d104956db43e057e034f015c8a/coverage-7.13.4-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e101609bcbbfb04605ea1027b10dc3735c094d12d40826a60f897b98b1c30256", size = 247114, upload-time = "2026-02-09T12:56:21.051Z" }, { url = "https://files.pythonhosted.org/packages/39/1f/f1885573b5970235e908da4389176936c8933e86cb316b9620aab1585fa2/coverage-7.13.4-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:aa3feb8db2e87ff5e6d00d7e1480ae241876286691265657b500886c98f38bda", size = 248143, upload-time = "2026-02-09T12:56:22.585Z" }, { url = "https://files.pythonhosted.org/packages/a8/cf/e80390c5b7480b722fa3e994f8202807799b85bc562aa4f1dde209fbb7be/coverage-7.13.4-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:4fc7fa81bbaf5a02801b65346c8b3e657f1d93763e58c0abdf7c992addd81a92", size = 246152, upload-time = "2026-02-09T12:56:23.748Z" }, { url = "https://files.pythonhosted.org/packages/44/bf/f89a8350d85572f95412debb0fb9bb4795b1d5b5232bd652923c759e787b/coverage-7.13.4-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:33901f604424145c6e9c2398684b92e176c0b12df77d52db81c20abd48c3794c", size = 249959, upload-time = "2026-02-09T12:56:25.209Z" }, { url = "https://files.pythonhosted.org/packages/f7/6e/612a02aece8178c818df273e8d1642190c4875402ca2ba74514394b27aba/coverage-7.13.4-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:bb28c0f2cf2782508a40cec377935829d5fcc3ad9a3681375af4e84eb34b6b58", size = 246416, upload-time = "2026-02-09T12:56:26.475Z" }, { url = "https://files.pythonhosted.org/packages/cb/98/b5afc39af67c2fa6786b03c3a7091fc300947387ce8914b096db8a73d67a/coverage-7.13.4-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:9d107aff57a83222ddbd8d9ee705ede2af2cc926608b57abed8ef96b50b7e8f9", size = 247025, upload-time = "2026-02-09T12:56:27.727Z" }, { url = "https://files.pythonhosted.org/packages/51/30/2bba8ef0682d5bd210c38fe497e12a06c9f8d663f7025e9f5c2c31ce847d/coverage-7.13.4-cp310-cp310-win32.whl", hash = "sha256:a6f94a7d00eb18f1b6d403c91a88fd58cfc92d4b16080dfdb774afc8294469bf", size = 221758, upload-time = "2026-02-09T12:56:29.051Z" }, { url = "https://files.pythonhosted.org/packages/78/13/331f94934cf6c092b8ea59ff868eb587bc8fe0893f02c55bc6c0183a192e/coverage-7.13.4-cp310-cp310-win_amd64.whl", hash = "sha256:2cb0f1e000ebc419632bbe04366a8990b6e32c4e0b51543a6484ffe15eaeda95", size = 222693, upload-time = "2026-02-09T12:56:30.366Z" }, { url = "https://files.pythonhosted.org/packages/b4/ad/b59e5b451cf7172b8d1043dc0fa718f23aab379bc1521ee13d4bd9bfa960/coverage-7.13.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:d490ba50c3f35dd7c17953c68f3270e7ccd1c6642e2d2afe2d8e720b98f5a053", size = 219278, upload-time = "2026-02-09T12:56:31.673Z" }, { url = "https://files.pythonhosted.org/packages/f1/17/0cb7ca3de72e5f4ef2ec2fa0089beafbcaaaead1844e8b8a63d35173d77d/coverage-7.13.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:19bc3c88078789f8ef36acb014d7241961dbf883fd2533d18cb1e7a5b4e28b11", size = 219783, upload-time = "2026-02-09T12:56:33.104Z" }, { url = "https://files.pythonhosted.org/packages/ab/63/325d8e5b11e0eaf6d0f6a44fad444ae58820929a9b0de943fa377fe73e85/coverage-7.13.4-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3998e5a32e62fdf410c0dbd3115df86297995d6e3429af80b8798aad894ca7aa", size = 250200, upload-time = "2026-02-09T12:56:34.474Z" }, { url = "https://files.pythonhosted.org/packages/76/53/c16972708cbb79f2942922571a687c52bd109a7bd51175aeb7558dff2236/coverage-7.13.4-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8e264226ec98e01a8e1054314af91ee6cde0eacac4f465cc93b03dbe0bce2fd7", size = 252114, upload-time = "2026-02-09T12:56:35.749Z" }, { url = "https://files.pythonhosted.org/packages/eb/c2/7ab36d8b8cc412bec9ea2d07c83c48930eb4ba649634ba00cb7e4e0f9017/coverage-7.13.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a3aa4e7b9e416774b21797365b358a6e827ffadaaca81b69ee02946852449f00", size = 254220, upload-time = "2026-02-09T12:56:37.796Z" }, { url = "https://files.pythonhosted.org/packages/d6/4d/cf52c9a3322c89a0e6febdfbc83bb45c0ed3c64ad14081b9503adee702e7/coverage-7.13.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:71ca20079dd8f27fcf808817e281e90220475cd75115162218d0e27549f95fef", size = 256164, upload-time = "2026-02-09T12:56:39.016Z" }, { url = "https://files.pythonhosted.org/packages/78/e9/eb1dd17bd6de8289df3580e967e78294f352a5df8a57ff4671ee5fc3dcd0/coverage-7.13.4-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e2f25215f1a359ab17320b47bcdaca3e6e6356652e8256f2441e4ef972052903", size = 250325, upload-time = "2026-02-09T12:56:40.668Z" }, { url = "https://files.pythonhosted.org/packages/71/07/8c1542aa873728f72267c07278c5cc0ec91356daf974df21335ccdb46368/coverage-7.13.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d65b2d373032411e86960604dc4edac91fdfb5dca539461cf2cbe78327d1e64f", size = 251913, upload-time = "2026-02-09T12:56:41.97Z" }, { url = "https://files.pythonhosted.org/packages/74/d7/c62e2c5e4483a748e27868e4c32ad3daa9bdddbba58e1bc7a15e252baa74/coverage-7.13.4-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:94eb63f9b363180aff17de3e7c8760c3ba94664ea2695c52f10111244d16a299", size = 249974, upload-time = "2026-02-09T12:56:43.323Z" }, { url = "https://files.pythonhosted.org/packages/98/9f/4c5c015a6e98ced54efd0f5cf8d31b88e5504ecb6857585fc0161bb1e600/coverage-7.13.4-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:e856bf6616714c3a9fbc270ab54103f4e685ba236fa98c054e8f87f266c93505", size = 253741, upload-time = "2026-02-09T12:56:45.155Z" }, { url = "https://files.pythonhosted.org/packages/bd/59/0f4eef89b9f0fcd9633b5d350016f54126ab49426a70ff4c4e87446cabdc/coverage-7.13.4-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:65dfcbe305c3dfe658492df2d85259e0d79ead4177f9ae724b6fb245198f55d6", size = 249695, upload-time = "2026-02-09T12:56:46.636Z" }, { url = "https://files.pythonhosted.org/packages/b5/2c/b7476f938deb07166f3eb281a385c262675d688ff4659ad56c6c6b8e2e70/coverage-7.13.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b507778ae8a4c915436ed5c2e05b4a6cecfa70f734e19c22a005152a11c7b6a9", size = 250599, upload-time = "2026-02-09T12:56:48.13Z" }, { url = "https://files.pythonhosted.org/packages/b8/34/c3420709d9846ee3785b9f2831b4d94f276f38884032dca1457fa83f7476/coverage-7.13.4-cp311-cp311-win32.whl", hash = "sha256:784fc3cf8be001197b652d51d3fd259b1e2262888693a4636e18879f613a62a9", size = 221780, upload-time = "2026-02-09T12:56:50.479Z" }, { url = "https://files.pythonhosted.org/packages/61/08/3d9c8613079d2b11c185b865de9a4c1a68850cfda2b357fae365cf609f29/coverage-7.13.4-cp311-cp311-win_amd64.whl", hash = "sha256:2421d591f8ca05b308cf0092807308b2facbefe54af7c02ac22548b88b95c98f", size = 222715, upload-time = "2026-02-09T12:56:51.815Z" }, { url = "https://files.pythonhosted.org/packages/18/1a/54c3c80b2f056164cc0a6cdcb040733760c7c4be9d780fe655f356f433e4/coverage-7.13.4-cp311-cp311-win_arm64.whl", hash = "sha256:79e73a76b854d9c6088fe5d8b2ebe745f8681c55f7397c3c0a016192d681045f", size = 221385, upload-time = "2026-02-09T12:56:53.194Z" }, { url = "https://files.pythonhosted.org/packages/d1/81/4ce2fdd909c5a0ed1f6dedb88aa57ab79b6d1fbd9b588c1ac7ef45659566/coverage-7.13.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:02231499b08dabbe2b96612993e5fc34217cdae907a51b906ac7fca8027a4459", size = 219449, upload-time = "2026-02-09T12:56:54.889Z" }, { url = "https://files.pythonhosted.org/packages/5d/96/5238b1efc5922ddbdc9b0db9243152c09777804fb7c02ad1741eb18a11c0/coverage-7.13.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40aa8808140e55dc022b15d8aa7f651b6b3d68b365ea0398f1441e0b04d859c3", size = 219810, upload-time = "2026-02-09T12:56:56.33Z" }, { url = "https://files.pythonhosted.org/packages/78/72/2f372b726d433c9c35e56377cf1d513b4c16fe51841060d826b95caacec1/coverage-7.13.4-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5b856a8ccf749480024ff3bd7310adaef57bf31fd17e1bfc404b7940b6986634", size = 251308, upload-time = "2026-02-09T12:56:57.858Z" }, { url = "https://files.pythonhosted.org/packages/5d/a0/2ea570925524ef4e00bb6c82649f5682a77fac5ab910a65c9284de422600/coverage-7.13.4-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2c048ea43875fbf8b45d476ad79f179809c590ec7b79e2035c662e7afa3192e3", size = 254052, upload-time = "2026-02-09T12:56:59.754Z" }, { url = "https://files.pythonhosted.org/packages/e8/ac/45dc2e19a1939098d783c846e130b8f862fbb50d09e0af663988f2f21973/coverage-7.13.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b7b38448866e83176e28086674fe7368ab8590e4610fb662b44e345b86d63ffa", size = 255165, upload-time = "2026-02-09T12:57:01.287Z" }, { url = "https://files.pythonhosted.org/packages/2d/4d/26d236ff35abc3b5e63540d3386e4c3b192168c1d96da5cb2f43c640970f/coverage-7.13.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:de6defc1c9badbf8b9e67ae90fd00519186d6ab64e5cc5f3d21359c2a9b2c1d3", size = 257432, upload-time = "2026-02-09T12:57:02.637Z" }, { url = "https://files.pythonhosted.org/packages/ec/55/14a966c757d1348b2e19caf699415a2a4c4f7feaa4bbc6326a51f5c7dd1b/coverage-7.13.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7eda778067ad7ffccd23ecffce537dface96212576a07924cbf0d8799d2ded5a", size = 251716, upload-time = "2026-02-09T12:57:04.056Z" }, { url = "https://files.pythonhosted.org/packages/77/33/50116647905837c66d28b2af1321b845d5f5d19be9655cb84d4a0ea806b4/coverage-7.13.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e87f6c587c3f34356c3759f0420693e35e7eb0e2e41e4c011cb6ec6ecbbf1db7", size = 253089, upload-time = "2026-02-09T12:57:05.503Z" }, { url = "https://files.pythonhosted.org/packages/c2/b4/8efb11a46e3665d92635a56e4f2d4529de6d33f2cb38afd47d779d15fc99/coverage-7.13.4-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:8248977c2e33aecb2ced42fef99f2d319e9904a36e55a8a68b69207fb7e43edc", size = 251232, upload-time = "2026-02-09T12:57:06.879Z" }, { url = "https://files.pythonhosted.org/packages/51/24/8cd73dd399b812cc76bb0ac260e671c4163093441847ffe058ac9fda1e32/coverage-7.13.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:25381386e80ae727608e662474db537d4df1ecd42379b5ba33c84633a2b36d47", size = 255299, upload-time = "2026-02-09T12:57:08.245Z" }, { url = "https://files.pythonhosted.org/packages/03/94/0a4b12f1d0e029ce1ccc1c800944a9984cbe7d678e470bb6d3c6bc38a0da/coverage-7.13.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:ee756f00726693e5ba94d6df2bdfd64d4852d23b09bb0bc700e3b30e6f333985", size = 250796, upload-time = "2026-02-09T12:57:10.142Z" }, { url = "https://files.pythonhosted.org/packages/73/44/6002fbf88f6698ca034360ce474c406be6d5a985b3fdb3401128031eef6b/coverage-7.13.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fdfc1e28e7c7cdce44985b3043bc13bbd9c747520f94a4d7164af8260b3d91f0", size = 252673, upload-time = "2026-02-09T12:57:12.197Z" }, { url = "https://files.pythonhosted.org/packages/de/c6/a0279f7c00e786be75a749a5674e6fa267bcbd8209cd10c9a450c655dfa7/coverage-7.13.4-cp312-cp312-win32.whl", hash = "sha256:01d4cbc3c283a17fc1e42d614a119f7f438eabb593391283adca8dc86eff1246", size = 221990, upload-time = "2026-02-09T12:57:14.085Z" }, { url = "https://files.pythonhosted.org/packages/77/4e/c0a25a425fcf5557d9abd18419c95b63922e897bc86c1f327f155ef234a9/coverage-7.13.4-cp312-cp312-win_amd64.whl", hash = "sha256:9401ebc7ef522f01d01d45532c68c5ac40fb27113019b6b7d8b208f6e9baa126", size = 222800, upload-time = "2026-02-09T12:57:15.944Z" }, { url = "https://files.pythonhosted.org/packages/47/ac/92da44ad9a6f4e3a7debd178949d6f3769bedca33830ce9b1dcdab589a37/coverage-7.13.4-cp312-cp312-win_arm64.whl", hash = "sha256:b1ec7b6b6e93255f952e27ab58fbc68dcc468844b16ecbee881aeb29b6ab4d8d", size = 221415, upload-time = "2026-02-09T12:57:17.497Z" }, { url = "https://files.pythonhosted.org/packages/db/23/aad45061a31677d68e47499197a131eea55da4875d16c1f42021ab963503/coverage-7.13.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b66a2da594b6068b48b2692f043f35d4d3693fb639d5ea8b39533c2ad9ac3ab9", size = 219474, upload-time = "2026-02-09T12:57:19.332Z" }, { url = "https://files.pythonhosted.org/packages/a5/70/9b8b67a0945f3dfec1fd896c5cefb7c19d5a3a6d74630b99a895170999ae/coverage-7.13.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:3599eb3992d814d23b35c536c28df1a882caa950f8f507cef23d1cbf334995ac", size = 219844, upload-time = "2026-02-09T12:57:20.66Z" }, { url = "https://files.pythonhosted.org/packages/97/fd/7e859f8fab324cef6c4ad7cff156ca7c489fef9179d5749b0c8d321281c2/coverage-7.13.4-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:93550784d9281e374fb5a12bf1324cc8a963fd63b2d2f223503ef0fd4aa339ea", size = 250832, upload-time = "2026-02-09T12:57:22.007Z" }, { url = "https://files.pythonhosted.org/packages/e4/dc/b2442d10020c2f52617828862d8b6ee337859cd8f3a1f13d607dddda9cf7/coverage-7.13.4-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b720ce6a88a2755f7c697c23268ddc47a571b88052e6b155224347389fdf6a3b", size = 253434, upload-time = "2026-02-09T12:57:23.339Z" }, { url = "https://files.pythonhosted.org/packages/5a/88/6728a7ad17428b18d836540630487231f5470fb82454871149502f5e5aa2/coverage-7.13.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7b322db1284a2ed3aa28ffd8ebe3db91c929b7a333c0820abec3d838ef5b3525", size = 254676, upload-time = "2026-02-09T12:57:24.774Z" }, { url = "https://files.pythonhosted.org/packages/7c/bc/21244b1b8cedf0dff0a2b53b208015fe798d5f2a8d5348dbfece04224fff/coverage-7.13.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f4594c67d8a7c89cf922d9df0438c7c7bb022ad506eddb0fdb2863359ff78242", size = 256807, upload-time = "2026-02-09T12:57:26.125Z" }, { url = "https://files.pythonhosted.org/packages/97/a0/ddba7ed3251cff51006737a727d84e05b61517d1784a9988a846ba508877/coverage-7.13.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:53d133df809c743eb8bce33b24bcababb371f4441340578cd406e084d94a6148", size = 251058, upload-time = "2026-02-09T12:57:27.614Z" }, { url = "https://files.pythonhosted.org/packages/9b/55/e289addf7ff54d3a540526f33751951bf0878f3809b47f6dfb3def69c6f7/coverage-7.13.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:76451d1978b95ba6507a039090ba076105c87cc76fc3efd5d35d72093964d49a", size = 252805, upload-time = "2026-02-09T12:57:29.066Z" }, { url = "https://files.pythonhosted.org/packages/13/4e/cc276b1fa4a59be56d96f1dabddbdc30f4ba22e3b1cd42504c37b3313255/coverage-7.13.4-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:7f57b33491e281e962021de110b451ab8a24182589be17e12a22c79047935e23", size = 250766, upload-time = "2026-02-09T12:57:30.522Z" }, { url = "https://files.pythonhosted.org/packages/94/44/1093b8f93018f8b41a8cf29636c9292502f05e4a113d4d107d14a3acd044/coverage-7.13.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:1731dc33dc276dafc410a885cbf5992f1ff171393e48a21453b78727d090de80", size = 254923, upload-time = "2026-02-09T12:57:31.946Z" }, { url = "https://files.pythonhosted.org/packages/8b/55/ea2796da2d42257f37dbea1aab239ba9263b31bd91d5527cdd6db5efe174/coverage-7.13.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:bd60d4fe2f6fa7dff9223ca1bbc9f05d2b6697bc5961072e5d3b952d46e1b1ea", size = 250591, upload-time = "2026-02-09T12:57:33.842Z" }, { url = "https://files.pythonhosted.org/packages/d4/fa/7c4bb72aacf8af5020675aa633e59c1fbe296d22aed191b6a5b711eb2bc7/coverage-7.13.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9181a3ccead280b828fae232df12b16652702b49d41e99d657f46cc7b1f6ec7a", size = 252364, upload-time = "2026-02-09T12:57:35.743Z" }, { url = "https://files.pythonhosted.org/packages/5c/38/a8d2ec0146479c20bbaa7181b5b455a0c41101eed57f10dd19a78ab44c80/coverage-7.13.4-cp313-cp313-win32.whl", hash = "sha256:f53d492307962561ac7de4cd1de3e363589b000ab69617c6156a16ba7237998d", size = 222010, upload-time = "2026-02-09T12:57:37.25Z" }, { url = "https://files.pythonhosted.org/packages/e2/0c/dbfafbe90a185943dcfbc766fe0e1909f658811492d79b741523a414a6cc/coverage-7.13.4-cp313-cp313-win_amd64.whl", hash = "sha256:e6f70dec1cc557e52df5306d051ef56003f74d56e9c4dd7ddb07e07ef32a84dd", size = 222818, upload-time = "2026-02-09T12:57:38.734Z" }, { url = "https://files.pythonhosted.org/packages/04/d1/934918a138c932c90d78301f45f677fb05c39a3112b96fd2c8e60503cdc7/coverage-7.13.4-cp313-cp313-win_arm64.whl", hash = "sha256:fb07dc5da7e849e2ad31a5d74e9bece81f30ecf5a42909d0a695f8bd1874d6af", size = 221438, upload-time = "2026-02-09T12:57:40.223Z" }, { url = "https://files.pythonhosted.org/packages/52/57/ee93ced533bcb3e6df961c0c6e42da2fc6addae53fb95b94a89b1e33ebd7/coverage-7.13.4-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:40d74da8e6c4b9ac18b15331c4b5ebc35a17069410cad462ad4f40dcd2d50c0d", size = 220165, upload-time = "2026-02-09T12:57:41.639Z" }, { url = "https://files.pythonhosted.org/packages/c5/e0/969fc285a6fbdda49d91af278488d904dcd7651b2693872f0ff94e40e84a/coverage-7.13.4-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4223b4230a376138939a9173f1bdd6521994f2aff8047fae100d6d94d50c5a12", size = 220516, upload-time = "2026-02-09T12:57:44.215Z" }, { url = "https://files.pythonhosted.org/packages/b1/b8/9531944e16267e2735a30a9641ff49671f07e8138ecf1ca13db9fd2560c7/coverage-7.13.4-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:1d4be36a5114c499f9f1f9195e95ebf979460dbe2d88e6816ea202010ba1c34b", size = 261804, upload-time = "2026-02-09T12:57:45.989Z" }, { url = "https://files.pythonhosted.org/packages/8a/f3/e63df6d500314a2a60390d1989240d5f27318a7a68fa30ad3806e2a9323e/coverage-7.13.4-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:200dea7d1e8095cc6e98cdabe3fd1d21ab17d3cee6dab00cadbb2fe35d9c15b9", size = 263885, upload-time = "2026-02-09T12:57:47.42Z" }, { url = "https://files.pythonhosted.org/packages/f3/67/7654810de580e14b37670b60a09c599fa348e48312db5b216d730857ffe6/coverage-7.13.4-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b8eb931ee8e6d8243e253e5ed7336deea6904369d2fd8ae6e43f68abbf167092", size = 266308, upload-time = "2026-02-09T12:57:49.345Z" }, { url = "https://files.pythonhosted.org/packages/37/6f/39d41eca0eab3cc82115953ad41c4e77935286c930e8fad15eaed1389d83/coverage-7.13.4-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:75eab1ebe4f2f64d9509b984f9314d4aa788540368218b858dad56dc8f3e5eb9", size = 267452, upload-time = "2026-02-09T12:57:50.811Z" }, { url = "https://files.pythonhosted.org/packages/50/6d/39c0fbb8fc5cd4d2090811e553c2108cf5112e882f82505ee7495349a6bf/coverage-7.13.4-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c35eb28c1d085eb7d8c9b3296567a1bebe03ce72962e932431b9a61f28facf26", size = 261057, upload-time = "2026-02-09T12:57:52.447Z" }, { url = "https://files.pythonhosted.org/packages/a4/a2/60010c669df5fa603bb5a97fb75407e191a846510da70ac657eb696b7fce/coverage-7.13.4-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:eb88b316ec33760714a4720feb2816a3a59180fd58c1985012054fa7aebee4c2", size = 263875, upload-time = "2026-02-09T12:57:53.938Z" }, { url = "https://files.pythonhosted.org/packages/3e/d9/63b22a6bdbd17f1f96e9ed58604c2a6b0e72a9133e37d663bef185877cf6/coverage-7.13.4-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:7d41eead3cc673cbd38a4417deb7fd0b4ca26954ff7dc6078e33f6ff97bed940", size = 261500, upload-time = "2026-02-09T12:57:56.012Z" }, { url = "https://files.pythonhosted.org/packages/70/bf/69f86ba1ad85bc3ad240e4c0e57a2e620fbc0e1645a47b5c62f0e941ad7f/coverage-7.13.4-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:fb26a934946a6afe0e326aebe0730cdff393a8bc0bbb65a2f41e30feddca399c", size = 265212, upload-time = "2026-02-09T12:57:57.5Z" }, { url = "https://files.pythonhosted.org/packages/ae/f2/5f65a278a8c2148731831574c73e42f57204243d33bedaaf18fa79c5958f/coverage-7.13.4-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:dae88bc0fc77edaa65c14be099bd57ee140cf507e6bfdeea7938457ab387efb0", size = 260398, upload-time = "2026-02-09T12:57:59.027Z" }, { url = "https://files.pythonhosted.org/packages/ef/80/6e8280a350ee9fea92f14b8357448a242dcaa243cb2c72ab0ca591f66c8c/coverage-7.13.4-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:845f352911777a8e722bfce168958214951e07e47e5d5d9744109fa5fe77f79b", size = 262584, upload-time = "2026-02-09T12:58:01.129Z" }, { url = "https://files.pythonhosted.org/packages/22/63/01ff182fc95f260b539590fb12c11ad3e21332c15f9799cb5e2386f71d9f/coverage-7.13.4-cp313-cp313t-win32.whl", hash = "sha256:2fa8d5f8de70688a28240de9e139fa16b153cc3cbb01c5f16d88d6505ebdadf9", size = 222688, upload-time = "2026-02-09T12:58:02.736Z" }, { url = "https://files.pythonhosted.org/packages/a9/43/89de4ef5d3cd53b886afa114065f7e9d3707bdb3e5efae13535b46ae483d/coverage-7.13.4-cp313-cp313t-win_amd64.whl", hash = "sha256:9351229c8c8407645840edcc277f4a2d44814d1bc34a2128c11c2a031d45a5dd", size = 223746, upload-time = "2026-02-09T12:58:05.362Z" }, { url = "https://files.pythonhosted.org/packages/35/39/7cf0aa9a10d470a5309b38b289b9bb07ddeac5d61af9b664fe9775a4cb3e/coverage-7.13.4-cp313-cp313t-win_arm64.whl", hash = "sha256:30b8d0512f2dc8c8747557e8fb459d6176a2c9e5731e2b74d311c03b78451997", size = 222003, upload-time = "2026-02-09T12:58:06.952Z" }, { url = "https://files.pythonhosted.org/packages/92/11/a9cf762bb83386467737d32187756a42094927150c3e107df4cb078e8590/coverage-7.13.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:300deaee342f90696ed186e3a00c71b5b3d27bffe9e827677954f4ee56969601", size = 219522, upload-time = "2026-02-09T12:58:08.623Z" }, { url = "https://files.pythonhosted.org/packages/d3/28/56e6d892b7b052236d67c95f1936b6a7cf7c3e2634bf27610b8cbd7f9c60/coverage-7.13.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:29e3220258d682b6226a9b0925bc563ed9a1ebcff3cad30f043eceea7eaf2689", size = 219855, upload-time = "2026-02-09T12:58:10.176Z" }, { url = "https://files.pythonhosted.org/packages/e5/69/233459ee9eb0c0d10fcc2fe425a029b3fa5ce0f040c966ebce851d030c70/coverage-7.13.4-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:391ee8f19bef69210978363ca930f7328081c6a0152f1166c91f0b5fdd2a773c", size = 250887, upload-time = "2026-02-09T12:58:12.503Z" }, { url = "https://files.pythonhosted.org/packages/06/90/2cdab0974b9b5bbc1623f7876b73603aecac11b8d95b85b5b86b32de5eab/coverage-7.13.4-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0dd7ab8278f0d58a0128ba2fca25824321f05d059c1441800e934ff2efa52129", size = 253396, upload-time = "2026-02-09T12:58:14.615Z" }, { url = "https://files.pythonhosted.org/packages/ac/15/ea4da0f85bf7d7b27635039e649e99deb8173fe551096ea15017f7053537/coverage-7.13.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:78cdf0d578b15148b009ccf18c686aa4f719d887e76e6b40c38ffb61d264a552", size = 254745, upload-time = "2026-02-09T12:58:16.162Z" }, { url = "https://files.pythonhosted.org/packages/99/11/bb356e86920c655ca4d61daee4e2bbc7258f0a37de0be32d233b561134ff/coverage-7.13.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:48685fee12c2eb3b27c62f2658e7ea21e9c3239cba5a8a242801a0a3f6a8c62a", size = 257055, upload-time = "2026-02-09T12:58:17.892Z" }, { url = "https://files.pythonhosted.org/packages/c9/0f/9ae1f8cb17029e09da06ca4e28c9e1d5c1c0a511c7074592e37e0836c915/coverage-7.13.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4e83efc079eb39480e6346a15a1bcb3e9b04759c5202d157e1dd4303cd619356", size = 250911, upload-time = "2026-02-09T12:58:19.495Z" }, { url = "https://files.pythonhosted.org/packages/89/3a/adfb68558fa815cbc29747b553bc833d2150228f251b127f1ce97e48547c/coverage-7.13.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ecae9737b72408d6a950f7e525f30aca12d4bd8dd95e37342e5beb3a2a8c4f71", size = 252754, upload-time = "2026-02-09T12:58:21.064Z" }, { url = "https://files.pythonhosted.org/packages/32/b1/540d0c27c4e748bd3cd0bd001076ee416eda993c2bae47a73b7cc9357931/coverage-7.13.4-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ae4578f8528569d3cf303fef2ea569c7f4c4059a38c8667ccef15c6e1f118aa5", size = 250720, upload-time = "2026-02-09T12:58:22.622Z" }, { url = "https://files.pythonhosted.org/packages/c7/95/383609462b3ffb1fe133014a7c84fc0dd01ed55ac6140fa1093b5af7ebb1/coverage-7.13.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:6fdef321fdfbb30a197efa02d48fcd9981f0d8ad2ae8903ac318adc653f5df98", size = 254994, upload-time = "2026-02-09T12:58:24.548Z" }, { url = "https://files.pythonhosted.org/packages/f7/ba/1761138e86c81680bfc3c49579d66312865457f9fe405b033184e5793cb3/coverage-7.13.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b0f6ccf3dbe577170bebfce1318707d0e8c3650003cb4b3a9dd744575daa8b5", size = 250531, upload-time = "2026-02-09T12:58:26.271Z" }, { url = "https://files.pythonhosted.org/packages/f8/8e/05900df797a9c11837ab59c4d6fe94094e029582aab75c3309a93e6fb4e3/coverage-7.13.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:75fcd519f2a5765db3f0e391eb3b7d150cce1a771bf4c9f861aeab86c767a3c0", size = 252189, upload-time = "2026-02-09T12:58:27.807Z" }, { url = "https://files.pythonhosted.org/packages/00/bd/29c9f2db9ea4ed2738b8a9508c35626eb205d51af4ab7bf56a21a2e49926/coverage-7.13.4-cp314-cp314-win32.whl", hash = "sha256:8e798c266c378da2bd819b0677df41ab46d78065fb2a399558f3f6cae78b2fbb", size = 222258, upload-time = "2026-02-09T12:58:29.441Z" }, { url = "https://files.pythonhosted.org/packages/a7/4d/1f8e723f6829977410efeb88f73673d794075091c8c7c18848d273dc9d73/coverage-7.13.4-cp314-cp314-win_amd64.whl", hash = "sha256:245e37f664d89861cf2329c9afa2c1fe9e6d4e1a09d872c947e70718aeeac505", size = 223073, upload-time = "2026-02-09T12:58:31.026Z" }, { url = "https://files.pythonhosted.org/packages/51/5b/84100025be913b44e082ea32abcf1afbf4e872f5120b7a1cab1d331b1e13/coverage-7.13.4-cp314-cp314-win_arm64.whl", hash = "sha256:ad27098a189e5838900ce4c2a99f2fe42a0bf0c2093c17c69b45a71579e8d4a2", size = 221638, upload-time = "2026-02-09T12:58:32.599Z" }, { url = "https://files.pythonhosted.org/packages/a7/e4/c884a405d6ead1370433dad1e3720216b4f9fd8ef5b64bfd984a2a60a11a/coverage-7.13.4-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:85480adfb35ffc32d40918aad81b89c69c9cc5661a9b8a81476d3e645321a056", size = 220246, upload-time = "2026-02-09T12:58:34.181Z" }, { url = "https://files.pythonhosted.org/packages/81/5c/4d7ed8b23b233b0fffbc9dfec53c232be2e695468523242ea9fd30f97ad2/coverage-7.13.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:79be69cf7f3bf9b0deeeb062eab7ac7f36cd4cc4c4dd694bd28921ba4d8596cc", size = 220514, upload-time = "2026-02-09T12:58:35.704Z" }, { url = "https://files.pythonhosted.org/packages/2f/6f/3284d4203fd2f28edd73034968398cd2d4cb04ab192abc8cff007ea35679/coverage-7.13.4-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:caa421e2684e382c5d8973ac55e4f36bed6821a9bad5c953494de960c74595c9", size = 261877, upload-time = "2026-02-09T12:58:37.864Z" }, { url = "https://files.pythonhosted.org/packages/09/aa/b672a647bbe1556a85337dc95bfd40d146e9965ead9cc2fe81bde1e5cbce/coverage-7.13.4-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:14375934243ee05f56c45393fe2ce81fe5cc503c07cee2bdf1725fb8bef3ffaf", size = 264004, upload-time = "2026-02-09T12:58:39.492Z" }, { url = "https://files.pythonhosted.org/packages/79/a1/aa384dbe9181f98bba87dd23dda436f0c6cf2e148aecbb4e50fc51c1a656/coverage-7.13.4-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:25a41c3104d08edb094d9db0d905ca54d0cd41c928bb6be3c4c799a54753af55", size = 266408, upload-time = "2026-02-09T12:58:41.852Z" }, { url = "https://files.pythonhosted.org/packages/53/5e/5150bf17b4019bc600799f376bb9606941e55bd5a775dc1e096b6ffea952/coverage-7.13.4-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6f01afcff62bf9a08fb32b2c1d6e924236c0383c02c790732b6537269e466a72", size = 267544, upload-time = "2026-02-09T12:58:44.093Z" }, { url = "https://files.pythonhosted.org/packages/e0/ed/f1de5c675987a4a7a672250d2c5c9d73d289dbf13410f00ed7181d8017dd/coverage-7.13.4-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:eb9078108fbf0bcdde37c3f4779303673c2fa1fe8f7956e68d447d0dd426d38a", size = 260980, upload-time = "2026-02-09T12:58:45.721Z" }, { url = "https://files.pythonhosted.org/packages/b3/e3/fe758d01850aa172419a6743fe76ba8b92c29d181d4f676ffe2dae2ba631/coverage-7.13.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0e086334e8537ddd17e5f16a344777c1ab8194986ec533711cbe6c41cde841b6", size = 263871, upload-time = "2026-02-09T12:58:47.334Z" }, { url = "https://files.pythonhosted.org/packages/b6/76/b829869d464115e22499541def9796b25312b8cf235d3bb00b39f1675395/coverage-7.13.4-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:725d985c5ab621268b2edb8e50dfe57633dc69bda071abc470fed55a14935fd3", size = 261472, upload-time = "2026-02-09T12:58:48.995Z" }, { url = "https://files.pythonhosted.org/packages/14/9e/caedb1679e73e2f6ad240173f55218488bfe043e38da577c4ec977489915/coverage-7.13.4-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:3c06f0f1337c667b971ca2f975523347e63ec5e500b9aa5882d91931cd3ef750", size = 265210, upload-time = "2026-02-09T12:58:51.178Z" }, { url = "https://files.pythonhosted.org/packages/3a/10/0dd02cb009b16ede425b49ec344aba13a6ae1dc39600840ea6abcb085ac4/coverage-7.13.4-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:590c0ed4bf8e85f745e6b805b2e1c457b2e33d5255dd9729743165253bc9ad39", size = 260319, upload-time = "2026-02-09T12:58:53.081Z" }, { url = "https://files.pythonhosted.org/packages/92/8e/234d2c927af27c6d7a5ffad5bd2cf31634c46a477b4c7adfbfa66baf7ebb/coverage-7.13.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:eb30bf180de3f632cd043322dad5751390e5385108b2807368997d1a92a509d0", size = 262638, upload-time = "2026-02-09T12:58:55.258Z" }, { url = "https://files.pythonhosted.org/packages/2f/64/e5547c8ff6964e5965c35a480855911b61509cce544f4d442caa759a0702/coverage-7.13.4-cp314-cp314t-win32.whl", hash = "sha256:c4240e7eded42d131a2d2c4dec70374b781b043ddc79a9de4d55ca71f8e98aea", size = 223040, upload-time = "2026-02-09T12:58:56.936Z" }, { url = "https://files.pythonhosted.org/packages/c7/96/38086d58a181aac86d503dfa9c47eb20715a79c3e3acbdf786e92e5c09a8/coverage-7.13.4-cp314-cp314t-win_amd64.whl", hash = "sha256:4c7d3cc01e7350f2f0f6f7036caaf5673fb56b6998889ccfe9e1c1fe75a9c932", size = 224148, upload-time = "2026-02-09T12:58:58.645Z" }, { url = "https://files.pythonhosted.org/packages/ce/72/8d10abd3740a0beb98c305e0c3faf454366221c0f37a8bcf8f60020bb65a/coverage-7.13.4-cp314-cp314t-win_arm64.whl", hash = "sha256:23e3f687cf945070d1c90f85db66d11e3025665d8dafa831301a0e0038f3db9b", size = 222172, upload-time = "2026-02-09T12:59:00.396Z" }, { url = "https://files.pythonhosted.org/packages/0d/4a/331fe2caf6799d591109bb9c08083080f6de90a823695d412a935622abb2/coverage-7.13.4-py3-none-any.whl", hash = "sha256:1af1641e57cf7ba1bd67d677c9abdbcd6cc2ab7da3bca7fa1e2b7e50e65f2ad0", size = 211242, upload-time = "2026-02-09T12:59:02.032Z" }, ] [package.optional-dependencies] toml = [ { name = "tomli", marker = "python_full_version <= '3.11'" }, ] [[package]] name = "distlib" version = "0.4.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/96/8e/709914eb2b5749865801041647dc7f4e6d00b549cfe88b65ca192995f07c/distlib-0.4.0.tar.gz", hash = "sha256:feec40075be03a04501a973d81f633735b4b69f98b05450592310c0f401a4e0d", size = 614605, upload-time = "2025-07-17T16:52:00.465Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/33/6b/e0547afaf41bf2c42e52430072fa5658766e3d65bd4b03a563d1b6336f57/distlib-0.4.0-py2.py3-none-any.whl", hash = "sha256:9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16", size = 469047, upload-time = "2025-07-17T16:51:58.613Z" }, ] [[package]] name = "django" version = "5.2.11" source = { registry = "https://pypi.org/simple" } resolution-markers = [ "python_full_version < '3.12'", ] dependencies = [ { name = "asgiref", marker = "python_full_version < '3.12'" }, { name = "sqlparse", marker = "python_full_version < '3.12'" }, { name = "tzdata", marker = "python_full_version < '3.12' and sys_platform == 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/17/f2/3e57ef696b95067e05ae206171e47a8e53b9c84eec56198671ef9eaa51a6/django-5.2.11.tar.gz", hash = "sha256:7f2d292ad8b9ee35e405d965fbbad293758b858c34bbf7f3df551aeeac6f02d3", size = 10885017, upload-time = "2026-02-03T13:52:50.554Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/91/a7/2b112ab430575bf3135b8304ac372248500d99c352f777485f53fdb9537e/django-5.2.11-py3-none-any.whl", hash = "sha256:e7130df33ada9ab5e5e929bc19346a20fe383f5454acb2cc004508f242ee92c0", size = 8291375, upload-time = "2026-02-03T13:52:42.47Z" }, ] [[package]] name = "django" version = "6.0.2" source = { registry = "https://pypi.org/simple" } resolution-markers = [ "python_full_version >= '3.12'", ] dependencies = [ { name = "asgiref", marker = "python_full_version >= '3.12'" }, { name = "sqlparse", marker = "python_full_version >= '3.12'" }, { name = "tzdata", marker = "python_full_version >= '3.12' and sys_platform == 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/26/3e/a1c4207c5dea4697b7a3387e26584919ba987d8f9320f59dc0b5c557a4eb/django-6.0.2.tar.gz", hash = "sha256:3046a53b0e40d4b676c3b774c73411d7184ae2745fe8ce5e45c0f33d3ddb71a7", size = 10886874, upload-time = "2026-02-03T13:50:31.596Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/96/ba/a6e2992bc5b8c688249c00ea48cb1b7a9bc09839328c81dc603671460928/django-6.0.2-py3-none-any.whl", hash = "sha256:610dd3b13d15ec3f1e1d257caedd751db8033c5ad8ea0e2d1219a8acf446ecc6", size = 8339381, upload-time = "2026-02-03T13:50:15.501Z" }, ] [[package]] name = "django-appconf" version = "1.2.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "django", version = "5.2.11", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, { name = "django", version = "6.0.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/d1/a2/e58bec8d7941b914af52a67c35b5709eceed2caa2848f28437f1666ed668/django_appconf-1.2.0.tar.gz", hash = "sha256:15a88d60dd942d6059f467412fe4581db632ef03018a3c183fb415d6fc9e5cec", size = 16127, upload-time = "2025-11-08T15:46:27.304Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/e8/e6/4c34d94dfb74bbcbc489606e61f1924933de30d22c593dd1f429f35fbd7f/django_appconf-1.2.0-py3-none-any.whl", hash = "sha256:b81bce5ef0ceb9d84df48dfb623a32235d941c78cc5e45dbb6947f154ea277f4", size = 6500, upload-time = "2025-11-08T15:46:25.957Z" }, ] [[package]] name = "django-fsm-2" version = "4.2.1" source = { editable = "." } dependencies = [ { name = "django", version = "5.2.11", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, { name = "django", version = "6.0.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, { name = "django-stubs-ext" }, { name = "typing-extensions", marker = "python_full_version < '3.12'" }, ] [package.optional-dependencies] graphviz = [ { name = "graphviz" }, ] [package.dev-dependencies] dev = [ { name = "coverage" }, { name = "django-fsm-log" }, { name = "django-guardian" }, { name = "graphviz" }, { name = "mypy" }, { name = "prek" }, { name = "pytest" }, { name = "pytest-cov" }, { name = "pytest-django" }, { name = "pytest-randomly" }, { name = "tox" }, ] [package.metadata] requires-dist = [ { name = "django", specifier = ">=4.2.29" }, { name = "django-stubs-ext" }, { name = "graphviz", marker = "extra == 'graphviz'" }, { name = "typing-extensions", marker = "python_full_version < '3.12'" }, ] provides-extras = ["graphviz"] [package.metadata.requires-dev] dev = [ { name = "coverage" }, { name = "django-fsm-log" }, { name = "django-guardian" }, { name = "graphviz" }, { name = "mypy" }, { name = "prek" }, { name = "pytest" }, { name = "pytest-cov" }, { name = "pytest-django" }, { name = "pytest-randomly" }, { name = "tox" }, ] [[package]] name = "django-fsm-log" version = "5.0.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "django", version = "5.2.11", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, { name = "django", version = "6.0.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, { name = "django-appconf" }, { name = "django-fsm-2" }, ] sdist = { url = "https://files.pythonhosted.org/packages/9f/5f/f59917a2e0abeaf9cf20b7920c3df966c813754c61cb3d6587ace30d5e28/django_fsm_log-5.0.2.tar.gz", hash = "sha256:5276a587bff3112c123c73456f37decf9c1bdc631e005c760e248adc25c8ec97", size = 8758, upload-time = "2026-01-27T11:08:49.532Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/b6/ca/79414e6548d0c2585c7d134250152f12071b6a270367a693e6ea704be011/django_fsm_log-5.0.2-py3-none-any.whl", hash = "sha256:02fbb61a54b21c1640e6360a251e3a07b564307bbdd391bbbf65ca8299f13d9e", size = 13964, upload-time = "2026-01-27T11:08:48.056Z" }, ] [[package]] name = "django-guardian" version = "3.3.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "django", version = "5.2.11", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, { name = "django", version = "6.0.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/d5/eb/bbeb4efd10d6cca8993697f571f17574e9fa7a912cead3ab39ce1d3793cd/django_guardian-3.3.0.tar.gz", hash = "sha256:abf1487399212cffdce7b3c909182a26fbe7e89746007299a8cab99f3d5ff009", size = 107443, upload-time = "2026-02-24T19:43:28.819Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/7c/3c/6517c5e27c6f9c165f989a5884f8798d66d25ce86fe44bf8c19aa4120351/django_guardian-3.3.0-py3-none-any.whl", hash = "sha256:4dca4fce104c7306e41b947a57d1cd6be46d9982548bef194ac8a6ad61d83686", size = 144003, upload-time = "2026-02-24T19:43:27Z" }, ] [[package]] name = "django-stubs-ext" version = "5.2.9" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "django", version = "5.2.11", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, { name = "django", version = "6.0.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/55/03/9c2be939490d2282328db4611bc5956899f5ff7eabc3e88bd4b964a87373/django_stubs_ext-5.2.9.tar.gz", hash = "sha256:6db4054d1580657b979b7d391474719f1a978773e66c7070a5e246cd445a25a9", size = 6497, upload-time = "2026-01-20T23:58:59.462Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/9b/f7/0d5f7d7e76fe972d9f560f687fdc0cab4db9e1624fd90728ca29b4ed7a63/django_stubs_ext-5.2.9-py3-none-any.whl", hash = "sha256:230c51575551b0165be40177f0f6805f1e3ebf799b835c85f5d64c371ca6cf71", size = 9974, upload-time = "2026-01-20T23:58:58.438Z" }, ] [[package]] name = "exceptiongroup" version = "1.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "typing-extensions", marker = "python_full_version < '3.12'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" }, ] [[package]] name = "filelock" version = "3.25.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/77/18/a1fd2231c679dcb9726204645721b12498aeac28e1ad0601038f94b42556/filelock-3.25.0.tar.gz", hash = "sha256:8f00faf3abf9dc730a1ffe9c354ae5c04e079ab7d3a683b7c32da5dd05f26af3", size = 40158, upload-time = "2026-03-01T15:08:45.916Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/f9/0b/de6f54d4a8bedfe8645c41497f3c18d749f0bd3218170c667bf4b81d0cdd/filelock-3.25.0-py3-none-any.whl", hash = "sha256:5ccf8069f7948f494968fc0713c10e5c182a9c9d9eef3a636307a20c2490f047", size = 26427, upload-time = "2026-03-01T15:08:44.593Z" }, ] [[package]] name = "graphviz" version = "0.21" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/f8/b3/3ac91e9be6b761a4b30d66ff165e54439dcd48b83f4e20d644867215f6ca/graphviz-0.21.tar.gz", hash = "sha256:20743e7183be82aaaa8ad6c93f8893c923bd6658a04c32ee115edb3c8a835f78", size = 200434, upload-time = "2025-06-15T09:35:05.824Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/91/4c/e0ce1ef95d4000ebc1c11801f9b944fa5910ecc15b5e351865763d8657f8/graphviz-0.21-py3-none-any.whl", hash = "sha256:54f33de9f4f911d7e84e4191749cac8cc5653f815b06738c54db9a15ab8b1e42", size = 47300, upload-time = "2025-06-15T09:35:04.433Z" }, ] [[package]] name = "iniconfig" version = "2.3.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, ] [[package]] name = "librt" version = "0.8.1" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/56/9c/b4b0c54d84da4a94b37bd44151e46d5e583c9534c7e02250b961b1b6d8a8/librt-0.8.1.tar.gz", hash = "sha256:be46a14693955b3bd96014ccbdb8339ee8c9346fbe11c1b78901b55125f14c73", size = 177471, upload-time = "2026-02-17T16:13:06.101Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/7c/5f/63f5fa395c7a8a93558c0904ba8f1c8d1b997ca6a3de61bc7659970d66bf/librt-0.8.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:81fd938344fecb9373ba1b155968c8a329491d2ce38e7ddb76f30ffb938f12dc", size = 65697, upload-time = "2026-02-17T16:11:06.903Z" }, { url = "https://files.pythonhosted.org/packages/ff/e0/0472cf37267b5920eff2f292ccfaede1886288ce35b7f3203d8de00abfe6/librt-0.8.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:5db05697c82b3a2ec53f6e72b2ed373132b0c2e05135f0696784e97d7f5d48e7", size = 68376, upload-time = "2026-02-17T16:11:08.395Z" }, { url = "https://files.pythonhosted.org/packages/c8/be/8bd1359fdcd27ab897cd5963294fa4a7c83b20a8564678e4fd12157e56a5/librt-0.8.1-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d56bc4011975f7460bea7b33e1ff425d2f1adf419935ff6707273c77f8a4ada6", size = 197084, upload-time = "2026-02-17T16:11:09.774Z" }, { url = "https://files.pythonhosted.org/packages/e2/fe/163e33fdd091d0c2b102f8a60cc0a61fd730ad44e32617cd161e7cd67a01/librt-0.8.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5cdc0f588ff4b663ea96c26d2a230c525c6fc62b28314edaaaca8ed5af931ad0", size = 207337, upload-time = "2026-02-17T16:11:11.311Z" }, { url = "https://files.pythonhosted.org/packages/01/99/f85130582f05dcf0c8902f3d629270231d2f4afdfc567f8305a952ac7f14/librt-0.8.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:97c2b54ff6717a7a563b72627990bec60d8029df17df423f0ed37d56a17a176b", size = 219980, upload-time = "2026-02-17T16:11:12.499Z" }, { url = "https://files.pythonhosted.org/packages/6f/54/cb5e4d03659e043a26c74e08206412ac9a3742f0477d96f9761a55313b5f/librt-0.8.1-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8f1125e6bbf2f1657d9a2f3ccc4a2c9b0c8b176965bb565dd4d86be67eddb4b6", size = 212921, upload-time = "2026-02-17T16:11:14.484Z" }, { url = "https://files.pythonhosted.org/packages/b1/81/a3a01e4240579c30f3487f6fed01eb4bc8ef0616da5b4ebac27ca19775f3/librt-0.8.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:8f4bb453f408137d7581be309b2fbc6868a80e7ef60c88e689078ee3a296ae71", size = 221381, upload-time = "2026-02-17T16:11:17.459Z" }, { url = "https://files.pythonhosted.org/packages/08/b0/fc2d54b4b1c6fb81e77288ff31ff25a2c1e62eaef4424a984f228839717b/librt-0.8.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:c336d61d2fe74a3195edc1646d53ff1cddd3a9600b09fa6ab75e5514ba4862a7", size = 216714, upload-time = "2026-02-17T16:11:19.197Z" }, { url = "https://files.pythonhosted.org/packages/96/96/85daa73ffbd87e1fb287d7af6553ada66bf25a2a6b0de4764344a05469f6/librt-0.8.1-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:eb5656019db7c4deacf0c1a55a898c5bb8f989be904597fcb5232a2f4828fa05", size = 214777, upload-time = "2026-02-17T16:11:20.443Z" }, { url = "https://files.pythonhosted.org/packages/12/9c/c3aa7a2360383f4bf4f04d98195f2739a579128720c603f4807f006a4225/librt-0.8.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:c25d9e338d5bed46c1632f851babf3d13c78f49a225462017cf5e11e845c5891", size = 237398, upload-time = "2026-02-17T16:11:22.083Z" }, { url = "https://files.pythonhosted.org/packages/61/19/d350ea89e5274665185dabc4bbb9c3536c3411f862881d316c8b8e00eb66/librt-0.8.1-cp310-cp310-win32.whl", hash = "sha256:aaab0e307e344cb28d800957ef3ec16605146ef0e59e059a60a176d19543d1b7", size = 54285, upload-time = "2026-02-17T16:11:23.27Z" }, { url = "https://files.pythonhosted.org/packages/4f/d6/45d587d3d41c112e9543a0093d883eb57a24a03e41561c127818aa2a6bcc/librt-0.8.1-cp310-cp310-win_amd64.whl", hash = "sha256:56e04c14b696300d47b3bc5f1d10a00e86ae978886d0cee14e5714fafb5df5d2", size = 61352, upload-time = "2026-02-17T16:11:24.207Z" }, { url = "https://files.pythonhosted.org/packages/1d/01/0e748af5e4fee180cf7cd12bd12b0513ad23b045dccb2a83191bde82d168/librt-0.8.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:681dc2451d6d846794a828c16c22dc452d924e9f700a485b7ecb887a30aad1fd", size = 65315, upload-time = "2026-02-17T16:11:25.152Z" }, { url = "https://files.pythonhosted.org/packages/9d/4d/7184806efda571887c798d573ca4134c80ac8642dcdd32f12c31b939c595/librt-0.8.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a3b4350b13cc0e6f5bec8fa7caf29a8fb8cdc051a3bae45cfbfd7ce64f009965", size = 68021, upload-time = "2026-02-17T16:11:26.129Z" }, { url = "https://files.pythonhosted.org/packages/ae/88/c3c52d2a5d5101f28d3dc89298444626e7874aa904eed498464c2af17627/librt-0.8.1-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:ac1e7817fd0ed3d14fd7c5df91daed84c48e4c2a11ee99c0547f9f62fdae13da", size = 194500, upload-time = "2026-02-17T16:11:27.177Z" }, { url = "https://files.pythonhosted.org/packages/d6/5d/6fb0a25b6a8906e85b2c3b87bee1d6ed31510be7605b06772f9374ca5cb3/librt-0.8.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:747328be0c5b7075cde86a0e09d7a9196029800ba75a1689332348e998fb85c0", size = 205622, upload-time = "2026-02-17T16:11:28.242Z" }, { url = "https://files.pythonhosted.org/packages/b2/a6/8006ae81227105476a45691f5831499e4d936b1c049b0c1feb17c11b02d1/librt-0.8.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f0af2bd2bc204fa27f3d6711d0f360e6b8c684a035206257a81673ab924aa11e", size = 218304, upload-time = "2026-02-17T16:11:29.344Z" }, { url = "https://files.pythonhosted.org/packages/ee/19/60e07886ad16670aae57ef44dada41912c90906a6fe9f2b9abac21374748/librt-0.8.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d480de377f5b687b6b1bc0c0407426da556e2a757633cc7e4d2e1a057aa688f3", size = 211493, upload-time = "2026-02-17T16:11:30.445Z" }, { url = "https://files.pythonhosted.org/packages/9c/cf/f666c89d0e861d05600438213feeb818c7514d3315bae3648b1fc145d2b6/librt-0.8.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d0ee06b5b5291f609ddb37b9750985b27bc567791bc87c76a569b3feed8481ac", size = 219129, upload-time = "2026-02-17T16:11:32.021Z" }, { url = "https://files.pythonhosted.org/packages/8f/ef/f1bea01e40b4a879364c031476c82a0dc69ce068daad67ab96302fed2d45/librt-0.8.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:9e2c6f77b9ad48ce5603b83b7da9ee3e36b3ab425353f695cba13200c5d96596", size = 213113, upload-time = "2026-02-17T16:11:33.192Z" }, { url = "https://files.pythonhosted.org/packages/9b/80/cdab544370cc6bc1b72ea369525f547a59e6938ef6863a11ab3cd24759af/librt-0.8.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:439352ba9373f11cb8e1933da194dcc6206daf779ff8df0ed69c5e39113e6a99", size = 212269, upload-time = "2026-02-17T16:11:34.373Z" }, { url = "https://files.pythonhosted.org/packages/9d/9c/48d6ed8dac595654f15eceab2035131c136d1ae9a1e3548e777bb6dbb95d/librt-0.8.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:82210adabbc331dbb65d7868b105185464ef13f56f7f76688565ad79f648b0fe", size = 234673, upload-time = "2026-02-17T16:11:36.063Z" }, { url = "https://files.pythonhosted.org/packages/16/01/35b68b1db517f27a01be4467593292eb5315def8900afad29fabf56304ba/librt-0.8.1-cp311-cp311-win32.whl", hash = "sha256:52c224e14614b750c0a6d97368e16804a98c684657c7518752c356834fff83bb", size = 54597, upload-time = "2026-02-17T16:11:37.544Z" }, { url = "https://files.pythonhosted.org/packages/71/02/796fe8f02822235966693f257bf2c79f40e11337337a657a8cfebba5febc/librt-0.8.1-cp311-cp311-win_amd64.whl", hash = "sha256:c00e5c884f528c9932d278d5c9cbbea38a6b81eb62c02e06ae53751a83a4d52b", size = 61733, upload-time = "2026-02-17T16:11:38.691Z" }, { url = "https://files.pythonhosted.org/packages/28/ad/232e13d61f879a42a4e7117d65e4984bb28371a34bb6fb9ca54ec2c8f54e/librt-0.8.1-cp311-cp311-win_arm64.whl", hash = "sha256:f7cdf7f26c2286ffb02e46d7bac56c94655540b26347673bea15fa52a6af17e9", size = 52273, upload-time = "2026-02-17T16:11:40.308Z" }, { url = "https://files.pythonhosted.org/packages/95/21/d39b0a87ac52fc98f621fb6f8060efb017a767ebbbac2f99fbcbc9ddc0d7/librt-0.8.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a28f2612ab566b17f3698b0da021ff9960610301607c9a5e8eaca62f5e1c350a", size = 66516, upload-time = "2026-02-17T16:11:41.604Z" }, { url = "https://files.pythonhosted.org/packages/69/f1/46375e71441c43e8ae335905e069f1c54febee63a146278bcee8782c84fd/librt-0.8.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:60a78b694c9aee2a0f1aaeaa7d101cf713e92e8423a941d2897f4fa37908dab9", size = 68634, upload-time = "2026-02-17T16:11:43.268Z" }, { url = "https://files.pythonhosted.org/packages/0a/33/c510de7f93bf1fa19e13423a606d8189a02624a800710f6e6a0a0f0784b3/librt-0.8.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:758509ea3f1eba2a57558e7e98f4659d0ea7670bff49673b0dde18a3c7e6c0eb", size = 198941, upload-time = "2026-02-17T16:11:44.28Z" }, { url = "https://files.pythonhosted.org/packages/dd/36/e725903416409a533d92398e88ce665476f275081d0d7d42f9c4951999e5/librt-0.8.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:039b9f2c506bd0ab0f8725aa5ba339c6f0cd19d3b514b50d134789809c24285d", size = 209991, upload-time = "2026-02-17T16:11:45.462Z" }, { url = "https://files.pythonhosted.org/packages/30/7a/8d908a152e1875c9f8eac96c97a480df425e657cdb47854b9efaa4998889/librt-0.8.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5bb54f1205a3a6ab41a6fd71dfcdcbd278670d3a90ca502a30d9da583105b6f7", size = 224476, upload-time = "2026-02-17T16:11:46.542Z" }, { url = "https://files.pythonhosted.org/packages/a8/b8/a22c34f2c485b8903a06f3fe3315341fe6876ef3599792344669db98fcff/librt-0.8.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:05bd41cdee35b0c59c259f870f6da532a2c5ca57db95b5f23689fcb5c9e42440", size = 217518, upload-time = "2026-02-17T16:11:47.746Z" }, { url = "https://files.pythonhosted.org/packages/79/6f/5c6fea00357e4f82ba44f81dbfb027921f1ab10e320d4a64e1c408d035d9/librt-0.8.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:adfab487facf03f0d0857b8710cf82d0704a309d8ffc33b03d9302b4c64e91a9", size = 225116, upload-time = "2026-02-17T16:11:49.298Z" }, { url = "https://files.pythonhosted.org/packages/f2/a0/95ced4e7b1267fe1e2720a111685bcddf0e781f7e9e0ce59d751c44dcfe5/librt-0.8.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:153188fe98a72f206042be10a2c6026139852805215ed9539186312d50a8e972", size = 217751, upload-time = "2026-02-17T16:11:50.49Z" }, { url = "https://files.pythonhosted.org/packages/93/c2/0517281cb4d4101c27ab59472924e67f55e375bc46bedae94ac6dc6e1902/librt-0.8.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:dd3c41254ee98604b08bd5b3af5bf0a89740d4ee0711de95b65166bf44091921", size = 218378, upload-time = "2026-02-17T16:11:51.783Z" }, { url = "https://files.pythonhosted.org/packages/43/e8/37b3ac108e8976888e559a7b227d0ceac03c384cfd3e7a1c2ee248dbae79/librt-0.8.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e0d138c7ae532908cbb342162b2611dbd4d90c941cd25ab82084aaf71d2c0bd0", size = 241199, upload-time = "2026-02-17T16:11:53.561Z" }, { url = "https://files.pythonhosted.org/packages/4b/5b/35812d041c53967fedf551a39399271bbe4257e681236a2cf1a69c8e7fa1/librt-0.8.1-cp312-cp312-win32.whl", hash = "sha256:43353b943613c5d9c49a25aaffdba46f888ec354e71e3529a00cca3f04d66a7a", size = 54917, upload-time = "2026-02-17T16:11:54.758Z" }, { url = "https://files.pythonhosted.org/packages/de/d1/fa5d5331b862b9775aaf2a100f5ef86854e5d4407f71bddf102f4421e034/librt-0.8.1-cp312-cp312-win_amd64.whl", hash = "sha256:ff8baf1f8d3f4b6b7257fcb75a501f2a5499d0dda57645baa09d4d0d34b19444", size = 62017, upload-time = "2026-02-17T16:11:55.748Z" }, { url = "https://files.pythonhosted.org/packages/c7/7c/c614252f9acda59b01a66e2ddfd243ed1c7e1deab0293332dfbccf862808/librt-0.8.1-cp312-cp312-win_arm64.whl", hash = "sha256:0f2ae3725904f7377e11cc37722d5d401e8b3d5851fb9273d7f4fe04f6b3d37d", size = 52441, upload-time = "2026-02-17T16:11:56.801Z" }, { url = "https://files.pythonhosted.org/packages/c5/3c/f614c8e4eaac7cbf2bbdf9528790b21d89e277ee20d57dc6e559c626105f/librt-0.8.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7e6bad1cd94f6764e1e21950542f818a09316645337fd5ab9a7acc45d99a8f35", size = 66529, upload-time = "2026-02-17T16:11:57.809Z" }, { url = "https://files.pythonhosted.org/packages/ab/96/5836544a45100ae411eda07d29e3d99448e5258b6e9c8059deb92945f5c2/librt-0.8.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cf450f498c30af55551ba4f66b9123b7185362ec8b625a773b3d39aa1a717583", size = 68669, upload-time = "2026-02-17T16:11:58.843Z" }, { url = "https://files.pythonhosted.org/packages/06/53/f0b992b57af6d5531bf4677d75c44f095f2366a1741fb695ee462ae04b05/librt-0.8.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:eca45e982fa074090057132e30585a7e8674e9e885d402eae85633e9f449ce6c", size = 199279, upload-time = "2026-02-17T16:11:59.862Z" }, { url = "https://files.pythonhosted.org/packages/f3/ad/4848cc16e268d14280d8168aee4f31cea92bbd2b79ce33d3e166f2b4e4fc/librt-0.8.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0c3811485fccfda840861905b8c70bba5ec094e02825598bb9d4ca3936857a04", size = 210288, upload-time = "2026-02-17T16:12:00.954Z" }, { url = "https://files.pythonhosted.org/packages/52/05/27fdc2e95de26273d83b96742d8d3b7345f2ea2bdbd2405cc504644f2096/librt-0.8.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5e4af413908f77294605e28cfd98063f54b2c790561383971d2f52d113d9c363", size = 224809, upload-time = "2026-02-17T16:12:02.108Z" }, { url = "https://files.pythonhosted.org/packages/7a/d0/78200a45ba3240cb042bc597d6f2accba9193a2c57d0356268cbbe2d0925/librt-0.8.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5212a5bd7fae98dae95710032902edcd2ec4dc994e883294f75c857b83f9aba0", size = 218075, upload-time = "2026-02-17T16:12:03.631Z" }, { url = "https://files.pythonhosted.org/packages/af/72/a210839fa74c90474897124c064ffca07f8d4b347b6574d309686aae7ca6/librt-0.8.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e692aa2d1d604e6ca12d35e51fdc36f4cda6345e28e36374579f7ef3611b3012", size = 225486, upload-time = "2026-02-17T16:12:04.725Z" }, { url = "https://files.pythonhosted.org/packages/a3/c1/a03cc63722339ddbf087485f253493e2b013039f5b707e8e6016141130fa/librt-0.8.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:4be2a5c926b9770c9e08e717f05737a269b9d0ebc5d2f0060f0fe3fe9ce47acb", size = 218219, upload-time = "2026-02-17T16:12:05.828Z" }, { url = "https://files.pythonhosted.org/packages/58/f5/fff6108af0acf941c6f274a946aea0e484bd10cd2dc37610287ce49388c5/librt-0.8.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:fd1a720332ea335ceb544cf0a03f81df92abd4bb887679fd1e460976b0e6214b", size = 218750, upload-time = "2026-02-17T16:12:07.09Z" }, { url = "https://files.pythonhosted.org/packages/71/67/5a387bfef30ec1e4b4f30562c8586566faf87e47d696768c19feb49e3646/librt-0.8.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:93c2af9e01e0ef80d95ae3c720be101227edae5f2fe7e3dc63d8857fadfc5a1d", size = 241624, upload-time = "2026-02-17T16:12:08.43Z" }, { url = "https://files.pythonhosted.org/packages/d4/be/24f8502db11d405232ac1162eb98069ca49c3306c1d75c6ccc61d9af8789/librt-0.8.1-cp313-cp313-win32.whl", hash = "sha256:086a32dbb71336627e78cc1d6ee305a68d038ef7d4c39aaff41ae8c9aa46e91a", size = 54969, upload-time = "2026-02-17T16:12:09.633Z" }, { url = "https://files.pythonhosted.org/packages/5c/73/c9fdf6cb2a529c1a092ce769a12d88c8cca991194dfe641b6af12fa964d2/librt-0.8.1-cp313-cp313-win_amd64.whl", hash = "sha256:e11769a1dbda4da7b00a76cfffa67aa47cfa66921d2724539eee4b9ede780b79", size = 62000, upload-time = "2026-02-17T16:12:10.632Z" }, { url = "https://files.pythonhosted.org/packages/d3/97/68f80ca3ac4924f250cdfa6e20142a803e5e50fca96ef5148c52ee8c10ea/librt-0.8.1-cp313-cp313-win_arm64.whl", hash = "sha256:924817ab3141aca17893386ee13261f1d100d1ef410d70afe4389f2359fea4f0", size = 52495, upload-time = "2026-02-17T16:12:11.633Z" }, { url = "https://files.pythonhosted.org/packages/c9/6a/907ef6800f7bca71b525a05f1839b21f708c09043b1c6aa77b6b827b3996/librt-0.8.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:6cfa7fe54fd4d1f47130017351a959fe5804bda7a0bc7e07a2cdbc3fdd28d34f", size = 66081, upload-time = "2026-02-17T16:12:12.766Z" }, { url = "https://files.pythonhosted.org/packages/1b/18/25e991cd5640c9fb0f8d91b18797b29066b792f17bf8493da183bf5caabe/librt-0.8.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:228c2409c079f8c11fb2e5d7b277077f694cb93443eb760e00b3b83cb8b3176c", size = 68309, upload-time = "2026-02-17T16:12:13.756Z" }, { url = "https://files.pythonhosted.org/packages/a4/36/46820d03f058cfb5a9de5940640ba03165ed8aded69e0733c417bb04df34/librt-0.8.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:7aae78ab5e3206181780e56912d1b9bb9f90a7249ce12f0e8bf531d0462dd0fc", size = 196804, upload-time = "2026-02-17T16:12:14.818Z" }, { url = "https://files.pythonhosted.org/packages/59/18/5dd0d3b87b8ff9c061849fbdb347758d1f724b9a82241aa908e0ec54ccd0/librt-0.8.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:172d57ec04346b047ca6af181e1ea4858086c80bdf455f61994c4aa6fc3f866c", size = 206907, upload-time = "2026-02-17T16:12:16.513Z" }, { url = "https://files.pythonhosted.org/packages/d1/96/ef04902aad1424fd7299b62d1890e803e6ab4018c3044dca5922319c4b97/librt-0.8.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6b1977c4ea97ce5eb7755a78fae68d87e4102e4aaf54985e8b56806849cc06a3", size = 221217, upload-time = "2026-02-17T16:12:17.906Z" }, { url = "https://files.pythonhosted.org/packages/6d/ff/7e01f2dda84a8f5d280637a2e5827210a8acca9a567a54507ef1c75b342d/librt-0.8.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:10c42e1f6fd06733ef65ae7bebce2872bcafd8d6e6b0a08fe0a05a23b044fb14", size = 214622, upload-time = "2026-02-17T16:12:19.108Z" }, { url = "https://files.pythonhosted.org/packages/1e/8c/5b093d08a13946034fed57619742f790faf77058558b14ca36a6e331161e/librt-0.8.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:4c8dfa264b9193c4ee19113c985c95f876fae5e51f731494fc4e0cf594990ba7", size = 221987, upload-time = "2026-02-17T16:12:20.331Z" }, { url = "https://files.pythonhosted.org/packages/d3/cc/86b0b3b151d40920ad45a94ce0171dec1aebba8a9d72bb3fa00c73ab25dd/librt-0.8.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:01170b6729a438f0dedc4a26ed342e3dc4f02d1000b4b19f980e1877f0c297e6", size = 215132, upload-time = "2026-02-17T16:12:21.54Z" }, { url = "https://files.pythonhosted.org/packages/fc/be/8588164a46edf1e69858d952654e216a9a91174688eeefb9efbb38a9c799/librt-0.8.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:7b02679a0d783bdae30d443025b94465d8c3dc512f32f5b5031f93f57ac32071", size = 215195, upload-time = "2026-02-17T16:12:23.073Z" }, { url = "https://files.pythonhosted.org/packages/f5/f2/0b9279bea735c734d69344ecfe056c1ba211694a72df10f568745c899c76/librt-0.8.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:190b109bb69592a3401fe1ffdea41a2e73370ace2ffdc4a0e8e2b39cdea81b78", size = 237946, upload-time = "2026-02-17T16:12:24.275Z" }, { url = "https://files.pythonhosted.org/packages/e9/cc/5f2a34fbc8aeb35314a3641f9956fa9051a947424652fad9882be7a97949/librt-0.8.1-cp314-cp314-win32.whl", hash = "sha256:e70a57ecf89a0f64c24e37f38d3fe217a58169d2fe6ed6d70554964042474023", size = 50689, upload-time = "2026-02-17T16:12:25.766Z" }, { url = "https://files.pythonhosted.org/packages/a0/76/cd4d010ab2147339ca2b93e959c3686e964edc6de66ddacc935c325883d7/librt-0.8.1-cp314-cp314-win_amd64.whl", hash = "sha256:7e2f3edca35664499fbb36e4770650c4bd4a08abc1f4458eab9df4ec56389730", size = 57875, upload-time = "2026-02-17T16:12:27.465Z" }, { url = "https://files.pythonhosted.org/packages/84/0f/2143cb3c3ca48bd3379dcd11817163ca50781927c4537345d608b5045998/librt-0.8.1-cp314-cp314-win_arm64.whl", hash = "sha256:0d2f82168e55ddefd27c01c654ce52379c0750ddc31ee86b4b266bcf4d65f2a3", size = 48058, upload-time = "2026-02-17T16:12:28.556Z" }, { url = "https://files.pythonhosted.org/packages/d2/0e/9b23a87e37baf00311c3efe6b48d6b6c168c29902dfc3f04c338372fd7db/librt-0.8.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:2c74a2da57a094bd48d03fa5d196da83d2815678385d2978657499063709abe1", size = 68313, upload-time = "2026-02-17T16:12:29.659Z" }, { url = "https://files.pythonhosted.org/packages/db/9a/859c41e5a4f1c84200a7d2b92f586aa27133c8243b6cac9926f6e54d01b9/librt-0.8.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a355d99c4c0d8e5b770313b8b247411ed40949ca44e33e46a4789b9293a907ee", size = 70994, upload-time = "2026-02-17T16:12:31.516Z" }, { url = "https://files.pythonhosted.org/packages/4c/28/10605366ee599ed34223ac2bf66404c6fb59399f47108215d16d5ad751a8/librt-0.8.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:2eb345e8b33fb748227409c9f1233d4df354d6e54091f0e8fc53acdb2ffedeb7", size = 220770, upload-time = "2026-02-17T16:12:33.294Z" }, { url = "https://files.pythonhosted.org/packages/af/8d/16ed8fd452dafae9c48d17a6bc1ee3e818fd40ef718d149a8eff2c9f4ea2/librt-0.8.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9be2f15e53ce4e83cc08adc29b26fb5978db62ef2a366fbdf716c8a6c8901040", size = 235409, upload-time = "2026-02-17T16:12:35.443Z" }, { url = "https://files.pythonhosted.org/packages/89/1b/7bdf3e49349c134b25db816e4a3db6b94a47ac69d7d46b1e682c2c4949be/librt-0.8.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:785ae29c1f5c6e7c2cde2c7c0e148147f4503da3abc5d44d482068da5322fd9e", size = 246473, upload-time = "2026-02-17T16:12:36.656Z" }, { url = "https://files.pythonhosted.org/packages/4e/8a/91fab8e4fd2a24930a17188c7af5380eb27b203d72101c9cc000dbdfd95a/librt-0.8.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1d3a7da44baf692f0c6aeb5b2a09c5e6fc7a703bca9ffa337ddd2e2da53f7732", size = 238866, upload-time = "2026-02-17T16:12:37.849Z" }, { url = "https://files.pythonhosted.org/packages/b9/e0/c45a098843fc7c07e18a7f8a24ca8496aecbf7bdcd54980c6ca1aaa79a8e/librt-0.8.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5fc48998000cbc39ec0d5311312dda93ecf92b39aaf184c5e817d5d440b29624", size = 250248, upload-time = "2026-02-17T16:12:39.445Z" }, { url = "https://files.pythonhosted.org/packages/82/30/07627de23036640c952cce0c1fe78972e77d7d2f8fd54fa5ef4554ff4a56/librt-0.8.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:e96baa6820280077a78244b2e06e416480ed859bbd8e5d641cf5742919d8beb4", size = 240629, upload-time = "2026-02-17T16:12:40.889Z" }, { url = "https://files.pythonhosted.org/packages/fb/c1/55bfe1ee3542eba055616f9098eaf6eddb966efb0ca0f44eaa4aba327307/librt-0.8.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:31362dbfe297b23590530007062c32c6f6176f6099646bb2c95ab1b00a57c382", size = 239615, upload-time = "2026-02-17T16:12:42.446Z" }, { url = "https://files.pythonhosted.org/packages/2b/39/191d3d28abc26c9099b19852e6c99f7f6d400b82fa5a4e80291bd3803e19/librt-0.8.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cc3656283d11540ab0ea01978378e73e10002145117055e03722417aeab30994", size = 263001, upload-time = "2026-02-17T16:12:43.627Z" }, { url = "https://files.pythonhosted.org/packages/b9/eb/7697f60fbe7042ab4e88f4ee6af496b7f222fffb0a4e3593ef1f29f81652/librt-0.8.1-cp314-cp314t-win32.whl", hash = "sha256:738f08021b3142c2918c03692608baed43bc51144c29e35807682f8070ee2a3a", size = 51328, upload-time = "2026-02-17T16:12:45.148Z" }, { url = "https://files.pythonhosted.org/packages/7c/72/34bf2eb7a15414a23e5e70ecb9440c1d3179f393d9349338a91e2781c0fb/librt-0.8.1-cp314-cp314t-win_amd64.whl", hash = "sha256:89815a22daf9c51884fb5dbe4f1ef65ee6a146e0b6a8df05f753e2e4a9359bf4", size = 58722, upload-time = "2026-02-17T16:12:46.85Z" }, { url = "https://files.pythonhosted.org/packages/b2/c8/d148e041732d631fc76036f8b30fae4e77b027a1e95b7a84bb522481a940/librt-0.8.1-cp314-cp314t-win_arm64.whl", hash = "sha256:bf512a71a23504ed08103a13c941f763db13fb11177beb3d9244c98c29fb4a61", size = 48755, upload-time = "2026-02-17T16:12:47.943Z" }, ] [[package]] name = "mypy" version = "1.19.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "librt", marker = "platform_python_implementation != 'PyPy'" }, { name = "mypy-extensions" }, { name = "pathspec" }, { name = "tomli", marker = "python_full_version < '3.11'" }, { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/f5/db/4efed9504bc01309ab9c2da7e352cc223569f05478012b5d9ece38fd44d2/mypy-1.19.1.tar.gz", hash = "sha256:19d88bb05303fe63f71dd2c6270daca27cb9401c4ca8255fe50d1d920e0eb9ba", size = 3582404, upload-time = "2025-12-15T05:03:48.42Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/2f/63/e499890d8e39b1ff2df4c0c6ce5d371b6844ee22b8250687a99fd2f657a8/mypy-1.19.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:5f05aa3d375b385734388e844bc01733bd33c644ab48e9684faa54e5389775ec", size = 13101333, upload-time = "2025-12-15T05:03:03.28Z" }, { url = "https://files.pythonhosted.org/packages/72/4b/095626fc136fba96effc4fd4a82b41d688ab92124f8c4f7564bffe5cf1b0/mypy-1.19.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:022ea7279374af1a5d78dfcab853fe6a536eebfda4b59deab53cd21f6cd9f00b", size = 12164102, upload-time = "2025-12-15T05:02:33.611Z" }, { url = "https://files.pythonhosted.org/packages/0c/5b/952928dd081bf88a83a5ccd49aaecfcd18fd0d2710c7ff07b8fb6f7032b9/mypy-1.19.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee4c11e460685c3e0c64a4c5de82ae143622410950d6be863303a1c4ba0e36d6", size = 12765799, upload-time = "2025-12-15T05:03:28.44Z" }, { url = "https://files.pythonhosted.org/packages/2a/0d/93c2e4a287f74ef11a66fb6d49c7a9f05e47b0a4399040e6719b57f500d2/mypy-1.19.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:de759aafbae8763283b2ee5869c7255391fbc4de3ff171f8f030b5ec48381b74", size = 13522149, upload-time = "2025-12-15T05:02:36.011Z" }, { url = "https://files.pythonhosted.org/packages/7b/0e/33a294b56aaad2b338d203e3a1d8b453637ac36cb278b45005e0901cf148/mypy-1.19.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:ab43590f9cd5108f41aacf9fca31841142c786827a74ab7cc8a2eacb634e09a1", size = 13810105, upload-time = "2025-12-15T05:02:40.327Z" }, { url = "https://files.pythonhosted.org/packages/0e/fd/3e82603a0cb66b67c5e7abababce6bf1a929ddf67bf445e652684af5c5a0/mypy-1.19.1-cp310-cp310-win_amd64.whl", hash = "sha256:2899753e2f61e571b3971747e302d5f420c3fd09650e1951e99f823bc3089dac", size = 10057200, upload-time = "2025-12-15T05:02:51.012Z" }, { url = "https://files.pythonhosted.org/packages/ef/47/6b3ebabd5474d9cdc170d1342fbf9dddc1b0ec13ec90bf9004ee6f391c31/mypy-1.19.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:d8dfc6ab58ca7dda47d9237349157500468e404b17213d44fc1cb77bce532288", size = 13028539, upload-time = "2025-12-15T05:03:44.129Z" }, { url = "https://files.pythonhosted.org/packages/5c/a6/ac7c7a88a3c9c54334f53a941b765e6ec6c4ebd65d3fe8cdcfbe0d0fd7db/mypy-1.19.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e3f276d8493c3c97930e354b2595a44a21348b320d859fb4a2b9f66da9ed27ab", size = 12083163, upload-time = "2025-12-15T05:03:37.679Z" }, { url = "https://files.pythonhosted.org/packages/67/af/3afa9cf880aa4a2c803798ac24f1d11ef72a0c8079689fac5cfd815e2830/mypy-1.19.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2abb24cf3f17864770d18d673c85235ba52456b36a06b6afc1e07c1fdcd3d0e6", size = 12687629, upload-time = "2025-12-15T05:02:31.526Z" }, { url = "https://files.pythonhosted.org/packages/2d/46/20f8a7114a56484ab268b0ab372461cb3a8f7deed31ea96b83a4e4cfcfca/mypy-1.19.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a009ffa5a621762d0c926a078c2d639104becab69e79538a494bcccb62cc0331", size = 13436933, upload-time = "2025-12-15T05:03:15.606Z" }, { url = "https://files.pythonhosted.org/packages/5b/f8/33b291ea85050a21f15da910002460f1f445f8007adb29230f0adea279cb/mypy-1.19.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f7cee03c9a2e2ee26ec07479f38ea9c884e301d42c6d43a19d20fb014e3ba925", size = 13661754, upload-time = "2025-12-15T05:02:26.731Z" }, { url = "https://files.pythonhosted.org/packages/fd/a3/47cbd4e85bec4335a9cd80cf67dbc02be21b5d4c9c23ad6b95d6c5196bac/mypy-1.19.1-cp311-cp311-win_amd64.whl", hash = "sha256:4b84a7a18f41e167f7995200a1d07a4a6810e89d29859df936f1c3923d263042", size = 10055772, upload-time = "2025-12-15T05:03:26.179Z" }, { url = "https://files.pythonhosted.org/packages/06/8a/19bfae96f6615aa8a0604915512e0289b1fad33d5909bf7244f02935d33a/mypy-1.19.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a8174a03289288c1f6c46d55cef02379b478bfbc8e358e02047487cad44c6ca1", size = 13206053, upload-time = "2025-12-15T05:03:46.622Z" }, { url = "https://files.pythonhosted.org/packages/a5/34/3e63879ab041602154ba2a9f99817bb0c85c4df19a23a1443c8986e4d565/mypy-1.19.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ffcebe56eb09ff0c0885e750036a095e23793ba6c2e894e7e63f6d89ad51f22e", size = 12219134, upload-time = "2025-12-15T05:03:24.367Z" }, { url = "https://files.pythonhosted.org/packages/89/cc/2db6f0e95366b630364e09845672dbee0cbf0bbe753a204b29a944967cd9/mypy-1.19.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b64d987153888790bcdb03a6473d321820597ab8dd9243b27a92153c4fa50fd2", size = 12731616, upload-time = "2025-12-15T05:02:44.725Z" }, { url = "https://files.pythonhosted.org/packages/00/be/dd56c1fd4807bc1eba1cf18b2a850d0de7bacb55e158755eb79f77c41f8e/mypy-1.19.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c35d298c2c4bba75feb2195655dfea8124d855dfd7343bf8b8c055421eaf0cf8", size = 13620847, upload-time = "2025-12-15T05:03:39.633Z" }, { url = "https://files.pythonhosted.org/packages/6d/42/332951aae42b79329f743bf1da088cd75d8d4d9acc18fbcbd84f26c1af4e/mypy-1.19.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:34c81968774648ab5ac09c29a375fdede03ba253f8f8287847bd480782f73a6a", size = 13834976, upload-time = "2025-12-15T05:03:08.786Z" }, { url = "https://files.pythonhosted.org/packages/6f/63/e7493e5f90e1e085c562bb06e2eb32cae27c5057b9653348d38b47daaecc/mypy-1.19.1-cp312-cp312-win_amd64.whl", hash = "sha256:b10e7c2cd7870ba4ad9b2d8a6102eb5ffc1f16ca35e3de6bfa390c1113029d13", size = 10118104, upload-time = "2025-12-15T05:03:10.834Z" }, { url = "https://files.pythonhosted.org/packages/de/9f/a6abae693f7a0c697dbb435aac52e958dc8da44e92e08ba88d2e42326176/mypy-1.19.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e3157c7594ff2ef1634ee058aafc56a82db665c9438fd41b390f3bde1ab12250", size = 13201927, upload-time = "2025-12-15T05:02:29.138Z" }, { url = "https://files.pythonhosted.org/packages/9a/a4/45c35ccf6e1c65afc23a069f50e2c66f46bd3798cbe0d680c12d12935caa/mypy-1.19.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bdb12f69bcc02700c2b47e070238f42cb87f18c0bc1fc4cdb4fb2bc5fd7a3b8b", size = 12206730, upload-time = "2025-12-15T05:03:01.325Z" }, { url = "https://files.pythonhosted.org/packages/05/bb/cdcf89678e26b187650512620eec8368fded4cfd99cfcb431e4cdfd19dec/mypy-1.19.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f859fb09d9583a985be9a493d5cfc5515b56b08f7447759a0c5deaf68d80506e", size = 12724581, upload-time = "2025-12-15T05:03:20.087Z" }, { url = "https://files.pythonhosted.org/packages/d1/32/dd260d52babf67bad8e6770f8e1102021877ce0edea106e72df5626bb0ec/mypy-1.19.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c9a6538e0415310aad77cb94004ca6482330fece18036b5f360b62c45814c4ef", size = 13616252, upload-time = "2025-12-15T05:02:49.036Z" }, { url = "https://files.pythonhosted.org/packages/71/d0/5e60a9d2e3bd48432ae2b454b7ef2b62a960ab51292b1eda2a95edd78198/mypy-1.19.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:da4869fc5e7f62a88f3fe0b5c919d1d9f7ea3cef92d3689de2823fd27e40aa75", size = 13840848, upload-time = "2025-12-15T05:02:55.95Z" }, { url = "https://files.pythonhosted.org/packages/98/76/d32051fa65ecf6cc8c6610956473abdc9b4c43301107476ac03559507843/mypy-1.19.1-cp313-cp313-win_amd64.whl", hash = "sha256:016f2246209095e8eda7538944daa1d60e1e8134d98983b9fc1e92c1fc0cb8dd", size = 10135510, upload-time = "2025-12-15T05:02:58.438Z" }, { url = "https://files.pythonhosted.org/packages/de/eb/b83e75f4c820c4247a58580ef86fcd35165028f191e7e1ba57128c52782d/mypy-1.19.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:06e6170bd5836770e8104c8fdd58e5e725cfeb309f0a6c681a811f557e97eac1", size = 13199744, upload-time = "2025-12-15T05:03:30.823Z" }, { url = "https://files.pythonhosted.org/packages/94/28/52785ab7bfa165f87fcbb61547a93f98bb20e7f82f90f165a1f69bce7b3d/mypy-1.19.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:804bd67b8054a85447c8954215a906d6eff9cabeabe493fb6334b24f4bfff718", size = 12215815, upload-time = "2025-12-15T05:02:42.323Z" }, { url = "https://files.pythonhosted.org/packages/0a/c6/bdd60774a0dbfb05122e3e925f2e9e846c009e479dcec4821dad881f5b52/mypy-1.19.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:21761006a7f497cb0d4de3d8ef4ca70532256688b0523eee02baf9eec895e27b", size = 12740047, upload-time = "2025-12-15T05:03:33.168Z" }, { url = "https://files.pythonhosted.org/packages/32/2a/66ba933fe6c76bd40d1fe916a83f04fed253152f451a877520b3c4a5e41e/mypy-1.19.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:28902ee51f12e0f19e1e16fbe2f8f06b6637f482c459dd393efddd0ec7f82045", size = 13601998, upload-time = "2025-12-15T05:03:13.056Z" }, { url = "https://files.pythonhosted.org/packages/e3/da/5055c63e377c5c2418760411fd6a63ee2b96cf95397259038756c042574f/mypy-1.19.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:481daf36a4c443332e2ae9c137dfee878fcea781a2e3f895d54bd3002a900957", size = 13807476, upload-time = "2025-12-15T05:03:17.977Z" }, { url = "https://files.pythonhosted.org/packages/cd/09/4ebd873390a063176f06b0dbf1f7783dd87bd120eae7727fa4ae4179b685/mypy-1.19.1-cp314-cp314-win_amd64.whl", hash = "sha256:8bb5c6f6d043655e055be9b542aa5f3bdd30e4f3589163e85f93f3640060509f", size = 10281872, upload-time = "2025-12-15T05:03:05.549Z" }, { url = "https://files.pythonhosted.org/packages/8d/f4/4ce9a05ce5ded1de3ec1c1d96cf9f9504a04e54ce0ed55cfa38619a32b8d/mypy-1.19.1-py3-none-any.whl", hash = "sha256:f1235f5ea01b7db5468d53ece6aaddf1ad0b88d9e7462b86ef96fe04995d7247", size = 2471239, upload-time = "2025-12-15T05:03:07.248Z" }, ] [[package]] name = "mypy-extensions" version = "1.1.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, ] [[package]] name = "packaging" version = "26.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" }, ] [[package]] name = "pathspec" version = "1.0.4" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/fa/36/e27608899f9b8d4dff0617b2d9ab17ca5608956ca44461ac14ac48b44015/pathspec-1.0.4.tar.gz", hash = "sha256:0210e2ae8a21a9137c0d470578cb0e595af87edaa6ebf12ff176f14a02e0e645", size = 131200, upload-time = "2026-01-27T03:59:46.938Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/ef/3c/2c197d226f9ea224a9ab8d197933f9da0ae0aac5b6e0f884e2b8d9c8e9f7/pathspec-1.0.4-py3-none-any.whl", hash = "sha256:fb6ae2fd4e7c921a165808a552060e722767cfa526f99ca5156ed2ce45a5c723", size = 55206, upload-time = "2026-01-27T03:59:45.137Z" }, ] [[package]] name = "platformdirs" version = "4.9.2" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/1b/04/fea538adf7dbbd6d186f551d595961e564a3b6715bdf276b477460858672/platformdirs-4.9.2.tar.gz", hash = "sha256:9a33809944b9db043ad67ca0db94b14bf452cc6aeaac46a88ea55b26e2e9d291", size = 28394, upload-time = "2026-02-16T03:56:10.574Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/48/31/05e764397056194206169869b50cf2fee4dbbbc71b344705b9c0d878d4d8/platformdirs-4.9.2-py3-none-any.whl", hash = "sha256:9170634f126f8efdae22fb58ae8a0eaa86f38365bc57897a6c4f781d1f5875bd", size = 21168, upload-time = "2026-02-16T03:56:08.891Z" }, ] [[package]] name = "pluggy" version = "1.6.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, ] [[package]] name = "prek" version = "0.3.4" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/c6/51/2324eaad93a4b144853ca1c56da76f357d3a70c7b4fd6659e972d7bb8660/prek-0.3.4.tar.gz", hash = "sha256:56a74d02d8b7dfe3c774ecfcd8c1b4e5f1e1b84369043a8003e8e3a779fce72d", size = 356633, upload-time = "2026-02-28T03:47:13.452Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/09/20/1a964cb72582307c2f1dc7f583caab90f42810ad41551e5220592406a4c3/prek-0.3.4-py3-none-linux_armv6l.whl", hash = "sha256:c35192d6e23fe7406bd2f333d1c7dab1a4b34ab9289789f453170f33550aa74d", size = 4641915, upload-time = "2026-02-28T03:47:03.772Z" }, { url = "https://files.pythonhosted.org/packages/c5/cb/4a21f37102bac37e415b61818344aa85de8d29a581253afa7db8c08d5a33/prek-0.3.4-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:6f784d78de72a8bbe58a5fe7bde787c364ae88f0aff5222c5c5c7287876c510a", size = 4649166, upload-time = "2026-02-28T03:47:06.164Z" }, { url = "https://files.pythonhosted.org/packages/85/9c/a7c0d117a098d57931428bdb60fcb796e0ebc0478c59288017a2e22eca96/prek-0.3.4-py3-none-macosx_11_0_arm64.whl", hash = "sha256:50a43f522625e8c968e8c9992accf9e29017abad6c782d6d176b73145ad680b7", size = 4274422, upload-time = "2026-02-28T03:46:59.356Z" }, { url = "https://files.pythonhosted.org/packages/59/84/81d06df1724d09266df97599a02543d82fde7dfaefd192f09d9b2ccb092f/prek-0.3.4-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:4bbb1d3912a88935f35c6ba4466b4242732e3e3a8c608623c708e83cea85de00", size = 4629873, upload-time = "2026-02-28T03:46:56.419Z" }, { url = "https://files.pythonhosted.org/packages/09/cd/bb0aefa25cfacd8dbced75b9a9d9945707707867fa5635fb69ae1bbc2d88/prek-0.3.4-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ca4d4134db8f6e8de3c418317becdf428957e3cab271807f475318105fd46d04", size = 4552507, upload-time = "2026-02-28T03:47:05.004Z" }, { url = "https://files.pythonhosted.org/packages/9b/c0/578a7af4861afb64ec81c03bfdcc1bb3341bb61f2fff8a094ecf13987a56/prek-0.3.4-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7fb6395f6eb76133bb1e11fc718db8144522466cdc2e541d05e7813d1bbcae7d", size = 4865929, upload-time = "2026-02-28T03:47:09.231Z" }, { url = "https://files.pythonhosted.org/packages/fc/48/f169406590028f7698ef2e1ff5bffd92ca05e017636c1163a2f5ef0f8275/prek-0.3.4-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:aae17813239ddcb4ae7b38418de4d49afff740f48f8e0556029c96f58e350412", size = 5390286, upload-time = "2026-02-28T03:47:10.796Z" }, { url = "https://files.pythonhosted.org/packages/05/c5/98a73fec052059c3ae06ce105bef67caca42334c56d84e9ef75df72ba152/prek-0.3.4-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:10a621a690d9c127afc3d21c275030d364d1fbef3296c095068d3ae80a59546e", size = 4891028, upload-time = "2026-02-28T03:47:07.916Z" }, { url = "https://files.pythonhosted.org/packages/a3/b4/029966e35e59b59c142be7e1d2208ad261709ac1a66aa4a3ce33c5b9f91f/prek-0.3.4-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:d978c31bc3b1f0b3d58895b7c6ac26f077e0ea846da54f46aeee4c7088b1b105", size = 4633986, upload-time = "2026-02-28T03:47:14.351Z" }, { url = "https://files.pythonhosted.org/packages/1d/27/d122802555745b6940c99fcb41496001c192ddcdf56ec947ec10a0298e05/prek-0.3.4-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:a8e089a030f0a023c22a4bb2ec4ff3fcc153585d701cff67acbfca2f37e173ae", size = 4680722, upload-time = "2026-02-28T03:47:12.224Z" }, { url = "https://files.pythonhosted.org/packages/34/40/92318c96b3a67b4e62ed82741016ede34d97ea9579d3cc1332b167632222/prek-0.3.4-py3-none-musllinux_1_1_armv7l.whl", hash = "sha256:8060c72b764f0b88112616763da9dd3a7c293e010f8520b74079893096160a2f", size = 4535623, upload-time = "2026-02-28T03:46:52.221Z" }, { url = "https://files.pythonhosted.org/packages/df/f5/6b383d94e722637da4926b4f609d36fe432827bb6f035ad46ee02bde66b6/prek-0.3.4-py3-none-musllinux_1_1_i686.whl", hash = "sha256:65b23268456b5a763278d4e1ec532f2df33918f13ded85869a1ddff761eb9697", size = 4729879, upload-time = "2026-02-28T03:46:57.886Z" }, { url = "https://files.pythonhosted.org/packages/79/f8/fdc705b807d813fd713ffa4f67f96741542ed1dafbb221206078c06f3df4/prek-0.3.4-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:3975c61139c7b3200e38dc3955e050b0f2615701d3deb9715696a902e850509e", size = 5001569, upload-time = "2026-02-28T03:47:00.892Z" }, { url = "https://files.pythonhosted.org/packages/84/92/b007a41f58e8192a1e611a21b396ad870d51d7873b7af12068ebae7fc15f/prek-0.3.4-py3-none-win32.whl", hash = "sha256:37449ae82f4dc08b72e542401e3d7318f05d1163e87c31ab260a40f425d6516e", size = 4297057, upload-time = "2026-02-28T03:47:02.219Z" }, { url = "https://files.pythonhosted.org/packages/bb/dc/bcb02de9b11461e8e0c7d3c8fdf8cfa15ac6efe73472a4375549ba5defd2/prek-0.3.4-py3-none-win_amd64.whl", hash = "sha256:60e9aa86ca65de963510ae28c5d94b9d7a97bcbaa6e4cdb5bf5083ed4c45dc71", size = 4655174, upload-time = "2026-02-28T03:46:53.749Z" }, { url = "https://files.pythonhosted.org/packages/0b/86/98f5598569f4cd3de7161e266fab6a8981e65555f79d4704810c1502ad0a/prek-0.3.4-py3-none-win_arm64.whl", hash = "sha256:486bdae8f4512d3b4f6eb61b83e5b7595da2adca385af4b2b7823c0ab38d1827", size = 4367817, upload-time = "2026-02-28T03:46:55.264Z" }, ] [[package]] name = "pygments" version = "2.19.2" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, ] [[package]] name = "pyproject-api" version = "1.10.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "packaging" }, { name = "tomli", marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/45/7b/c0e1333b61d41c69e59e5366e727b18c4992688caf0de1be10b3e5265f6b/pyproject_api-1.10.0.tar.gz", hash = "sha256:40c6f2d82eebdc4afee61c773ed208c04c19db4c4a60d97f8d7be3ebc0bbb330", size = 22785, upload-time = "2025-10-09T19:12:27.21Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/54/cc/cecf97be298bee2b2a37dd360618c819a2a7fd95251d8e480c1f0eb88f3b/pyproject_api-1.10.0-py3-none-any.whl", hash = "sha256:8757c41a79c0f4ab71b99abed52b97ecf66bd20b04fa59da43b5840bac105a09", size = 13218, upload-time = "2025-10-09T19:12:24.428Z" }, ] [[package]] name = "pytest" version = "9.0.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, { name = "iniconfig" }, { name = "packaging" }, { name = "pluggy" }, { name = "pygments" }, { name = "tomli", marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" }, ] [[package]] name = "pytest-cov" version = "7.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "coverage", extra = ["toml"] }, { name = "pluggy" }, { name = "pytest" }, ] sdist = { url = "https://files.pythonhosted.org/packages/5e/f7/c933acc76f5208b3b00089573cf6a2bc26dc80a8aece8f52bb7d6b1855ca/pytest_cov-7.0.0.tar.gz", hash = "sha256:33c97eda2e049a0c5298e91f519302a1334c26ac65c1a483d6206fd458361af1", size = 54328, upload-time = "2025-09-09T10:57:02.113Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/ee/49/1377b49de7d0c1ce41292161ea0f721913fa8722c19fb9c1e3aa0367eecb/pytest_cov-7.0.0-py3-none-any.whl", hash = "sha256:3b8e9558b16cc1479da72058bdecf8073661c7f57f7d3c5f22a1c23507f2d861", size = 22424, upload-time = "2025-09-09T10:57:00.695Z" }, ] [[package]] name = "pytest-django" version = "4.12.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pytest" }, ] sdist = { url = "https://files.pythonhosted.org/packages/13/2b/db9a193df89e5660137f5428063bcc2ced7ad790003b26974adf5c5ceb3b/pytest_django-4.12.0.tar.gz", hash = "sha256:df94ec819a83c8979c8f6de13d9cdfbe76e8c21d39473cfe2b40c9fc9be3c758", size = 91156, upload-time = "2026-02-14T18:40:49.235Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/83/a5/41d091f697c09609e7ef1d5d61925494e0454ebf51de7de05f0f0a728f1d/pytest_django-4.12.0-py3-none-any.whl", hash = "sha256:3ff300c49f8350ba2953b90297d23bf5f589db69545f56f1ec5f8cff5da83e85", size = 26123, upload-time = "2026-02-14T18:40:47.381Z" }, ] [[package]] name = "pytest-randomly" version = "4.0.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pytest" }, ] sdist = { url = "https://files.pythonhosted.org/packages/c4/1d/258a4bf1109258c00c35043f40433be5c16647387b6e7cd5582d638c116b/pytest_randomly-4.0.1.tar.gz", hash = "sha256:174e57bb12ac2c26f3578188490bd333f0e80620c3f47340158a86eca0593cd8", size = 14130, upload-time = "2025-09-12T15:23:00.085Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/33/3e/a4a9227807b56869790aad3e24472a554b585974fe7e551ea350f50897ae/pytest_randomly-4.0.1-py3-none-any.whl", hash = "sha256:e0dfad2fd4f35e07beff1e47c17fbafcf98f9bf4531fd369d9260e2f858bfcb7", size = 8304, upload-time = "2025-09-12T15:22:58.946Z" }, ] [[package]] name = "python-discovery" version = "1.1.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "filelock" }, { name = "platformdirs" }, ] sdist = { url = "https://files.pythonhosted.org/packages/82/bb/93a3e83bdf9322c7e21cafd092e56a4a17c4d8ef4277b6eb01af1a540a6f/python_discovery-1.1.0.tar.gz", hash = "sha256:447941ba1aed8cc2ab7ee3cb91be5fc137c5bdbb05b7e6ea62fbdcb66e50b268", size = 55674, upload-time = "2026-02-26T09:42:49.668Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/06/54/82a6e2ef37f0f23dccac604b9585bdcbd0698604feb64807dcb72853693e/python_discovery-1.1.0-py3-none-any.whl", hash = "sha256:a162893b8809727f54594a99ad2179d2ede4bf953e12d4c7abc3cc9cdbd1437b", size = 30687, upload-time = "2026-02-26T09:42:48.548Z" }, ] [[package]] name = "sqlparse" version = "0.5.5" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/90/76/437d71068094df0726366574cf3432a4ed754217b436eb7429415cf2d480/sqlparse-0.5.5.tar.gz", hash = "sha256:e20d4a9b0b8585fdf63b10d30066c7c94c5d7a7ec47c889a2d83a3caa93ff28e", size = 120815, upload-time = "2025-12-19T07:17:45.073Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/49/4b/359f28a903c13438ef59ebeee215fb25da53066db67b305c125f1c6d2a25/sqlparse-0.5.5-py3-none-any.whl", hash = "sha256:12a08b3bf3eec877c519589833aed092e2444e68240a3577e8e26148acc7b1ba", size = 46138, upload-time = "2025-12-19T07:17:46.573Z" }, ] [[package]] name = "tomli" version = "2.4.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/82/30/31573e9457673ab10aa432461bee537ce6cef177667deca369efb79df071/tomli-2.4.0.tar.gz", hash = "sha256:aa89c3f6c277dd275d8e243ad24f3b5e701491a860d5121f2cdd399fbb31fc9c", size = 17477, upload-time = "2026-01-11T11:22:38.165Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/3c/d9/3dc2289e1f3b32eb19b9785b6a006b28ee99acb37d1d47f78d4c10e28bf8/tomli-2.4.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b5ef256a3fd497d4973c11bf142e9ed78b150d36f5773f1ca6088c230ffc5867", size = 153663, upload-time = "2026-01-11T11:21:45.27Z" }, { url = "https://files.pythonhosted.org/packages/51/32/ef9f6845e6b9ca392cd3f64f9ec185cc6f09f0a2df3db08cbe8809d1d435/tomli-2.4.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5572e41282d5268eb09a697c89a7bee84fae66511f87533a6f88bd2f7b652da9", size = 148469, upload-time = "2026-01-11T11:21:46.873Z" }, { url = "https://files.pythonhosted.org/packages/d6/c2/506e44cce89a8b1b1e047d64bd495c22c9f71f21e05f380f1a950dd9c217/tomli-2.4.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:551e321c6ba03b55676970b47cb1b73f14a0a4dce6a3e1a9458fd6d921d72e95", size = 236039, upload-time = "2026-01-11T11:21:48.503Z" }, { url = "https://files.pythonhosted.org/packages/b3/40/e1b65986dbc861b7e986e8ec394598187fa8aee85b1650b01dd925ca0be8/tomli-2.4.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5e3f639a7a8f10069d0e15408c0b96a2a828cfdec6fca05296ebcdcc28ca7c76", size = 243007, upload-time = "2026-01-11T11:21:49.456Z" }, { url = "https://files.pythonhosted.org/packages/9c/6f/6e39ce66b58a5b7ae572a0f4352ff40c71e8573633deda43f6a379d56b3e/tomli-2.4.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1b168f2731796b045128c45982d3a4874057626da0e2ef1fdd722848b741361d", size = 240875, upload-time = "2026-01-11T11:21:50.755Z" }, { url = "https://files.pythonhosted.org/packages/aa/ad/cb089cb190487caa80204d503c7fd0f4d443f90b95cf4ef5cf5aa0f439b0/tomli-2.4.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:133e93646ec4300d651839d382d63edff11d8978be23da4cc106f5a18b7d0576", size = 246271, upload-time = "2026-01-11T11:21:51.81Z" }, { url = "https://files.pythonhosted.org/packages/0b/63/69125220e47fd7a3a27fd0de0c6398c89432fec41bc739823bcc66506af6/tomli-2.4.0-cp311-cp311-win32.whl", hash = "sha256:b6c78bdf37764092d369722d9946cb65b8767bfa4110f902a1b2542d8d173c8a", size = 96770, upload-time = "2026-01-11T11:21:52.647Z" }, { url = "https://files.pythonhosted.org/packages/1e/0d/a22bb6c83f83386b0008425a6cd1fa1c14b5f3dd4bad05e98cf3dbbf4a64/tomli-2.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:d3d1654e11d724760cdb37a3d7691f0be9db5fbdaef59c9f532aabf87006dbaa", size = 107626, upload-time = "2026-01-11T11:21:53.459Z" }, { url = "https://files.pythonhosted.org/packages/2f/6d/77be674a3485e75cacbf2ddba2b146911477bd887dda9d8c9dfb2f15e871/tomli-2.4.0-cp311-cp311-win_arm64.whl", hash = "sha256:cae9c19ed12d4e8f3ebf46d1a75090e4c0dc16271c5bce1c833ac168f08fb614", size = 94842, upload-time = "2026-01-11T11:21:54.831Z" }, { url = "https://files.pythonhosted.org/packages/3c/43/7389a1869f2f26dba52404e1ef13b4784b6b37dac93bac53457e3ff24ca3/tomli-2.4.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:920b1de295e72887bafa3ad9f7a792f811847d57ea6b1215154030cf131f16b1", size = 154894, upload-time = "2026-01-11T11:21:56.07Z" }, { url = "https://files.pythonhosted.org/packages/e9/05/2f9bf110b5294132b2edf13fe6ca6ae456204f3d749f623307cbb7a946f2/tomli-2.4.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7d6d9a4aee98fac3eab4952ad1d73aee87359452d1c086b5ceb43ed02ddb16b8", size = 149053, upload-time = "2026-01-11T11:21:57.467Z" }, { url = "https://files.pythonhosted.org/packages/e8/41/1eda3ca1abc6f6154a8db4d714a4d35c4ad90adc0bcf700657291593fbf3/tomli-2.4.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:36b9d05b51e65b254ea6c2585b59d2c4cb91c8a3d91d0ed0f17591a29aaea54a", size = 243481, upload-time = "2026-01-11T11:21:58.661Z" }, { url = "https://files.pythonhosted.org/packages/d2/6d/02ff5ab6c8868b41e7d4b987ce2b5f6a51d3335a70aa144edd999e055a01/tomli-2.4.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1c8a885b370751837c029ef9bc014f27d80840e48bac415f3412e6593bbc18c1", size = 251720, upload-time = "2026-01-11T11:22:00.178Z" }, { url = "https://files.pythonhosted.org/packages/7b/57/0405c59a909c45d5b6f146107c6d997825aa87568b042042f7a9c0afed34/tomli-2.4.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8768715ffc41f0008abe25d808c20c3d990f42b6e2e58305d5da280ae7d1fa3b", size = 247014, upload-time = "2026-01-11T11:22:01.238Z" }, { url = "https://files.pythonhosted.org/packages/2c/0e/2e37568edd944b4165735687cbaf2fe3648129e440c26d02223672ee0630/tomli-2.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7b438885858efd5be02a9a133caf5812b8776ee0c969fea02c45e8e3f296ba51", size = 251820, upload-time = "2026-01-11T11:22:02.727Z" }, { url = "https://files.pythonhosted.org/packages/5a/1c/ee3b707fdac82aeeb92d1a113f803cf6d0f37bdca0849cb489553e1f417a/tomli-2.4.0-cp312-cp312-win32.whl", hash = "sha256:0408e3de5ec77cc7f81960c362543cbbd91ef883e3138e81b729fc3eea5b9729", size = 97712, upload-time = "2026-01-11T11:22:03.777Z" }, { url = "https://files.pythonhosted.org/packages/69/13/c07a9177d0b3bab7913299b9278845fc6eaaca14a02667c6be0b0a2270c8/tomli-2.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:685306e2cc7da35be4ee914fd34ab801a6acacb061b6a7abca922aaf9ad368da", size = 108296, upload-time = "2026-01-11T11:22:04.86Z" }, { url = "https://files.pythonhosted.org/packages/18/27/e267a60bbeeee343bcc279bb9e8fbed0cbe224bc7b2a3dc2975f22809a09/tomli-2.4.0-cp312-cp312-win_arm64.whl", hash = "sha256:5aa48d7c2356055feef06a43611fc401a07337d5b006be13a30f6c58f869e3c3", size = 94553, upload-time = "2026-01-11T11:22:05.854Z" }, { url = "https://files.pythonhosted.org/packages/34/91/7f65f9809f2936e1f4ce6268ae1903074563603b2a2bd969ebbda802744f/tomli-2.4.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:84d081fbc252d1b6a982e1870660e7330fb8f90f676f6e78b052ad4e64714bf0", size = 154915, upload-time = "2026-01-11T11:22:06.703Z" }, { url = "https://files.pythonhosted.org/packages/20/aa/64dd73a5a849c2e8f216b755599c511badde80e91e9bc2271baa7b2cdbb1/tomli-2.4.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:9a08144fa4cba33db5255f9b74f0b89888622109bd2776148f2597447f92a94e", size = 149038, upload-time = "2026-01-11T11:22:07.56Z" }, { url = "https://files.pythonhosted.org/packages/9e/8a/6d38870bd3d52c8d1505ce054469a73f73a0fe62c0eaf5dddf61447e32fa/tomli-2.4.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c73add4bb52a206fd0c0723432db123c0c75c280cbd67174dd9d2db228ebb1b4", size = 242245, upload-time = "2026-01-11T11:22:08.344Z" }, { url = "https://files.pythonhosted.org/packages/59/bb/8002fadefb64ab2669e5b977df3f5e444febea60e717e755b38bb7c41029/tomli-2.4.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1fb2945cbe303b1419e2706e711b7113da57b7db31ee378d08712d678a34e51e", size = 250335, upload-time = "2026-01-11T11:22:09.951Z" }, { url = "https://files.pythonhosted.org/packages/a5/3d/4cdb6f791682b2ea916af2de96121b3cb1284d7c203d97d92d6003e91c8d/tomli-2.4.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bbb1b10aa643d973366dc2cb1ad94f99c1726a02343d43cbc011edbfac579e7c", size = 245962, upload-time = "2026-01-11T11:22:11.27Z" }, { url = "https://files.pythonhosted.org/packages/f2/4a/5f25789f9a460bd858ba9756ff52d0830d825b458e13f754952dd15fb7bb/tomli-2.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4cbcb367d44a1f0c2be408758b43e1ffb5308abe0ea222897d6bfc8e8281ef2f", size = 250396, upload-time = "2026-01-11T11:22:12.325Z" }, { url = "https://files.pythonhosted.org/packages/aa/2f/b73a36fea58dfa08e8b3a268750e6853a6aac2a349241a905ebd86f3047a/tomli-2.4.0-cp313-cp313-win32.whl", hash = "sha256:7d49c66a7d5e56ac959cb6fc583aff0651094ec071ba9ad43df785abc2320d86", size = 97530, upload-time = "2026-01-11T11:22:13.865Z" }, { url = "https://files.pythonhosted.org/packages/3b/af/ca18c134b5d75de7e8dc551c5234eaba2e8e951f6b30139599b53de9c187/tomli-2.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:3cf226acb51d8f1c394c1b310e0e0e61fecdd7adcb78d01e294ac297dd2e7f87", size = 108227, upload-time = "2026-01-11T11:22:15.224Z" }, { url = "https://files.pythonhosted.org/packages/22/c3/b386b832f209fee8073c8138ec50f27b4460db2fdae9ffe022df89a57f9b/tomli-2.4.0-cp313-cp313-win_arm64.whl", hash = "sha256:d20b797a5c1ad80c516e41bc1fb0443ddb5006e9aaa7bda2d71978346aeb9132", size = 94748, upload-time = "2026-01-11T11:22:16.009Z" }, { url = "https://files.pythonhosted.org/packages/f3/c4/84047a97eb1004418bc10bdbcfebda209fca6338002eba2dc27cc6d13563/tomli-2.4.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:26ab906a1eb794cd4e103691daa23d95c6919cc2fa9160000ac02370cc9dd3f6", size = 154725, upload-time = "2026-01-11T11:22:17.269Z" }, { url = "https://files.pythonhosted.org/packages/a8/5d/d39038e646060b9d76274078cddf146ced86dc2b9e8bbf737ad5983609a0/tomli-2.4.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:20cedb4ee43278bc4f2fee6cb50daec836959aadaf948db5172e776dd3d993fc", size = 148901, upload-time = "2026-01-11T11:22:18.287Z" }, { url = "https://files.pythonhosted.org/packages/73/e5/383be1724cb30f4ce44983d249645684a48c435e1cd4f8b5cded8a816d3c/tomli-2.4.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:39b0b5d1b6dd03684b3fb276407ebed7090bbec989fa55838c98560c01113b66", size = 243375, upload-time = "2026-01-11T11:22:19.154Z" }, { url = "https://files.pythonhosted.org/packages/31/f0/bea80c17971c8d16d3cc109dc3585b0f2ce1036b5f4a8a183789023574f2/tomli-2.4.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a26d7ff68dfdb9f87a016ecfd1e1c2bacbe3108f4e0f8bcd2228ef9a766c787d", size = 250639, upload-time = "2026-01-11T11:22:20.168Z" }, { url = "https://files.pythonhosted.org/packages/2c/8f/2853c36abbb7608e3f945d8a74e32ed3a74ee3a1f468f1ffc7d1cb3abba6/tomli-2.4.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:20ffd184fb1df76a66e34bd1b36b4a4641bd2b82954befa32fe8163e79f1a702", size = 246897, upload-time = "2026-01-11T11:22:21.544Z" }, { url = "https://files.pythonhosted.org/packages/49/f0/6c05e3196ed5337b9fe7ea003e95fd3819a840b7a0f2bf5a408ef1dad8ed/tomli-2.4.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:75c2f8bbddf170e8effc98f5e9084a8751f8174ea6ccf4fca5398436e0320bc8", size = 254697, upload-time = "2026-01-11T11:22:23.058Z" }, { url = "https://files.pythonhosted.org/packages/f3/f5/2922ef29c9f2951883525def7429967fc4d8208494e5ab524234f06b688b/tomli-2.4.0-cp314-cp314-win32.whl", hash = "sha256:31d556d079d72db7c584c0627ff3a24c5d3fb4f730221d3444f3efb1b2514776", size = 98567, upload-time = "2026-01-11T11:22:24.033Z" }, { url = "https://files.pythonhosted.org/packages/7b/31/22b52e2e06dd2a5fdbc3ee73226d763b184ff21fc24e20316a44ccc4d96b/tomli-2.4.0-cp314-cp314-win_amd64.whl", hash = "sha256:43e685b9b2341681907759cf3a04e14d7104b3580f808cfde1dfdb60ada85475", size = 108556, upload-time = "2026-01-11T11:22:25.378Z" }, { url = "https://files.pythonhosted.org/packages/48/3d/5058dff3255a3d01b705413f64f4306a141a8fd7a251e5a495e3f192a998/tomli-2.4.0-cp314-cp314-win_arm64.whl", hash = "sha256:3d895d56bd3f82ddd6faaff993c275efc2ff38e52322ea264122d72729dca2b2", size = 96014, upload-time = "2026-01-11T11:22:26.138Z" }, { url = "https://files.pythonhosted.org/packages/b8/4e/75dab8586e268424202d3a1997ef6014919c941b50642a1682df43204c22/tomli-2.4.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:5b5807f3999fb66776dbce568cc9a828544244a8eb84b84b9bafc080c99597b9", size = 163339, upload-time = "2026-01-11T11:22:27.143Z" }, { url = "https://files.pythonhosted.org/packages/06/e3/b904d9ab1016829a776d97f163f183a48be6a4deb87304d1e0116a349519/tomli-2.4.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c084ad935abe686bd9c898e62a02a19abfc9760b5a79bc29644463eaf2840cb0", size = 159490, upload-time = "2026-01-11T11:22:28.399Z" }, { url = "https://files.pythonhosted.org/packages/e3/5a/fc3622c8b1ad823e8ea98a35e3c632ee316d48f66f80f9708ceb4f2a0322/tomli-2.4.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0f2e3955efea4d1cfbcb87bc321e00dc08d2bcb737fd1d5e398af111d86db5df", size = 269398, upload-time = "2026-01-11T11:22:29.345Z" }, { url = "https://files.pythonhosted.org/packages/fd/33/62bd6152c8bdd4c305ad9faca48f51d3acb2df1f8791b1477d46ff86e7f8/tomli-2.4.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0e0fe8a0b8312acf3a88077a0802565cb09ee34107813bba1c7cd591fa6cfc8d", size = 276515, upload-time = "2026-01-11T11:22:30.327Z" }, { url = "https://files.pythonhosted.org/packages/4b/ff/ae53619499f5235ee4211e62a8d7982ba9e439a0fb4f2f351a93d67c1dd2/tomli-2.4.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:413540dce94673591859c4c6f794dfeaa845e98bf35d72ed59636f869ef9f86f", size = 273806, upload-time = "2026-01-11T11:22:32.56Z" }, { url = "https://files.pythonhosted.org/packages/47/71/cbca7787fa68d4d0a9f7072821980b39fbb1b6faeb5f5cf02f4a5559fa28/tomli-2.4.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0dc56fef0e2c1c470aeac5b6ca8cc7b640bb93e92d9803ddaf9ea03e198f5b0b", size = 281340, upload-time = "2026-01-11T11:22:33.505Z" }, { url = "https://files.pythonhosted.org/packages/f5/00/d595c120963ad42474cf6ee7771ad0d0e8a49d0f01e29576ee9195d9ecdf/tomli-2.4.0-cp314-cp314t-win32.whl", hash = "sha256:d878f2a6707cc9d53a1be1414bbb419e629c3d6e67f69230217bb663e76b5087", size = 108106, upload-time = "2026-01-11T11:22:34.451Z" }, { url = "https://files.pythonhosted.org/packages/de/69/9aa0c6a505c2f80e519b43764f8b4ba93b5a0bbd2d9a9de6e2b24271b9a5/tomli-2.4.0-cp314-cp314t-win_amd64.whl", hash = "sha256:2add28aacc7425117ff6364fe9e06a183bb0251b03f986df0e78e974047571fd", size = 120504, upload-time = "2026-01-11T11:22:35.764Z" }, { url = "https://files.pythonhosted.org/packages/b3/9f/f1668c281c58cfae01482f7114a4b88d345e4c140386241a1a24dcc9e7bc/tomli-2.4.0-cp314-cp314t-win_arm64.whl", hash = "sha256:2b1e3b80e1d5e52e40e9b924ec43d81570f0e7d09d11081b797bc4692765a3d4", size = 99561, upload-time = "2026-01-11T11:22:36.624Z" }, { url = "https://files.pythonhosted.org/packages/23/d1/136eb2cb77520a31e1f64cbae9d33ec6df0d78bdf4160398e86eec8a8754/tomli-2.4.0-py3-none-any.whl", hash = "sha256:1f776e7d669ebceb01dee46484485f43a4048746235e683bcdffacdf1fb4785a", size = 14477, upload-time = "2026-01-11T11:22:37.446Z" }, ] [[package]] name = "tox" version = "4.47.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cachetools" }, { name = "colorama" }, { name = "filelock" }, { name = "packaging" }, { name = "platformdirs" }, { name = "pluggy" }, { name = "pyproject-api" }, { name = "tomli", marker = "python_full_version < '3.11'" }, { name = "typing-extensions", marker = "python_full_version < '3.11'" }, { name = "virtualenv" }, ] sdist = { url = "https://files.pythonhosted.org/packages/30/4a/6ea2602afe685f842ff9b5e07196e693f1aba885164d171af4807075cb30/tox-4.47.0.tar.gz", hash = "sha256:db08368214f6f44b3e9b6c6e937140e25a4b0cea63f8489bf1c9b6b34d3e42e3", size = 253965, upload-time = "2026-03-01T15:00:05.563Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/a3/95/727c8cde3ef125706825242833c41d0293307983cb9de7d1ad6dda503bfa/tox-4.47.0-py3-none-any.whl", hash = "sha256:79260c47814086313eea516c6cd4ce374f93c19be2de6125e7d330356a000364", size = 202065, upload-time = "2026-03-01T15:00:03.808Z" }, ] [[package]] name = "typing-extensions" version = "4.15.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, ] [[package]] name = "tzdata" version = "2025.3" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/5e/a7/c202b344c5ca7daf398f3b8a477eeb205cf3b6f32e7ec3a6bac0629ca975/tzdata-2025.3.tar.gz", hash = "sha256:de39c2ca5dc7b0344f2eba86f49d614019d29f060fc4ebc8a417896a620b56a7", size = 196772, upload-time = "2025-12-13T17:45:35.667Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/c7/b0/003792df09decd6849a5e39c28b513c06e84436a54440380862b5aeff25d/tzdata-2025.3-py2.py3-none-any.whl", hash = "sha256:06a47e5700f3081aab02b2e513160914ff0694bce9947d6b76ebd6bf57cfc5d1", size = 348521, upload-time = "2025-12-13T17:45:33.889Z" }, ] [[package]] name = "virtualenv" version = "21.1.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "distlib" }, { name = "filelock" }, { name = "platformdirs" }, { name = "python-discovery" }, { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/2f/c9/18d4b36606d6091844daa3bd93cf7dc78e6f5da21d9f21d06c221104b684/virtualenv-21.1.0.tar.gz", hash = "sha256:1990a0188c8f16b6b9cf65c9183049007375b26aad415514d377ccacf1e4fb44", size = 5840471, upload-time = "2026-02-27T08:49:29.702Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/78/55/896b06bf93a49bec0f4ae2a6f1ed12bd05c8860744ac3a70eda041064e4d/virtualenv-21.1.0-py3-none-any.whl", hash = "sha256:164f5e14c5587d170cf98e60378eb91ea35bf037be313811905d3a24ea33cc07", size = 5825072, upload-time = "2026-02-27T08:49:27.516Z" }, ]