├── tests ├── __init__.py ├── settings.py ├── conftest.py └── test_ulidfield.py ├── .gitattributes ├── .github ├── CODEOWNERS ├── workflows │ ├── test.yaml │ └── release.yaml ├── PULL_REQUEST_TEMPLATE.md └── actions │ └── build_image │ └── action.yaml ├── poetry.toml ├── src └── django_ulidfield │ ├── __init__.py │ └── fields.py ├── .readthedocs.yaml ├── mkdocs.yaml ├── .pre-commit-config.yaml ├── LICENSE ├── pyproject.toml ├── docs └── index.md ├── .gitignore ├── README.md └── poetry.lock /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto 2 | -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @dumaas 2 | -------------------------------------------------------------------------------- /poetry.toml: -------------------------------------------------------------------------------- 1 | [virtualenvs] 2 | create = true 3 | in-project = true 4 | -------------------------------------------------------------------------------- /src/django_ulidfield/__init__.py: -------------------------------------------------------------------------------- 1 | from .fields import ULIDField 2 | 3 | __all__ = ["ULIDField"] 4 | -------------------------------------------------------------------------------- /.readthedocs.yaml: -------------------------------------------------------------------------------- 1 | # Required 2 | version: 2 3 | 4 | # Set the OS, Python version, and other tools you might need 5 | build: 6 | os: ubuntu-24.04 7 | tools: 8 | python: "3.13" 9 | 10 | # Build documentation with Mkdocs 11 | mkdocs: 12 | configuration: mkdocs.yaml 13 | -------------------------------------------------------------------------------- /mkdocs.yaml: -------------------------------------------------------------------------------- 1 | site_name: django-ulidfield 2 | repo_url: https://github.com/dumaas/django-ulidfield 3 | site_url: https://django-ulidfield.readthedocs.io 4 | theme: 5 | name: readthedocs 6 | 7 | nav: 8 | - Home: index.md 9 | 10 | markdown_extensions: 11 | - toc: 12 | permalink: true 13 | -------------------------------------------------------------------------------- /tests/settings.py: -------------------------------------------------------------------------------- 1 | SECRET_KEY = "mock-key" 2 | 3 | INSTALLED_APPS = [ 4 | "django.contrib.contenttypes", 5 | "django_ulidfield", 6 | "tests", 7 | ] 8 | 9 | DATABASES = { 10 | "default": { 11 | "ENGINE": "django.db.backends.sqlite3", 12 | "NAME": ":memory:", 13 | } 14 | } 15 | 16 | USE_TZ = True 17 | -------------------------------------------------------------------------------- /.github/workflows/test.yaml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: 4 | push: 5 | branches-ignore: 6 | - main 7 | 8 | permissions: 9 | contents: read 10 | 11 | jobs: 12 | test: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: actions/checkout@v4 16 | 17 | - uses: ./.github/actions/build_image 18 | 19 | - name: Unit test 20 | run: poetry run pytest -v 21 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | ## Description 2 | 3 | What does this PR do? Briefly describe the changes and why they're needed. 4 | 5 | ## Related Issues / Pull Requests 6 | 7 | - Closes # 8 | - Related to # 9 | 10 | ## Checklist 11 | 12 | - [ ] Added or updated tests as needed 13 | - [ ] Updated the README/docs (if applicable) 14 | 15 | ## Notes 16 | 17 | Any additional context, edge cases, or questions you'd like reviewers to consider? 18 | -------------------------------------------------------------------------------- /.github/actions/build_image/action.yaml: -------------------------------------------------------------------------------- 1 | name: Build 2 | description: Build the project and install dependencies 3 | 4 | runs: 5 | using: composite 6 | steps: 7 | - name: Set up Python 3.9 8 | uses: actions/setup-python@v5 9 | with: 10 | python-version: "3.9" 11 | 12 | - name: Install Poetry 13 | uses: abatilo/actions-poetry@v4 14 | 15 | - uses: actions/cache@v4 16 | name: Define cache 17 | with: 18 | path: ./.venv 19 | key: venv-${{ hashFiles('poetry.lock') }} 20 | 21 | - name: Install the project dependencies 22 | run: poetry install 23 | shell: bash 24 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | # Pre-commit hooks for general file cleanup 3 | - repo: https://github.com/pre-commit/pre-commit-hooks 4 | rev: v5.0.0 5 | hooks: 6 | - id: trailing-whitespace 7 | - id: end-of-file-fixer 8 | - id: check-yaml 9 | - id: check-toml 10 | - id: check-merge-conflict 11 | - id: check-added-large-files 12 | - id: check-case-conflict 13 | - id: check-ast 14 | 15 | # Ruff for linting and formatting (replaces black, isort, flake8, etc.) 16 | - repo: https://github.com/astral-sh/ruff-pre-commit 17 | rev: v0.12.4 18 | hooks: 19 | # Run the linter 20 | - id: ruff 21 | args: [--fix] 22 | # Run the formatter (replaces black) 23 | - id: ruff-format 24 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from django.db import connection 3 | 4 | 5 | @pytest.fixture(scope="session") 6 | def django_db_setup(django_db_setup, django_db_blocker): 7 | """ 8 | Override the default django_db_setup to create our test model tables. 9 | This ensures the tables exist before any tests run. 10 | """ 11 | with django_db_blocker.unblock(): 12 | # Create test model tables 13 | from tests.test_ulidfield import ( 14 | BlankAllowedModel, 15 | MockModel, 16 | NonPrimaryKeyModel, 17 | NonPrimaryKeyModelNoDefault, 18 | ) 19 | 20 | with connection.schema_editor() as schema_editor: 21 | schema_editor.create_model(MockModel) 22 | schema_editor.create_model(NonPrimaryKeyModel) 23 | schema_editor.create_model(NonPrimaryKeyModelNoDefault) 24 | schema_editor.create_model(BlankAllowedModel) 25 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Christian Gonzalez 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /.github/workflows/release.yaml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | pull_request: 5 | types: 6 | - closed 7 | 8 | permissions: 9 | id-token: write 10 | contents: write 11 | 12 | jobs: 13 | bump-version: 14 | runs-on: ubuntu-latest 15 | if: github.event.pull_request.merged == true 16 | steps: 17 | - uses: actions/checkout@v4 18 | 19 | - uses: ./.github/actions/build_image 20 | 21 | - name: Bump version 22 | run: poetry version patch 23 | 24 | - uses: stefanzweifel/git-auto-commit-action@v6 25 | name: Commit changes 26 | 27 | generate-release: 28 | runs-on: ubuntu-latest 29 | needs: bump-version 30 | steps: 31 | - uses: actions/checkout@v4 32 | 33 | - uses: ./.github/actions/build_image 34 | 35 | - name: Generate release 36 | run: | 37 | VERSION=$(poetry version -s) 38 | gh release create "v$VERSION" --fail-on-no-commits --generate-notes 39 | env: 40 | GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} 41 | 42 | publish: 43 | runs-on: ubuntu-latest 44 | needs: generate-release 45 | environment: 46 | name: pypi 47 | url: https://pypi.org/project/django-ulidfield/ 48 | steps: 49 | - uses: actions/checkout@v4 50 | 51 | - uses: ./.github/actions/build_image 52 | 53 | - name: Publish to PyPI 54 | run: | 55 | poetry publish --build \ 56 | --username __token__ \ 57 | --password ${{ secrets.PYPI_API_TOKEN }} 58 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "django-ulidfield" 3 | version = "0.1.0" 4 | description = "A drop-in Django model field for storing sortable, time-encoded ULIDs as 26-character strings." 5 | authors = [ 6 | {name = "Christian Gonzalez",email = "christiangonzalezblack@gmail.com"} 7 | ] 8 | license = {text = "MIT"} 9 | readme = "README.md" 10 | requires-python = ">=3.9" 11 | dependencies = [ 12 | "python-ulid (>=3.0.0,<4.0.0)", 13 | "django (>=4.2,<5.3)" 14 | ] 15 | 16 | [tool.poetry] 17 | packages = [{include = "django_ulidfield", from = "src"}] 18 | 19 | [tool.poetry.group.dev.dependencies] 20 | pytest = "^8.4.1" 21 | pytest-django = "^4.11.1" 22 | pre-commit = "^4.2.0" 23 | 24 | 25 | [tool.poetry.group.docs.dependencies] 26 | mkdocs = "^1.6.1" 27 | 28 | [tool.pytest.ini_options] 29 | DJANGO_SETTINGS_MODULE = "tests.settings" 30 | python_files = ["test_*.py", "*_test.py"] 31 | pythonpath = ["src", "."] 32 | 33 | [tool.ruff] 34 | target-version = "py39" 35 | line-length = 88 36 | src = ["src", "tests"] 37 | 38 | [tool.ruff.lint] 39 | select = [ 40 | "E", # pycodestyle errors 41 | "W", # pycodestyle warnings 42 | "F", # pyflakes 43 | "I", # isort 44 | "B", # flake8-bugbear 45 | "C4", # flake8-comprehensions 46 | "DJ", # flake8-django 47 | "UP", # pyupgrade 48 | ] 49 | ignore = [ 50 | "E501", # line too long (handled by formatter) 51 | ] 52 | 53 | [tool.ruff.lint.per-file-ignores] 54 | "tests/*.py" = ["DJ01", "DJ008"] # Allow non-model classes in tests and skip __str__ requirement 55 | 56 | [tool.ruff.format] 57 | quote-style = "double" 58 | indent-style = "space" 59 | skip-magic-trailing-comma = false 60 | line-ending = "auto" 61 | 62 | [build-system] 63 | requires = ["poetry-core>=2.0.0,<3.0.0"] 64 | build-backend = "poetry.core.masonry.api" 65 | -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | # django-ulidfield 2 | 3 | A custom Django model field for storing [ULID](https://github.com/ulid/spec) values as 26-character, lexicographically sortable strings. 4 | 5 | ## Features 6 | 7 | - Stores ULIDs as 26-character base32 strings in a `CharField` 8 | - Auto-generates ULIDs using the `ulid-py` library 9 | - Provides built-in validation to ensure string conforms to ULID spec 10 | - Supports use as primary key (`primary_key=True`) 11 | - Drop-in replacement for `UUIDField` or `AutoField` when sortability is desired 12 | 13 | ## Installation 14 | 15 | Install the package via PyPI: 16 | 17 | ```bash 18 | pip install django-ulidfield 19 | ``` 20 | 21 | Or with Poetry: 22 | 23 | ```bash 24 | poetry add django-ulidfield 25 | ``` 26 | 27 | ## Usage 28 | 29 | Add the `ULIDField` to your model like any Django field: 30 | 31 | ```python 32 | from django.db import models 33 | from django_ulidfield import ULIDField 34 | 35 | class MyModel(models.Model): 36 | id = ULIDField(primary_key=True) 37 | name = models.CharField(max_length=100) 38 | ``` 39 | 40 | By default: 41 | - The field is non-editable (`editable=False`) 42 | - `max_length=26` is enforced 43 | - It auto-generates new ULID values via `ulid.ULID()` 44 | 45 | ## When to Use This 46 | 47 | Use `ULIDField` when: 48 | - You need globally unique IDs 49 | - You want time-sortable primary keys 50 | - You're operating at high scale and want to avoid integer collisions or out-of-order UUIDs 51 | - You want readable IDs that work in URLs 52 | 53 | ## How It Works 54 | 55 | Internally: 56 | - Values are stored as strings in the database 57 | - On creation, `ulid.ULID()` generates a new identifier unless explicitly provided 58 | - A custom validator ensures all values conform to ULID format 59 | 60 | ## Project Info 61 | 62 | - **Source:** [GitHub](https://github.com/dumaas/django-ulidfield) 63 | - **License:** MIT 64 | - **PyPI:** [django-ulidfield](https://pypi.org/project/django-ulidfield/) 65 | - **Python versions:** 3.9+ 66 | - **Django versions:** 4.2+ 67 | 68 | ## Contributing 69 | 70 | Pull requests are welcome! For major changes, please open an issue first to discuss what you’d like to change. 71 | 72 | To run tests: 73 | 74 | ```bash 75 | poetry install 76 | poetry run pytest 77 | ``` 78 | 79 | ## License 80 | 81 | MIT License 82 | -------------------------------------------------------------------------------- /src/django_ulidfield/fields.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Optional 2 | 3 | from django.core.exceptions import ValidationError 4 | from django.db import models 5 | from ulid import ULID 6 | 7 | 8 | def generate_ulid() -> str: 9 | """Generate a new ULID.""" 10 | return str(ULID()) 11 | 12 | 13 | def validate_ulid(value): 14 | """Validate that a value is a valid ULID string.""" 15 | if value is None: 16 | return # Allow None values (handled by null=True/False) 17 | 18 | # Convert to string if it's not already 19 | str_value = str(value) 20 | 21 | # Allow empty strings - Django's blank=True/False handles this 22 | if str_value == "": 23 | return 24 | 25 | try: 26 | # Try to parse as ULID - this will raise an exception if invalid 27 | ULID.from_str(str_value) 28 | except Exception as e: 29 | raise ValidationError( 30 | f"'{str_value}' is not a valid ULID. ULIDs must be 26-character base32 strings.", 31 | code="invalid_ulid", 32 | ) from e 33 | 34 | 35 | class ULIDField(models.CharField): 36 | """ 37 | A Django model field that stores ULIDs (Universally Unique Lexicographically Sortable Identifiers) 38 | as 26-character base32 strings. 39 | 40 | This field behaves like a CharField but auto-generates a ULID value by default, enforces uniqueness, 41 | and is typically used as a primary key or unique identifier. 42 | """ 43 | 44 | description = "A field for storing ULIDs (Universally Unique Lexicographically Sortable Identifiers)" 45 | 46 | def __init__(self, *args, **kwargs): 47 | kwargs.setdefault("max_length", 26) # ULID is 26 characters long 48 | kwargs.setdefault("unique", True) # ULIDs are unique 49 | kwargs.setdefault("editable", False) # ULIDs are typically not editable 50 | kwargs.setdefault("default", generate_ulid) # Generate a new ULID by default 51 | kwargs.setdefault("blank", False) # Don't allow blank values by default 52 | 53 | # Add ULID validation 54 | validators = kwargs.setdefault("validators", []) 55 | if validate_ulid not in validators: 56 | validators.append(validate_ulid) 57 | 58 | super().__init__(*args, **kwargs) 59 | 60 | def to_python(self, value: Any) -> Optional[str]: 61 | """ 62 | Convert the input value into a string, or return None. 63 | 64 | Django calls this during model deserialization or when loading from the database. 65 | We keep values as strings and let the validator handle ULID validation. 66 | """ 67 | if value is None: 68 | return value 69 | if isinstance(value, ULID): 70 | return str(value) 71 | # Return as string - validation happens in validators 72 | return str(value) 73 | 74 | def get_prep_value(self, value: Any) -> Optional[str]: 75 | """ 76 | Convert the value to a string before saving to the database. 77 | """ 78 | if value is None: 79 | return None 80 | return str(value) 81 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[codz] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | share/python-wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .nox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | *.py.cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | cover/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | .pybuilder/ 76 | target/ 77 | 78 | # Jupyter Notebook 79 | .ipynb_checkpoints 80 | 81 | # IPython 82 | profile_default/ 83 | ipython_config.py 84 | 85 | # pyenv 86 | # For a library or package, you might want to ignore these files since the code is 87 | # intended to run in multiple environments; otherwise, check them in: 88 | # .python-version 89 | 90 | # pipenv 91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 93 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 94 | # install all needed dependencies. 95 | #Pipfile.lock 96 | 97 | # UV 98 | # Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control. 99 | # This is especially recommended for binary packages to ensure reproducibility, and is more 100 | # commonly ignored for libraries. 101 | #uv.lock 102 | 103 | # poetry 104 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 105 | # This is especially recommended for binary packages to ensure reproducibility, and is more 106 | # commonly ignored for libraries. 107 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 108 | #poetry.lock 109 | #poetry.toml 110 | 111 | # pdm 112 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 113 | # pdm recommends including project-wide configuration in pdm.toml, but excluding .pdm-python. 114 | # https://pdm-project.org/en/latest/usage/project/#working-with-version-control 115 | #pdm.lock 116 | #pdm.toml 117 | .pdm-python 118 | .pdm-build/ 119 | 120 | # pixi 121 | # Similar to Pipfile.lock, it is generally recommended to include pixi.lock in version control. 122 | #pixi.lock 123 | # Pixi creates a virtual environment in the .pixi directory, just like venv module creates one 124 | # in the .venv directory. It is recommended not to include this directory in version control. 125 | .pixi 126 | 127 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 128 | __pypackages__/ 129 | 130 | # Celery stuff 131 | celerybeat-schedule 132 | celerybeat.pid 133 | 134 | # SageMath parsed files 135 | *.sage.py 136 | 137 | # Environments 138 | .env 139 | .envrc 140 | .venv 141 | env/ 142 | venv/ 143 | ENV/ 144 | env.bak/ 145 | venv.bak/ 146 | 147 | # Spyder project settings 148 | .spyderproject 149 | .spyproject 150 | 151 | # Rope project settings 152 | .ropeproject 153 | 154 | # mkdocs documentation 155 | /site 156 | 157 | # mypy 158 | .mypy_cache/ 159 | .dmypy.json 160 | dmypy.json 161 | 162 | # Pyre type checker 163 | .pyre/ 164 | 165 | # pytype static type analyzer 166 | .pytype/ 167 | 168 | # Cython debug symbols 169 | cython_debug/ 170 | 171 | # PyCharm 172 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 173 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 174 | # and can be added to the global gitignore or merged into this file. For a more nuclear 175 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 176 | #.idea/ 177 | 178 | # Abstra 179 | # Abstra is an AI-powered process automation framework. 180 | # Ignore directories containing user credentials, local state, and settings. 181 | # Learn more at https://abstra.io/docs 182 | .abstra/ 183 | 184 | # Visual Studio Code 185 | # Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore 186 | # that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore 187 | # and can be added to the global gitignore or merged into this file. However, if you prefer, 188 | # you could uncomment the following to ignore the entire vscode folder 189 | # .vscode/ 190 | 191 | # Ruff stuff: 192 | .ruff_cache/ 193 | 194 | # PyPI configuration file 195 | .pypirc 196 | 197 | # Marimo 198 | marimo/_static/ 199 | marimo/_lsp/ 200 | __marimo__/ 201 | 202 | # Streamlit 203 | .streamlit/secrets.toml 204 | -------------------------------------------------------------------------------- /tests/test_ulidfield.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from django.core.exceptions import ValidationError 3 | from django.db import models 4 | from ulid import ULID 5 | 6 | from django_ulidfield import ULIDField 7 | 8 | 9 | class MockModel(models.Model): 10 | id = ULIDField(primary_key=True) 11 | 12 | class Meta: 13 | app_label = "tests" 14 | 15 | 16 | class NonPrimaryKeyModel(models.Model): 17 | id = models.AutoField(primary_key=True) 18 | ulid_field = ULIDField() 19 | 20 | class Meta: 21 | app_label = "tests" 22 | 23 | 24 | class NonPrimaryKeyModelNoDefault(models.Model): 25 | id = models.AutoField(primary_key=True) 26 | ulid_field = ULIDField(default=None, null=True) 27 | 28 | class Meta: 29 | app_label = "tests" 30 | 31 | 32 | class BlankAllowedModel(models.Model): 33 | id = models.AutoField(primary_key=True) 34 | ulid_field = ULIDField(blank=True, default="") 35 | 36 | class Meta: 37 | app_label = "tests" 38 | 39 | 40 | @pytest.mark.django_db 41 | def test_ulid_field(): 42 | """Test that ULID field generates a valid ULID.""" 43 | obj = MockModel.objects.create() 44 | assert obj.id is not None 45 | assert len(obj.id) == 26 46 | assert isinstance(obj.id, str) 47 | 48 | 49 | @pytest.mark.django_db 50 | def test_ulid_field_persistence(): 51 | """Test that ULID field persists correctly in the database.""" 52 | obj = MockModel.objects.create() 53 | res = MockModel.objects.get(id=obj.id) 54 | assert res.id == obj.id 55 | 56 | 57 | @pytest.mark.django_db 58 | def test_ulid_field_validation_valid_ulid(): 59 | """Test that valid ULID strings are accepted.""" 60 | valid_ulid = str(ULID()) 61 | obj = NonPrimaryKeyModel.objects.create(ulid_field=valid_ulid) 62 | assert obj.ulid_field == valid_ulid 63 | 64 | 65 | @pytest.mark.django_db 66 | def test_ulid_field_validation_invalid_string(): 67 | """Test that invalid ULID strings are rejected.""" 68 | with pytest.raises(ValidationError) as exc_info: 69 | obj = NonPrimaryKeyModel(ulid_field="invalid-ulid-string") 70 | obj.full_clean() # This triggers field validation 71 | 72 | assert "not a valid ULID" in str(exc_info.value) 73 | 74 | 75 | @pytest.mark.django_db 76 | def test_ulid_field_validation_wrong_length(): 77 | """Test that strings with wrong length are rejected.""" 78 | with pytest.raises(ValidationError) as exc_info: 79 | obj = NonPrimaryKeyModel(ulid_field="TOO_SHORT") 80 | obj.full_clean() 81 | 82 | assert "not a valid ULID" in str(exc_info.value) 83 | 84 | 85 | @pytest.mark.django_db 86 | def test_ulid_field_validation_invalid_characters(): 87 | """Test that strings with invalid characters are rejected.""" 88 | with pytest.raises(ValidationError) as exc_info: 89 | # Use a 26-character string with invalid base32 characters (I, L, O, U) 90 | obj = NonPrimaryKeyModel( 91 | ulid_field="01ILOU567890123456789012IL" 92 | ) # Contains I, L, O, U 93 | obj.full_clean() 94 | 95 | assert "not a valid ULID" in str(exc_info.value) 96 | 97 | 98 | @pytest.mark.django_db 99 | def test_ulid_field_empty_string_with_blank_false(): 100 | """Test that empty strings are handled when blank=False (default).""" 101 | # Note: Django's blank=False means "required in forms" but doesn't automatically 102 | # replace explicitly-set empty strings with defaults during validation. 103 | # This is standard Django behavior. 104 | obj = NonPrimaryKeyModel(ulid_field="") 105 | 106 | # Since we explicitly set an empty string AND our validator allows empty strings 107 | # (leaving blank validation to Django), this should pass validation 108 | obj.full_clean() 109 | 110 | # The empty string should remain as-is since we explicitly set it 111 | assert obj.ulid_field == "" 112 | 113 | 114 | @pytest.mark.django_db 115 | def test_ulid_field_uses_default_when_not_specified(): 116 | """Test that default ULID is generated when no value is specified.""" 117 | # When we don't specify a value, the default should be used 118 | obj = NonPrimaryKeyModel() # No ulid_field specified 119 | 120 | # The default should have been applied 121 | assert obj.ulid_field is not None 122 | assert len(obj.ulid_field) == 26 123 | assert obj.ulid_field != "" 124 | 125 | 126 | @pytest.mark.django_db 127 | def test_ulid_field_empty_string_with_blank_true(): 128 | """Test that empty strings are allowed when blank=True.""" 129 | obj = BlankAllowedModel(ulid_field="") 130 | obj.full_clean() # Should not raise ValidationError 131 | 132 | # The field should accept the empty string 133 | assert obj.ulid_field == "" 134 | 135 | 136 | def test_ulid_field_allows_null_if_configured(): 137 | """Test that null values are allowed when null=True.""" 138 | 139 | class NullableULIDModel(models.Model): 140 | nullable_ulid = ULIDField(null=True, blank=True) 141 | 142 | class Meta: 143 | app_label = "tests" 144 | 145 | # This should not raise an exception 146 | NullableULIDModel(nullable_ulid=None) 147 | # Note: We can't test full_clean() without creating the table, 148 | # but we can test that the validator allows None 149 | from django_ulidfield.fields import validate_ulid 150 | 151 | validate_ulid(None) # Should not raise 152 | 153 | 154 | def test_validate_ulid_function(): 155 | """Test the validate_ulid function directly.""" 156 | from django_ulidfield.fields import validate_ulid 157 | 158 | # Valid ULID should not raise 159 | valid_ulid = str(ULID()) 160 | validate_ulid(valid_ulid) 161 | 162 | # Invalid ULID should raise ValidationError 163 | with pytest.raises(ValidationError): 164 | validate_ulid("invalid") 165 | 166 | # None should not raise (handled by field's null/blank settings) 167 | validate_ulid(None) 168 | 169 | # Empty string should not raise (handled by field's blank setting) 170 | validate_ulid("") 171 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![PyPI version](https://img.shields.io/pypi/v/django-ulidfield.svg)](https://pypi.org/project/django-ulidfield/) 2 | [![CI](https://github.com/dumaas/django-ulidfield/actions/workflows/test.yaml/badge.svg)](https://github.com/dumaas/django-ulidfield/actions/workflows/test.yaml) 3 | [![License](https://img.shields.io/github/license/dumaas/django-ulidfield)](LICENSE) 4 | [![Docs](https://readthedocs.org/projects/django-ulidfield/badge/?version=latest)](https://django-ulidfield.readthedocs.io/en/latest/) 5 | [![Published on Django Packages](https://img.shields.io/badge/Published%20on-Django%20Packages-0c3c26)](https://djangopackages.org/packages/p/django-ulidfield/) 6 | 7 | # django-ulidfield 8 | 9 | A drop-in Django model field for storing sortable, time-encoded ULIDs as 26-character strings. 10 | 11 | ## What are ULIDs? 12 | 13 | ULIDs (Universally Unique Lexicographically Sortable Identifiers) are a modern alternative to UUIDs that combine the benefits of both sequential integers and random UUIDs. They consist of: 14 | 15 | - **48-bit timestamp** (milliseconds since Unix epoch) 16 | - **80-bit randomness** 17 | 18 | ``` 19 | 01AN4Z07BY 79KA1307SR9X4MV3 20 | |----------------| |------------------------| 21 | time randomness 22 | 48bits 80bits 23 | ``` 24 | 25 | ULIDs are encoded in base-32 (Crockford's Base32) resulting in 26-character strings that are: 26 | - **Sortable** by creation time 27 | - **URL-safe** (no special characters) 28 | - **Case-insensitive** 29 | - **Compatible** with UUID storage (128-bit) 30 | 31 | ## Why ULIDs over UUIDs? 32 | 33 | As explained in Brandur Leach's article ["Identity Crisis: Sequence v. UUID as Primary Key"](https://brandur.org/nanoglyphs/026-ids), ULIDs solve several problems with traditional UUID v4: 34 | 35 | ### Problems with Random UUIDs 36 | - **Poor database performance**: Random UUIDs cause index fragmentation and cache misses 37 | - **High WAL overhead**: More write-ahead log data due to scattered page updates 38 | - **No temporal ordering**: Can't sort by creation time 39 | 40 | ### ULID Advantages 41 | - **Time-ordered**: ULIDs sort naturally by creation time 42 | - **Better database performance**: Sequential timestamp prefix reduces index fragmentation 43 | - **Distributed generation**: No single point of failure like auto-incrementing integers 44 | - **Opaque to users**: Prevents enumeration attacks and business intelligence leakage 45 | - **UUID compatible**: Can be stored in UUID columns when needed 46 | 47 | ## Installation 48 | 49 | ```bash 50 | pip install django-ulidfield 51 | ``` 52 | 53 | Or with Poetry: 54 | 55 | ```bash 56 | poetry add django-ulidfield 57 | ``` 58 | 59 | ## Usage 60 | 61 | ### Basic Usage 62 | 63 | ```python 64 | from django.db import models 65 | from django_ulidfield import ULIDField 66 | 67 | class Article(models.Model): 68 | id = ULIDField(primary_key=True) 69 | title = models.CharField(max_length=200) 70 | content = models.TextField() 71 | created_at = models.DateTimeField(auto_now_add=True) 72 | 73 | # ULIDs are automatically generated 74 | article = Article.objects.create(title="Hello World", content="...") 75 | print(article.id) # Output: 01AN4Z07BY79KA1307SR9X4MV3 76 | ``` 77 | 78 | ### Non-Primary Key Usage 79 | 80 | ```python 81 | class Order(models.Model): 82 | id = models.AutoField(primary_key=True) 83 | order_id = ULIDField() # Unique by default 84 | customer_email = models.EmailField() 85 | total = models.DecimalField(max_digits=10, decimal_places=2) 86 | ``` 87 | 88 | ### Custom Configuration 89 | 90 | ```python 91 | class Document(models.Model): 92 | # Allow null values 93 | doc_id = ULIDField(null=True, blank=True) 94 | 95 | # Custom default function 96 | tracking_id = ULIDField(default=None, null=True) 97 | 98 | # Allow duplicates (not recommended) 99 | reference_id = ULIDField(unique=False) 100 | ``` 101 | 102 | ## Field Options 103 | 104 | `ULIDField` inherits from Django's `CharField` and accepts all the same options, with these defaults: 105 | 106 | - `max_length=26` (ULIDs are always 26 characters) 107 | - `unique=True` (ULIDs should be unique) 108 | - `editable=False` (ULIDs are typically auto-generated) 109 | - `default=generate_ulid` (automatically generates new ULIDs) 110 | - `blank=False` (ULIDs are required by default) 111 | 112 | ## Validation 113 | 114 | The field automatically validates that values are proper ULIDs: 115 | 116 | ```python 117 | # This will raise a ValidationError 118 | invalid_article = Article(id=\"invalid-ulid\") 119 | invalid_article.full_clean() # ValidationError: 'invalid-ulid' is not a valid ULID 120 | ``` 121 | 122 | ## Database Considerations 123 | 124 | ### Index Performance 125 | ULIDs provide better database performance than random UUIDs because: 126 | - The timestamp prefix keeps new insertions clustered together 127 | - Reduces index page splits and cache misses 128 | - Minimizes write-ahead log (WAL) overhead 129 | 130 | ### Storage 131 | - **Database storage**: 26 characters (can be optimized to 16 bytes in UUID columns) 132 | - **Memory/JSON**: 26-character string 133 | - **URL-safe**: Can be used directly in URLs 134 | 135 | ## Migration from UUIDs 136 | 137 | If you're migrating from UUIDs, you can: 138 | 139 | 1. **Direct replacement** (new records only): 140 | ```python 141 | # Change this: 142 | id = models.UUIDField(primary_key=True, default=uuid.uuid4) 143 | 144 | # To this: 145 | id = ULIDField(primary_key=True) 146 | ``` 147 | 148 | 2. **Gradual migration** (with a new field): 149 | ```python 150 | class MyModel(models.Model): 151 | id = models.UUIDField(primary_key=True, default=uuid.uuid4) # Keep existing 152 | ulid = ULIDField(null=True, blank=True) # Add new field 153 | ``` 154 | 155 | ## Time Extraction 156 | 157 | You can extract the timestamp from a ULID: 158 | 159 | ```python 160 | from ulid import ULID 161 | 162 | # Get timestamp from ULID 163 | ulid_obj = ULID.from_str(article.id) 164 | timestamp = ulid_obj.timestamp() 165 | datetime_obj = ulid_obj.datetime() 166 | ``` 167 | 168 | ## Development 169 | 170 | ### Setup 171 | ```bash 172 | git clone https://github.com/your-username/django-ulidfield 173 | cd django-ulidfield 174 | poetry install 175 | poetry run pre-commit install 176 | ``` 177 | 178 | ### Running Tests 179 | ```bash 180 | poetry run pytest 181 | ``` 182 | 183 | ### Code Quality 184 | This project uses: 185 | - **Ruff** for linting and formatting 186 | - **pytest** for testing 187 | - **pre-commit** for code quality checks 188 | 189 | ## Requirements 190 | 191 | - Python 3.9+ 192 | - Django 4.2+ 193 | - python-ulid 3.0.0+ 194 | 195 | ## License 196 | 197 | MIT License - see LICENSE file for details. 198 | 199 | ## Related Resources 200 | 201 | - [ULID Specification](https://github.com/ulid/spec) 202 | - [\"Identity Crisis: Sequence v. UUID as Primary Key\"](https://brandur.org/nanoglyphs/026-ids) - Deep dive on database identifier strategies 203 | - [python-ulid library](https://pypi.org/project/python-ulid/) 204 | 205 | ## Contributing 206 | 207 | Contributions are welcome! Please feel free to submit a Pull Request. 208 | -------------------------------------------------------------------------------- /poetry.lock: -------------------------------------------------------------------------------- 1 | # This file is automatically @generated by Poetry 2.1.3 and should not be changed by hand. 2 | 3 | [[package]] 4 | name = "asgiref" 5 | version = "3.9.1" 6 | description = "ASGI specs, helper code, and adapters" 7 | optional = false 8 | python-versions = ">=3.9" 9 | groups = ["main"] 10 | files = [ 11 | {file = "asgiref-3.9.1-py3-none-any.whl", hash = "sha256:f3bba7092a48005b5f5bacd747d36ee4a5a61f4a269a6df590b43144355ebd2c"}, 12 | {file = "asgiref-3.9.1.tar.gz", hash = "sha256:a5ab6582236218e5ef1648f242fd9f10626cfd4de8dc377db215d5d5098e3142"}, 13 | ] 14 | 15 | [package.dependencies] 16 | typing_extensions = {version = ">=4", markers = "python_version < \"3.11\""} 17 | 18 | [package.extras] 19 | tests = ["mypy (>=1.14.0)", "pytest", "pytest-asyncio"] 20 | 21 | [[package]] 22 | name = "cfgv" 23 | version = "3.4.0" 24 | description = "Validate configuration and produce human readable error messages." 25 | optional = false 26 | python-versions = ">=3.8" 27 | groups = ["dev"] 28 | files = [ 29 | {file = "cfgv-3.4.0-py2.py3-none-any.whl", hash = "sha256:b7265b1f29fd3316bfcd2b330d63d024f2bfd8bcb8b0272f8e19a504856c48f9"}, 30 | {file = "cfgv-3.4.0.tar.gz", hash = "sha256:e52591d4c5f5dead8e0f673fb16db7949d2cfb3f7da4582893288f0ded8fe560"}, 31 | ] 32 | 33 | [[package]] 34 | name = "click" 35 | version = "8.1.8" 36 | description = "Composable command line interface toolkit" 37 | optional = false 38 | python-versions = ">=3.7" 39 | groups = ["docs"] 40 | files = [ 41 | {file = "click-8.1.8-py3-none-any.whl", hash = "sha256:63c132bbbed01578a06712a2d1f497bb62d9c1c0d329b7903a866228027263b2"}, 42 | {file = "click-8.1.8.tar.gz", hash = "sha256:ed53c9d8990d83c2a27deae68e4ee337473f6330c040a31d4225c9574d16096a"}, 43 | ] 44 | 45 | [package.dependencies] 46 | colorama = {version = "*", markers = "platform_system == \"Windows\""} 47 | 48 | [[package]] 49 | name = "colorama" 50 | version = "0.4.6" 51 | description = "Cross-platform colored terminal text." 52 | optional = false 53 | python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" 54 | groups = ["dev", "docs"] 55 | files = [ 56 | {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, 57 | {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, 58 | ] 59 | markers = {dev = "sys_platform == \"win32\"", docs = "platform_system == \"Windows\""} 60 | 61 | [[package]] 62 | name = "distlib" 63 | version = "0.4.0" 64 | description = "Distribution utilities" 65 | optional = false 66 | python-versions = "*" 67 | groups = ["dev"] 68 | files = [ 69 | {file = "distlib-0.4.0-py2.py3-none-any.whl", hash = "sha256:9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16"}, 70 | {file = "distlib-0.4.0.tar.gz", hash = "sha256:feec40075be03a04501a973d81f633735b4b69f98b05450592310c0f401a4e0d"}, 71 | ] 72 | 73 | [[package]] 74 | name = "django" 75 | version = "4.2.23" 76 | description = "A high-level Python web framework that encourages rapid development and clean, pragmatic design." 77 | optional = false 78 | python-versions = ">=3.8" 79 | groups = ["main"] 80 | files = [ 81 | {file = "django-4.2.23-py3-none-any.whl", hash = "sha256:dafbfaf52c2f289bd65f4ab935791cb4fb9a198f2a5ba9faf35d7338a77e9803"}, 82 | {file = "django-4.2.23.tar.gz", hash = "sha256:42fdeaba6e6449d88d4f66de47871015097dc6f1b87910db00a91946295cfae4"}, 83 | ] 84 | 85 | [package.dependencies] 86 | asgiref = ">=3.6.0,<4" 87 | sqlparse = ">=0.3.1" 88 | tzdata = {version = "*", markers = "sys_platform == \"win32\""} 89 | 90 | [package.extras] 91 | argon2 = ["argon2-cffi (>=19.1.0)"] 92 | bcrypt = ["bcrypt"] 93 | 94 | [[package]] 95 | name = "exceptiongroup" 96 | version = "1.3.0" 97 | description = "Backport of PEP 654 (exception groups)" 98 | optional = false 99 | python-versions = ">=3.7" 100 | groups = ["dev"] 101 | markers = "python_version < \"3.11\"" 102 | files = [ 103 | {file = "exceptiongroup-1.3.0-py3-none-any.whl", hash = "sha256:4d111e6e0c13d0644cad6ddaa7ed0261a0b36971f6d23e7ec9b4b9097da78a10"}, 104 | {file = "exceptiongroup-1.3.0.tar.gz", hash = "sha256:b241f5885f560bc56a59ee63ca4c6a8bfa46ae4ad651af316d4e81817bb9fd88"}, 105 | ] 106 | 107 | [package.dependencies] 108 | typing-extensions = {version = ">=4.6.0", markers = "python_version < \"3.13\""} 109 | 110 | [package.extras] 111 | test = ["pytest (>=6)"] 112 | 113 | [[package]] 114 | name = "filelock" 115 | version = "3.18.0" 116 | description = "A platform independent file lock." 117 | optional = false 118 | python-versions = ">=3.9" 119 | groups = ["dev"] 120 | files = [ 121 | {file = "filelock-3.18.0-py3-none-any.whl", hash = "sha256:c401f4f8377c4464e6db25fff06205fd89bdd83b65eb0488ed1b160f780e21de"}, 122 | {file = "filelock-3.18.0.tar.gz", hash = "sha256:adbc88eabb99d2fec8c9c1b229b171f18afa655400173ddc653d5d01501fb9f2"}, 123 | ] 124 | 125 | [package.extras] 126 | docs = ["furo (>=2024.8.6)", "sphinx (>=8.1.3)", "sphinx-autodoc-typehints (>=3)"] 127 | testing = ["covdefaults (>=2.3)", "coverage (>=7.6.10)", "diff-cover (>=9.2.1)", "pytest (>=8.3.4)", "pytest-asyncio (>=0.25.2)", "pytest-cov (>=6)", "pytest-mock (>=3.14)", "pytest-timeout (>=2.3.1)", "virtualenv (>=20.28.1)"] 128 | typing = ["typing-extensions (>=4.12.2) ; python_version < \"3.11\""] 129 | 130 | [[package]] 131 | name = "ghp-import" 132 | version = "2.1.0" 133 | description = "Copy your docs directly to the gh-pages branch." 134 | optional = false 135 | python-versions = "*" 136 | groups = ["docs"] 137 | files = [ 138 | {file = "ghp-import-2.1.0.tar.gz", hash = "sha256:9c535c4c61193c2df8871222567d7fd7e5014d835f97dc7b7439069e2413d343"}, 139 | {file = "ghp_import-2.1.0-py3-none-any.whl", hash = "sha256:8337dd7b50877f163d4c0289bc1f1c7f127550241988d568c1db512c4324a619"}, 140 | ] 141 | 142 | [package.dependencies] 143 | python-dateutil = ">=2.8.1" 144 | 145 | [package.extras] 146 | dev = ["flake8", "markdown", "twine", "wheel"] 147 | 148 | [[package]] 149 | name = "identify" 150 | version = "2.6.12" 151 | description = "File identification library for Python" 152 | optional = false 153 | python-versions = ">=3.9" 154 | groups = ["dev"] 155 | files = [ 156 | {file = "identify-2.6.12-py2.py3-none-any.whl", hash = "sha256:ad9672d5a72e0d2ff7c5c8809b62dfa60458626352fb0eb7b55e69bdc45334a2"}, 157 | {file = "identify-2.6.12.tar.gz", hash = "sha256:d8de45749f1efb108badef65ee8386f0f7bb19a7f26185f74de6367bffbaf0e6"}, 158 | ] 159 | 160 | [package.extras] 161 | license = ["ukkonen"] 162 | 163 | [[package]] 164 | name = "importlib-metadata" 165 | version = "8.7.0" 166 | description = "Read metadata from Python packages" 167 | optional = false 168 | python-versions = ">=3.9" 169 | groups = ["docs"] 170 | markers = "python_version == \"3.9\"" 171 | files = [ 172 | {file = "importlib_metadata-8.7.0-py3-none-any.whl", hash = "sha256:e5dd1551894c77868a30651cef00984d50e1002d06942a7101d34870c5f02afd"}, 173 | {file = "importlib_metadata-8.7.0.tar.gz", hash = "sha256:d13b81ad223b890aa16c5471f2ac3056cf76c5f10f82d6f9292f0b415f389000"}, 174 | ] 175 | 176 | [package.dependencies] 177 | zipp = ">=3.20" 178 | 179 | [package.extras] 180 | check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1) ; sys_platform != \"cygwin\""] 181 | cover = ["pytest-cov"] 182 | doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] 183 | enabler = ["pytest-enabler (>=2.2)"] 184 | perf = ["ipython"] 185 | test = ["flufl.flake8", "importlib_resources (>=1.3) ; python_version < \"3.9\"", "jaraco.test (>=5.4)", "packaging", "pyfakefs", "pytest (>=6,!=8.1.*)", "pytest-perf (>=0.9.2)"] 186 | type = ["pytest-mypy"] 187 | 188 | [[package]] 189 | name = "iniconfig" 190 | version = "2.1.0" 191 | description = "brain-dead simple config-ini parsing" 192 | optional = false 193 | python-versions = ">=3.8" 194 | groups = ["dev"] 195 | files = [ 196 | {file = "iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760"}, 197 | {file = "iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7"}, 198 | ] 199 | 200 | [[package]] 201 | name = "jinja2" 202 | version = "3.1.6" 203 | description = "A very fast and expressive template engine." 204 | optional = false 205 | python-versions = ">=3.7" 206 | groups = ["docs"] 207 | files = [ 208 | {file = "jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67"}, 209 | {file = "jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d"}, 210 | ] 211 | 212 | [package.dependencies] 213 | MarkupSafe = ">=2.0" 214 | 215 | [package.extras] 216 | i18n = ["Babel (>=2.7)"] 217 | 218 | [[package]] 219 | name = "markdown" 220 | version = "3.8.2" 221 | description = "Python implementation of John Gruber's Markdown." 222 | optional = false 223 | python-versions = ">=3.9" 224 | groups = ["docs"] 225 | files = [ 226 | {file = "markdown-3.8.2-py3-none-any.whl", hash = "sha256:5c83764dbd4e00bdd94d85a19b8d55ccca20fe35b2e678a1422b380324dd5f24"}, 227 | {file = "markdown-3.8.2.tar.gz", hash = "sha256:247b9a70dd12e27f67431ce62523e675b866d254f900c4fe75ce3dda62237c45"}, 228 | ] 229 | 230 | [package.dependencies] 231 | importlib-metadata = {version = ">=4.4", markers = "python_version < \"3.10\""} 232 | 233 | [package.extras] 234 | docs = ["mdx_gh_links (>=0.2)", "mkdocs (>=1.6)", "mkdocs-gen-files", "mkdocs-literate-nav", "mkdocs-nature (>=0.6)", "mkdocs-section-index", "mkdocstrings[python]"] 235 | testing = ["coverage", "pyyaml"] 236 | 237 | [[package]] 238 | name = "markupsafe" 239 | version = "3.0.2" 240 | description = "Safely add untrusted strings to HTML/XML markup." 241 | optional = false 242 | python-versions = ">=3.9" 243 | groups = ["docs"] 244 | files = [ 245 | {file = "MarkupSafe-3.0.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:7e94c425039cde14257288fd61dcfb01963e658efbc0ff54f5306b06054700f8"}, 246 | {file = "MarkupSafe-3.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9e2d922824181480953426608b81967de705c3cef4d1af983af849d7bd619158"}, 247 | {file = "MarkupSafe-3.0.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:38a9ef736c01fccdd6600705b09dc574584b89bea478200c5fbf112a6b0d5579"}, 248 | {file = "MarkupSafe-3.0.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bbcb445fa71794da8f178f0f6d66789a28d7319071af7a496d4d507ed566270d"}, 249 | {file = "MarkupSafe-3.0.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:57cb5a3cf367aeb1d316576250f65edec5bb3be939e9247ae594b4bcbc317dfb"}, 250 | {file = "MarkupSafe-3.0.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:3809ede931876f5b2ec92eef964286840ed3540dadf803dd570c3b7e13141a3b"}, 251 | {file = "MarkupSafe-3.0.2-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e07c3764494e3776c602c1e78e298937c3315ccc9043ead7e685b7f2b8d47b3c"}, 252 | {file = "MarkupSafe-3.0.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:b424c77b206d63d500bcb69fa55ed8d0e6a3774056bdc4839fc9298a7edca171"}, 253 | {file = "MarkupSafe-3.0.2-cp310-cp310-win32.whl", hash = "sha256:fcabf5ff6eea076f859677f5f0b6b5c1a51e70a376b0579e0eadef8db48c6b50"}, 254 | {file = "MarkupSafe-3.0.2-cp310-cp310-win_amd64.whl", hash = "sha256:6af100e168aa82a50e186c82875a5893c5597a0c1ccdb0d8b40240b1f28b969a"}, 255 | {file = "MarkupSafe-3.0.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:9025b4018f3a1314059769c7bf15441064b2207cb3f065e6ea1e7359cb46db9d"}, 256 | {file = "MarkupSafe-3.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:93335ca3812df2f366e80509ae119189886b0f3c2b81325d39efdb84a1e2ae93"}, 257 | {file = "MarkupSafe-3.0.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2cb8438c3cbb25e220c2ab33bb226559e7afb3baec11c4f218ffa7308603c832"}, 258 | {file = "MarkupSafe-3.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a123e330ef0853c6e822384873bef7507557d8e4a082961e1defa947aa59ba84"}, 259 | {file = "MarkupSafe-3.0.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1e084f686b92e5b83186b07e8a17fc09e38fff551f3602b249881fec658d3eca"}, 260 | {file = "MarkupSafe-3.0.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d8213e09c917a951de9d09ecee036d5c7d36cb6cb7dbaece4c71a60d79fb9798"}, 261 | {file = "MarkupSafe-3.0.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:5b02fb34468b6aaa40dfc198d813a641e3a63b98c2b05a16b9f80b7ec314185e"}, 262 | {file = "MarkupSafe-3.0.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:0bff5e0ae4ef2e1ae4fdf2dfd5b76c75e5c2fa4132d05fc1b0dabcd20c7e28c4"}, 263 | {file = "MarkupSafe-3.0.2-cp311-cp311-win32.whl", hash = "sha256:6c89876f41da747c8d3677a2b540fb32ef5715f97b66eeb0c6b66f5e3ef6f59d"}, 264 | {file = "MarkupSafe-3.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:70a87b411535ccad5ef2f1df5136506a10775d267e197e4cf531ced10537bd6b"}, 265 | {file = "MarkupSafe-3.0.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:9778bd8ab0a994ebf6f84c2b949e65736d5575320a17ae8984a77fab08db94cf"}, 266 | {file = "MarkupSafe-3.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:846ade7b71e3536c4e56b386c2a47adf5741d2d8b94ec9dc3e92e5e1ee1e2225"}, 267 | {file = "MarkupSafe-3.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1c99d261bd2d5f6b59325c92c73df481e05e57f19837bdca8413b9eac4bd8028"}, 268 | {file = "MarkupSafe-3.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e17c96c14e19278594aa4841ec148115f9c7615a47382ecb6b82bd8fea3ab0c8"}, 269 | {file = "MarkupSafe-3.0.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:88416bd1e65dcea10bc7569faacb2c20ce071dd1f87539ca2ab364bf6231393c"}, 270 | {file = "MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2181e67807fc2fa785d0592dc2d6206c019b9502410671cc905d132a92866557"}, 271 | {file = "MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:52305740fe773d09cffb16f8ed0427942901f00adedac82ec8b67752f58a1b22"}, 272 | {file = "MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ad10d3ded218f1039f11a75f8091880239651b52e9bb592ca27de44eed242a48"}, 273 | {file = "MarkupSafe-3.0.2-cp312-cp312-win32.whl", hash = "sha256:0f4ca02bea9a23221c0182836703cbf8930c5e9454bacce27e767509fa286a30"}, 274 | {file = "MarkupSafe-3.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:8e06879fc22a25ca47312fbe7c8264eb0b662f6db27cb2d3bbbc74b1df4b9b87"}, 275 | {file = "MarkupSafe-3.0.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ba9527cdd4c926ed0760bc301f6728ef34d841f405abf9d4f959c478421e4efd"}, 276 | {file = "MarkupSafe-3.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f8b3d067f2e40fe93e1ccdd6b2e1d16c43140e76f02fb1319a05cf2b79d99430"}, 277 | {file = "MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:569511d3b58c8791ab4c2e1285575265991e6d8f8700c7be0e88f86cb0672094"}, 278 | {file = "MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:15ab75ef81add55874e7ab7055e9c397312385bd9ced94920f2802310c930396"}, 279 | {file = "MarkupSafe-3.0.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f3818cb119498c0678015754eba762e0d61e5b52d34c8b13d770f0719f7b1d79"}, 280 | {file = "MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:cdb82a876c47801bb54a690c5ae105a46b392ac6099881cdfb9f6e95e4014c6a"}, 281 | {file = "MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:cabc348d87e913db6ab4aa100f01b08f481097838bdddf7c7a84b7575b7309ca"}, 282 | {file = "MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:444dcda765c8a838eaae23112db52f1efaf750daddb2d9ca300bcae1039adc5c"}, 283 | {file = "MarkupSafe-3.0.2-cp313-cp313-win32.whl", hash = "sha256:bcf3e58998965654fdaff38e58584d8937aa3096ab5354d493c77d1fdd66d7a1"}, 284 | {file = "MarkupSafe-3.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:e6a2a455bd412959b57a172ce6328d2dd1f01cb2135efda2e4576e8a23fa3b0f"}, 285 | {file = "MarkupSafe-3.0.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:b5a6b3ada725cea8a5e634536b1b01c30bcdcd7f9c6fff4151548d5bf6b3a36c"}, 286 | {file = "MarkupSafe-3.0.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:a904af0a6162c73e3edcb969eeeb53a63ceeb5d8cf642fade7d39e7963a22ddb"}, 287 | {file = "MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4aa4e5faecf353ed117801a068ebab7b7e09ffb6e1d5e412dc852e0da018126c"}, 288 | {file = "MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c0ef13eaeee5b615fb07c9a7dadb38eac06a0608b41570d8ade51c56539e509d"}, 289 | {file = "MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d16a81a06776313e817c951135cf7340a3e91e8c1ff2fac444cfd75fffa04afe"}, 290 | {file = "MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:6381026f158fdb7c72a168278597a5e3a5222e83ea18f543112b2662a9b699c5"}, 291 | {file = "MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:3d79d162e7be8f996986c064d1c7c817f6df3a77fe3d6859f6f9e7be4b8c213a"}, 292 | {file = "MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:131a3c7689c85f5ad20f9f6fb1b866f402c445b220c19fe4308c0b147ccd2ad9"}, 293 | {file = "MarkupSafe-3.0.2-cp313-cp313t-win32.whl", hash = "sha256:ba8062ed2cf21c07a9e295d5b8a2a5ce678b913b45fdf68c32d95d6c1291e0b6"}, 294 | {file = "MarkupSafe-3.0.2-cp313-cp313t-win_amd64.whl", hash = "sha256:e444a31f8db13eb18ada366ab3cf45fd4b31e4db1236a4448f68778c1d1a5a2f"}, 295 | {file = "MarkupSafe-3.0.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:eaa0a10b7f72326f1372a713e73c3f739b524b3af41feb43e4921cb529f5929a"}, 296 | {file = "MarkupSafe-3.0.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:48032821bbdf20f5799ff537c7ac3d1fba0ba032cfc06194faffa8cda8b560ff"}, 297 | {file = "MarkupSafe-3.0.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1a9d3f5f0901fdec14d8d2f66ef7d035f2157240a433441719ac9a3fba440b13"}, 298 | {file = "MarkupSafe-3.0.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:88b49a3b9ff31e19998750c38e030fc7bb937398b1f78cfa599aaef92d693144"}, 299 | {file = "MarkupSafe-3.0.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:cfad01eed2c2e0c01fd0ecd2ef42c492f7f93902e39a42fc9ee1692961443a29"}, 300 | {file = "MarkupSafe-3.0.2-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:1225beacc926f536dc82e45f8a4d68502949dc67eea90eab715dea3a21c1b5f0"}, 301 | {file = "MarkupSafe-3.0.2-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:3169b1eefae027567d1ce6ee7cae382c57fe26e82775f460f0b2778beaad66c0"}, 302 | {file = "MarkupSafe-3.0.2-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:eb7972a85c54febfb25b5c4b4f3af4dcc731994c7da0d8a0b4a6eb0640e1d178"}, 303 | {file = "MarkupSafe-3.0.2-cp39-cp39-win32.whl", hash = "sha256:8c4e8c3ce11e1f92f6536ff07154f9d49677ebaaafc32db9db4620bc11ed480f"}, 304 | {file = "MarkupSafe-3.0.2-cp39-cp39-win_amd64.whl", hash = "sha256:6e296a513ca3d94054c2c881cc913116e90fd030ad1c656b3869762b754f5f8a"}, 305 | {file = "markupsafe-3.0.2.tar.gz", hash = "sha256:ee55d3edf80167e48ea11a923c7386f4669df67d7994554387f84e7d8b0a2bf0"}, 306 | ] 307 | 308 | [[package]] 309 | name = "mergedeep" 310 | version = "1.3.4" 311 | description = "A deep merge function for 🐍." 312 | optional = false 313 | python-versions = ">=3.6" 314 | groups = ["docs"] 315 | files = [ 316 | {file = "mergedeep-1.3.4-py3-none-any.whl", hash = "sha256:70775750742b25c0d8f36c55aed03d24c3384d17c951b3175d898bd778ef0307"}, 317 | {file = "mergedeep-1.3.4.tar.gz", hash = "sha256:0096d52e9dad9939c3d975a774666af186eda617e6ca84df4c94dec30004f2a8"}, 318 | ] 319 | 320 | [[package]] 321 | name = "mkdocs" 322 | version = "1.6.1" 323 | description = "Project documentation with Markdown." 324 | optional = false 325 | python-versions = ">=3.8" 326 | groups = ["docs"] 327 | files = [ 328 | {file = "mkdocs-1.6.1-py3-none-any.whl", hash = "sha256:db91759624d1647f3f34aa0c3f327dd2601beae39a366d6e064c03468d35c20e"}, 329 | {file = "mkdocs-1.6.1.tar.gz", hash = "sha256:7b432f01d928c084353ab39c57282f29f92136665bdd6abf7c1ec8d822ef86f2"}, 330 | ] 331 | 332 | [package.dependencies] 333 | click = ">=7.0" 334 | colorama = {version = ">=0.4", markers = "platform_system == \"Windows\""} 335 | ghp-import = ">=1.0" 336 | importlib-metadata = {version = ">=4.4", markers = "python_version < \"3.10\""} 337 | jinja2 = ">=2.11.1" 338 | markdown = ">=3.3.6" 339 | markupsafe = ">=2.0.1" 340 | mergedeep = ">=1.3.4" 341 | mkdocs-get-deps = ">=0.2.0" 342 | packaging = ">=20.5" 343 | pathspec = ">=0.11.1" 344 | pyyaml = ">=5.1" 345 | pyyaml-env-tag = ">=0.1" 346 | watchdog = ">=2.0" 347 | 348 | [package.extras] 349 | i18n = ["babel (>=2.9.0)"] 350 | min-versions = ["babel (==2.9.0)", "click (==7.0)", "colorama (==0.4) ; platform_system == \"Windows\"", "ghp-import (==1.0)", "importlib-metadata (==4.4) ; python_version < \"3.10\"", "jinja2 (==2.11.1)", "markdown (==3.3.6)", "markupsafe (==2.0.1)", "mergedeep (==1.3.4)", "mkdocs-get-deps (==0.2.0)", "packaging (==20.5)", "pathspec (==0.11.1)", "pyyaml (==5.1)", "pyyaml-env-tag (==0.1)", "watchdog (==2.0)"] 351 | 352 | [[package]] 353 | name = "mkdocs-get-deps" 354 | version = "0.2.0" 355 | description = "MkDocs extension that lists all dependencies according to a mkdocs.yml file" 356 | optional = false 357 | python-versions = ">=3.8" 358 | groups = ["docs"] 359 | files = [ 360 | {file = "mkdocs_get_deps-0.2.0-py3-none-any.whl", hash = "sha256:2bf11d0b133e77a0dd036abeeb06dec8775e46efa526dc70667d8863eefc6134"}, 361 | {file = "mkdocs_get_deps-0.2.0.tar.gz", hash = "sha256:162b3d129c7fad9b19abfdcb9c1458a651628e4b1dea628ac68790fb3061c60c"}, 362 | ] 363 | 364 | [package.dependencies] 365 | importlib-metadata = {version = ">=4.3", markers = "python_version < \"3.10\""} 366 | mergedeep = ">=1.3.4" 367 | platformdirs = ">=2.2.0" 368 | pyyaml = ">=5.1" 369 | 370 | [[package]] 371 | name = "nodeenv" 372 | version = "1.9.1" 373 | description = "Node.js virtual environment builder" 374 | optional = false 375 | python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" 376 | groups = ["dev"] 377 | files = [ 378 | {file = "nodeenv-1.9.1-py2.py3-none-any.whl", hash = "sha256:ba11c9782d29c27c70ffbdda2d7415098754709be8a7056d79a737cd901155c9"}, 379 | {file = "nodeenv-1.9.1.tar.gz", hash = "sha256:6ec12890a2dab7946721edbfbcd91f3319c6ccc9aec47be7c7e6b7011ee6645f"}, 380 | ] 381 | 382 | [[package]] 383 | name = "packaging" 384 | version = "25.0" 385 | description = "Core utilities for Python packages" 386 | optional = false 387 | python-versions = ">=3.8" 388 | groups = ["dev", "docs"] 389 | files = [ 390 | {file = "packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484"}, 391 | {file = "packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f"}, 392 | ] 393 | 394 | [[package]] 395 | name = "pathspec" 396 | version = "0.12.1" 397 | description = "Utility library for gitignore style pattern matching of file paths." 398 | optional = false 399 | python-versions = ">=3.8" 400 | groups = ["docs"] 401 | files = [ 402 | {file = "pathspec-0.12.1-py3-none-any.whl", hash = "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08"}, 403 | {file = "pathspec-0.12.1.tar.gz", hash = "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712"}, 404 | ] 405 | 406 | [[package]] 407 | name = "platformdirs" 408 | version = "4.3.8" 409 | description = "A small Python package for determining appropriate platform-specific dirs, e.g. a `user data dir`." 410 | optional = false 411 | python-versions = ">=3.9" 412 | groups = ["dev", "docs"] 413 | files = [ 414 | {file = "platformdirs-4.3.8-py3-none-any.whl", hash = "sha256:ff7059bb7eb1179e2685604f4aaf157cfd9535242bd23742eadc3c13542139b4"}, 415 | {file = "platformdirs-4.3.8.tar.gz", hash = "sha256:3d512d96e16bcb959a814c9f348431070822a6496326a4be0911c40b5a74c2bc"}, 416 | ] 417 | 418 | [package.extras] 419 | docs = ["furo (>=2024.8.6)", "proselint (>=0.14)", "sphinx (>=8.1.3)", "sphinx-autodoc-typehints (>=3)"] 420 | test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=8.3.4)", "pytest-cov (>=6)", "pytest-mock (>=3.14)"] 421 | type = ["mypy (>=1.14.1)"] 422 | 423 | [[package]] 424 | name = "pluggy" 425 | version = "1.6.0" 426 | description = "plugin and hook calling mechanisms for python" 427 | optional = false 428 | python-versions = ">=3.9" 429 | groups = ["dev"] 430 | files = [ 431 | {file = "pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746"}, 432 | {file = "pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3"}, 433 | ] 434 | 435 | [package.extras] 436 | dev = ["pre-commit", "tox"] 437 | testing = ["coverage", "pytest", "pytest-benchmark"] 438 | 439 | [[package]] 440 | name = "pre-commit" 441 | version = "4.2.0" 442 | description = "A framework for managing and maintaining multi-language pre-commit hooks." 443 | optional = false 444 | python-versions = ">=3.9" 445 | groups = ["dev"] 446 | files = [ 447 | {file = "pre_commit-4.2.0-py2.py3-none-any.whl", hash = "sha256:a009ca7205f1eb497d10b845e52c838a98b6cdd2102a6c8e4540e94ee75c58bd"}, 448 | {file = "pre_commit-4.2.0.tar.gz", hash = "sha256:601283b9757afd87d40c4c4a9b2b5de9637a8ea02eaff7adc2d0fb4e04841146"}, 449 | ] 450 | 451 | [package.dependencies] 452 | cfgv = ">=2.0.0" 453 | identify = ">=1.0.0" 454 | nodeenv = ">=0.11.1" 455 | pyyaml = ">=5.1" 456 | virtualenv = ">=20.10.0" 457 | 458 | [[package]] 459 | name = "pygments" 460 | version = "2.19.2" 461 | description = "Pygments is a syntax highlighting package written in Python." 462 | optional = false 463 | python-versions = ">=3.8" 464 | groups = ["dev"] 465 | files = [ 466 | {file = "pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b"}, 467 | {file = "pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887"}, 468 | ] 469 | 470 | [package.extras] 471 | windows-terminal = ["colorama (>=0.4.6)"] 472 | 473 | [[package]] 474 | name = "pytest" 475 | version = "8.4.1" 476 | description = "pytest: simple powerful testing with Python" 477 | optional = false 478 | python-versions = ">=3.9" 479 | groups = ["dev"] 480 | files = [ 481 | {file = "pytest-8.4.1-py3-none-any.whl", hash = "sha256:539c70ba6fcead8e78eebbf1115e8b589e7565830d7d006a8723f19ac8a0afb7"}, 482 | {file = "pytest-8.4.1.tar.gz", hash = "sha256:7c67fd69174877359ed9371ec3af8a3d2b04741818c51e5e99cc1742251fa93c"}, 483 | ] 484 | 485 | [package.dependencies] 486 | colorama = {version = ">=0.4", markers = "sys_platform == \"win32\""} 487 | exceptiongroup = {version = ">=1", markers = "python_version < \"3.11\""} 488 | iniconfig = ">=1" 489 | packaging = ">=20" 490 | pluggy = ">=1.5,<2" 491 | pygments = ">=2.7.2" 492 | tomli = {version = ">=1", markers = "python_version < \"3.11\""} 493 | 494 | [package.extras] 495 | dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "requests", "setuptools", "xmlschema"] 496 | 497 | [[package]] 498 | name = "pytest-django" 499 | version = "4.11.1" 500 | description = "A Django plugin for pytest." 501 | optional = false 502 | python-versions = ">=3.8" 503 | groups = ["dev"] 504 | files = [ 505 | {file = "pytest_django-4.11.1-py3-none-any.whl", hash = "sha256:1b63773f648aa3d8541000c26929c1ea63934be1cfa674c76436966d73fe6a10"}, 506 | {file = "pytest_django-4.11.1.tar.gz", hash = "sha256:a949141a1ee103cb0e7a20f1451d355f83f5e4a5d07bdd4dcfdd1fd0ff227991"}, 507 | ] 508 | 509 | [package.dependencies] 510 | pytest = ">=7.0.0" 511 | 512 | [package.extras] 513 | docs = ["sphinx", "sphinx_rtd_theme"] 514 | testing = ["Django", "django-configurations (>=2.0)"] 515 | 516 | [[package]] 517 | name = "python-dateutil" 518 | version = "2.9.0.post0" 519 | description = "Extensions to the standard Python datetime module" 520 | optional = false 521 | python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" 522 | groups = ["docs"] 523 | files = [ 524 | {file = "python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3"}, 525 | {file = "python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427"}, 526 | ] 527 | 528 | [package.dependencies] 529 | six = ">=1.5" 530 | 531 | [[package]] 532 | name = "python-ulid" 533 | version = "3.0.0" 534 | description = "Universally unique lexicographically sortable identifier" 535 | optional = false 536 | python-versions = ">=3.9" 537 | groups = ["main"] 538 | files = [ 539 | {file = "python_ulid-3.0.0-py3-none-any.whl", hash = "sha256:e4c4942ff50dbd79167ad01ac725ec58f924b4018025ce22c858bfcff99a5e31"}, 540 | {file = "python_ulid-3.0.0.tar.gz", hash = "sha256:e50296a47dc8209d28629a22fc81ca26c00982c78934bd7766377ba37ea49a9f"}, 541 | ] 542 | 543 | [package.extras] 544 | pydantic = ["pydantic (>=2.0)"] 545 | 546 | [[package]] 547 | name = "pyyaml" 548 | version = "6.0.2" 549 | description = "YAML parser and emitter for Python" 550 | optional = false 551 | python-versions = ">=3.8" 552 | groups = ["dev", "docs"] 553 | files = [ 554 | {file = "PyYAML-6.0.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0a9a2848a5b7feac301353437eb7d5957887edbf81d56e903999a75a3d743086"}, 555 | {file = "PyYAML-6.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:29717114e51c84ddfba879543fb232a6ed60086602313ca38cce623c1d62cfbf"}, 556 | {file = "PyYAML-6.0.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8824b5a04a04a047e72eea5cec3bc266db09e35de6bdfe34c9436ac5ee27d237"}, 557 | {file = "PyYAML-6.0.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7c36280e6fb8385e520936c3cb3b8042851904eba0e58d277dca80a5cfed590b"}, 558 | {file = "PyYAML-6.0.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ec031d5d2feb36d1d1a24380e4db6d43695f3748343d99434e6f5f9156aaa2ed"}, 559 | {file = "PyYAML-6.0.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:936d68689298c36b53b29f23c6dbb74de12b4ac12ca6cfe0e047bedceea56180"}, 560 | {file = "PyYAML-6.0.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:23502f431948090f597378482b4812b0caae32c22213aecf3b55325e049a6c68"}, 561 | {file = "PyYAML-6.0.2-cp310-cp310-win32.whl", hash = "sha256:2e99c6826ffa974fe6e27cdb5ed0021786b03fc98e5ee3c5bfe1fd5015f42b99"}, 562 | {file = "PyYAML-6.0.2-cp310-cp310-win_amd64.whl", hash = "sha256:a4d3091415f010369ae4ed1fc6b79def9416358877534caf6a0fdd2146c87a3e"}, 563 | {file = "PyYAML-6.0.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:cc1c1159b3d456576af7a3e4d1ba7e6924cb39de8f67111c735f6fc832082774"}, 564 | {file = "PyYAML-6.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1e2120ef853f59c7419231f3bf4e7021f1b936f6ebd222406c3b60212205d2ee"}, 565 | {file = "PyYAML-6.0.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5d225db5a45f21e78dd9358e58a98702a0302f2659a3c6cd320564b75b86f47c"}, 566 | {file = "PyYAML-6.0.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5ac9328ec4831237bec75defaf839f7d4564be1e6b25ac710bd1a96321cc8317"}, 567 | {file = "PyYAML-6.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3ad2a3decf9aaba3d29c8f537ac4b243e36bef957511b4766cb0057d32b0be85"}, 568 | {file = "PyYAML-6.0.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:ff3824dc5261f50c9b0dfb3be22b4567a6f938ccce4587b38952d85fd9e9afe4"}, 569 | {file = "PyYAML-6.0.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:797b4f722ffa07cc8d62053e4cff1486fa6dc094105d13fea7b1de7d8bf71c9e"}, 570 | {file = "PyYAML-6.0.2-cp311-cp311-win32.whl", hash = "sha256:11d8f3dd2b9c1207dcaf2ee0bbbfd5991f571186ec9cc78427ba5bd32afae4b5"}, 571 | {file = "PyYAML-6.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:e10ce637b18caea04431ce14fabcf5c64a1c61ec9c56b071a4b7ca131ca52d44"}, 572 | {file = "PyYAML-6.0.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:c70c95198c015b85feafc136515252a261a84561b7b1d51e3384e0655ddf25ab"}, 573 | {file = "PyYAML-6.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ce826d6ef20b1bc864f0a68340c8b3287705cae2f8b4b1d932177dcc76721725"}, 574 | {file = "PyYAML-6.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1f71ea527786de97d1a0cc0eacd1defc0985dcf6b3f17bb77dcfc8c34bec4dc5"}, 575 | {file = "PyYAML-6.0.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9b22676e8097e9e22e36d6b7bda33190d0d400f345f23d4065d48f4ca7ae0425"}, 576 | {file = "PyYAML-6.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:80bab7bfc629882493af4aa31a4cfa43a4c57c83813253626916b8c7ada83476"}, 577 | {file = "PyYAML-6.0.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:0833f8694549e586547b576dcfaba4a6b55b9e96098b36cdc7ebefe667dfed48"}, 578 | {file = "PyYAML-6.0.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8b9c7197f7cb2738065c481a0461e50ad02f18c78cd75775628afb4d7137fb3b"}, 579 | {file = "PyYAML-6.0.2-cp312-cp312-win32.whl", hash = "sha256:ef6107725bd54b262d6dedcc2af448a266975032bc85ef0172c5f059da6325b4"}, 580 | {file = "PyYAML-6.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:7e7401d0de89a9a855c839bc697c079a4af81cf878373abd7dc625847d25cbd8"}, 581 | {file = "PyYAML-6.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:efdca5630322a10774e8e98e1af481aad470dd62c3170801852d752aa7a783ba"}, 582 | {file = "PyYAML-6.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:50187695423ffe49e2deacb8cd10510bc361faac997de9efef88badc3bb9e2d1"}, 583 | {file = "PyYAML-6.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0ffe8360bab4910ef1b9e87fb812d8bc0a308b0d0eef8c8f44e0254ab3b07133"}, 584 | {file = "PyYAML-6.0.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:17e311b6c678207928d649faa7cb0d7b4c26a0ba73d41e99c4fff6b6c3276484"}, 585 | {file = "PyYAML-6.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:70b189594dbe54f75ab3a1acec5f1e3faa7e8cf2f1e08d9b561cb41b845f69d5"}, 586 | {file = "PyYAML-6.0.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:41e4e3953a79407c794916fa277a82531dd93aad34e29c2a514c2c0c5fe971cc"}, 587 | {file = "PyYAML-6.0.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:68ccc6023a3400877818152ad9a1033e3db8625d899c72eacb5a668902e4d652"}, 588 | {file = "PyYAML-6.0.2-cp313-cp313-win32.whl", hash = "sha256:bc2fa7c6b47d6bc618dd7fb02ef6fdedb1090ec036abab80d4681424b84c1183"}, 589 | {file = "PyYAML-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:8388ee1976c416731879ac16da0aff3f63b286ffdd57cdeb95f3f2e085687563"}, 590 | {file = "PyYAML-6.0.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:24471b829b3bf607e04e88d79542a9d48bb037c2267d7927a874e6c205ca7e9a"}, 591 | {file = "PyYAML-6.0.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d7fded462629cfa4b685c5416b949ebad6cec74af5e2d42905d41e257e0869f5"}, 592 | {file = "PyYAML-6.0.2-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d84a1718ee396f54f3a086ea0a66d8e552b2ab2017ef8b420e92edbc841c352d"}, 593 | {file = "PyYAML-6.0.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9056c1ecd25795207ad294bcf39f2db3d845767be0ea6e6a34d856f006006083"}, 594 | {file = "PyYAML-6.0.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:82d09873e40955485746739bcb8b4586983670466c23382c19cffecbf1fd8706"}, 595 | {file = "PyYAML-6.0.2-cp38-cp38-win32.whl", hash = "sha256:43fa96a3ca0d6b1812e01ced1044a003533c47f6ee8aca31724f78e93ccc089a"}, 596 | {file = "PyYAML-6.0.2-cp38-cp38-win_amd64.whl", hash = "sha256:01179a4a8559ab5de078078f37e5c1a30d76bb88519906844fd7bdea1b7729ff"}, 597 | {file = "PyYAML-6.0.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:688ba32a1cffef67fd2e9398a2efebaea461578b0923624778664cc1c914db5d"}, 598 | {file = "PyYAML-6.0.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:a8786accb172bd8afb8be14490a16625cbc387036876ab6ba70912730faf8e1f"}, 599 | {file = "PyYAML-6.0.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d8e03406cac8513435335dbab54c0d385e4a49e4945d2909a581c83647ca0290"}, 600 | {file = "PyYAML-6.0.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f753120cb8181e736c57ef7636e83f31b9c0d1722c516f7e86cf15b7aa57ff12"}, 601 | {file = "PyYAML-6.0.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3b1fdb9dc17f5a7677423d508ab4f243a726dea51fa5e70992e59a7411c89d19"}, 602 | {file = "PyYAML-6.0.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:0b69e4ce7a131fe56b7e4d770c67429700908fc0752af059838b1cfb41960e4e"}, 603 | {file = "PyYAML-6.0.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:a9f8c2e67970f13b16084e04f134610fd1d374bf477b17ec1599185cf611d725"}, 604 | {file = "PyYAML-6.0.2-cp39-cp39-win32.whl", hash = "sha256:6395c297d42274772abc367baaa79683958044e5d3835486c16da75d2a694631"}, 605 | {file = "PyYAML-6.0.2-cp39-cp39-win_amd64.whl", hash = "sha256:39693e1f8320ae4f43943590b49779ffb98acb81f788220ea932a6b6c51004d8"}, 606 | {file = "pyyaml-6.0.2.tar.gz", hash = "sha256:d584d9ec91ad65861cc08d42e834324ef890a082e591037abe114850ff7bbc3e"}, 607 | ] 608 | 609 | [[package]] 610 | name = "pyyaml-env-tag" 611 | version = "1.1" 612 | description = "A custom YAML tag for referencing environment variables in YAML files." 613 | optional = false 614 | python-versions = ">=3.9" 615 | groups = ["docs"] 616 | files = [ 617 | {file = "pyyaml_env_tag-1.1-py3-none-any.whl", hash = "sha256:17109e1a528561e32f026364712fee1264bc2ea6715120891174ed1b980d2e04"}, 618 | {file = "pyyaml_env_tag-1.1.tar.gz", hash = "sha256:2eb38b75a2d21ee0475d6d97ec19c63287a7e140231e4214969d0eac923cd7ff"}, 619 | ] 620 | 621 | [package.dependencies] 622 | pyyaml = "*" 623 | 624 | [[package]] 625 | name = "six" 626 | version = "1.17.0" 627 | description = "Python 2 and 3 compatibility utilities" 628 | optional = false 629 | python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" 630 | groups = ["docs"] 631 | files = [ 632 | {file = "six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274"}, 633 | {file = "six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81"}, 634 | ] 635 | 636 | [[package]] 637 | name = "sqlparse" 638 | version = "0.5.3" 639 | description = "A non-validating SQL parser." 640 | optional = false 641 | python-versions = ">=3.8" 642 | groups = ["main"] 643 | files = [ 644 | {file = "sqlparse-0.5.3-py3-none-any.whl", hash = "sha256:cf2196ed3418f3ba5de6af7e82c694a9fbdbfecccdfc72e281548517081f16ca"}, 645 | {file = "sqlparse-0.5.3.tar.gz", hash = "sha256:09f67787f56a0b16ecdbde1bfc7f5d9c3371ca683cfeaa8e6ff60b4807ec9272"}, 646 | ] 647 | 648 | [package.extras] 649 | dev = ["build", "hatch"] 650 | doc = ["sphinx"] 651 | 652 | [[package]] 653 | name = "tomli" 654 | version = "2.2.1" 655 | description = "A lil' TOML parser" 656 | optional = false 657 | python-versions = ">=3.8" 658 | groups = ["dev"] 659 | markers = "python_version < \"3.11\"" 660 | files = [ 661 | {file = "tomli-2.2.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:678e4fa69e4575eb77d103de3df8a895e1591b48e740211bd1067378c69e8249"}, 662 | {file = "tomli-2.2.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:023aa114dd824ade0100497eb2318602af309e5a55595f76b626d6d9f3b7b0a6"}, 663 | {file = "tomli-2.2.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ece47d672db52ac607a3d9599a9d48dcb2f2f735c6c2d1f34130085bb12b112a"}, 664 | {file = "tomli-2.2.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6972ca9c9cc9f0acaa56a8ca1ff51e7af152a9f87fb64623e31d5c83700080ee"}, 665 | {file = "tomli-2.2.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c954d2250168d28797dd4e3ac5cf812a406cd5a92674ee4c8f123c889786aa8e"}, 666 | {file = "tomli-2.2.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8dd28b3e155b80f4d54beb40a441d366adcfe740969820caf156c019fb5c7ec4"}, 667 | {file = "tomli-2.2.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:e59e304978767a54663af13c07b3d1af22ddee3bb2fb0618ca1593e4f593a106"}, 668 | {file = "tomli-2.2.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:33580bccab0338d00994d7f16f4c4ec25b776af3ffaac1ed74e0b3fc95e885a8"}, 669 | {file = "tomli-2.2.1-cp311-cp311-win32.whl", hash = "sha256:465af0e0875402f1d226519c9904f37254b3045fc5084697cefb9bdde1ff99ff"}, 670 | {file = "tomli-2.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:2d0f2fdd22b02c6d81637a3c95f8cd77f995846af7414c5c4b8d0545afa1bc4b"}, 671 | {file = "tomli-2.2.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4a8f6e44de52d5e6c657c9fe83b562f5f4256d8ebbfe4ff922c495620a7f6cea"}, 672 | {file = "tomli-2.2.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8d57ca8095a641b8237d5b079147646153d22552f1c637fd3ba7f4b0b29167a8"}, 673 | {file = "tomli-2.2.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e340144ad7ae1533cb897d406382b4b6fede8890a03738ff1683af800d54192"}, 674 | {file = "tomli-2.2.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:db2b95f9de79181805df90bedc5a5ab4c165e6ec3fe99f970d0e302f384ad222"}, 675 | {file = "tomli-2.2.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:40741994320b232529c802f8bc86da4e1aa9f413db394617b9a256ae0f9a7f77"}, 676 | {file = "tomli-2.2.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:400e720fe168c0f8521520190686ef8ef033fb19fc493da09779e592861b78c6"}, 677 | {file = "tomli-2.2.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:02abe224de6ae62c19f090f68da4e27b10af2b93213d36cf44e6e1c5abd19fdd"}, 678 | {file = "tomli-2.2.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b82ebccc8c8a36f2094e969560a1b836758481f3dc360ce9a3277c65f374285e"}, 679 | {file = "tomli-2.2.1-cp312-cp312-win32.whl", hash = "sha256:889f80ef92701b9dbb224e49ec87c645ce5df3fa2cc548664eb8a25e03127a98"}, 680 | {file = "tomli-2.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:7fc04e92e1d624a4a63c76474610238576942d6b8950a2d7f908a340494e67e4"}, 681 | {file = "tomli-2.2.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f4039b9cbc3048b2416cc57ab3bda989a6fcf9b36cf8937f01a6e731b64f80d7"}, 682 | {file = "tomli-2.2.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:286f0ca2ffeeb5b9bd4fcc8d6c330534323ec51b2f52da063b11c502da16f30c"}, 683 | {file = "tomli-2.2.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a92ef1a44547e894e2a17d24e7557a5e85a9e1d0048b0b5e7541f76c5032cb13"}, 684 | {file = "tomli-2.2.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9316dc65bed1684c9a98ee68759ceaed29d229e985297003e494aa825ebb0281"}, 685 | {file = "tomli-2.2.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e85e99945e688e32d5a35c1ff38ed0b3f41f43fad8df0bdf79f72b2ba7bc5272"}, 686 | {file = "tomli-2.2.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ac065718db92ca818f8d6141b5f66369833d4a80a9d74435a268c52bdfa73140"}, 687 | {file = "tomli-2.2.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:d920f33822747519673ee656a4b6ac33e382eca9d331c87770faa3eef562aeb2"}, 688 | {file = "tomli-2.2.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a198f10c4d1b1375d7687bc25294306e551bf1abfa4eace6650070a5c1ae2744"}, 689 | {file = "tomli-2.2.1-cp313-cp313-win32.whl", hash = "sha256:d3f5614314d758649ab2ab3a62d4f2004c825922f9e370b29416484086b264ec"}, 690 | {file = "tomli-2.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:a38aa0308e754b0e3c67e344754dff64999ff9b513e691d0e786265c93583c69"}, 691 | {file = "tomli-2.2.1-py3-none-any.whl", hash = "sha256:cb55c73c5f4408779d0cf3eef9f762b9c9f147a77de7b258bef0a5628adc85cc"}, 692 | {file = "tomli-2.2.1.tar.gz", hash = "sha256:cd45e1dc79c835ce60f7404ec8119f2eb06d38b1deba146f07ced3bbc44505ff"}, 693 | ] 694 | 695 | [[package]] 696 | name = "typing-extensions" 697 | version = "4.14.1" 698 | description = "Backported and Experimental Type Hints for Python 3.9+" 699 | optional = false 700 | python-versions = ">=3.9" 701 | groups = ["main", "dev"] 702 | markers = "python_version < \"3.11\"" 703 | files = [ 704 | {file = "typing_extensions-4.14.1-py3-none-any.whl", hash = "sha256:d1e1e3b58374dc93031d6eda2420a48ea44a36c2b4766a4fdeb3710755731d76"}, 705 | {file = "typing_extensions-4.14.1.tar.gz", hash = "sha256:38b39f4aeeab64884ce9f74c94263ef78f3c22467c8724005483154c26648d36"}, 706 | ] 707 | 708 | [[package]] 709 | name = "tzdata" 710 | version = "2025.2" 711 | description = "Provider of IANA time zone data" 712 | optional = false 713 | python-versions = ">=2" 714 | groups = ["main"] 715 | markers = "sys_platform == \"win32\"" 716 | files = [ 717 | {file = "tzdata-2025.2-py2.py3-none-any.whl", hash = "sha256:1a403fada01ff9221ca8044d701868fa132215d84beb92242d9acd2147f667a8"}, 718 | {file = "tzdata-2025.2.tar.gz", hash = "sha256:b60a638fcc0daffadf82fe0f57e53d06bdec2f36c4df66280ae79bce6bd6f2b9"}, 719 | ] 720 | 721 | [[package]] 722 | name = "virtualenv" 723 | version = "20.32.0" 724 | description = "Virtual Python Environment builder" 725 | optional = false 726 | python-versions = ">=3.8" 727 | groups = ["dev"] 728 | files = [ 729 | {file = "virtualenv-20.32.0-py3-none-any.whl", hash = "sha256:2c310aecb62e5aa1b06103ed7c2977b81e042695de2697d01017ff0f1034af56"}, 730 | {file = "virtualenv-20.32.0.tar.gz", hash = "sha256:886bf75cadfdc964674e6e33eb74d787dff31ca314ceace03ca5810620f4ecf0"}, 731 | ] 732 | 733 | [package.dependencies] 734 | distlib = ">=0.3.7,<1" 735 | filelock = ">=3.12.2,<4" 736 | platformdirs = ">=3.9.1,<5" 737 | 738 | [package.extras] 739 | docs = ["furo (>=2023.7.26)", "proselint (>=0.13)", "sphinx (>=7.1.2,!=7.3)", "sphinx-argparse (>=0.4)", "sphinxcontrib-towncrier (>=0.2.1a0)", "towncrier (>=23.6)"] 740 | test = ["covdefaults (>=2.3)", "coverage (>=7.2.7)", "coverage-enable-subprocess (>=1)", "flaky (>=3.7)", "packaging (>=23.1)", "pytest (>=7.4)", "pytest-env (>=0.8.2)", "pytest-freezer (>=0.4.8) ; platform_python_implementation == \"PyPy\" or platform_python_implementation == \"GraalVM\" or platform_python_implementation == \"CPython\" and sys_platform == \"win32\" and python_version >= \"3.13\"", "pytest-mock (>=3.11.1)", "pytest-randomly (>=3.12)", "pytest-timeout (>=2.1)", "setuptools (>=68)", "time-machine (>=2.10) ; platform_python_implementation == \"CPython\""] 741 | 742 | [[package]] 743 | name = "watchdog" 744 | version = "6.0.0" 745 | description = "Filesystem events monitoring" 746 | optional = false 747 | python-versions = ">=3.9" 748 | groups = ["docs"] 749 | files = [ 750 | {file = "watchdog-6.0.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d1cdb490583ebd691c012b3d6dae011000fe42edb7a82ece80965b42abd61f26"}, 751 | {file = "watchdog-6.0.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:bc64ab3bdb6a04d69d4023b29422170b74681784ffb9463ed4870cf2f3e66112"}, 752 | {file = "watchdog-6.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c897ac1b55c5a1461e16dae288d22bb2e412ba9807df8397a635d88f671d36c3"}, 753 | {file = "watchdog-6.0.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:6eb11feb5a0d452ee41f824e271ca311a09e250441c262ca2fd7ebcf2461a06c"}, 754 | {file = "watchdog-6.0.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ef810fbf7b781a5a593894e4f439773830bdecb885e6880d957d5b9382a960d2"}, 755 | {file = "watchdog-6.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:afd0fe1b2270917c5e23c2a65ce50c2a4abb63daafb0d419fde368e272a76b7c"}, 756 | {file = "watchdog-6.0.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:bdd4e6f14b8b18c334febb9c4425a878a2ac20efd1e0b231978e7b150f92a948"}, 757 | {file = "watchdog-6.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c7c15dda13c4eb00d6fb6fc508b3c0ed88b9d5d374056b239c4ad1611125c860"}, 758 | {file = "watchdog-6.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6f10cb2d5902447c7d0da897e2c6768bca89174d0c6e1e30abec5421af97a5b0"}, 759 | {file = "watchdog-6.0.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:490ab2ef84f11129844c23fb14ecf30ef3d8a6abafd3754a6f75ca1e6654136c"}, 760 | {file = "watchdog-6.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:76aae96b00ae814b181bb25b1b98076d5fc84e8a53cd8885a318b42b6d3a5134"}, 761 | {file = "watchdog-6.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a175f755fc2279e0b7312c0035d52e27211a5bc39719dd529625b1930917345b"}, 762 | {file = "watchdog-6.0.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:e6f0e77c9417e7cd62af82529b10563db3423625c5fce018430b249bf977f9e8"}, 763 | {file = "watchdog-6.0.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:90c8e78f3b94014f7aaae121e6b909674df5b46ec24d6bebc45c44c56729af2a"}, 764 | {file = "watchdog-6.0.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:e7631a77ffb1f7d2eefa4445ebbee491c720a5661ddf6df3498ebecae5ed375c"}, 765 | {file = "watchdog-6.0.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:c7ac31a19f4545dd92fc25d200694098f42c9a8e391bc00bdd362c5736dbf881"}, 766 | {file = "watchdog-6.0.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:9513f27a1a582d9808cf21a07dae516f0fab1cf2d7683a742c498b93eedabb11"}, 767 | {file = "watchdog-6.0.0-pp39-pypy39_pp73-macosx_10_15_x86_64.whl", hash = "sha256:7a0e56874cfbc4b9b05c60c8a1926fedf56324bb08cfbc188969777940aef3aa"}, 768 | {file = "watchdog-6.0.0-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:e6439e374fc012255b4ec786ae3c4bc838cd7309a540e5fe0952d03687d8804e"}, 769 | {file = "watchdog-6.0.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:7607498efa04a3542ae3e05e64da8202e58159aa1fa4acddf7678d34a35d4f13"}, 770 | {file = "watchdog-6.0.0-py3-none-manylinux2014_armv7l.whl", hash = "sha256:9041567ee8953024c83343288ccc458fd0a2d811d6a0fd68c4c22609e3490379"}, 771 | {file = "watchdog-6.0.0-py3-none-manylinux2014_i686.whl", hash = "sha256:82dc3e3143c7e38ec49d61af98d6558288c415eac98486a5c581726e0737c00e"}, 772 | {file = "watchdog-6.0.0-py3-none-manylinux2014_ppc64.whl", hash = "sha256:212ac9b8bf1161dc91bd09c048048a95ca3a4c4f5e5d4a7d1b1a7d5752a7f96f"}, 773 | {file = "watchdog-6.0.0-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:e3df4cbb9a450c6d49318f6d14f4bbc80d763fa587ba46ec86f99f9e6876bb26"}, 774 | {file = "watchdog-6.0.0-py3-none-manylinux2014_s390x.whl", hash = "sha256:2cce7cfc2008eb51feb6aab51251fd79b85d9894e98ba847408f662b3395ca3c"}, 775 | {file = "watchdog-6.0.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:20ffe5b202af80ab4266dcd3e91aae72bf2da48c0d33bdb15c66658e685e94e2"}, 776 | {file = "watchdog-6.0.0-py3-none-win32.whl", hash = "sha256:07df1fdd701c5d4c8e55ef6cf55b8f0120fe1aef7ef39a1c6fc6bc2e606d517a"}, 777 | {file = "watchdog-6.0.0-py3-none-win_amd64.whl", hash = "sha256:cbafb470cf848d93b5d013e2ecb245d4aa1c8fd0504e863ccefa32445359d680"}, 778 | {file = "watchdog-6.0.0-py3-none-win_ia64.whl", hash = "sha256:a1914259fa9e1454315171103c6a30961236f508b9b623eae470268bbcc6a22f"}, 779 | {file = "watchdog-6.0.0.tar.gz", hash = "sha256:9ddf7c82fda3ae8e24decda1338ede66e1c99883db93711d8fb941eaa2d8c282"}, 780 | ] 781 | 782 | [package.extras] 783 | watchmedo = ["PyYAML (>=3.10)"] 784 | 785 | [[package]] 786 | name = "zipp" 787 | version = "3.23.0" 788 | description = "Backport of pathlib-compatible object wrapper for zip files" 789 | optional = false 790 | python-versions = ">=3.9" 791 | groups = ["docs"] 792 | markers = "python_version == \"3.9\"" 793 | files = [ 794 | {file = "zipp-3.23.0-py3-none-any.whl", hash = "sha256:071652d6115ed432f5ce1d34c336c0adfd6a884660d1e9712a256d3d3bd4b14e"}, 795 | {file = "zipp-3.23.0.tar.gz", hash = "sha256:a07157588a12518c9d4034df3fbbee09c814741a33ff63c05fa29d26a2404166"}, 796 | ] 797 | 798 | [package.extras] 799 | check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1) ; sys_platform != \"cygwin\""] 800 | cover = ["pytest-cov"] 801 | doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] 802 | enabler = ["pytest-enabler (>=2.2)"] 803 | test = ["big-O", "jaraco.functools", "jaraco.itertools", "jaraco.test", "more_itertools", "pytest (>=6,!=8.1.*)", "pytest-ignore-flaky"] 804 | type = ["pytest-mypy"] 805 | 806 | [metadata] 807 | lock-version = "2.1" 808 | python-versions = ">=3.9" 809 | content-hash = "4467541d11246bf3bb454943ab35c463fc3be2f5938300837e627b5c5bdfe7c9" 810 | --------------------------------------------------------------------------------