├── .github └── workflows │ └── build.yml ├── .gitignore ├── .readthedocs.yaml ├── LICENCE.txt ├── README.rst ├── docs ├── Makefile ├── advanced_topics.rst ├── api.rst ├── conf.py ├── entry_points.rst ├── getting_started.rst ├── index.rst ├── iterating_over_registries.rst ├── make.bat ├── service_registries.rst └── upgrading_to_v5.rst ├── pyproject.toml ├── renovate.json ├── setup.py ├── src └── class_registry │ ├── __init__.py │ ├── auto_register.py │ ├── base.py │ ├── cache.py │ ├── entry_points.py │ ├── patcher.py │ ├── py.typed │ └── registry.py ├── test ├── __init__.py ├── dummy_package.egg-info │ ├── PKG-INFO │ ├── SOURCES.txt │ ├── dependency_links.txt │ ├── entry_points.txt │ ├── requires.txt │ └── top_level.txt ├── helper.py ├── test_auto_register.py ├── test_auto_register_deprecated.py ├── test_cache.py ├── test_class_registry.py ├── test_entry_points.py ├── test_gen_lookup_key.py ├── test_patcher.py └── test_sorted_class_registry.py └── uv.lock /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | # https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python 2 | name: CI 3 | 4 | on: 5 | push: ~ 6 | 7 | jobs: 8 | build: 9 | runs-on: ubuntu-latest 10 | strategy: 11 | matrix: 12 | python-version: 13 | # Note: Use quotes to avoid float cast - especially important if the 14 | # version number ends with 0! 15 | - "3.11" 16 | - "3.12" 17 | - "3.13" 18 | 19 | steps: 20 | - name: Clone repo 21 | uses: actions/checkout@v5 22 | - name: Install uv 23 | uses: astral-sh/setup-uv@v7 24 | - name: Set up Python ${{ matrix.python-version }} 25 | uses: actions/setup-python@v6 26 | with: 27 | python-version: ${{ matrix.python-version }} 28 | - name: Install dependencies 29 | run: uv sync --group ci 30 | - name: Run tests 31 | run: uv run pytest 32 | 33 | type-check: 34 | runs-on: ubuntu-latest 35 | 36 | steps: 37 | - name: Clone repo 38 | uses: actions/checkout@v5 39 | - name: Install uv 40 | uses: astral-sh/setup-uv@v7 41 | - name: Set up Python 42 | uses: actions/setup-python@v6 43 | with: 44 | python-version-file: "pyproject.toml" 45 | - name: Install dependencies 46 | run: uv sync --group ci 47 | - name: Type checking 48 | run: uv run mypy src test 49 | 50 | docs: 51 | runs-on: ubuntu-latest 52 | 53 | steps: 54 | - name: Clone repo 55 | uses: actions/checkout@v5 56 | - name: Install uv 57 | uses: astral-sh/setup-uv@v7 58 | - name: Set up Python 59 | uses: actions/setup-python@v6 60 | with: 61 | python-version-file: "pyproject.toml" 62 | - name: Install dependencies 63 | run: uv sync --group ci 64 | - name: Check docs build 65 | run: | 66 | cd docs 67 | mkdir -p _static 68 | uv run make html 69 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__ 3 | *.pyc 4 | 5 | # Distribution / packaging 6 | build 7 | dist 8 | *.egg-info 9 | 10 | # dotenv 11 | .env 12 | 13 | # Sphinx documentation 14 | docs/_build/ 15 | 16 | # Tox 17 | .tox 18 | -------------------------------------------------------------------------------- /.readthedocs.yaml: -------------------------------------------------------------------------------- 1 | # https://docs.readthedocs.io/en/stable/config-file/v2.html 2 | version: 2 3 | 4 | build: 5 | os: ubuntu-24.04 6 | tools: 7 | python: "latest" 8 | 9 | # Install the package, so that Sphinx can generate autodocs. 10 | # https://docs.readthedocs.io/en/stable/build-customization.html#install-dependencies-with-poetry 11 | jobs: 12 | post_create_environment: 13 | # Install uv 14 | # https://github.com/astral-sh/uv/issues/10074#issuecomment-2593075059 15 | - pip install uv 16 | post_install: 17 | # Virtualenv path needs to be set manually for now. 18 | # See https://github.com/readthedocs/readthedocs.org/pull/11152/ 19 | - UV_PROJECT_ENVIRONMENT=$READTHEDOCS_VIRTUALENV_PATH uv sync --group ci 20 | 21 | sphinx: 22 | configuration: docs/conf.py 23 | fail_on_warning: true 24 | -------------------------------------------------------------------------------- /LICENCE.txt: -------------------------------------------------------------------------------- 1 | MIT Licence 2 | 3 | Copyright (c) 2017 EFL Global 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | .. image:: https://github.com/todofixthis/class-registry/actions/workflows/build.yml/badge.svg 2 | :target: https://github.com/todofixthis/class-registry/actions/workflows/build.yml 3 | .. image:: https://readthedocs.org/projects/class-registry/badge/?version=latest 4 | :target: http://class-registry.readthedocs.io/ 5 | 6 | ClassRegistry 7 | ============= 8 | At the intersection of the Registry and Factory patterns lies the ``ClassRegistry``: 9 | 10 | - Define global factories that generate new class instances based on configurable keys. 11 | - Seamlessly create powerful service registries. 12 | - Integrate with setuptools's ``entry_points`` system to make your registries infinitely 13 | extensible by 3rd-party libraries! 14 | - And more! 15 | 16 | Upgrading from ClassRegistry v4 17 | ------------------------------- 18 | .. important:: 19 | 20 | ClassRegistry v5 introduces some changes that can break code that was previously 21 | using ClassRegistry v4. If you are upgrading from ClassRegistry v4 to ClassRegistry 22 | v5, please read `Upgrading to ClassRegistry v5 <./docs/upgrading_to_v5.rst>`_. 23 | 24 | 25 | Getting Started 26 | --------------- 27 | Create a registry using the ``class_registry.ClassRegistry`` class, then 28 | decorate any classes that you wish to register with its ``register`` method: 29 | 30 | .. code-block:: python 31 | 32 | from class_registry import ClassRegistry 33 | 34 | pokedex = ClassRegistry() 35 | 36 | @pokedex.register('fire') 37 | class Charizard(Pokemon): 38 | ... 39 | 40 | @pokedex.register('grass') 41 | class Bulbasaur(Pokemon): 42 | ... 43 | 44 | @pokedex.register('water') 45 | class Squirtle(Pokemon): 46 | ... 47 | 48 | To create a class instance from a registry, use the subscript operator: 49 | 50 | .. code-block:: python 51 | 52 | # Charizard, I choose you! 53 | fighter1 = pokedex['fire'] 54 | 55 | # CHARIZARD fainted! 56 | # How come my rival always picks the type that my pokémon is weak against?? 57 | fighter2 = pokedex['grass'] 58 | 59 | .. tip:: 60 | 61 | If a ``ClassRegistry`` always returns objects derived from a particular base class, 62 | you can provide a 63 | `type parameter `_ 64 | to help with type checking, autocomplete, etc.: 65 | 66 | .. code-block:: python 67 | 68 | # Add type parameter ``[Pokemon]``: 69 | pokedex = ClassRegistry[Pokemon]() 70 | 71 | # Your IDE will automatically infer that ``fighter1`` is a ``Pokemon``. 72 | fighter1 = pokedex['fire'] 73 | 74 | 75 | Advanced Usage 76 | ~~~~~~~~~~~~~~ 77 | There's a whole lot more you can do with ClassRegistry, including: 78 | 79 | - Provide args and kwargs to new class instances. 80 | - Automatically register non-abstract classes. 81 | - Integrate with setuptools's ``entry_points`` system so that 3rd-party libraries can 82 | add their own classes to your registries. 83 | - Wrap your registry in an instance cache to create a service registry. 84 | - And more! 85 | 86 | For more advanced usage, check out the documentation on 87 | `ReadTheDocs `_! 88 | 89 | 90 | Requirements 91 | ------------ 92 | ClassRegistry is known to be compatible with the following Python versions: 93 | 94 | - 3.13 95 | - 3.12 96 | - 3.11 97 | 98 | .. note:: 99 | 100 | I'm only one person, so to keep from getting overwhelmed, I'm only committing to 101 | supporting the 3 most recent versions of Python. ClassRegistry's code is pretty 102 | simple, so it's likely to be compatible with versions not listed here; there just 103 | won't be any test coverage to prove it 😇 104 | 105 | Installation 106 | ------------ 107 | Install the latest stable version via pip:: 108 | 109 | pip install phx-class-registry 110 | 111 | .. important:: 112 | 113 | Make sure to install `phx-class-registry`, **not** `class-registry`. I created the 114 | latter at a previous job years ago, and after I left they never touched that project 115 | again and stopped responding to my emails — so in the end I had to fork it 🤷 116 | 117 | Maintainers 118 | =========== 119 | To install the distribution for local development, some additional setup is required: 120 | 121 | #. `Install uv `_ (only needs 122 | to be done once). 123 | 124 | #. Run the following command to install additional dependencies:: 125 | 126 | uv sync --group=dev 127 | 128 | #. Activate pre-commit hook:: 129 | 130 | uv run autohooks activate --mode=pythonpath 131 | 132 | Running Unit Tests and Type Checker 133 | ----------------------------------- 134 | Run the tests for all supported versions of Python using 135 | `tox `_:: 136 | 137 | uv run tox -p 138 | 139 | The ``-p`` flag runs tests for each version of Python in parallel. Omit it if you want 140 | to see the tests run for one Python version at a time. 141 | 142 | .. note:: 143 | 144 | The first time this runs, it will take awhile, as mypy needs to build up its cache. 145 | Subsequent runs should be much faster. 146 | 147 | If you just want to run unit tests in the current virtualenv (using 148 | `pytest `_):: 149 | 150 | uv run pytest 151 | 152 | If you just want to run type checking in the current virtualenv (using 153 | `mypy `_):: 154 | 155 | uv run mypy src test 156 | 157 | Documentation 158 | ------------- 159 | To build the documentation locally: 160 | 161 | #. Switch to the ``docs`` directory:: 162 | 163 | cd docs 164 | 165 | #. Build the documentation:: 166 | 167 | uv run make html 168 | 169 | Releases 170 | -------- 171 | Steps to build releases are based on 172 | `Packaging Python Projects Tutorial `_. 173 | 174 | .. important:: 175 | 176 | Make sure to build releases off of the ``main`` branch, and check that all changes 177 | from ``develop`` have been merged before creating the release! 178 | 179 | 1. Build the Project 180 | ~~~~~~~~~~~~~~~~~~~~ 181 | #. Delete artefacts from previous builds, if applicable:: 182 | 183 | rm dist/* 184 | 185 | #. Run the build:: 186 | 187 | uv build 188 | 189 | #. The build artefacts will be located in the ``dist`` directory at the top 190 | level of the project. 191 | 192 | 2. Upload to PyPI 193 | ~~~~~~~~~~~~~~~~~ 194 | #. `Create a PyPI API token `_ (you only have to 195 | do this once). 196 | #. Increment the version number in ``pyproject.toml``. 197 | #. Upload build artefacts to PyPI:: 198 | 199 | uv publish 200 | 201 | 3. Create GitHub Release 202 | ~~~~~~~~~~~~~~~~~~~~~~~~ 203 | #. Create a tag and push to GitHub:: 204 | 205 | git tag 206 | git push 207 | 208 | ```` must match the updated version number in ``pyproject.toml``. 209 | 210 | #. Go to the `Releases page for the repo`_. 211 | #. Click ``Draft a new release``. 212 | #. Select the tag that you created in step 1. 213 | #. Specify the title of the release (e.g., ``ClassRegistry v1.2.3``). 214 | #. Write a description for the release. Make sure to include: 215 | - Credit for code contributed by community members. 216 | - Significant functionality that was added/changed/removed. 217 | - Any backwards-incompatible changes and/or migration instructions. 218 | - SHA256 hashes of the build artefacts. 219 | #. GPG-sign the description for the release (ASCII-armoured). 220 | #. Attach the build artefacts to the release. 221 | #. Click ``Publish release``. 222 | 223 | .. _Releases page for the repo: https://github.com/todofixthis/class-registry/releases 224 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line, and also 5 | # from the environment for the first two. 6 | SPHINXOPTS ?= 7 | SPHINXBUILD ?= sphinx-build 8 | SOURCEDIR = . 9 | BUILDDIR = _build 10 | 11 | # Put it first so that "make" without argument is like "make help". 12 | help: 13 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 14 | 15 | .PHONY: help Makefile 16 | 17 | # Catch-all target: route all unknown targets to Sphinx using the new 18 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 19 | %: Makefile 20 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 21 | -------------------------------------------------------------------------------- /docs/advanced_topics.rst: -------------------------------------------------------------------------------- 1 | Advanced Topics 2 | =============== 3 | This section covers more advanced or esoteric uses of ClassRegistry features. 4 | 5 | Registering Classes Automatically 6 | --------------------------------- 7 | Tired of having to add the ``register`` decorator to every class that you want to add to 8 | a class registry? Surely there's a better way! 9 | 10 | The answer is :py:func:`class_registry.base.AutoRegister`! 11 | 12 | Call ``AutoRegister()`` and pass in a registry, and it returns a base class. Any 13 | non-abstract class that extends from that base class automatically gets added to the 14 | registry. 15 | 16 | Here's an example: 17 | 18 | .. code-block:: python 19 | 20 | from abc import ABC, abstractmethod 21 | from class_registry import ClassRegistry 22 | from class_registry.base import AutoRegister 23 | 24 | pokedex = ClassRegistry('element') 25 | 26 | # Note ``AutoRegister(pokedex)`` used as a base class here, as well as ``ABC``. 27 | class Pokemon(AutoRegister(pokedex), ABC): 28 | @abstractmethod 29 | def get_abilities(self): 30 | raise NotImplementedError() 31 | 32 | # Define some non-abstract subclasses. 33 | class Butterfree(Pokemon): 34 | element = 'bug' 35 | 36 | def get_abilities(self): 37 | return ['compound_eyes'] 38 | 39 | class Spearow(Pokemon): 40 | element = 'flying' 41 | 42 | def get_abilities(self): 43 | return ['keen_eye'] 44 | 45 | # Any non-abstract class that extends ``Pokemon`` will automatically get registered 46 | # in our Pokédex! 47 | assert list(pokedex.keys()) == ['bug', 'flying'] 48 | assert list(pokedex.classes()) == [Butterfree, Spearow] 49 | 50 | In the above example, note that ``Butterfree`` and ``Spearow`` were added to 51 | ``pokedex`` automatically. However, the ``Pokemon`` base class was not added, 52 | because it is abstract. 53 | 54 | .. important:: 55 | 56 | Python defines an abstract class as a class with at least one unimplemented abstract 57 | method. You can't just add ``ABC``! 58 | 59 | .. code-block:: python 60 | 61 | from abc import ABC 62 | 63 | # Declare an "abstract" class. 64 | class ElectricPokemon(Pokemon, ABC): 65 | element = 'electric' 66 | 67 | def get_abilities(self): 68 | return ['shock'] 69 | 70 | assert list(pokedex.keys()) == ['bug', 'flying', 'electric'] 71 | assert list(pokedex.classes()) == [Butterfree, Spearow, ElectricPokemon] 72 | 73 | Note in the above example that ``ElectricPokemon`` was added to ``pokedex``, 74 | even though it extends :py:class:`abc.ABC`. 75 | 76 | Because ``ElectricPokemon`` doesn't have any unimplemented abstract methods, 77 | Python does not consider it to be abstract. 78 | 79 | We can verify this by using :py:func:`inspect.isabstract`: 80 | 81 | .. code-block:: python 82 | 83 | from inspect import isabstract 84 | assert not isabstract(ElectricPokemon) 85 | 86 | .. note:: 87 | 88 | In previous versions of ClassRegistry, ``AutoRegister`` returned a metaclass instead 89 | of a base class. The metaclass version of the function still exists at 90 | :py:func:`class_registry.auto_register.AutoRegister`, but 91 | `it is deprecated and will be removed in a future version of ClassRegistry `. 92 | 93 | If your code is still using the old ``AutoRegister`` function, you can change it like 94 | this: 95 | 96 | .. code-block:: python 97 | 98 | # Deprecated: 99 | from class_registry.auto_register import AutoRegister 100 | 101 | class MyBaseClass(metaclass=AutoRegister(my_registry)): 102 | ... 103 | 104 | # Update to this: 105 | from abc import ABC 106 | from class_registry.base import AutoRegister 107 | 108 | class MyBaseClass(AutoRegister(my_registry), ABC): 109 | ... 110 | 111 | Patching 112 | -------- 113 | From time to time, you might need to register classes temporarily. For example, you 114 | might need to patch a global class registry in a unit test, ensuring that the extra 115 | classes are removed when the test finishes. 116 | 117 | ClassRegistry provides a :py:class:`RegistryPatcher` that you can use for just such a 118 | purpose: 119 | 120 | .. code-block:: python 121 | 122 | from class_registry import ClassRegistry, RegistryKeyError 123 | from class_registry.patcher import RegistryPatcher 124 | 125 | pokedex = ClassRegistry('element') 126 | 127 | # Create a couple of new classes, but don't register them yet! 128 | class Oddish: 129 | element = 'grass' 130 | 131 | class Meowth: 132 | element = 'normal' 133 | 134 | # As expected, neither of these classes are registered. 135 | try: 136 | pokedex['grass'] 137 | except RegistryKeyError: 138 | pass 139 | 140 | # Use a patcher to temporarily register these classes. 141 | with RegistryPatcher(pokedex, Oddish, Meowth): 142 | abbot = pokedex['grass'] 143 | assert isinstance(abbot, Oddish) 144 | 145 | costello = pokedex['normal'] 146 | assert isinstance(costello, Meowth) 147 | 148 | # Outside the context, the classes are no longer registered! 149 | try: 150 | pokedex['grass'] 151 | except RegistryKeyError: 152 | pass 153 | 154 | If desired, you can also change existing registry keys, or even replace a class that is 155 | already registered. 156 | 157 | .. code-block:: python 158 | 159 | @pokedex.register 160 | class Squirtle: 161 | element = 'water' 162 | 163 | # Get your diving suit Meowth; we're going to Atlantis! 164 | with RegistryPatcher(pokedex, water=Meowth): 165 | nemo = pokedex['water'] 166 | assert isinstance(nemo, Meowth) 167 | 168 | # After the context exits, the previously-registered class is restored. 169 | ponsonby = pokedex['water'] 170 | assert isinstance(ponsonby, Squirtle) 171 | 172 | .. important:: 173 | 174 | Only mutable registries can be patched (any class that extends 175 | :py:class:`BaseMutableRegistry`). 176 | 177 | In particular, this means that :py:class:`EntryPointClassRegistry` can not be patched 178 | using :py:class:`RegistryPatcher`. 179 | 180 | 181 | Overriding Lookup Keys 182 | ---------------------- 183 | In some cases, you may want to customise the way a ``ClassRegistry`` looks up which 184 | class to use. For example, you may need to change the registry key for a particular 185 | class, but you want to maintain backwards-compatibility for existing code that 186 | references the old key. 187 | 188 | To customise this, create a subclass of ``ClassRegistry`` and override its 189 | ``gen_lookup_key`` method: 190 | 191 | .. code-block:: python 192 | 193 | import typing 194 | from class_registry import ClassRegistry 195 | 196 | class FacadeRegistry(ClassRegistry): 197 | @staticmethod 198 | def gen_lookup_key(key: typing.Hashable) -> typing.Hashable: 199 | """ 200 | In a previous version of the codebase, some pokémon had the 'bird' 201 | type, but this was later dropped in favour of 'flying'. 202 | """ 203 | if key == 'bird': 204 | return 'flying' 205 | 206 | return key 207 | 208 | pokedex = FacadeRegistry('element') 209 | 210 | @pokedex.register 211 | class MissingNo: 212 | element = 'flying' 213 | 214 | @pokedex.register 215 | class Meowth: 216 | element = 'normal' 217 | 218 | # MissingNo can be accessed by either key. 219 | assert isinstance(pokedex['bird'], MissingNo) 220 | assert isinstance(pokedex['flying'], MissingNo) 221 | 222 | # Other pokémon work as you'd expect. 223 | assert isinstance(pokedex['normal'], Meowth) 224 | -------------------------------------------------------------------------------- /docs/api.rst: -------------------------------------------------------------------------------- 1 | API Documentation 2 | ================= 3 | 4 | .. automodule:: class_registry.base 5 | .. automodule:: class_registry.cache 6 | .. automodule:: class_registry.entry_points 7 | .. automodule:: class_registry.patcher 8 | .. automodule:: class_registry.registry 9 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # No type checking in this file, please (: 2 | # type: ignore 3 | 4 | # Configuration file for the Sphinx documentation builder. 5 | # 6 | # For the full list of built-in configuration values, see the documentation: 7 | # https://www.sphinx-doc.org/en/master/usage/configuration.html 8 | 9 | # -- Project information ----------------------------------------------------- 10 | # https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information 11 | 12 | project = "ClassRegistry" 13 | copyright = "2017 Phoenix Zerin" 14 | author = "Phoenix Zerin" 15 | 16 | # -- General configuration --------------------------------------------------- 17 | # https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration 18 | 19 | extensions = [ 20 | "sphinx.ext.autodoc", 21 | "sphinx.ext.intersphinx", 22 | "sphinx.ext.napoleon", 23 | ] 24 | 25 | templates_path = ["_templates"] 26 | exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"] 27 | 28 | language = "en-nz" 29 | 30 | # -- Options for HTML output ------------------------------------------------- 31 | # https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output 32 | 33 | html_theme = "sphinx_rtd_theme" 34 | html_static_path = ["_static"] 35 | 36 | # -- Options for intersphinx extension --------------------------------------- 37 | # https://www.sphinx-doc.org/en/master/usage/extensions/intersphinx.html#configuration 38 | intersphinx_mapping = { 39 | "python": ("https://docs.python.org/3", None), 40 | } 41 | 42 | # -- Options for autodoc extension ------------------------------------------- 43 | # https://www.sphinx-doc.org/en/master/usage/extensions/autodoc.html#configuration 44 | autoclass_content = "both" 45 | autodoc_default_options = { 46 | "members": True, 47 | "member-order": "alphabetical", 48 | "special-members": False, 49 | "undoc-members": True, 50 | } 51 | 52 | # -- Options for napoleon extension ------------------------------------------ 53 | # https://www.sphinx-doc.org/en/master/usage/extensions/napoleon.html#configuration 54 | napoleon_attr_annotations = True 55 | napoleon_google_docstring = True 56 | napoleon_numpy_docstring = False 57 | napoleon_type_aliases = None 58 | napoleon_use_admonition_for_examples = False 59 | napoleon_use_admonition_for_notes = False 60 | napoleon_use_admonition_for_references = False 61 | -------------------------------------------------------------------------------- /docs/entry_points.rst: -------------------------------------------------------------------------------- 1 | Entry Points Integration 2 | ======================== 3 | A serially-underused feature of setuptools is its 4 | `entry points `_. This 5 | feature allows you to expose a pluggable interface in your project. Other libraries can 6 | then declare entry points and inject their own classes into your class registries! 7 | 8 | Let's see what that might look like in practice. 9 | 10 | First, we'll create a package with its own ``pyproject.toml`` file: 11 | 12 | .. code-block:: toml 13 | 14 | # generation_2/pyproject.toml 15 | [project] 16 | name="pokemon-generation-2" 17 | description="Extends the pokédex with generation 2 pokémon!" 18 | 19 | [project.entry-points.pokemon] 20 | grass="gen2.pokemon:Chikorita" 21 | fire="gen2.pokemon:Cyndaquil" 22 | water="gen2.pokemon:Totodile" 23 | 24 | Note that we declared some ``pokemon`` entry points. 25 | 26 | .. tip:: 27 | 28 | If your project uses `poetry `_, it will look like this 29 | instead: 30 | 31 | .. code-block:: toml 32 | 33 | # generation_2/pyproject.toml 34 | [tool.poetry] 35 | name="pokemon-generation-2" 36 | description="Extends the pokédex with generation 2 pokémon!" 37 | 38 | [tool.poetry.plugins.pokemon] 39 | grass="gen2.pokemon:Chikorita" 40 | fire="gen2.pokemon:Cyndaquil" 41 | water="gen2.pokemon:Totodile" 42 | 43 | Let's see what happens once the ``pokemon-generation-2`` package is installed:: 44 | 45 | % pip install pokemon-generation-2 46 | % ipython 47 | 48 | In [1]: from class_registry.entry_points import EntryPointClassRegistry 49 | 50 | In [2]: pokedex = EntryPointClassRegistry('pokemon') 51 | 52 | In [3]: list(pokedex.keys()) 53 | Out[3]: ['grass', 'fire', 'water'] 54 | 55 | In [4]: list(pokedex.classes()) 56 | Out[4]: 57 | [, 58 | , 59 | ] 60 | 61 | Simply declare an :py:class:`EntryPointClassRegistry` instance, and it will 62 | automatically find any classes registered to that entry point group across every 63 | installed project in your virtualenv! 64 | 65 | Reverse Lookups 66 | --------------- 67 | From time to time, you may need to perform a "reverse lookup": Given a class or 68 | instance, you want to determine which registry key is associated with it. 69 | 70 | For :py:class:`ClassRegistry`, performing a reverse lookup is simple because the 71 | registry key is (usually) defined by an attribute on the class itself. 72 | 73 | However, :py:class:`EntryPointClassRegistry` uses an external source to define the 74 | registry keys, so it's a bit tricky to go back and find the registry key for a given 75 | class. 76 | 77 | If you would like to enable reverse lookups in your application, you can provide an 78 | optional ``attr_name`` argument to the registry's initialiser, which will cause the 79 | registry to "brand" every object it returns with the corresponding registry key. 80 | 81 | .. code-block:: python 82 | 83 | In [1]: from class_registry.entry_points import EntryPointClassRegistry 84 | 85 | In [2]: pokedex = EntryPointClassRegistry('pokemon', attr_name='element') 86 | 87 | In [3]: fire_pokemon = pokedex['fire'] 88 | 89 | In [4]: fire_pokemon.element 90 | Out[4]: 'fire' 91 | 92 | In [5]: water_pokemon_class = pokedex.get_class('water') 93 | 94 | In [6]: water_pokemon_class.element 95 | Out[6]: 'water' 96 | 97 | We set ``attr_name='element'`` when initializing the 98 | :py:class:`EntryPointClassRegistry`, so it set the ``element`` attribute on every class 99 | and instance that it returned. 100 | 101 | .. caution:: 102 | 103 | If a class already has an attribute with the same name, the registry will overwrite 104 | it. 105 | -------------------------------------------------------------------------------- /docs/getting_started.rst: -------------------------------------------------------------------------------- 1 | Getting Started 2 | =============== 3 | As you saw in the :doc:`introduction `, you can create a new registry using the 4 | :py:class:`class_registry.ClassRegistry` class. 5 | 6 | :py:class:`ClassRegistry` defines a ``register`` method that you can use as a decorator 7 | to add classes to the registry: 8 | 9 | .. code-block:: python 10 | 11 | from class_registry import ClassRegistry 12 | 13 | pokedex = ClassRegistry() 14 | 15 | @pokedex.register('fire') 16 | class Charizard: 17 | pass 18 | 19 | Once you've registered a class, you can then create a new instance using the 20 | corresponding registry key: 21 | 22 | .. code-block:: python 23 | 24 | sparky = pokedex['fire'] 25 | assert isinstance(sparky, Charizard) 26 | 27 | Note in the above example that ``sparky`` is an `instance` of ``Charizard``. 28 | 29 | If you try to access a registry key that has no classes registered, it will raise a 30 | :py:class:`class_registry.RegistryKeyError`: 31 | 32 | .. code-block:: python 33 | 34 | from class_registry import RegistryKeyError 35 | 36 | try: 37 | tex = pokedex['spicy'] 38 | except RegistryKeyError: 39 | pass 40 | 41 | Typed Registries 42 | ---------------- 43 | If a :py:class:`ClassRegistry` always returns objects derived from a particular 44 | base class, you can provide a 45 | `type parameter `_ 46 | to help with type checking, autocomplete, etc.: 47 | 48 | .. code-block:: python 49 | 50 | # Add type parameter ``[Pokemon]``: 51 | pokedex = ClassRegistry[Pokemon]() 52 | 53 | # Your IDE will automatically infer that ``fighter1`` is a ``Pokemon``. 54 | fighter1 = pokedex['fire'] 55 | 56 | Auto-Detecting Registry Keys 57 | ---------------------------- 58 | By default, you have to provide the registry key whenever you register a new class. 59 | But, there's an easier way to do it! 60 | 61 | When you initialise your :py:class:`ClassRegistry`, provide an ``attr_name`` parameter. 62 | When you register new classes, your registry will automatically extract the registry key 63 | using that attribute: 64 | 65 | .. code-block:: python 66 | 67 | pokedex = ClassRegistry('element') 68 | 69 | @pokedex.register 70 | class Squirtle: 71 | element = 'water' 72 | 73 | beauregard = pokedex['water'] 74 | assert isinstance(beauregard, Squirtle) 75 | 76 | Note in the above example that the registry automatically extracted the registry key for 77 | the ``Squirtle`` class using its ``element`` attribute. 78 | 79 | Collisions 80 | ---------- 81 | What happens if two classes have the same registry key? 82 | 83 | .. code-block:: python 84 | 85 | pokedex = ClassRegistry('element') 86 | 87 | @pokedex.register 88 | class Bulbasaur: 89 | element = 'grass' 90 | 91 | @pokedex.register 92 | class Ivysaur: 93 | element = 'grass' 94 | 95 | janet = pokedex['grass'] 96 | assert isinstance(janet, Ivysaur) 97 | 98 | As you can see, if two (or more) classes have the same registry key, whichever one is 99 | registered last will override any of the other(s). 100 | 101 | .. note:: 102 | 103 | It is not always easy to predict the order in which classes will be registered, 104 | especially when they are spread across different modules, so you probably don't 105 | want to rely on this behaviour! 106 | 107 | If you want to prevent collisions, you can pass ``unique=True`` to the 108 | :py:class:`ClassRegistry` initialiser to raise an exception whenever a collision occurs: 109 | 110 | .. code-block:: python 111 | 112 | from class_registry import RegistryKeyError 113 | 114 | pokedex = ClassRegistry('element', unique=True) 115 | 116 | @pokedex.register 117 | class Bulbasaur: 118 | element = 'grass' 119 | 120 | try: 121 | @pokedex.register 122 | class Ivysaur: 123 | element = 'grass' 124 | except RegistryKeyError: 125 | pass 126 | 127 | janet = pokedex['grass'] 128 | assert isinstance(janet, Bulbasaur) 129 | 130 | Because we passed ``unique=True`` to the :py:class:`ClassRegistry` initialiser, 131 | attempting to register ``Ivysaur`` with the same registry key as ``Bulbasaur`` raised a 132 | :py:class:`RegistryKeyError`, so it didn't override ``Bulbasaur``. 133 | 134 | Init Params 135 | ----------- 136 | Every time you access a registry key in a :py:class:`ClassRegistry`, it creates a new 137 | instance: 138 | 139 | .. code-block:: python 140 | 141 | marlene = pokedex['grass'] 142 | charlene = pokedex['grass'] 143 | 144 | assert marlene is not charlene 145 | 146 | Since you're creating a new instance every time, you also have the option of providing 147 | args and kwargs to the class initialiser using the registry's :py:meth:`get` method: 148 | 149 | .. code-block:: python 150 | 151 | pokedex = ClassRegistry('element') 152 | 153 | @pokedex.register 154 | class Caterpie: 155 | element = 'bug' 156 | 157 | def __init__(self, level=1): 158 | super(Caterpie, self).__init__() 159 | self.level = level 160 | 161 | timmy = pokedex.get('bug') 162 | assert timmy.level == 1 163 | 164 | tommy = pokedex.get('bug', 16) 165 | assert tommy.level == 16 166 | 167 | tammy = pokedex.get('bug', level=42) 168 | assert tammy.level == 42 169 | 170 | Any arguments that you provide to :py:meth:`get` will be passed directly to the 171 | corresponding class' initialiser. 172 | 173 | .. hint:: 174 | 175 | You can create a service registry that always returns the same instance per registry 176 | key by wrapping it in a :py:class:`ClassRegistryInstanceCache`. See 177 | :doc:`service_registries` for more information. 178 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | ClassRegistry 2 | ============= 3 | .. toctree:: 4 | :maxdepth: 1 5 | :caption: Contents: 6 | 7 | getting_started 8 | service_registries 9 | iterating_over_registries 10 | entry_points 11 | advanced_topics 12 | upgrading_to_v5 13 | api 14 | 15 | 16 | ClassRegistry 17 | ============= 18 | At the intersection of the Registry and Factory patterns lies the ``ClassRegistry``: 19 | 20 | - Define global factories that generate new class instances based on configurable keys. 21 | - Seamlessly create powerful service registries. 22 | - Integrate with setuptools's ``entry_points`` system to make your registries infinitely 23 | extensible by 3rd-party libraries! 24 | - And more! 25 | 26 | Upgrading from ClassRegistry v4 27 | ------------------------------- 28 | .. important:: 29 | 30 | ClassRegistry v5 introduces some changes that can break code that was previously 31 | using ClassRegistry v4. If you are upgrading from ClassRegistry v4 to ClassRegistry 32 | v5, please read :doc:`upgrading_to_v5`! 33 | 34 | Getting Started 35 | --------------- 36 | Create a registry using the ``class_registry.ClassRegistry`` class, then decorate any 37 | classes that you wish to register with its ``register`` method: 38 | 39 | .. code-block:: python 40 | 41 | from class_registry import ClassRegistry 42 | 43 | pokedex = ClassRegistry() 44 | 45 | @pokedex.register('fire') 46 | class Charizard(Pokemon): 47 | ... 48 | 49 | @pokedex.register('grass') 50 | class Bulbasaur(Pokemon): 51 | ... 52 | 53 | @pokedex.register('water') 54 | class Squirtle(Pokemon): 55 | ... 56 | 57 | To create a class instance from a registry, use the subscript operator: 58 | 59 | .. code-block:: python 60 | 61 | # Charizard, I choose you! 62 | fighter1 = pokedex['fire'] 63 | 64 | # CHARIZARD fainted! 65 | # How come my rival always picks the type that my pokémon is weak against?? 66 | fighter2 = pokedex['water'] 67 | 68 | .. tip:: 69 | 70 | If a :py:class:`ClassRegistry` always returns objects derived from a 71 | particular base class, you can provide a 72 | `type parameter `_ 73 | to help with type checking, autocomplete, etc.: 74 | 75 | .. code-block:: python 76 | 77 | # Add type parameter ``[Pokemon]``: 78 | pokedex = ClassRegistry[Pokemon]() 79 | 80 | # Your IDE will automatically infer that ``fighter1`` is a ``Pokemon``. 81 | fighter1 = pokedex['fire'] 82 | 83 | Advanced Usage 84 | -------------- 85 | There's a whole lot more you can do with ClassRegistry, including: 86 | 87 | - Provide args and kwargs to new class instances. 88 | - Automatically register non-abstract classes. 89 | - Integrate with setuptools's ``entry_points`` system so that 3rd-party libraries can 90 | add their own classes to your registries. 91 | - Wrap your registry in an instance cache to create a service registry. 92 | - And more! 93 | 94 | To learn more about what you can do with ClassRegistry, 95 | :doc:`keep reading! ` 96 | 97 | Requirements 98 | ------------ 99 | ClassRegistry is known to be compatible with the following Python versions: 100 | 101 | - 3.13 102 | - 3.12 103 | - 3.11 104 | 105 | .. note:: 106 | 107 | I'm only one person, so to keep from getting overwhelmed, I'm only committing to 108 | supporting the 3 most recent versions of Python. ClassRegistry's code is pretty 109 | simple, so it's likely to be compatible with versions not listed here; there just 110 | won't be any test coverage to prove it 😇 111 | 112 | Installation 113 | ------------ 114 | Install the latest stable version via pip:: 115 | 116 | pip install phx-class-registry 117 | 118 | .. important:: 119 | 120 | Make sure to install `phx-class-registry`, **not** `class-registry`. I created the 121 | latter at a previous job years ago, and after I left they never touched that project 122 | again and stopped responding to my emails — so in the end I had to fork it 🤷 123 | -------------------------------------------------------------------------------- /docs/iterating_over_registries.rst: -------------------------------------------------------------------------------- 1 | Iterating Over Registries 2 | ========================= 3 | Sometimes, you want to iterate over all of the classes registered in a 4 | :py:class:`ClassRegistry`. There are three methods included to help you do this: 5 | 6 | - :py:meth:`keys` iterates over the registry keys. 7 | - :py:meth:`classes` iterates over the registered classes. 8 | 9 | Here's an example: 10 | 11 | .. code-block:: python 12 | 13 | from class_registry import ClassRegistry 14 | 15 | pokedex = ClassRegistry('element') 16 | 17 | @pokedex.register 18 | class Geodude: 19 | element = 'rock' 20 | 21 | @pokedex.register 22 | class Machop: 23 | element = 'fighting' 24 | 25 | @pokedex.register 26 | class Bellsprout: 27 | element = 'grass' 28 | 29 | assert list(pokedex.keys()) == ['rock', 'fighting', 'grass'] 30 | assert list(pokedex.classes()) == [Geodude, Machop, Bellsprout] 31 | 32 | .. tip:: 33 | 34 | Tired of having to add the :py:meth:`register` decorator to every class? 35 | 36 | You can use the :py:func:`AutoRegister` metaclass to automatically register all 37 | non-abstract subclasses of a particular base class. See :doc:`advanced_topics` for 38 | more information. 39 | 40 | Changing the Sort Order 41 | ----------------------- 42 | As you probably noticed, these functions iterate over classes in the order that they are 43 | registered. 44 | 45 | If you'd like to customise this ordering, use :py:class:`SortedClassRegistry`: 46 | 47 | .. code-block:: python 48 | 49 | from class_registry.registry import SortedClassRegistry 50 | 51 | pokedex = SortedClassRegistry(attr_name='element', sort_key='weight') 52 | 53 | @pokedex.register 54 | class Geodude: 55 | element = 'rock' 56 | weight = 1000 57 | 58 | @pokedex.register 59 | class Machop: 60 | element = 'fighting' 61 | weight = 75 62 | 63 | @pokedex.register 64 | class Bellsprout: 65 | element = 'grass' 66 | weight = 15 67 | 68 | assert list(pokedex.keys()) == ['grass', 'fighting', 'rock'] 69 | assert list(pokedex.values()) == [Bellsprout, Machop, Geodude] 70 | 71 | In the above example, the code iterates over registered classes in ascending order by 72 | their ``weight`` attributes. 73 | 74 | You can provide a sorting function instead if you need more control over how the items 75 | are sorted: 76 | 77 | .. code-block:: python 78 | 79 | from functools import cmp_to_key 80 | 81 | def sorter(a, b): 82 | """ 83 | Sorts items by weight, using registry key as a tiebreaker. 84 | 85 | :param a: Tuple of (key, class) 86 | :param b: Tuple of (key, class) 87 | """ 88 | # Sort descending by weight first. 89 | weight_cmp = ( 90 | (a[1].weight < b[1].weight) 91 | - (a[1].weight > b[1].weight) 92 | ) 93 | 94 | if weight_cmp != 0: 95 | return weight_cmp 96 | 97 | # Use registry key as a fallback. 98 | return ((a[0] > b[0]) - (a[0] < b[0])) 99 | 100 | pokedex =\ 101 | SortedClassRegistry( 102 | attr_name = 'element', 103 | 104 | # Note that we pass ``sorter`` through ``cmp_to_key`` first! 105 | sort_key = cmp_to_key(sorter), 106 | ) 107 | 108 | @pokedex.register 109 | class Horsea: 110 | element = 'water' 111 | weight = 5 112 | 113 | @pokedex.register 114 | class Koffing: 115 | element = 'poison' 116 | weight = 20 117 | 118 | @pokedex.register 119 | class Voltorb: 120 | element = 'electric' 121 | weight = 5 122 | 123 | assert list(pokedex.keys()) == ['poison', 'electric', 'water'] 124 | assert list(pokedex.values()) == [Koffing, Voltorb, Horsea] 125 | 126 | This time, the :py:class:`SortedClassRegistry` used our custom sorter function, so that 127 | the classes were sorted descending by weight, with the registry key used as a 128 | tiebreaker. 129 | 130 | .. important:: 131 | 132 | Note that we had to pass the sorter function through :py:func:`functools.cmp_to_key` 133 | before providing it to the :py:class:`SortedClassRegistry` initialiser. 134 | 135 | This is necessary because of how sorting works in Python. See 136 | `Sorting HOW TO `_ for 137 | more information. 138 | -------------------------------------------------------------------------------- /docs/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | pushd %~dp0 4 | 5 | REM Command file for Sphinx documentation 6 | 7 | if "%SPHINXBUILD%" == "" ( 8 | set SPHINXBUILD=python -msphinx 9 | ) 10 | set SOURCEDIR=. 11 | set BUILDDIR=_build 12 | set SPHINXPROJ=ClassRegistry 13 | 14 | if "%1" == "" goto help 15 | 16 | %SPHINXBUILD% >NUL 2>NUL 17 | if errorlevel 9009 ( 18 | echo. 19 | echo.The Sphinx module was not found. Make sure you have Sphinx installed, 20 | echo.then set the SPHINXBUILD environment variable to point to the full 21 | echo.path of the 'sphinx-build' executable. Alternatively you may add the 22 | echo.Sphinx directory to PATH. 23 | echo. 24 | echo.If you don't have Sphinx installed, grab it from 25 | echo.http://sphinx-doc.org/ 26 | exit /b 1 27 | ) 28 | 29 | %SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% 30 | goto end 31 | 32 | :help 33 | %SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% 34 | 35 | :end 36 | popd 37 | -------------------------------------------------------------------------------- /docs/service_registries.rst: -------------------------------------------------------------------------------- 1 | Creating Service Registries 2 | =========================== 3 | Despite its name, :py:class:`ClassRegistry` also has aspects in common with the Factory 4 | pattern. Most notably, accessing a registry key automatically creates a new instance of 5 | the corresponding class. 6 | 7 | But, what if you want a :py:class:`ClassRegistry` to behave more strictly like a service 8 | registry — always returning the the `same` instance each time the same key is accessed? 9 | 10 | This is where :py:class:`ClassRegistryInstanceCache` comes into play. It wraps a 11 | :py:class:`ClassRegistry` and provides a caching mechanism, so that each time your code 12 | accesses a particular key, it always returns the same instance for that key. 13 | 14 | Let's see what this looks like in action. First, we'll create a 15 | :py:class:`ClassRegistry`: 16 | 17 | .. code-block:: python 18 | 19 | from class_registry import ClassRegistry 20 | 21 | pokedex = ClassRegistry('element') 22 | 23 | @pokedex.register 24 | class Pikachu: 25 | element = 'electric' 26 | 27 | @pokedex.register 28 | class Alakazam: 29 | element = 'psychic' 30 | 31 | # Accessing the ClassRegistry yields a different instance every time. 32 | pika_1 = pokedex['electric'] 33 | pika_2 = pokedex['electric'] 34 | assert pika_1 is not pika_2 35 | 36 | Next we'll wrap the registry in a :py:class:`ClassRegistryInstanceCache`: 37 | 38 | .. code-block:: python 39 | 40 | from class_registry.cache import ClassRegistryInstanceCache 41 | 42 | fighters = ClassRegistryInstanceCache(pokedex) 43 | 44 | # ClassRegistryInstanceCache works just like ClassRegistry, except it returns the 45 | # same instance per key. 46 | darth_vader = fighters['psychic'] 47 | anakin_skywalker = fighters['psychic'] 48 | assert darth_vader is anakin_skywalker 49 | 50 | Note in the above example that the :py:class:`ClassRegistryInstanceCache` always returns 51 | the same instance every time its ``psychic`` key is accessed. 52 | 53 | Typed Service Registries 54 | ------------------------ 55 | :py:class:`ClassRegistryInstanceCache` inherits the 56 | `type parameter `_ 57 | from the :py:class:`ClassRegistry` that it wraps in order to help with type checking, 58 | autocompletion, etc.: 59 | 60 | .. code-block:: python 61 | 62 | # Add type parameter ``[Pokemon]``: 63 | registry = ClassRegistry[Pokemon]() 64 | 65 | # The ``ClassRegistryInstanceCache`` inherits the type parameters from the 66 | # ``ClassRegistry`` that it wraps. 67 | pokedex = ClassRegistryInstanceCache(registry) 68 | 69 | # Your IDE will automatically infer that ``fire_fighter`` is a ``Pokemon``. 70 | fire_fighter = pokedex['fire'] 71 | 72 | Alternatively, you can apply the type parameter to the 73 | :py:class:`ClassRegistryInstanceCache` directly: 74 | 75 | .. code-block:: python 76 | 77 | pokedex = ClassRegistryInstanceCache[Pokemon](registry) 78 | -------------------------------------------------------------------------------- /docs/upgrading_to_v5.rst: -------------------------------------------------------------------------------- 1 | Upgrading to ClassRegistry v5 2 | ============================= 3 | 4 | `ClassRegistry v5 `_ 5 | introduces some changes that can break code that was previously using ClassRegistry v4. 6 | If you are upgrading from ClassRegistry v4 to ClassRegistry v5, you'll need to make the 7 | following changes: 8 | 9 | Type Parameters 10 | --------------- 11 | I thought I'd start this off with some good news 😺 12 | 13 | If a :py:class:`ClassRegistry` always returns objects derived from a particular 14 | base class, you can now provide a 15 | `type parameter `_ 16 | to help with type checking, autocomplete, etc.: 17 | 18 | .. code-block:: python 19 | 20 | # Add type parameter ``[Pokemon]``: 21 | pokedex = ClassRegistry[Pokemon]() 22 | 23 | # Your IDE will automatically infer that ``fighter1`` is a ``Pokemon``. 24 | fighter1 = pokedex['fire'] 25 | 26 | :py:class:`ClassRegistryInstanceCache` now inherits the 27 | `type parameter `_ 28 | from the :py:class:`ClassRegistry` that it wraps in order to help with type checking, 29 | autocompletion, etc.: 30 | 31 | .. code-block:: python 32 | 33 | # Add type parameter ``[Pokemon]``: 34 | registry = ClassRegistry[Pokemon]() 35 | 36 | # The ``ClassRegistryInstanceCache`` inherits the type parameters from the 37 | # ``ClassRegistry`` that it wraps. 38 | pokedex = ClassRegistryInstanceCache(registry) 39 | 40 | # Your IDE will automatically infer that ``fire_fighter`` is a ``Pokemon``. 41 | fire_fighter = pokedex['fire'] 42 | 43 | Alternatively, you can apply the type parameter to the 44 | :py:class:`ClassRegistryInstanceCache` directly: 45 | 46 | .. code-block:: python 47 | 48 | pokedex = ClassRegistryInstanceCache[Pokemon](registry) 49 | 50 | Imports 51 | ------- 52 | Now for the tricky parts. 53 | 54 | In ClassRegistry v5 many symbols were removed from the top-level ``class_registry`` 55 | namespace. The table below shows how to import each symbol in ClassRegistry v5 in your 56 | code: 57 | 58 | ====================================== =================================================================== 59 | Symbol How to Import in ClassRegistry v5 60 | ====================================== =================================================================== 61 | :py:func:`AutoRegister` ``from class_registry.base import AutoRegister`` 62 | :py:class:`ClassRegistry` ``from class_registry import Classregistry`` (unchanged) 63 | :py:class:`ClassRegistryInstanceCache` ``from class_registry.cache import ClassRegistryInstanceCache`` 64 | :py:class:`EntryPointClassRegistry` ``from class_registry.entry_points import EntryPointClassRegistry`` 65 | :py:class:`RegistryKeyError` ``from class_registry import RegistryKeyError`` (unchanged) 66 | :py:class:`RegistryPatcher` ``from class_registry.patcher import RegistryPatcher`` 67 | :py:class:`SortedClassRegistry` ``from class_registry.registry import SortedClassRegistry`` 68 | ====================================== =================================================================== 69 | 70 | AutoRegister 71 | ------------ 72 | In ClassRegistry v5, :py:func:`AutoRegister` now returns a base class instead of a 73 | metaclass. The example below shows how to update classes that use 74 | :py:func:`AutoRegister`: 75 | 76 | ClassRegistry v4: 77 | 78 | .. code-block:: python 79 | 80 | from class_registry import AutoRegister 81 | 82 | class MyBaseClass(metaclass=AutoRegister(my_registry)): 83 | ... 84 | 85 | ClassRegistry v5: 86 | 87 | .. code-block:: python 88 | 89 | from abc import ABC 90 | from class_registry.base import AutoRegister 91 | 92 | class MyBaseClass(AutoRegister(my_registry), ABC): 93 | ... 94 | 95 | .. note:: 96 | 97 | If this is a non-trivial change for your code, you can continue to use the 98 | (deprecated) metaclass version of :py:func:`AutoRegister` which is located at 99 | ``class_registry.auto_register.AutoRegister``. 100 | 101 | The metaclass version of :py:func:`AutoRegister` will be removed in a future version 102 | of ClassRegistry, so it's recommended that you update your code. If you need help, 103 | `post in the ClassRegistry issue tracker `_, 104 | and I'll have a look 🙂 105 | 106 | Other Changes 107 | ------------- 108 | 109 | BaseRegistry 110 | ^^^^^^^^^^^^ 111 | 112 | .. important:: 113 | :py:class:`BaseRegistry` no longer implements :py:class:`typing.Mapping` due to 114 | violations of the Liskov Substitutability Principle: 115 | 116 | .. code-block:: python 117 | 118 | >>> isinstance(ClassRegistry(), typing.Mapping) 119 | False 120 | 121 | If your code relies on the previous behaviour, 122 | `post in the ClassRegistry issue tracker `_, 123 | so that we can find an alternative solution. 124 | 125 | Additionally, the following methods have been deprecated and will be removed in a future 126 | version: 127 | 128 | - :py:meth:`BaseRegistry.items` is deprecated. If you still need this functionality, use 129 | the following workaround: 130 | 131 | ClassRegistry v4: 132 | 133 | .. code-block:: python 134 | 135 | registry.items() 136 | 137 | ClassRegistry v5: 138 | 139 | .. code-block:: python 140 | 141 | zip(registry.keys(), registry.classes()) 142 | 143 | - :py:meth:`BaseRegistry.values` is now renamed to :py:meth:`BaseRegistry.classes`: 144 | 145 | ClassRegistry v4: 146 | 147 | .. code-block:: python 148 | 149 | registry.values() 150 | 151 | ClassRegistry v5: 152 | 153 | .. code-block:: python 154 | 155 | registry.classes() 156 | 157 | BaseMutableRegistry 158 | ^^^^^^^^^^^^^^^^^^^ 159 | 160 | .. important:: 161 | :py:class:`BaseMutableRegistry` no longer implements 162 | :py:class:`typing.MutableMapping` due to violations of the Liskov Substitutability 163 | Principle: 164 | 165 | .. code-block:: python 166 | 167 | >>> isinstance(ClassRegistry(), typing.MutableMapping) 168 | False 169 | 170 | If your code relies on the previous behaviour, 171 | `post in the ClassRegistry issue tracker `_, 172 | so that we can find an alternative solution. 173 | 174 | - ``BaseMutableRegistry.__delitem__()`` method has been removed. Use the 175 | ``unregister()`` method instead: 176 | 177 | ClassRegistry v4: 178 | 179 | .. code-block:: python 180 | 181 | del registry["fire"] 182 | 183 | ClassRegistry v5: 184 | 185 | .. code-block:: python 186 | 187 | registry.unregister("fire") 188 | 189 | - ``BaseMutableRegistry.__setitem__()`` method has been removed. Use the ``register()`` 190 | method instead: 191 | 192 | ClassRegistry v4: 193 | 194 | .. code-block:: python 195 | 196 | registry["fire"] = Charizard 197 | 198 | ClassRegistry v5: 199 | 200 | .. code-block:: python 201 | 202 | registry.register("fire")(Charizard) 203 | 204 | .. note:: 205 | 206 | If you initialised the :py:class:`ClassRegistry` with ``unique=True``, you will 207 | need to ``unregister()`` the key first: 208 | 209 | .. code-block:: python 210 | 211 | >>> registry = ClassRegistry(unique=True) 212 | >>> registry.register(Charmander) 213 | 214 | # Attempting to register over an existing class will fail. 215 | >>> registry.register("fire")(Charizard) 216 | RegistryKeyError: 217 | 218 | # Instead, unregister the current class and then register the new one. 219 | >>> registry.unregister("fire") 220 | >>> registry.register("fire")(Charizard) 221 | 222 | New Methods 223 | ----------- 224 | The following methods have been added: 225 | 226 | - :py:meth:`BaseRegistry.__dir__` method returns the list of registered keys as strings. 227 | - :py:meth:`BaseRegistry.__len__` method returns the number of registered symbols. 228 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["hatchling"] 3 | build-backend = "hatchling.build" 4 | 5 | [project] 6 | name = "phx-class-registry" 7 | version = "5.1.1" 8 | description = "Factory+Registry pattern for Python classes" 9 | authors = [{ name = "Phoenix Zerin", email = "phx@phx.nz" }] 10 | requires-python = "~=3.11" 11 | readme = "README.rst" 12 | license = "MIT" 13 | keywords = [ 14 | "design pattern", 15 | "factory pattern", 16 | "registry pattern", 17 | "service registry", 18 | ] 19 | classifiers = [ 20 | "Development Status :: 5 - Production/Stable", 21 | "Intended Audience :: Developers", 22 | "Topic :: Software Development :: Libraries :: Python Modules", 23 | ] 24 | 25 | [project.urls] 26 | Repository = "https://github.com/todofixthis/class-registry" 27 | Documentation = "https://class-registry.readthedocs.io/" 28 | Changelog = "https://github.com/todofixthis/class-registry/releases" 29 | Issues = "https://github.com/todofixthis/class-registry/issues" 30 | 31 | [dependency-groups] 32 | dev = [ 33 | "autohooks>=25,<26", 34 | "autohooks-plugin-black>=23,<24", 35 | "autohooks-plugin-mypy>=23,<24", 36 | "autohooks-plugin-pytest>=23,<24", 37 | "autohooks-plugin-ruff>=25,<26", 38 | "mypy>=1,<2", 39 | "pytest>=8,<9", 40 | "sphinx>=8,<9", 41 | "sphinx_rtd_theme>=3,<4", 42 | "tox>=4,<5", 43 | "tox-uv>=1,<2", 44 | ] 45 | ci = [ 46 | "mypy>=1,<2", 47 | "pytest>=8,<9", 48 | "sphinx>=8,<9", 49 | "sphinx_rtd_theme>=3,<4", 50 | "tox-uv>=1,<2", 51 | ] 52 | 53 | [tool.autohooks] 54 | mode = "pythonpath" 55 | pre-commit = [ 56 | "autohooks.plugins.black", 57 | "autohooks.plugins.mypy", 58 | "autohooks.plugins.pytest", 59 | "autohooks.plugins.ruff", 60 | ] 61 | 62 | [tool.hatch.build.targets.sdist] 63 | include = [ 64 | "src/class_registry", 65 | "LICENCE.txt", 66 | "docs", 67 | "test", 68 | ] 69 | exclude = ["docs/_build"] 70 | 71 | [tool.hatch.build.targets.wheel] 72 | include = ["src/class_registry"] 73 | exclude = ["docs/_build"] 74 | 75 | [tool.hatch.build.targets.wheel.sources] 76 | "src/class_registry" = "class_registry" 77 | 78 | [tool.mypy] 79 | strict = true 80 | 81 | [tool.pytest.ini_options] 82 | testpaths = ["test"] 83 | 84 | [tool.tox] 85 | env_list = ["py313", "py312", "py311"] 86 | 87 | [tool.tox.env_run_base] 88 | commands = [ 89 | ["pytest"], 90 | ["mypy", "src", "test"] 91 | ] 92 | runner = "uv-venv-lock-runner" 93 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": [ 4 | "config:recommended" 5 | ] 6 | } 7 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup 2 | 3 | if __name__ == '__main__': 4 | # :see: https://stackoverflow.com/a/62983901 5 | setup() 6 | -------------------------------------------------------------------------------- /src/class_registry/__init__.py: -------------------------------------------------------------------------------- 1 | __all__ = ["ClassRegistry", "RegistryKeyError"] 2 | 3 | from .base import RegistryKeyError 4 | from .registry import ClassRegistry 5 | -------------------------------------------------------------------------------- /src/class_registry/auto_register.py: -------------------------------------------------------------------------------- 1 | # This module is deprecated and will be removed in a future version of ClassRegistry, so 2 | # not going to bother getting the type hints just right (aka it was too difficult for 3 | # me to figure out, and so I looked for a justification for giving up XD). 4 | # type: ignore 5 | __all__ = ["AutoRegister"] 6 | 7 | from abc import ABCMeta 8 | from inspect import isabstract as is_abstract 9 | from warnings import warn 10 | 11 | from .base import BaseMutableRegistry 12 | 13 | 14 | def AutoRegister(registry: BaseMutableRegistry, base_type: type = ABCMeta) -> type: 15 | """ 16 | DEPRECATED: Use ``class_registry.base.AutoRegister`` instead (returns a base class 17 | instead of a metaclass). 18 | 19 | Creates a metaclass that automatically registers all non-abstract subclasses in the 20 | specified registry. 21 | 22 | Example:: 23 | 24 | commands = ClassRegistry(attr_name='command_name') 25 | 26 | # Specify ``AutoRegister`` as the metaclass: 27 | class BaseCommand(metaclass=AutoRegister(commands)): 28 | @abstractmethod 29 | def print(self): 30 | raise NotImplementedError() 31 | 32 | class PrintCommand(BaseCommand): 33 | command_name = 'print' 34 | 35 | def print(self): 36 | ... 37 | 38 | print(list(commands.items())) # [('print', PrintCommand)] 39 | 40 | .. important:: 41 | 42 | Python defines abstract as "having at least one unimplemented abstract method"; 43 | adding :py:class:`abc.ABC` as a base class is not enough. 44 | 45 | :param registry: 46 | The registry that new classes will be added to. 47 | 48 | .. note:: 49 | 50 | The registry's ``attr_name`` attribute must be set. 51 | 52 | :param base_type: 53 | The base type of the metaclass returned by this function. 54 | 55 | 99.99% of the time, this should be :py:class:`abc.ABCMeta`. 56 | """ 57 | warn( 58 | "class_registry.auto_register.AutoRegister is deprecated and will be removed in" 59 | "a future version of ClassRegistry. Use class_registry.base.AutoRegister" 60 | "instead (returns a base class instead of a metaclass). See" 61 | "https://github.com/todofixthis/class-registry/issues/14 for more information.", 62 | DeprecationWarning, 63 | ) 64 | 65 | if not registry.attr_name: 66 | raise ValueError(f"Missing `attr_name` in {registry}.") 67 | 68 | class _metaclass(base_type): 69 | def __init__(self, what, bases=None, attrs=None): 70 | super().__init__(what, bases, attrs) 71 | 72 | if not is_abstract(self): 73 | registry.register(self) 74 | 75 | return _metaclass 76 | -------------------------------------------------------------------------------- /src/class_registry/base.py: -------------------------------------------------------------------------------- 1 | __all__ = ["AutoRegister", "BaseMutableRegistry", "BaseRegistry", "RegistryKeyError"] 2 | 3 | import typing 4 | from abc import ABC, abstractmethod as abstract_method 5 | from inspect import isabstract as is_abstract, isclass as is_class 6 | from warnings import warn 7 | 8 | 9 | class RegistryKeyError(KeyError): 10 | """ 11 | Used to differentiate a registry lookup from a standard KeyError. 12 | 13 | This is especially useful when a registry class expects to extract values from dicts 14 | to generate keys. 15 | """ 16 | 17 | pass 18 | 19 | 20 | T = typing.TypeVar("T") 21 | 22 | # [#53] Fix incorrect return type from ``register`` 23 | D = typing.TypeVar("D", bound=typing.Callable[..., typing.Any]) 24 | 25 | 26 | class BaseRegistry(typing.Generic[T], ABC): 27 | """ 28 | Base functionality for registries. 29 | """ 30 | 31 | def __contains__(self, key: typing.Hashable) -> bool: 32 | """ 33 | Returns whether the specified key is registered. 34 | """ 35 | try: 36 | # Use :py:meth:`get_class` instead of :py:meth:`__getitem__`, to avoid 37 | # creating a new instance unnecessarily (i.e., prevent errors if the 38 | # corresponding class' constructor requires arguments). 39 | self.get_class(key) 40 | except RegistryKeyError: 41 | return False 42 | else: 43 | return True 44 | 45 | def __dir__(self) -> typing.Iterable[str]: 46 | """ 47 | Attempts to return the list of registered keys. 48 | 49 | Raises: 50 | TypeError: if a key cannot be cast as a string. 51 | """ 52 | return list(map(str, self.keys())) 53 | 54 | def __getitem__(self, key: typing.Hashable) -> T: 55 | """ 56 | Shortcut for calling :py:meth:`get` with empty args/kwargs. 57 | """ 58 | return self.get(key) 59 | 60 | def __iter__(self) -> typing.Iterator[typing.Hashable]: 61 | """ 62 | Iterates over registry keys. 63 | """ 64 | return iter(self.keys()) 65 | 66 | def __len__(self) -> int: 67 | """ 68 | Returns the number of registered classes. 69 | """ 70 | return sum(1 for _ in self.keys()) 71 | 72 | def __missing__(self, key: typing.Hashable) -> typing.Type[T]: 73 | """ 74 | Defines what to do when trying to access an unregistered key. 75 | 76 | Default behaviour is to throw a typed exception, but you could override this in 77 | a subclass, e.g., to return a default value. 78 | 79 | .. note:: 80 | 81 | This method must return a class, not an instance. 82 | """ 83 | raise RegistryKeyError(key) 84 | 85 | @abstract_method 86 | def get_class(self, key: typing.Hashable) -> typing.Type[T]: 87 | """ 88 | Returns the class associated with the specified key. 89 | """ 90 | raise NotImplementedError() 91 | 92 | def get(self, key: typing.Hashable, *args: typing.Any, **kwargs: typing.Any) -> T: 93 | """ 94 | Creates a new instance of the class matching the specified key. 95 | 96 | Args: 97 | key: 98 | The corresponding load key. 99 | args: 100 | Positional arguments passed to class initializer. 101 | Ignored if the class registry was initialized with a null template 102 | function. 103 | kwargs: 104 | Keyword arguments passed to class initializer. 105 | Ignored if the class registry was initialized with a null template 106 | function. 107 | 108 | References: 109 | - :py:meth:`__init__` 110 | """ 111 | return self.create_instance(self.get_class(key), *args, **kwargs) 112 | 113 | @abstract_method 114 | def keys(self) -> typing.Iterable[typing.Hashable]: 115 | """ 116 | Returns the collection of registered keys. 117 | """ 118 | raise NotImplementedError() 119 | 120 | def classes(self) -> typing.Iterable[typing.Type[T]]: 121 | """ 122 | Returns the collection of registered classes. 123 | """ 124 | return iter(self.get_class(key) for key in self.keys()) 125 | 126 | @staticmethod 127 | def gen_lookup_key(key: typing.Hashable) -> typing.Hashable: 128 | """ 129 | Used by :py:meth:`get` to generate a lookup key. 130 | 131 | You may override this method in a subclass, for example if you need to support 132 | legacy aliases, etc. 133 | 134 | Args: 135 | key: 136 | The key value provided to e.g., :py:meth:`__getitem__` 137 | 138 | Returns: 139 | The registry key, used to look up the corresponding class. 140 | """ 141 | return key 142 | 143 | @staticmethod 144 | def create_instance( 145 | class_: typing.Type[T], *args: typing.Any, **kwargs: typing.Any 146 | ) -> T: 147 | """ 148 | Prepares the return value for :py:meth:`get`. 149 | 150 | You may override this method in a subclass, if you want to customize the way new 151 | instances are created. 152 | 153 | Args: 154 | class_: 155 | The requested class. 156 | args: 157 | Positional keywords passed to :py:meth:`get`. 158 | kwargs: 159 | Keyword arguments passed to :py:meth:`get`. 160 | """ 161 | return class_(*args, **kwargs) 162 | 163 | 164 | class BaseMutableRegistry(BaseRegistry[T], ABC): 165 | """ 166 | Extends :py:class:`BaseRegistry` with methods that can be used to modify the 167 | registered classes. 168 | """ 169 | 170 | def __init__(self, attr_name: typing.Optional[str] = None) -> None: 171 | """ 172 | Args: 173 | attr_name: 174 | If provided, :py:meth:`register` will automatically detect the key to 175 | use when registering new classes. 176 | """ 177 | super().__init__() 178 | 179 | self.attr_name = attr_name 180 | 181 | # Map lookup keys to readable keys. 182 | # Only needed when :py:meth:`gen_lookup_key` is overridden, but I'm not good 183 | # enough at reflection black magic to figure out how to do that (: 184 | self._lookup_keys: dict[typing.Hashable, typing.Hashable] = {} 185 | 186 | def __repr__(self) -> str: 187 | return f"{type(self).__name__}({self.attr_name!r})" 188 | 189 | def keys(self) -> typing.Iterable[typing.Hashable]: 190 | """ 191 | Returns the collection of registry keys, in the order that they were registered. 192 | """ 193 | return iter(self._lookup_keys.keys()) 194 | 195 | def items(self) -> typing.Iterable[tuple[typing.Hashable, typing.Type[T]]]: 196 | """ 197 | .. warning:: 198 | 199 | DEPRECATED: use :py:meth:`keys` or :py:meth:`classes` instead. 200 | 201 | Returns the collection of registered key-class pairs, in the order that they 202 | were registered. 203 | """ 204 | warn( 205 | f"{type(self).__name__}.items() is deprecated and will be removed in a " 206 | f"future version of ClassRegistry. Use `zip({type(self).__name__}.keys(), " 207 | f"{type(self).__name__}.classes())` instead.", 208 | DeprecationWarning, 209 | ) 210 | return iter(zip(self.keys(), self.classes())) 211 | 212 | def values(self) -> typing.Iterable[typing.Type[T]]: 213 | """ 214 | .. warning:: 215 | 216 | DEPRECATED: use :py:meth:`classes` instead. 217 | 218 | Returns the collection of registered classes, in the order that they were 219 | registered. 220 | """ 221 | warn( 222 | f"{type(self).__name__}.values() is deprecated and will be removed in a " 223 | f"future version of ClassRegistry. Use {type(self).__name__}.classes()" 224 | f"instead.", 225 | DeprecationWarning, 226 | ) 227 | return self.classes() 228 | 229 | # [#53] Using ``D`` instead of ``T`` to prevent scrubbing type info when decorating 230 | # a class. 231 | # :see: https://mypy.readthedocs.io/en/stable/generics.html#decorator-factories 232 | # :see: https://docs.python.org/3/library/typing.html#typing.overload 233 | @typing.overload 234 | def register(self, key: D, /) -> D: 235 | """Bare decorator variant""" 236 | ... 237 | 238 | @typing.overload 239 | def register(self, key: typing.Hashable) -> typing.Callable[[D], D]: 240 | """Decorator factory variant""" 241 | ... 242 | 243 | def register(self, key: typing.Union[D, typing.Hashable]) -> typing.Union[ 244 | D, 245 | typing.Callable[[D], D], 246 | ]: 247 | """ 248 | Decorator that registers a class with the registry. 249 | 250 | Example:: 251 | 252 | registry = ClassRegistry(attr_name='widget_type') 253 | 254 | @registry.register 255 | class CustomWidget(BaseWidget): 256 | widget_type = 'custom' 257 | ... 258 | 259 | # Override the registry key: 260 | @registry.register('premium') 261 | class AdvancedWidget(BaseWidget): 262 | ... 263 | 264 | Args: 265 | key: 266 | The registry key to use for the registered class. 267 | Optional if the registry's :py:attr:`attr_name` is set. 268 | """ 269 | # ``@register`` usage: 270 | if is_class(key): 271 | if typing.TYPE_CHECKING: 272 | key = typing.cast(D, key) 273 | 274 | if self.attr_name: 275 | attr_key = getattr(key, self.attr_name) 276 | lookup_key = self.gen_lookup_key(attr_key) 277 | 278 | self._register(lookup_key, typing.cast(typing.Type[T], key)) 279 | self._lookup_keys[attr_key] = lookup_key 280 | 281 | return key 282 | else: 283 | raise ValueError( 284 | f"Attempting to register {key.__name__} to {type(self).__name__}" 285 | f"via decorator, but `{type(self).__name__}.attr_key` is not set." 286 | ) 287 | else: 288 | # :see: https://github.com/python/mypy/issues/16640 289 | if typing.TYPE_CHECKING: 290 | key = typing.cast(typing.Hashable, key) 291 | 292 | # ``@register('some_attr')`` usage: 293 | def _decorator(cls: D) -> D: 294 | lookup_key_ = self.gen_lookup_key(key) 295 | 296 | self._register(lookup_key_, typing.cast(typing.Type[T], cls)) 297 | self._lookup_keys[key] = lookup_key_ 298 | 299 | return cls 300 | 301 | return _decorator 302 | 303 | def unregister(self, key: typing.Hashable) -> typing.Type[T]: 304 | """ 305 | Unregisters the class with the specified key. 306 | 307 | Args: 308 | key: 309 | The registry key to remove (not the registered class!). 310 | 311 | Returns: 312 | The class that was unregistered. 313 | 314 | Raises: 315 | KeyError: if the key is not registered. 316 | """ 317 | result = self._unregister(self.gen_lookup_key(key)) 318 | del self._lookup_keys[key] 319 | 320 | return result 321 | 322 | @abstract_method 323 | def _register(self, key: typing.Hashable, class_: typing.Type[T]) -> None: 324 | """ 325 | Registers a class with the registry. 326 | 327 | Args: 328 | key: 329 | Return value from :py:meth:`gen_lookup_key`. 330 | """ 331 | raise NotImplementedError() 332 | 333 | @abstract_method 334 | def _unregister(self, key: typing.Hashable) -> typing.Type[T]: 335 | """ 336 | Unregisters the class at the specified key. 337 | 338 | Args: 339 | key: 340 | Return value from :py:meth:`gen_lookup_key`. 341 | """ 342 | raise NotImplementedError() 343 | 344 | 345 | def AutoRegister(registry: BaseMutableRegistry[T]) -> type: 346 | """ 347 | Creates a base class that automatically registers all non-abstract subclasses in the 348 | specified registry. 349 | 350 | Example:: 351 | 352 | commands = ClassRegistry(attr_name='command_name') 353 | 354 | class BaseCommand(AutoRegister(commands), ABC): 355 | @abstractmethod 356 | def exec(self): 357 | raise NotImplementedError() 358 | 359 | class PrintCommand(BaseCommand): 360 | command_name = 'print' 361 | 362 | def exec(self): 363 | ... 364 | 365 | print(list(commands.items())) # [('print', PrintCommand)] 366 | 367 | .. important:: 368 | 369 | Python defines abstract as "having at least one unimplemented abstract method"; 370 | adding :py:class:`abc.ABC` as a base class is not enough. 371 | 372 | Args: 373 | registry: 374 | The registry that new classes will be added to. 375 | 376 | .. note:: 377 | 378 | The registry's ``attr_name`` attribute must be set. 379 | 380 | 381 | """ 382 | if not registry.attr_name: 383 | raise ValueError(f"Missing `attr_name` in {registry}.") 384 | 385 | class _Base: 386 | def __init_subclass__(cls, **kwargs: typing.Any) -> None: 387 | super().__init_subclass__(**kwargs) 388 | 389 | if not is_abstract(cls): 390 | registry.register(cls) 391 | 392 | return _Base 393 | -------------------------------------------------------------------------------- /src/class_registry/cache.py: -------------------------------------------------------------------------------- 1 | __all__ = ["ClassRegistryInstanceCache"] 2 | 3 | import typing 4 | from collections import defaultdict 5 | 6 | from . import ClassRegistry 7 | 8 | T = typing.TypeVar("T") 9 | 10 | 11 | class ClassRegistryInstanceCache(typing.Mapping[typing.Hashable, T]): 12 | """ 13 | Wraps a ClassRegistry instance, caching instances as they are created. 14 | 15 | This allows you to create [multiple] registries that cache instances locally (so 16 | that they can be scoped and garbage-collected), whilst keeping the class registry 17 | separate. 18 | 19 | Note that the internal class registry is copied by reference, so any classes that 20 | are registered afterward are accessible to both the ``ClassRegistry`` and the 21 | ``ClassRegistryInstanceCache``. 22 | """ 23 | 24 | def __init__( 25 | self, 26 | class_registry: ClassRegistry[T], 27 | *args: typing.Any, 28 | **kwargs: typing.Any, 29 | ) -> None: 30 | """ 31 | Args: 32 | class_registry: 33 | The wrapped ClassRegistry. 34 | args: 35 | Positional arguments passed to the class registry's template when 36 | creating new instances. 37 | kwargs: 38 | Keyword arguments passed to the class registry's template function when 39 | creating new instances. 40 | """ 41 | super().__init__() 42 | 43 | self._registry: ClassRegistry[T] = class_registry 44 | self._cache: dict[typing.Hashable, T] = {} 45 | 46 | self._key_map: dict[typing.Hashable, list[typing.Hashable]] = defaultdict(list) 47 | 48 | self._template_args = args 49 | self._template_kwargs = kwargs 50 | 51 | def __getitem__(self, key: typing.Hashable) -> T: 52 | """ 53 | Returns the cached instance associated with the specified key. 54 | """ 55 | instance_key = self.get_instance_key(key) 56 | 57 | if instance_key not in self._cache: 58 | class_key = self.get_class_key(key) 59 | 60 | # Map lookup keys to cache keys so that we can iterate over them in the 61 | # correct order. 62 | self._key_map[class_key].append(instance_key) 63 | 64 | self._cache[instance_key] = self._registry.get( 65 | class_key, *self._template_args, **self._template_kwargs 66 | ) 67 | 68 | return self._cache[instance_key] 69 | 70 | def __iter__(self) -> typing.Generator[T, None, None]: 71 | """ 72 | Returns a generator for iterating over cached instances, using the wrapped 73 | registry to determine sort order. 74 | 75 | If a key has not been accessed yet, it will not be included. 76 | """ 77 | for lookup_key in self._registry.keys(): 78 | for cache_key in self._key_map[lookup_key]: 79 | yield self._cache[cache_key] 80 | 81 | def __len__(self) -> int: 82 | """ 83 | Returns the number of cached instances. 84 | 85 | Does not reflect any registered classes that haven't been accessed yet. 86 | """ 87 | return len(self._cache) 88 | 89 | @property 90 | def registry(self) -> ClassRegistry[T]: 91 | """ 92 | Accessor for the wrapped class registry. 93 | """ 94 | return self._registry 95 | 96 | def warm_cache(self) -> None: 97 | """ 98 | Warms up the cache, ensuring that an instance is created for every key currently 99 | in the registry. 100 | 101 | .. note:: 102 | 103 | This method has no effect for any classes added to the wrapped 104 | :py:class:`ClassRegistry` after calling ``warm_cache``. 105 | """ 106 | for key in self._registry.keys(): 107 | self.__getitem__(key) 108 | 109 | def get_instance_key(self, key: typing.Hashable) -> typing.Hashable: 110 | """ 111 | Generates a key that can be used to store/lookup values in the instance cache. 112 | 113 | Args: 114 | key: 115 | Value provided to :py:meth:`__getitem__`. 116 | """ 117 | return self.get_class_key(key) 118 | 119 | def get_class_key(self, key: typing.Hashable) -> typing.Hashable: 120 | """ 121 | Generates a key that can be used to store/lookup values in the wrapped 122 | :py:class:`ClassRegistry` instance. 123 | 124 | This method is only invoked in the event of a cache miss. 125 | 126 | Args: 127 | key: 128 | Value provided to :py:meth:`__getitem__`. 129 | """ 130 | return self._registry.gen_lookup_key(key) 131 | -------------------------------------------------------------------------------- /src/class_registry/entry_points.py: -------------------------------------------------------------------------------- 1 | __all__ = ["EntryPointClassRegistry"] 2 | 3 | import typing 4 | from importlib.metadata import entry_points 5 | 6 | from .base import BaseRegistry 7 | 8 | T = typing.TypeVar("T") 9 | 10 | 11 | class EntryPointClassRegistry(BaseRegistry[T]): 12 | """ 13 | A class registry that loads classes using setuptools entry points. 14 | """ 15 | 16 | def __init__( 17 | self, 18 | group: str, 19 | attr_name: typing.Optional[str] = None, 20 | ) -> None: 21 | """ 22 | Args: 23 | group: 24 | The name of the entry point group that will be used to load new classes. 25 | 26 | attr_name: 27 | If set, the registry will "brand" each class with its corresponding 28 | registry key. This makes it easier to perform reverse lookups later. 29 | 30 | Note: if a class already defines this attribute, the registry will 31 | overwrite it! 32 | """ 33 | super().__init__() 34 | 35 | self.attr_name = attr_name 36 | self.group = group 37 | 38 | self._cache: typing.Optional[dict[typing.Hashable, typing.Type[T]]] = None 39 | """ 40 | Caches registered classes locally, so that we don't have to keep iterating over 41 | entry points. 42 | """ 43 | 44 | # If :py:attr:`attr_name` is set, warm the cache immediately, to apply branding. 45 | if self.attr_name: 46 | self._get_cache() 47 | 48 | def __len__(self) -> int: 49 | return len(self._get_cache()) 50 | 51 | def __repr__(self) -> str: 52 | return f"{type(self).__name__}(group={self.group!r})" 53 | 54 | def get(self, key: typing.Hashable, *args: typing.Any, **kwargs: typing.Any) -> T: 55 | instance = super().get(key, *args, **kwargs) 56 | 57 | if self.attr_name: 58 | # Apply branding to the instance explicitly. 59 | # This is particularly important if the corresponding entry point references 60 | # a function or method. 61 | setattr(instance, self.attr_name, key) 62 | 63 | return instance 64 | 65 | def get_class(self, key: typing.Hashable) -> typing.Type[T]: 66 | try: 67 | return self._get_cache()[key] 68 | except KeyError: 69 | return self.__missing__(key) 70 | 71 | def keys(self) -> typing.Iterable[typing.Hashable]: 72 | return iter(self._get_cache().keys()) 73 | 74 | def refresh(self) -> None: 75 | """ 76 | Purges the local cache. The next access attempt will reload all entry points. 77 | 78 | This is useful if you load a distribution at runtime...such as during unit tests 79 | for ``phx-class-registry``. Otherwise, it probably serves no useful purpose (: 80 | """ 81 | self._cache = None 82 | 83 | def _get_cache(self) -> dict[typing.Hashable, typing.Type[T]]: 84 | """ 85 | Populates the cache (if necessary) and returns it. 86 | """ 87 | if self._cache is None: 88 | self._cache = {} 89 | for e in entry_points(group=self.group): 90 | cls = e.load() 91 | 92 | # Try to apply branding, but only for compatible types (i.e., functions 93 | # and methods can't be branded this way). 94 | if self.attr_name and isinstance(cls, type): 95 | setattr(cls, self.attr_name, e.name) 96 | 97 | self._cache[e.name] = cls 98 | 99 | return self._cache 100 | -------------------------------------------------------------------------------- /src/class_registry/patcher.py: -------------------------------------------------------------------------------- 1 | __all__ = ["RegistryPatcher"] 2 | 3 | import typing 4 | from types import TracebackType 5 | 6 | from . import RegistryKeyError 7 | from .base import BaseMutableRegistry 8 | 9 | T = typing.TypeVar("T") 10 | 11 | 12 | class RegistryPatcher(typing.Generic[T]): 13 | """ 14 | Creates a context in which classes are temporarily registered with a class registry, 15 | then removed when the context exits. 16 | 17 | .. note:: 18 | 19 | Only mutable registries can be patched. 20 | """ 21 | 22 | class DoesNotExist(object): 23 | """ 24 | Used to identify a value that did not exist before we started. 25 | """ 26 | 27 | pass 28 | 29 | def __init__( 30 | self, 31 | registry: BaseMutableRegistry[T], 32 | *args: typing.Type[T], 33 | **kwargs: typing.Type[T] 34 | ) -> None: 35 | """ 36 | Args: 37 | registry: 38 | A :py:class:`MutableRegistry` instance to patch. 39 | 40 | args: 41 | Classes to add to the registry. 42 | 43 | This behaves the same as decorating each class with 44 | ``@registry.register``. 45 | 46 | .. note:: 47 | 48 | ``registry.attr_name`` must be set. 49 | 50 | kwargs: 51 | Same as ``args``, except you explicitly specify the registry keys. 52 | 53 | In the event of a conflict, values in ``args`` override values in 54 | ``kwargs``. 55 | """ 56 | super().__init__() 57 | 58 | assert registry.attr_name is not None 59 | for class_ in args: 60 | kwargs[getattr(class_, registry.attr_name)] = class_ 61 | 62 | self.target: BaseMutableRegistry[T] = registry 63 | 64 | self._new_values: dict[str, typing.Type[T]] = kwargs 65 | self._prev_values: dict[ 66 | typing.Hashable, 67 | typing.Union[typing.Type[T], typing.Type[RegistryPatcher.DoesNotExist]], 68 | ] = {} 69 | 70 | def __enter__(self) -> None: 71 | self.apply() 72 | 73 | def __exit__( 74 | self, 75 | exc_type: typing.Optional[typing.Type[BaseException]], 76 | exc_val: typing.Optional[BaseException], 77 | exc_tb: typing.Optional[TracebackType], 78 | ) -> None: 79 | self.restore() 80 | 81 | def apply(self) -> None: 82 | """ 83 | Applies the new values. 84 | """ 85 | # Back up previous values. 86 | self._prev_values = { 87 | key: self._get_value(key, self.DoesNotExist) for key in self._new_values 88 | } 89 | 90 | # Patch values. 91 | for key, value in self._new_values.items(): 92 | # Remove the existing value first (prevents issues if the registry has 93 | # ``unique=True``). 94 | self._del_value(key) 95 | 96 | if value is not self.DoesNotExist: 97 | self._set_value(key, value) 98 | 99 | def restore(self) -> None: 100 | """ 101 | Restores previous settings. 102 | """ 103 | for key, value in self._prev_values.items(): 104 | # Remove the existing value first (prevents issues if the registry has 105 | # ``unique=True``). 106 | self._del_value(key) 107 | 108 | if value is not self.DoesNotExist: 109 | if typing.TYPE_CHECKING: 110 | # Convince mypy that ``value`` cannot be ``self.DoesNotExist``. 111 | value = typing.cast(typing.Type[T], value) 112 | 113 | self._set_value(key, value) 114 | 115 | def _get_value( 116 | self, 117 | key: typing.Hashable, 118 | default: typing.Any = None, 119 | ) -> typing.Any: 120 | try: 121 | return self.target.get_class(key) 122 | except RegistryKeyError: 123 | return default 124 | 125 | def _set_value(self, key: typing.Hashable, value: typing.Type[T]) -> None: 126 | self.target.register(key)(value) 127 | 128 | def _del_value(self, key: typing.Hashable) -> None: 129 | try: 130 | self.target.unregister(key) 131 | except RegistryKeyError: 132 | pass 133 | -------------------------------------------------------------------------------- /src/class_registry/py.typed: -------------------------------------------------------------------------------- 1 | https://typing.readthedocs.io/en/latest/spec/distributing.html#packaging-typed-libraries 2 | -------------------------------------------------------------------------------- /src/class_registry/registry.py: -------------------------------------------------------------------------------- 1 | __all__ = ["ClassRegistry", "SortedClassRegistry"] 2 | 3 | import typing 4 | from functools import cmp_to_key 5 | 6 | from .base import BaseMutableRegistry, RegistryKeyError 7 | 8 | # :see: https://github.com/python/typeshed/blob/main/stdlib/_typeshed/README.md 9 | if typing.TYPE_CHECKING: 10 | from _typeshed import SupportsAllComparisons 11 | 12 | T = typing.TypeVar("T") 13 | 14 | 15 | class ClassRegistry(BaseMutableRegistry[T]): 16 | """ 17 | Maintains a registry of classes and provides a generic factory for instantiating 18 | them. 19 | """ 20 | 21 | def __init__( 22 | self, 23 | attr_name: typing.Optional[str] = None, 24 | unique: bool = False, 25 | ) -> None: 26 | """ 27 | Args: 28 | attr_name: 29 | If provided, :py:meth:`register` will automatically detect the key to 30 | use when registering new classes. 31 | 32 | unique: 33 | Determines what happens when two classes are registered with the same 34 | key: 35 | 36 | - ``True``: A :py:class:`KeyError` will be raised. 37 | - ``False``: The second class will replace the first one. 38 | """ 39 | super().__init__(attr_name) 40 | 41 | self.unique = unique 42 | 43 | self._registry: dict[typing.Hashable, typing.Type[T]] = {} 44 | 45 | def __len__(self) -> int: 46 | return len(self._registry) 47 | 48 | def __repr__(self) -> str: 49 | return f"{type(self).__name__}(attr_name={self.attr_name!r}, unique={self.unique!r})" 50 | 51 | def get_class(self, key: typing.Hashable) -> typing.Type[T]: 52 | """ 53 | Returns the class associated with the specified key. 54 | """ 55 | lookup_key = self.gen_lookup_key(key) 56 | 57 | try: 58 | return self._registry[lookup_key] 59 | except KeyError: 60 | return self.__missing__(lookup_key) 61 | 62 | def _register(self, key: typing.Hashable, class_: typing.Type[T]) -> None: 63 | """ 64 | Registers a class with the registry. 65 | 66 | Args: 67 | key: 68 | Has already been processed by :py:meth:`gen_lookup_key`. 69 | """ 70 | if key in ["", None]: 71 | raise ValueError( 72 | f"Attempting to register class {class_.__name__} " 73 | "with empty registry key {key!r}." 74 | ) 75 | 76 | if self.unique and (key in self._registry): 77 | raise RegistryKeyError( 78 | f"{class_.__name__} with key {key!r} is already registered.", 79 | ) 80 | 81 | self._registry[key] = class_ 82 | 83 | def _unregister(self, key: typing.Hashable) -> typing.Type[T]: 84 | """ 85 | Unregisters the class at the specified key. 86 | 87 | Args: 88 | key: 89 | Has already been processed by :py:meth:`gen_lookup_key`. 90 | """ 91 | return ( 92 | self._registry.pop(key) if key in self._registry else self.__missing__(key) 93 | ) 94 | 95 | 96 | class SortedClassRegistry(ClassRegistry[T]): 97 | """ 98 | A ClassRegistry that uses a function to determine sort order when iterating. 99 | """ 100 | 101 | def __init__( 102 | self, 103 | sort_key: typing.Any, 104 | attr_name: typing.Optional[str] = None, 105 | unique: bool = False, 106 | reverse: bool = False, 107 | ) -> None: 108 | """ 109 | Args: 110 | sort_key: 111 | Attribute name or callable, used to determine the sort value. 112 | 113 | If callable, must accept two tuples of (key, class, lookup_key). 114 | 115 | You can also use :py:func:`functools.cmp_to_key`. 116 | attr_name: 117 | If provided, :py:meth:`register` will automatically detect the key to 118 | use when registering new classes. 119 | unique: 120 | Determines what happens when two classes are registered with the same 121 | key: 122 | 123 | - ``True``: The second class will replace the first one. 124 | - ``False``: A ``ValueError`` will be raised. 125 | reverse: 126 | Whether to reverse the sort ordering. 127 | """ 128 | super().__init__(attr_name, unique) 129 | 130 | self._sort_key = ( 131 | sort_key if callable(sort_key) else self.create_sorter(sort_key) 132 | ) 133 | 134 | self.reverse = reverse 135 | 136 | def keys(self) -> typing.Iterable[typing.Hashable]: 137 | """ 138 | Returns the collection of registry keys, in the order that they were registered. 139 | """ 140 | return iter( 141 | key 142 | for key, _, _ in sorted( 143 | ( 144 | # Provide both human-readable and lookup keys to the sorter. 145 | (key, self.get_class(key), self.gen_lookup_key(key)) 146 | for key in super().keys() 147 | ), 148 | key=self._sort_key, 149 | reverse=self.reverse, 150 | ) 151 | ) 152 | 153 | @staticmethod 154 | def create_sorter(sort_key: str) -> typing.Callable[..., "SupportsAllComparisons"]: 155 | """ 156 | Given a sort key, creates a function that can be used to sort items when 157 | iterating over the registry. 158 | """ 159 | 160 | def sorter( 161 | a: typing.Tuple[typing.Hashable, typing.Type[T], typing.Hashable], 162 | b: typing.Tuple[typing.Hashable, typing.Type[T], typing.Hashable], 163 | ) -> int: 164 | a_attr = getattr(a[1], sort_key) 165 | b_attr = getattr(b[1], sort_key) 166 | 167 | # Technically ``typing.Hashable`` objects don't have to implement comparison 168 | # support, so this is slightly unsafe. But, in the vast majority of cases we 169 | # expect keys to be str values, so this should be fine (:O 170 | # 171 | # Incidentally, that's why we need a seemingly-unnecessary ``cast`` here. 172 | return typing.cast(int, (a_attr > b_attr) - (a_attr < b_attr)) 173 | 174 | return cmp_to_key(sorter) 175 | -------------------------------------------------------------------------------- /test/__init__.py: -------------------------------------------------------------------------------- 1 | import typing 2 | 3 | 4 | class Pokemon: 5 | """ 6 | A basic class with some attributes that we can use to test out class 7 | registries. 8 | """ 9 | 10 | element: str 11 | 12 | def __init__(self, name: typing.Optional[str] = None): 13 | super().__init__() 14 | 15 | self.name: typing.Optional[str] = name 16 | 17 | 18 | # Define some classes that we can register. 19 | class Charmander(Pokemon): 20 | element = "fire" 21 | 22 | 23 | class Charmeleon(Pokemon): 24 | element = "fire" 25 | 26 | 27 | class Squirtle(Pokemon): 28 | element = "water" 29 | 30 | 31 | class Wartortle(Pokemon): 32 | element = "water" 33 | 34 | 35 | class Bulbasaur(Pokemon): 36 | element = "grass" 37 | 38 | 39 | class Ivysaur(Pokemon): 40 | element = "grass" 41 | 42 | 43 | class Mew(Pokemon): 44 | element = "psychic" 45 | 46 | 47 | class PokemonFactory: 48 | """ 49 | A factory that can produce new pokémon on demand. Used to test how registries 50 | behave when a method/function is registered instead of a class. 51 | """ 52 | 53 | @classmethod 54 | def create_psychic_pokemon(cls, name: typing.Optional[str] = None) -> Mew: 55 | return Mew(name) 56 | -------------------------------------------------------------------------------- /test/dummy_package.egg-info/PKG-INFO: -------------------------------------------------------------------------------- 1 | Metadata-Version: 1.1 2 | Name: dummy_package 3 | Version: 1.0.0 4 | Summary: Dummy package loaded during unit tests for EntryPointClassRegistry. 5 | Home-page: https://class-registry.readthedocs.io/ 6 | Author: Phoenix Zerin 7 | Author-email: phoenix.zerin@eflglobal.com 8 | License: MIT 9 | Description: Dummy package loaded during unit tests for EntryPointClassRegistry. 10 | Keywords: test 11 | Platform: UNKNOWN 12 | -------------------------------------------------------------------------------- /test/dummy_package.egg-info/SOURCES.txt: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/todofixthis/class-registry/cbd0bbb91e22b8155a8ee5ef917f7a16b9556f2d/test/dummy_package.egg-info/SOURCES.txt -------------------------------------------------------------------------------- /test/dummy_package.egg-info/dependency_links.txt: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /test/dummy_package.egg-info/entry_points.txt: -------------------------------------------------------------------------------- 1 | [pokemon] 2 | fire = test:Charmander 3 | grass = test:Bulbasaur 4 | psychic = test:PokemonFactory.create_psychic_pokemon 5 | water = test:Squirtle 6 | -------------------------------------------------------------------------------- /test/dummy_package.egg-info/requires.txt: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/todofixthis/class-registry/cbd0bbb91e22b8155a8ee5ef917f7a16b9556f2d/test/dummy_package.egg-info/requires.txt -------------------------------------------------------------------------------- /test/dummy_package.egg-info/top_level.txt: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/todofixthis/class-registry/cbd0bbb91e22b8155a8ee5ef917f7a16b9556f2d/test/dummy_package.egg-info/top_level.txt -------------------------------------------------------------------------------- /test/helper.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import typing 3 | from importlib.metadata import DistributionFinder, PathDistribution 4 | from os import path 5 | from pathlib import Path 6 | 7 | 8 | class DummyDistributionFinder(DistributionFinder): 9 | """ 10 | Injects a dummy distribution into the meta path finder, so that we can 11 | pretend like it's been pip installed during unit tests (i.e., so that we 12 | can test ``EntryPointsClassRegistry``), without polluting the persistent 13 | virtualenv. 14 | """ 15 | 16 | DUMMY_PACKAGE_DIR = "dummy_package.egg-info" 17 | 18 | @classmethod 19 | def install(cls) -> None: 20 | for finder in sys.meta_path: 21 | if isinstance(finder, cls): 22 | # If we've already installed an instance of the class, then 23 | # something is probably wrong with our tests. 24 | raise ValueError(f"{cls.__name__} is already installed") 25 | 26 | sys.meta_path.append(cls()) 27 | 28 | @classmethod 29 | def uninstall(cls) -> None: 30 | for i, finder in enumerate(sys.meta_path): 31 | if isinstance(finder, cls): 32 | sys.meta_path.pop(i) 33 | return 34 | else: 35 | # If we didn't find an installed instance of the class, then 36 | # something is probably wrong with our tests. 37 | raise ValueError(f"{cls.__name__} was not installed") 38 | 39 | # ``context`` should be a ``DistributionFinder.Context``, but that type isn't 40 | # compatible with ``EllipsisType``, and mypy isn't having any of it, so :shrug: 41 | def find_distributions(self, context: typing.Any = ...) -> list[PathDistribution]: 42 | return [ 43 | PathDistribution( 44 | Path(path.join(path.dirname(__file__), self.DUMMY_PACKAGE_DIR)) 45 | ) 46 | ] 47 | -------------------------------------------------------------------------------- /test/test_auto_register.py: -------------------------------------------------------------------------------- 1 | """ 2 | Unit tests for :py:class:`class_registry.base.AutoRegister`, which replaces 3 | :py:class:`class_registry.auto_register.AutoRegister` (the latter returns a metaclass, 4 | whilst the former returns a base class). 5 | 6 | :see: https://github.com/todofixthis/class-registry/issues/14 7 | """ 8 | 9 | from abc import ABC, abstractmethod as abstract_method 10 | 11 | import pytest 12 | 13 | from class_registry import ClassRegistry 14 | from class_registry.base import AutoRegister 15 | from test import Pokemon 16 | 17 | 18 | def test_auto_register() -> None: 19 | """ 20 | Using :py:func:`AutoRegister` to, well, auto-register classes. 21 | """ 22 | registry = ClassRegistry["BasePokemon"](attr_name="element") 23 | 24 | # Note that we declare :py:func:`AutoRegister` as a base class. 25 | # Dynamic subclasses are not supported by mypy, so have to ignore type check here. 26 | # :see: https://github.com/python/mypy/wiki/Unsupported-Python-Features 27 | class BasePokemon(AutoRegister(registry), ABC): # type: ignore 28 | """ 29 | Abstract base class; will not get registered. 30 | """ 31 | 32 | @abstract_method 33 | def get_abilities(self) -> list[str]: 34 | raise NotImplementedError() 35 | 36 | class Sandslash(BasePokemon): 37 | """ 38 | Non-abstract subclass; will get registered automatically. 39 | """ 40 | 41 | element = "ground" 42 | 43 | def get_abilities(self) -> list[str]: 44 | return ["sand veil"] 45 | 46 | class BaseEvolvingPokemon(BasePokemon, ABC): 47 | """ 48 | Abstract subclass; will not get registered. 49 | """ 50 | 51 | @abstract_method 52 | def evolve(self) -> str: 53 | raise NotImplementedError() 54 | 55 | class Ekans(BaseEvolvingPokemon): 56 | """ 57 | Non-abstract subclass; will get registered automatically. 58 | """ 59 | 60 | element = "poison" 61 | 62 | def get_abilities(self) -> list[str]: 63 | return ["intimidate", "shed skin"] 64 | 65 | def evolve(self) -> str: 66 | return "Congratulations! Your EKANS evolved into ARBOK!" 67 | 68 | # Note that only non-abstract classes got registered. 69 | assert list(registry.classes()) == [Sandslash, Ekans] 70 | 71 | 72 | def test_abstract_strict_definition() -> None: 73 | """ 74 | If a class has no unimplemented abstract methods, it gets registered. 75 | """ 76 | registry = ClassRegistry["FightingPokemon"](attr_name="element") 77 | 78 | # Dynamic subclasses are not supported by mypy, so have to ignore type check here. 79 | # :see: https://github.com/python/mypy/wiki/Unsupported-Python-Features 80 | class FightingPokemon(AutoRegister(registry)): # type: ignore 81 | element = "fighting" 82 | 83 | # :py:class:`FightingPokemon` does not define any abstract methods, so it is not 84 | # considered to be abstract :shrug: 85 | assert list(registry.classes()) == [FightingPokemon] 86 | 87 | 88 | def test_error_attr_name_missing() -> None: 89 | """ 90 | The registry doesn't have an ``attr_name``. 91 | """ 92 | registry = ClassRegistry[Pokemon]() 93 | 94 | with pytest.raises(ValueError): 95 | AutoRegister(registry) 96 | -------------------------------------------------------------------------------- /test/test_auto_register_deprecated.py: -------------------------------------------------------------------------------- 1 | # This module is deprecated and will be removed in a future version of ClassRegistry, so 2 | # not going to bother getting the type hints just right (aka it was too difficult for 3 | # me to figure out, and so I looked for a justification for giving up XD). 4 | # type: ignore 5 | """ 6 | Unit tests for the deprecated :py:class:`class_registry.auto_register.AutoRegister` 7 | function. 8 | 9 | This function is deprecated; use :py:class:`class_registry.base.AutoRegister` instead. 10 | :see: https://github.com/todofixthis/class-registry/issues/14 11 | """ 12 | 13 | from abc import ABC, abstractmethod as abstract_method 14 | 15 | import pytest 16 | 17 | from class_registry import ClassRegistry 18 | 19 | # noinspection PyDeprecation 20 | from class_registry.auto_register import AutoRegister 21 | 22 | 23 | def test_auto_register(): 24 | """ 25 | Using :py:func:`AutoRegister` to, well, auto-register classes. 26 | """ 27 | registry = ClassRegistry(attr_name="element") 28 | 29 | # :py:func:`AutoRegister` is deprecated. 30 | # :see: https://github.com/todofixthis/class-registry/issues/14 31 | with pytest.deprecated_call(): 32 | # Note that we declare :py:func:`AutoRegister` as the metaclass 33 | # for our base class. 34 | # noinspection PyDeprecation 35 | class BasePokemon(metaclass=AutoRegister(registry)): 36 | """ 37 | Abstract base class; will not get registered. 38 | """ 39 | 40 | @abstract_method 41 | def get_abilities(self): 42 | raise NotImplementedError() 43 | 44 | class Sandslash(BasePokemon): 45 | """ 46 | Non-abstract subclass; will get registered automatically. 47 | """ 48 | 49 | element = "ground" 50 | 51 | def get_abilities(self): 52 | return ["sand veil"] 53 | 54 | class BaseEvolvingPokemon(BasePokemon, ABC): 55 | """ 56 | Abstract subclass; will not get registered. 57 | """ 58 | 59 | @abstract_method 60 | def evolve(self): 61 | raise NotImplementedError() 62 | 63 | class Ekans(BaseEvolvingPokemon): 64 | """ 65 | Non-abstract subclass; will get registered automatically. 66 | """ 67 | 68 | element = "poison" 69 | 70 | def get_abilities(self): 71 | return ["intimidate", "shed skin"] 72 | 73 | def evolve(self): 74 | return "Congratulations! Your EKANS evolved into ARBOK!" 75 | 76 | # Note that only non-abstract classes got registered. 77 | assert list(registry.classes()) == [Sandslash, Ekans] 78 | 79 | 80 | def test_abstract_strict_definition(): 81 | """ 82 | If a class has no unimplemented abstract methods, it gets registered. 83 | """ 84 | registry = ClassRegistry(attr_name="element") 85 | 86 | # :py:func:`AutoRegister` is deprecated. 87 | # :see: https://github.com/todofixthis/class-registry/issues/14 88 | with pytest.deprecated_call(): 89 | # noinspection PyDeprecation 90 | class FightingPokemon(metaclass=AutoRegister(registry)): 91 | element = "fighting" 92 | 93 | # :py:class:`FightingPokemon` does not define any abstract methods, so it is not 94 | # considered to be abstract! 95 | assert list(registry.classes()) == [FightingPokemon] 96 | 97 | 98 | def test_error_attr_name_missing(): 99 | """ 100 | The registry doesn't have an ``attr_name``. 101 | """ 102 | registry = ClassRegistry() 103 | 104 | with pytest.raises(ValueError): 105 | # :py:func:`AutoRegister` is deprecated. 106 | # :see: https://github.com/todofixthis/class-registry/issues/14 107 | with pytest.deprecated_call(): 108 | # noinspection PyDeprecation 109 | AutoRegister(registry) 110 | -------------------------------------------------------------------------------- /test/test_cache.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from class_registry import ClassRegistry 4 | from class_registry.cache import ClassRegistryInstanceCache 5 | from test import Bulbasaur, Charmander, Pokemon, Squirtle 6 | 7 | 8 | @pytest.fixture(name="registry") 9 | def fixture_registry() -> ClassRegistry[Pokemon]: 10 | registry = ClassRegistry[Pokemon](attr_name="element") 11 | registry.register(Bulbasaur) 12 | registry.register(Charmander) 13 | registry.register(Squirtle) 14 | return registry 15 | 16 | 17 | @pytest.fixture(name="cache") 18 | def fixture_cache( 19 | registry: ClassRegistry[Pokemon], 20 | ) -> ClassRegistryInstanceCache[Pokemon]: 21 | return ClassRegistryInstanceCache[Pokemon](registry) 22 | 23 | 24 | def test_get( 25 | cache: ClassRegistryInstanceCache[Pokemon], 26 | registry: ClassRegistry[Pokemon], 27 | ) -> None: 28 | """ 29 | When an instance is returned from 30 | :py:meth:`ClassRegistryInstanceCache.get`, future invocations return 31 | the same instance. 32 | """ 33 | poke_1 = cache["grass"] 34 | assert isinstance(poke_1, Bulbasaur) 35 | 36 | # Same key = exact same instance. 37 | poke_2 = cache["grass"] 38 | assert poke_2 is poke_1 39 | 40 | poke_3 = cache["water"] 41 | assert isinstance(poke_3, Squirtle) 42 | 43 | # If we pull a class directly from the wrapped registry, we get 44 | # a new instance. 45 | poke_4 = registry["water"] 46 | assert isinstance(poke_4, Squirtle) 47 | assert poke_3 is not poke_4 48 | 49 | 50 | def test_template_args(registry: ClassRegistry[Pokemon]) -> None: 51 | """ 52 | Extra params passed to the cache constructor are passed to the template 53 | function when creating new instances. 54 | """ 55 | # Add an extra init param to the cache. 56 | cache = ClassRegistryInstanceCache(registry, name="Bruce") 57 | 58 | # The cache parameters are automatically applied to the class' 59 | # initializer. 60 | poke_1 = cache["fire"] 61 | assert isinstance(poke_1, Charmander) 62 | assert poke_1.name == "Bruce" 63 | 64 | poke_2 = cache["water"] 65 | assert isinstance(poke_2, Squirtle) 66 | assert poke_2.name == "Bruce" 67 | 68 | 69 | def test_len( 70 | cache: ClassRegistryInstanceCache[Pokemon], 71 | registry: ClassRegistry[Pokemon], 72 | ) -> None: 73 | """ 74 | Checking the length of a cache. 75 | """ 76 | # The length only reflects the number of cached instances. 77 | assert len(cache) == 0 78 | 79 | # Calling ``__getitem__`` directly to sneak past the linter (: 80 | cache.__getitem__("water") 81 | cache.__getitem__("grass") 82 | 83 | assert len(cache) == 2 84 | -------------------------------------------------------------------------------- /test/test_class_registry.py: -------------------------------------------------------------------------------- 1 | import typing 2 | 3 | import pytest 4 | 5 | from class_registry import ClassRegistry, RegistryKeyError 6 | from test import Bulbasaur, Charmander, Charmeleon, Pokemon, Squirtle, Wartortle 7 | 8 | 9 | def test_register_manual_keys() -> None: 10 | """ 11 | Registers a few classes with manually-assigned identifiers and verifies that the 12 | factory returns them correctly. 13 | """ 14 | registry = ClassRegistry[Pokemon]() 15 | 16 | @registry.register("fire") 17 | class Charizard(Pokemon): 18 | pass 19 | 20 | class Blastoise(Pokemon): 21 | pass 22 | 23 | # Alternate syntax (rarely used; mostly just here to give mypy more to work with): 24 | registry.register("water")(Blastoise) 25 | 26 | # By default, you have to specify a registry key when registering new classes. 27 | # We'll see how to assign registry keys automatically in the next test. 28 | with pytest.raises(ValueError): 29 | # noinspection PyUnusedLocal 30 | @registry.register 31 | class Venusaur(Pokemon): 32 | pass 33 | 34 | assert registry.get_class("fire") is Charizard 35 | assert isinstance(registry["fire"], Charizard) 36 | 37 | assert registry.get_class("water") is Blastoise 38 | assert isinstance(registry["water"], Blastoise) 39 | 40 | 41 | def test_register_detect_keys() -> None: 42 | """ 43 | If an attribute name is passed to ClassRegistry's constructor, it will automatically 44 | check for this attribute when registering classes. 45 | """ 46 | registry = ClassRegistry[Pokemon](attr_name="element") 47 | 48 | @registry.register 49 | class Charizard(Pokemon): 50 | element = "fire" 51 | 52 | class Blastoise(Pokemon): 53 | element = "water" 54 | 55 | # Alternate syntax (rarely used; mostly just here to give mypy more to work with): 56 | registry.register(Blastoise) 57 | 58 | # You can still override the registry key if you want. 59 | @registry.register("poison") 60 | class Venusaur(Pokemon): 61 | element = "grass" 62 | 63 | assert isinstance(registry["fire"], Charizard) 64 | assert isinstance(registry["water"], Blastoise) 65 | assert isinstance(registry["poison"], Venusaur) 66 | 67 | # We overrode the registry key for this class. 68 | with pytest.raises(RegistryKeyError): 69 | # noinspection PyStatementEffect 70 | registry["grass"] 71 | 72 | 73 | def test_register_error_empty_key() -> None: 74 | """ 75 | Attempting to register a class with an empty key. 76 | """ 77 | registry = ClassRegistry[Pokemon]("element") 78 | 79 | with pytest.raises(ValueError): 80 | # noinspection PyUnusedLocal 81 | @registry.register("") 82 | class Rapidash(Pokemon): 83 | element = "fire" 84 | 85 | with pytest.raises(ValueError): 86 | # noinspection PyUnusedLocal 87 | @registry.register 88 | class Mewtwo(Pokemon): 89 | element = "" 90 | 91 | 92 | def test_unique_keys() -> None: 93 | """ 94 | Specifying ``unique=True`` when creating the registry requires all keys to be 95 | unique. 96 | """ 97 | registry = ClassRegistry[Pokemon](attr_name="element", unique=True) 98 | 99 | # We can register any class like normal... 100 | registry.register(Charmander) 101 | 102 | # ... but if we try to register a second class with the same key, we 103 | # get an error. 104 | with pytest.raises(RegistryKeyError): 105 | registry.register(Charmeleon) 106 | 107 | 108 | def test_unregister() -> None: 109 | """ 110 | Removing a class from the registry. 111 | 112 | .. note:: 113 | This is not used that often outside unit tests (e.g., to remove artefacts when a 114 | test has to add a class to a global registry). 115 | """ 116 | registry = ClassRegistry[Pokemon](attr_name="element") 117 | registry.register(Charmander) 118 | registry.register(Squirtle) 119 | 120 | assert registry.unregister("fire") is Charmander 121 | 122 | with pytest.raises(RegistryKeyError): 123 | registry.get("fire") 124 | 125 | # Note that you must unregister the KEY, not the CLASS. 126 | with pytest.raises(KeyError): 127 | registry.unregister(Squirtle) 128 | 129 | # If you try to unregister a key that isn't registered, you'll 130 | # get an error. 131 | with pytest.raises(KeyError): 132 | registry.unregister("fire") 133 | 134 | 135 | def test_constructor_params() -> None: 136 | """ 137 | Params can be passed to the registered class' constructor. 138 | """ 139 | registry = ClassRegistry[Pokemon](attr_name="element") 140 | registry.register(Bulbasaur) 141 | 142 | # Goofus uses positional arguments, which are magical and make his code more 143 | # difficult to read. 144 | goofus = registry.get("grass", "goofus") 145 | 146 | # Gallant uses keyword arguments, producing self-documenting code and being 147 | # courteous to his fellow developers. 148 | # He still names his pokémon after himself, though. Narcissist. 149 | gallant = registry.get("grass", name="gallant") 150 | 151 | assert isinstance(goofus, Bulbasaur) 152 | assert goofus.name == "goofus" 153 | 154 | assert isinstance(gallant, Bulbasaur) 155 | assert gallant.name == "gallant" 156 | 157 | 158 | def test_new_instance_every_time() -> None: 159 | """ 160 | Every time a registered class is invoked, a new instance is returned. 161 | """ 162 | registry = ClassRegistry[Pokemon](attr_name="element") 163 | registry.register(Wartortle) 164 | 165 | assert registry["water"] is not registry["water"] 166 | 167 | 168 | def test_register_function() -> None: 169 | """ 170 | Functions can be registered as well (so long as they walk and quack like a class). 171 | """ 172 | registry = ClassRegistry[Pokemon]() 173 | 174 | @registry.register("fire") 175 | def pokemon_factory(name: typing.Optional[str] = None) -> Charmeleon: 176 | return Charmeleon(name=name) 177 | 178 | # Alternate syntax (rarely used; mostly just here to give mypy more to work with): 179 | # PyCharm doesn't like it, but mypy thinks it's fine :shrug: 180 | # noinspection PyTypeChecker 181 | registry.register("water")(pokemon_factory) 182 | 183 | poke1 = registry.get("fire", name="trogdor") 184 | assert isinstance(poke1, Charmeleon) 185 | assert poke1.name == "trogdor" 186 | 187 | poke2 = registry.get("water", name="leeroy") 188 | assert isinstance(poke2, Charmeleon) 189 | assert poke2.name == "leeroy" 190 | 191 | 192 | def test_regression_contains_when_class_init_requires_arguments() -> None: 193 | """ 194 | Special case when checking if a class is registered, and that class' initialiser 195 | requires arguments. 196 | """ 197 | registry = ClassRegistry[Pokemon](attr_name="element") 198 | 199 | @registry.register 200 | class Butterfree(Pokemon): 201 | element = "bug" 202 | 203 | def __init__(self, name: str): 204 | super(Butterfree, self).__init__(name) 205 | 206 | # This line would raise a TypeError in a previous version of ClassRegistry. 207 | assert "bug" in registry 208 | -------------------------------------------------------------------------------- /test/test_entry_points.py: -------------------------------------------------------------------------------- 1 | import typing 2 | from importlib.metadata import entry_points 3 | 4 | import pytest 5 | 6 | from class_registry import RegistryKeyError 7 | from class_registry.entry_points import EntryPointClassRegistry 8 | from test import Bulbasaur, Charmander, Mew, Pokemon, PokemonFactory, Squirtle 9 | from test.helper import DummyDistributionFinder 10 | 11 | 12 | @pytest.fixture(name="distro", autouse=True) 13 | def fixture_distro() -> typing.Generator[None, None, None]: 14 | # Inject a distribution that defines some entry points. 15 | DummyDistributionFinder.install() 16 | yield 17 | DummyDistributionFinder.uninstall() 18 | 19 | 20 | def test_happy_path() -> None: 21 | """ 22 | Loading classes automatically via entry points. 23 | 24 | See ``dummy_package.egg-info/entry_points.txt`` for more info. 25 | """ 26 | registry = EntryPointClassRegistry[Pokemon]("pokemon") 27 | 28 | fire = registry["fire"] 29 | assert isinstance(fire, Charmander) 30 | assert fire.name is None 31 | 32 | grass = registry.get("grass") 33 | assert isinstance(grass, Bulbasaur) 34 | assert grass.name is None 35 | 36 | water = registry.get("water", name="archibald") 37 | assert isinstance(water, Squirtle) 38 | assert water.name == "archibald" 39 | 40 | # The 'psychic' entry point actually references a function, but it 41 | # works exactly the same as a class. 42 | psychic = registry.get("psychic", "snuggles") 43 | assert isinstance(psychic, Mew) 44 | assert psychic.name == "snuggles" 45 | 46 | 47 | def test_branding() -> None: 48 | """ 49 | Configuring the registry to "brand" each class/instance with its 50 | corresponding key. 51 | """ 52 | registry = EntryPointClassRegistry[Pokemon]("pokemon", attr_name="poke_type") 53 | try: 54 | # Branding is applied immediately to each registered class. 55 | assert getattr(Charmander, "poke_type") == "fire" 56 | assert getattr(Squirtle, "poke_type") == "water" 57 | 58 | # Instances, too! 59 | assert getattr(registry["fire"], "poke_type") == "fire" 60 | assert getattr(registry.get("water", "phil"), "poke_type") == "water" 61 | 62 | # Registered functions and methods can't be branded this way, 63 | # though... 64 | assert not hasattr(PokemonFactory.create_psychic_pokemon, "poke_type") 65 | 66 | # ... but we can brand the resulting instances. 67 | assert getattr(registry["psychic"], "poke_type") == "psychic" 68 | assert getattr(registry.get("psychic"), "poke_type") == "psychic" 69 | finally: 70 | # Clean up after ourselves. 71 | for cls in registry.classes(): 72 | if isinstance(cls, type): 73 | try: 74 | delattr(cls, "poke_type") 75 | except AttributeError: 76 | pass 77 | 78 | 79 | def test_len() -> None: 80 | """ 81 | Getting the length of an entry point class registry. 82 | """ 83 | # Just in case some other package defines pokémon entry 84 | # points (: 85 | expected = len(list(entry_points(group="pokemon"))) 86 | 87 | # Quick sanity check, to make sure our test pokémon are 88 | # registered correctly. 89 | assert expected >= 4 90 | 91 | registry = EntryPointClassRegistry[Pokemon]("pokemon") 92 | assert len(registry) == expected 93 | 94 | 95 | def test_error_wrong_group() -> None: 96 | """ 97 | The registry can't find entry points associated with the wrong group. 98 | """ 99 | registry = EntryPointClassRegistry[Pokemon]("fhqwhgads") 100 | 101 | with pytest.raises(RegistryKeyError): 102 | registry.get("fire") 103 | -------------------------------------------------------------------------------- /test/test_gen_lookup_key.py: -------------------------------------------------------------------------------- 1 | """ 2 | Verifies registry behaviour when :py:func:`class_registry.ClassRegistry.gen_lookup_key` 3 | is modified. 4 | """ 5 | 6 | import typing 7 | 8 | import pytest 9 | 10 | from class_registry import ClassRegistry 11 | from test import Charmander, Pokemon, Squirtle 12 | 13 | 14 | @pytest.fixture(name="customised_registry") 15 | def fixture_customised_registry() -> ClassRegistry[Pokemon]: 16 | class CustomisedLookupRegistry(ClassRegistry[Pokemon]): 17 | @staticmethod 18 | def gen_lookup_key(key: typing.Hashable) -> typing.Hashable: 19 | """ 20 | Simple override of `gen_lookup_key`, to ensure the registry 21 | behaves as expected when the lookup key is different. 22 | """ 23 | if isinstance(key, str): 24 | return "".join(reversed(key)) 25 | return key 26 | 27 | registry = CustomisedLookupRegistry() 28 | registry.register("fire")(Charmander) 29 | registry.register("water")(Squirtle) 30 | return registry 31 | 32 | 33 | def test_contains(customised_registry: ClassRegistry[Pokemon]) -> None: 34 | assert "fire" in customised_registry 35 | assert "erif" not in customised_registry 36 | 37 | 38 | def test_getitem(customised_registry: ClassRegistry[Pokemon]) -> None: 39 | assert isinstance(customised_registry["fire"], Charmander) 40 | 41 | 42 | def test_iter(customised_registry: ClassRegistry[Pokemon]) -> None: 43 | generator = iter(customised_registry) 44 | 45 | assert next(generator) == "fire" 46 | assert next(generator) == "water" 47 | 48 | with pytest.raises(StopIteration): 49 | next(generator) 50 | 51 | 52 | def test_len(customised_registry: ClassRegistry[Pokemon]) -> None: 53 | assert len(customised_registry) == 2 54 | 55 | 56 | def test_get_class(customised_registry: ClassRegistry[Pokemon]) -> None: 57 | assert customised_registry.get_class("fire") is Charmander 58 | 59 | 60 | def test_get(customised_registry: ClassRegistry[Pokemon]) -> None: 61 | assert isinstance(customised_registry.get("fire"), Charmander) 62 | 63 | 64 | def test_unregister(customised_registry: ClassRegistry[Pokemon]) -> None: 65 | customised_registry.unregister("fire") 66 | 67 | assert "fire" not in customised_registry 68 | assert "erif" not in customised_registry 69 | 70 | 71 | def test_use_case_aliases() -> None: 72 | """ 73 | A common use case for overriding `gen_lookup_key` is to specify some 74 | aliases (e.g., for backwards-compatibility when refactoring an existing 75 | registry). 76 | """ 77 | 78 | class TestRegistry(ClassRegistry[Pokemon]): 79 | @staticmethod 80 | def gen_lookup_key(key: typing.Hashable) -> typing.Hashable: 81 | """ 82 | Simulate a scenario where we renamed the key for a class in the 83 | registry, but we want to preserve backwards-compatibility with 84 | existing code that hasn't been updated yet. 85 | """ 86 | if key == "bird": 87 | return "flying" 88 | 89 | return key 90 | 91 | registry = TestRegistry() 92 | 93 | @registry.register("flying") 94 | class MissingNo(Pokemon): 95 | pass 96 | 97 | assert isinstance(registry["bird"], MissingNo) 98 | assert isinstance(registry["flying"], MissingNo) 99 | 100 | assert "bird" in registry 101 | assert "flying" in registry 102 | -------------------------------------------------------------------------------- /test/test_patcher.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from class_registry import ClassRegistry, RegistryKeyError 4 | from class_registry.patcher import RegistryPatcher 5 | from test import Bulbasaur, Charmander, Charmeleon, Ivysaur, Pokemon, Squirtle 6 | 7 | 8 | @pytest.fixture(name="registry") 9 | def fixture_registry() -> ClassRegistry[Pokemon]: 10 | return ClassRegistry(attr_name="element", unique=True) 11 | 12 | 13 | def test_patch_detect_keys(registry: ClassRegistry[Pokemon]) -> None: 14 | """ 15 | Patching a registry in a context, with registry keys detected 16 | automatically. 17 | """ 18 | registry.register(Charmander) 19 | registry.register(Squirtle) 20 | 21 | with RegistryPatcher(registry, Charmeleon, Bulbasaur): 22 | assert isinstance(registry["fire"], Charmeleon) 23 | assert isinstance(registry["water"], Squirtle) 24 | 25 | # Nesting contexts? You betcha! 26 | with RegistryPatcher(registry, Ivysaur): 27 | assert isinstance(registry["grass"], Ivysaur) 28 | 29 | assert isinstance(registry["grass"], Bulbasaur) 30 | 31 | # Save file corrupted. Restoring previous save... 32 | assert isinstance(registry["fire"], Charmander) 33 | assert isinstance(registry["water"], Squirtle) 34 | 35 | with pytest.raises(RegistryKeyError): 36 | registry.get("grass") 37 | 38 | 39 | def test_patch_manual_keys(registry: ClassRegistry[Pokemon]) -> None: 40 | """ 41 | Patching a registry in a context, specifying registry keys manually. 42 | """ 43 | registry.register("sparky")(Charmander) 44 | registry.register("chad")(Squirtle) 45 | 46 | with RegistryPatcher(registry, sparky=Charmeleon, rex=Bulbasaur): 47 | assert isinstance(registry["sparky"], Charmeleon) 48 | assert isinstance(registry["chad"], Squirtle) 49 | 50 | # Don't worry Chad; your day will come! 51 | with RegistryPatcher(registry, rex=Ivysaur): 52 | assert isinstance(registry["rex"], Ivysaur) 53 | 54 | assert isinstance(registry["rex"], Bulbasaur) 55 | 56 | # Save file corrupted. Restoring previous save... 57 | assert isinstance(registry["sparky"], Charmander) 58 | assert isinstance(registry["chad"], Squirtle) 59 | 60 | with pytest.raises(RegistryKeyError): 61 | registry.get("jodie") 62 | -------------------------------------------------------------------------------- /test/test_sorted_class_registry.py: -------------------------------------------------------------------------------- 1 | import typing 2 | from functools import cmp_to_key 3 | 4 | from class_registry.registry import SortedClassRegistry 5 | from test import Bulbasaur, Charmander, Pokemon, Squirtle 6 | 7 | 8 | def test_sort_key() -> None: 9 | """ 10 | When iterating over a SortedClassRegistry, classes are returned in 11 | sorted order rather than inclusion order. 12 | """ 13 | registry = SortedClassRegistry[Pokemon]( 14 | attr_name="element", 15 | sort_key="weight", 16 | ) 17 | 18 | @registry.register 19 | class Geodude(Pokemon): 20 | element = "rock" 21 | weight = 100 22 | 23 | @registry.register 24 | class Machop(Pokemon): 25 | element = "fighting" 26 | weight = 75 27 | 28 | @registry.register 29 | class Bellsprout(Pokemon): 30 | element = "grass" 31 | weight = 15 32 | 33 | # The registry iterates over registered classes in ascending order by 34 | # ``weight``. 35 | assert list(registry.classes()) == [Bellsprout, Machop, Geodude] 36 | 37 | 38 | def test_sort_key_reverse() -> None: 39 | """ 40 | Reversing the order of a sort key. 41 | """ 42 | registry = SortedClassRegistry[Pokemon]( 43 | attr_name="element", 44 | sort_key="weight", 45 | reverse=True, 46 | ) 47 | 48 | @registry.register 49 | class Geodude(Pokemon): 50 | element = "rock" 51 | weight = 100 52 | 53 | @registry.register 54 | class Machop(Pokemon): 55 | element = "fighting" 56 | weight = 75 57 | 58 | @registry.register 59 | class Bellsprout(Pokemon): 60 | element = "grass" 61 | weight = 15 62 | 63 | # The registry iterates over registered classes in descending order by ``weight``. 64 | assert list(registry.classes()) == [Geodude, Machop, Bellsprout] 65 | 66 | 67 | def test_cmp_to_key() -> None: 68 | """ 69 | If you want to use a ``cmp`` function to define the ordering, 70 | you must use the :py:func:`cmp_to_key` function. 71 | """ 72 | 73 | class PopularPokemon(Pokemon): 74 | popularity: int 75 | 76 | def compare_pokemon( 77 | a: typing.Tuple[typing.Hashable, typing.Type[PopularPokemon], typing.Hashable], 78 | b: typing.Tuple[typing.Hashable, typing.Type[PopularPokemon], typing.Hashable], 79 | ) -> int: 80 | """ 81 | Sort in descending order by popularity. 82 | 83 | :param a: Tuple of (key, class, lookup_key) 84 | :param b: Tuple of (key, class, lookup_key) 85 | """ 86 | return (a[1].popularity < b[1].popularity) - (a[1].popularity > b[1].popularity) 87 | 88 | registry = SortedClassRegistry[PopularPokemon]( 89 | attr_name="element", 90 | sort_key=cmp_to_key(compare_pokemon), 91 | ) 92 | 93 | @registry.register 94 | class Onix(PopularPokemon): 95 | element = "rock" 96 | popularity = 50 97 | 98 | @registry.register 99 | class Cubone(PopularPokemon): 100 | element = "water" 101 | popularity = 100 102 | 103 | @registry.register 104 | class Exeggcute(PopularPokemon): 105 | element = "grass" 106 | popularity = 10 107 | 108 | # The registry iterates over registered classes in descending order by 109 | # ``popularity``. 110 | assert list(registry.classes()) == [Cubone, Onix, Exeggcute] 111 | 112 | 113 | def test_gen_lookup_key_overridden() -> None: 114 | """ 115 | When a ``SortedClassRegistry`` overrides the ``gen_lookup_key`` method, 116 | it can sort by lookup keys if desired. 117 | """ 118 | 119 | def compare_by_lookup_key( 120 | a: typing.Tuple[str, typing.Type[Pokemon], str], 121 | b: typing.Tuple[str, typing.Type[Pokemon], str], 122 | ) -> int: 123 | """ 124 | :param a: Tuple of (key, class, lookup_key) 125 | :param b: Tuple of (key, class, lookup_key) 126 | """ 127 | return (a[2] > b[2]) - (a[2] < b[2]) 128 | 129 | class TestRegistry(SortedClassRegistry[Pokemon]): 130 | @staticmethod 131 | def gen_lookup_key(key: typing.Hashable) -> typing.Hashable: 132 | """ 133 | Simple override of `gen_lookup_key`, to ensure the sorting 134 | behaves as expected when the lookup key is different. 135 | """ 136 | if isinstance(key, str): 137 | return "".join(reversed(key)) 138 | return key 139 | 140 | registry = TestRegistry(sort_key=cmp_to_key(compare_by_lookup_key)) 141 | 142 | registry.register("fire")(Charmander) 143 | registry.register("grass")(Bulbasaur) 144 | registry.register("water")(Squirtle) 145 | 146 | assert list(registry.classes()) == [Charmander, Squirtle, Bulbasaur] 147 | -------------------------------------------------------------------------------- /uv.lock: -------------------------------------------------------------------------------- 1 | version = 1 2 | requires-python = ">=3.11, <4" 3 | 4 | [[package]] 5 | name = "alabaster" 6 | version = "1.0.0" 7 | source = { registry = "https://pypi.org/simple" } 8 | sdist = { url = "https://files.pythonhosted.org/packages/a6/f8/d9c74d0daf3f742840fd818d69cfae176fa332022fd44e3469487d5a9420/alabaster-1.0.0.tar.gz", hash = "sha256:c00dca57bca26fa62a6d7d0a9fcce65f3e026e9bfe33e9c538fd3fbb2144fd9e", size = 24210 } 9 | wheels = [ 10 | { url = "https://files.pythonhosted.org/packages/7e/b3/6b4067be973ae96ba0d615946e314c5ae35f9f993eca561b356540bb0c2b/alabaster-1.0.0-py3-none-any.whl", hash = "sha256:fc6786402dc3fcb2de3cabd5fe455a2db534b371124f1f21de8731783dec828b", size = 13929 }, 11 | ] 12 | 13 | [[package]] 14 | name = "anyio" 15 | version = "4.7.0" 16 | source = { registry = "https://pypi.org/simple" } 17 | dependencies = [ 18 | { name = "idna" }, 19 | { name = "sniffio" }, 20 | { name = "typing-extensions", marker = "python_full_version < '3.13'" }, 21 | ] 22 | sdist = { url = "https://files.pythonhosted.org/packages/f6/40/318e58f669b1a9e00f5c4453910682e2d9dd594334539c7b7817dabb765f/anyio-4.7.0.tar.gz", hash = "sha256:2f834749c602966b7d456a7567cafcb309f96482b5081d14ac93ccd457f9dd48", size = 177076 } 23 | wheels = [ 24 | { url = "https://files.pythonhosted.org/packages/a0/7a/4daaf3b6c08ad7ceffea4634ec206faeff697526421c20f07628c7372156/anyio-4.7.0-py3-none-any.whl", hash = "sha256:ea60c3723ab42ba6fff7e8ccb0488c898ec538ff4df1f1d5e642c3601d07e352", size = 93052 }, 25 | ] 26 | 27 | [[package]] 28 | name = "autohooks" 29 | version = "25.2.0" 30 | source = { registry = "https://pypi.org/simple" } 31 | dependencies = [ 32 | { name = "pontos" }, 33 | { name = "rich" }, 34 | { name = "shtab" }, 35 | { name = "tomlkit" }, 36 | ] 37 | sdist = { url = "https://files.pythonhosted.org/packages/8e/c1/653f4d1f9d93c9481cc0368168f2786390b99e4a2d18b34b35563d11209c/autohooks-25.2.0.tar.gz", hash = "sha256:e306a24801fbc815628ebe48b063e2853a0857eb00bbdae733175e06e0ce8a84", size = 79662 } 38 | wheels = [ 39 | { url = "https://files.pythonhosted.org/packages/81/9e/5fa1ef776621c9f2a30b39ef15805da03395fc9caa301a72fb365fe58a64/autohooks-25.2.0-py3-none-any.whl", hash = "sha256:5a2ed5fc03d899725638d2ca6bcb367f025a6196b225a5ea177e502fdf1bb065", size = 35650 }, 40 | ] 41 | 42 | [[package]] 43 | name = "autohooks-plugin-black" 44 | version = "23.10.0" 45 | source = { registry = "https://pypi.org/simple" } 46 | dependencies = [ 47 | { name = "autohooks" }, 48 | { name = "black" }, 49 | ] 50 | sdist = { url = "https://files.pythonhosted.org/packages/1c/47/5948b7b08281498c37efe869b59cb380724aa14aa132ae70d14e584221b0/autohooks_plugin_black-23.10.0.tar.gz", hash = "sha256:8415b5f566d861236bde2b0973699f64a8b861208af4fa05fe04a1f923ea3ef6", size = 33384 } 51 | wheels = [ 52 | { url = "https://files.pythonhosted.org/packages/da/4b/8cedb513acc0ac4dbddebaa18d28861e5bbdcd3da2b6d3621f4bc99abf15/autohooks_plugin_black-23.10.0-py3-none-any.whl", hash = "sha256:88d648251df749586af9ea5be3105daa4358ed916b61aee738d0727387214470", size = 16979 }, 53 | ] 54 | 55 | [[package]] 56 | name = "autohooks-plugin-mypy" 57 | version = "23.10.0" 58 | source = { registry = "https://pypi.org/simple" } 59 | dependencies = [ 60 | { name = "autohooks" }, 61 | { name = "mypy" }, 62 | ] 63 | sdist = { url = "https://files.pythonhosted.org/packages/02/3c/54d5fe6bc9a3f141b987b6ebab36ff89b5ac69eba178eee34049b2c2286c/autohooks_plugin_mypy-23.10.0.tar.gz", hash = "sha256:ebefaa83074b662de38c914f6cac9f4f8e3452e36f54a5834df3f1590cc0c540", size = 16730 } 64 | wheels = [ 65 | { url = "https://files.pythonhosted.org/packages/95/72/eed8de6210a8a78cde7b18f4a10baad750f08a3c5e0c5651ec192e020764/autohooks_plugin_mypy-23.10.0-py3-none-any.whl", hash = "sha256:8ac36b74900b2f2456fec046126e564374acd6de2752d87255c6f71c4e6a73ff", size = 17117 }, 66 | ] 67 | 68 | [[package]] 69 | name = "autohooks-plugin-pytest" 70 | version = "23.10.0" 71 | source = { registry = "https://pypi.org/simple" } 72 | dependencies = [ 73 | { name = "autohooks" }, 74 | { name = "pytest" }, 75 | ] 76 | sdist = { url = "https://files.pythonhosted.org/packages/56/6f/76342f15644c27e8164bd736361dd5995d2d337532a1a03777b9c92fe641/autohooks_plugin_pytest-23.10.0.tar.gz", hash = "sha256:87125b8291e0e04da2d24255581622b957f090f2c96cf50a4396fb455a3c8864", size = 15471 } 77 | wheels = [ 78 | { url = "https://files.pythonhosted.org/packages/18/35/2041cdf31e40e921d28e9ffaad2a04fe455622e367fd61522b65cfa1299d/autohooks_plugin_pytest-23.10.0-py3-none-any.whl", hash = "sha256:581a92e09cf7882f5488973290d29f35684bec984537832f26c8a7e7c245eedb", size = 16929 }, 79 | ] 80 | 81 | [[package]] 82 | name = "autohooks-plugin-ruff" 83 | version = "25.2.0" 84 | source = { registry = "https://pypi.org/simple" } 85 | dependencies = [ 86 | { name = "autohooks" }, 87 | { name = "ruff" }, 88 | ] 89 | sdist = { url = "https://files.pythonhosted.org/packages/83/59/0c9ae7d7a44f71fb0616030ad216e9da655e0a0d08405dee20499e127b18/autohooks_plugin_ruff-25.2.0.tar.gz", hash = "sha256:3290cd5b1939d80113e5a3b6408e4728e1c2bed0960b8101479897a196dc69d6", size = 33341 } 90 | wheels = [ 91 | { url = "https://files.pythonhosted.org/packages/bf/ca/efd78e1b1227be7d38d0abb9d50cbc6c829f506fde7f4c600eabacc38083/autohooks_plugin_ruff-25.2.0-py3-none-any.whl", hash = "sha256:b0e3bbfb2d8bb94c509dad291801af1f6821877147b7f02f7e93812f8aef6ba1", size = 17836 }, 92 | ] 93 | 94 | [[package]] 95 | name = "babel" 96 | version = "2.16.0" 97 | source = { registry = "https://pypi.org/simple" } 98 | sdist = { url = "https://files.pythonhosted.org/packages/2a/74/f1bc80f23eeba13393b7222b11d95ca3af2c1e28edca18af487137eefed9/babel-2.16.0.tar.gz", hash = "sha256:d1f3554ca26605fe173f3de0c65f750f5a42f924499bf134de6423582298e316", size = 9348104 } 99 | wheels = [ 100 | { url = "https://files.pythonhosted.org/packages/ed/20/bc79bc575ba2e2a7f70e8a1155618bb1301eaa5132a8271373a6903f73f8/babel-2.16.0-py3-none-any.whl", hash = "sha256:368b5b98b37c06b7daf6696391c3240c938b37767d4584413e8438c5c435fa8b", size = 9587599 }, 101 | ] 102 | 103 | [[package]] 104 | name = "black" 105 | version = "24.10.0" 106 | source = { registry = "https://pypi.org/simple" } 107 | dependencies = [ 108 | { name = "click" }, 109 | { name = "mypy-extensions" }, 110 | { name = "packaging" }, 111 | { name = "pathspec" }, 112 | { name = "platformdirs" }, 113 | ] 114 | sdist = { url = "https://files.pythonhosted.org/packages/d8/0d/cc2fb42b8c50d80143221515dd7e4766995bd07c56c9a3ed30baf080b6dc/black-24.10.0.tar.gz", hash = "sha256:846ea64c97afe3bc677b761787993be4991810ecc7a4a937816dd6bddedc4875", size = 645813 } 115 | wheels = [ 116 | { url = "https://files.pythonhosted.org/packages/c2/cc/7496bb63a9b06a954d3d0ac9fe7a73f3bf1cd92d7a58877c27f4ad1e9d41/black-24.10.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:5a2221696a8224e335c28816a9d331a6c2ae15a2ee34ec857dcf3e45dbfa99ad", size = 1607468 }, 117 | { url = "https://files.pythonhosted.org/packages/2b/e3/69a738fb5ba18b5422f50b4f143544c664d7da40f09c13969b2fd52900e0/black-24.10.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f9da3333530dbcecc1be13e69c250ed8dfa67f43c4005fb537bb426e19200d50", size = 1437270 }, 118 | { url = "https://files.pythonhosted.org/packages/c9/9b/2db8045b45844665c720dcfe292fdaf2e49825810c0103e1191515fc101a/black-24.10.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4007b1393d902b48b36958a216c20c4482f601569d19ed1df294a496eb366392", size = 1737061 }, 119 | { url = "https://files.pythonhosted.org/packages/a3/95/17d4a09a5be5f8c65aa4a361444d95edc45def0de887810f508d3f65db7a/black-24.10.0-cp311-cp311-win_amd64.whl", hash = "sha256:394d4ddc64782e51153eadcaaca95144ac4c35e27ef9b0a42e121ae7e57a9175", size = 1423293 }, 120 | { url = "https://files.pythonhosted.org/packages/90/04/bf74c71f592bcd761610bbf67e23e6a3cff824780761f536512437f1e655/black-24.10.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b5e39e0fae001df40f95bd8cc36b9165c5e2ea88900167bddf258bacef9bbdc3", size = 1644256 }, 121 | { url = "https://files.pythonhosted.org/packages/4c/ea/a77bab4cf1887f4b2e0bce5516ea0b3ff7d04ba96af21d65024629afedb6/black-24.10.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:d37d422772111794b26757c5b55a3eade028aa3fde43121ab7b673d050949d65", size = 1448534 }, 122 | { url = "https://files.pythonhosted.org/packages/4e/3e/443ef8bc1fbda78e61f79157f303893f3fddf19ca3c8989b163eb3469a12/black-24.10.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:14b3502784f09ce2443830e3133dacf2c0110d45191ed470ecb04d0f5f6fcb0f", size = 1761892 }, 123 | { url = "https://files.pythonhosted.org/packages/52/93/eac95ff229049a6901bc84fec6908a5124b8a0b7c26ea766b3b8a5debd22/black-24.10.0-cp312-cp312-win_amd64.whl", hash = "sha256:30d2c30dc5139211dda799758559d1b049f7f14c580c409d6ad925b74a4208a8", size = 1434796 }, 124 | { url = "https://files.pythonhosted.org/packages/d0/a0/a993f58d4ecfba035e61fca4e9f64a2ecae838fc9f33ab798c62173ed75c/black-24.10.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1cbacacb19e922a1d75ef2b6ccaefcd6e93a2c05ede32f06a21386a04cedb981", size = 1643986 }, 125 | { url = "https://files.pythonhosted.org/packages/37/d5/602d0ef5dfcace3fb4f79c436762f130abd9ee8d950fa2abdbf8bbc555e0/black-24.10.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:1f93102e0c5bb3907451063e08b9876dbeac810e7da5a8bfb7aeb5a9ef89066b", size = 1448085 }, 126 | { url = "https://files.pythonhosted.org/packages/47/6d/a3a239e938960df1a662b93d6230d4f3e9b4a22982d060fc38c42f45a56b/black-24.10.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ddacb691cdcdf77b96f549cf9591701d8db36b2f19519373d60d31746068dbf2", size = 1760928 }, 127 | { url = "https://files.pythonhosted.org/packages/dd/cf/af018e13b0eddfb434df4d9cd1b2b7892bab119f7a20123e93f6910982e8/black-24.10.0-cp313-cp313-win_amd64.whl", hash = "sha256:680359d932801c76d2e9c9068d05c6b107f2584b2a5b88831c83962eb9984c1b", size = 1436875 }, 128 | { url = "https://files.pythonhosted.org/packages/8d/a7/4b27c50537ebca8bec139b872861f9d2bf501c5ec51fcf897cb924d9e264/black-24.10.0-py3-none-any.whl", hash = "sha256:3bb2b7a1f7b685f85b11fed1ef10f8a9148bceb49853e47a294a3dd963c1dd7d", size = 206898 }, 129 | ] 130 | 131 | [[package]] 132 | name = "cachetools" 133 | version = "5.5.0" 134 | source = { registry = "https://pypi.org/simple" } 135 | sdist = { url = "https://files.pythonhosted.org/packages/c3/38/a0f315319737ecf45b4319a8cd1f3a908e29d9277b46942263292115eee7/cachetools-5.5.0.tar.gz", hash = "sha256:2cc24fb4cbe39633fb7badd9db9ca6295d766d9c2995f245725a46715d050f2a", size = 27661 } 136 | wheels = [ 137 | { url = "https://files.pythonhosted.org/packages/a4/07/14f8ad37f2d12a5ce41206c21820d8cb6561b728e51fad4530dff0552a67/cachetools-5.5.0-py3-none-any.whl", hash = "sha256:02134e8439cdc2ffb62023ce1debca2944c3f289d66bb17ead3ab3dede74b292", size = 9524 }, 138 | ] 139 | 140 | [[package]] 141 | name = "certifi" 142 | version = "2024.12.14" 143 | source = { registry = "https://pypi.org/simple" } 144 | sdist = { url = "https://files.pythonhosted.org/packages/0f/bd/1d41ee578ce09523c81a15426705dd20969f5abf006d1afe8aeff0dd776a/certifi-2024.12.14.tar.gz", hash = "sha256:b650d30f370c2b724812bee08008be0c4163b163ddaec3f2546c1caf65f191db", size = 166010 } 145 | wheels = [ 146 | { url = "https://files.pythonhosted.org/packages/a5/32/8f6669fc4798494966bf446c8c4a162e0b5d893dff088afddf76414f70e1/certifi-2024.12.14-py3-none-any.whl", hash = "sha256:1275f7a45be9464efc1173084eaa30f866fe2e47d389406136d332ed4967ec56", size = 164927 }, 147 | ] 148 | 149 | [[package]] 150 | name = "chardet" 151 | version = "5.2.0" 152 | source = { registry = "https://pypi.org/simple" } 153 | sdist = { url = "https://files.pythonhosted.org/packages/f3/0d/f7b6ab21ec75897ed80c17d79b15951a719226b9fababf1e40ea74d69079/chardet-5.2.0.tar.gz", hash = "sha256:1b3b6ff479a8c414bc3fa2c0852995695c4a026dcd6d0633b2dd092ca39c1cf7", size = 2069618 } 154 | wheels = [ 155 | { url = "https://files.pythonhosted.org/packages/38/6f/f5fbc992a329ee4e0f288c1fe0e2ad9485ed064cac731ed2fe47dcc38cbf/chardet-5.2.0-py3-none-any.whl", hash = "sha256:e1cf59446890a00105fe7b7912492ea04b6e6f06d4b742b2c788469e34c82970", size = 199385 }, 156 | ] 157 | 158 | [[package]] 159 | name = "charset-normalizer" 160 | version = "3.4.1" 161 | source = { registry = "https://pypi.org/simple" } 162 | sdist = { url = "https://files.pythonhosted.org/packages/16/b0/572805e227f01586461c80e0fd25d65a2115599cc9dad142fee4b747c357/charset_normalizer-3.4.1.tar.gz", hash = "sha256:44251f18cd68a75b56585dd00dae26183e102cd5e0f9f1466e6df5da2ed64ea3", size = 123188 } 163 | wheels = [ 164 | { url = "https://files.pythonhosted.org/packages/72/80/41ef5d5a7935d2d3a773e3eaebf0a9350542f2cab4eac59a7a4741fbbbbe/charset_normalizer-3.4.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:8bfa33f4f2672964266e940dd22a195989ba31669bd84629f05fab3ef4e2d125", size = 194995 }, 165 | { url = "https://files.pythonhosted.org/packages/7a/28/0b9fefa7b8b080ec492110af6d88aa3dea91c464b17d53474b6e9ba5d2c5/charset_normalizer-3.4.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:28bf57629c75e810b6ae989f03c0828d64d6b26a5e205535585f96093e405ed1", size = 139471 }, 166 | { url = "https://files.pythonhosted.org/packages/71/64/d24ab1a997efb06402e3fc07317e94da358e2585165930d9d59ad45fcae2/charset_normalizer-3.4.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f08ff5e948271dc7e18a35641d2f11a4cd8dfd5634f55228b691e62b37125eb3", size = 149831 }, 167 | { url = "https://files.pythonhosted.org/packages/37/ed/be39e5258e198655240db5e19e0b11379163ad7070962d6b0c87ed2c4d39/charset_normalizer-3.4.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:234ac59ea147c59ee4da87a0c0f098e9c8d169f4dc2a159ef720f1a61bbe27cd", size = 142335 }, 168 | { url = "https://files.pythonhosted.org/packages/88/83/489e9504711fa05d8dde1574996408026bdbdbd938f23be67deebb5eca92/charset_normalizer-3.4.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd4ec41f914fa74ad1b8304bbc634b3de73d2a0889bd32076342a573e0779e00", size = 143862 }, 169 | { url = "https://files.pythonhosted.org/packages/c6/c7/32da20821cf387b759ad24627a9aca289d2822de929b8a41b6241767b461/charset_normalizer-3.4.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:eea6ee1db730b3483adf394ea72f808b6e18cf3cb6454b4d86e04fa8c4327a12", size = 145673 }, 170 | { url = "https://files.pythonhosted.org/packages/68/85/f4288e96039abdd5aeb5c546fa20a37b50da71b5cf01e75e87f16cd43304/charset_normalizer-3.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c96836c97b1238e9c9e3fe90844c947d5afbf4f4c92762679acfe19927d81d77", size = 140211 }, 171 | { url = "https://files.pythonhosted.org/packages/28/a3/a42e70d03cbdabc18997baf4f0227c73591a08041c149e710045c281f97b/charset_normalizer-3.4.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:4d86f7aff21ee58f26dcf5ae81a9addbd914115cdebcbb2217e4f0ed8982e146", size = 148039 }, 172 | { url = "https://files.pythonhosted.org/packages/85/e4/65699e8ab3014ecbe6f5c71d1a55d810fb716bbfd74f6283d5c2aa87febf/charset_normalizer-3.4.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:09b5e6733cbd160dcc09589227187e242a30a49ca5cefa5a7edd3f9d19ed53fd", size = 151939 }, 173 | { url = "https://files.pythonhosted.org/packages/b1/82/8e9fe624cc5374193de6860aba3ea8070f584c8565ee77c168ec13274bd2/charset_normalizer-3.4.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:5777ee0881f9499ed0f71cc82cf873d9a0ca8af166dfa0af8ec4e675b7df48e6", size = 149075 }, 174 | { url = "https://files.pythonhosted.org/packages/3d/7b/82865ba54c765560c8433f65e8acb9217cb839a9e32b42af4aa8e945870f/charset_normalizer-3.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:237bdbe6159cff53b4f24f397d43c6336c6b0b42affbe857970cefbb620911c8", size = 144340 }, 175 | { url = "https://files.pythonhosted.org/packages/b5/b6/9674a4b7d4d99a0d2df9b215da766ee682718f88055751e1e5e753c82db0/charset_normalizer-3.4.1-cp311-cp311-win32.whl", hash = "sha256:8417cb1f36cc0bc7eaba8ccb0e04d55f0ee52df06df3ad55259b9a323555fc8b", size = 95205 }, 176 | { url = "https://files.pythonhosted.org/packages/1e/ab/45b180e175de4402dcf7547e4fb617283bae54ce35c27930a6f35b6bef15/charset_normalizer-3.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:d7f50a1f8c450f3925cb367d011448c39239bb3eb4117c36a6d354794de4ce76", size = 102441 }, 177 | { url = "https://files.pythonhosted.org/packages/0a/9a/dd1e1cdceb841925b7798369a09279bd1cf183cef0f9ddf15a3a6502ee45/charset_normalizer-3.4.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:73d94b58ec7fecbc7366247d3b0b10a21681004153238750bb67bd9012414545", size = 196105 }, 178 | { url = "https://files.pythonhosted.org/packages/d3/8c/90bfabf8c4809ecb648f39794cf2a84ff2e7d2a6cf159fe68d9a26160467/charset_normalizer-3.4.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dad3e487649f498dd991eeb901125411559b22e8d7ab25d3aeb1af367df5efd7", size = 140404 }, 179 | { url = "https://files.pythonhosted.org/packages/ad/8f/e410d57c721945ea3b4f1a04b74f70ce8fa800d393d72899f0a40526401f/charset_normalizer-3.4.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c30197aa96e8eed02200a83fba2657b4c3acd0f0aa4bdc9f6c1af8e8962e0757", size = 150423 }, 180 | { url = "https://files.pythonhosted.org/packages/f0/b8/e6825e25deb691ff98cf5c9072ee0605dc2acfca98af70c2d1b1bc75190d/charset_normalizer-3.4.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2369eea1ee4a7610a860d88f268eb39b95cb588acd7235e02fd5a5601773d4fa", size = 143184 }, 181 | { url = "https://files.pythonhosted.org/packages/3e/a2/513f6cbe752421f16d969e32f3583762bfd583848b763913ddab8d9bfd4f/charset_normalizer-3.4.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc2722592d8998c870fa4e290c2eec2c1569b87fe58618e67d38b4665dfa680d", size = 145268 }, 182 | { url = "https://files.pythonhosted.org/packages/74/94/8a5277664f27c3c438546f3eb53b33f5b19568eb7424736bdc440a88a31f/charset_normalizer-3.4.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ffc9202a29ab3920fa812879e95a9e78b2465fd10be7fcbd042899695d75e616", size = 147601 }, 183 | { url = "https://files.pythonhosted.org/packages/7c/5f/6d352c51ee763623a98e31194823518e09bfa48be2a7e8383cf691bbb3d0/charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:804a4d582ba6e5b747c625bf1255e6b1507465494a40a2130978bda7b932c90b", size = 141098 }, 184 | { url = "https://files.pythonhosted.org/packages/78/d4/f5704cb629ba5ab16d1d3d741396aec6dc3ca2b67757c45b0599bb010478/charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:0f55e69f030f7163dffe9fd0752b32f070566451afe180f99dbeeb81f511ad8d", size = 149520 }, 185 | { url = "https://files.pythonhosted.org/packages/c5/96/64120b1d02b81785f222b976c0fb79a35875457fa9bb40827678e54d1bc8/charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:c4c3e6da02df6fa1410a7680bd3f63d4f710232d3139089536310d027950696a", size = 152852 }, 186 | { url = "https://files.pythonhosted.org/packages/84/c9/98e3732278a99f47d487fd3468bc60b882920cef29d1fa6ca460a1fdf4e6/charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:5df196eb874dae23dcfb968c83d4f8fdccb333330fe1fc278ac5ceeb101003a9", size = 150488 }, 187 | { url = "https://files.pythonhosted.org/packages/13/0e/9c8d4cb99c98c1007cc11eda969ebfe837bbbd0acdb4736d228ccaabcd22/charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e358e64305fe12299a08e08978f51fc21fac060dcfcddd95453eabe5b93ed0e1", size = 146192 }, 188 | { url = "https://files.pythonhosted.org/packages/b2/21/2b6b5b860781a0b49427309cb8670785aa543fb2178de875b87b9cc97746/charset_normalizer-3.4.1-cp312-cp312-win32.whl", hash = "sha256:9b23ca7ef998bc739bf6ffc077c2116917eabcc901f88da1b9856b210ef63f35", size = 95550 }, 189 | { url = "https://files.pythonhosted.org/packages/21/5b/1b390b03b1d16c7e382b561c5329f83cc06623916aab983e8ab9239c7d5c/charset_normalizer-3.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:6ff8a4a60c227ad87030d76e99cd1698345d4491638dfa6673027c48b3cd395f", size = 102785 }, 190 | { url = "https://files.pythonhosted.org/packages/38/94/ce8e6f63d18049672c76d07d119304e1e2d7c6098f0841b51c666e9f44a0/charset_normalizer-3.4.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:aabfa34badd18f1da5ec1bc2715cadc8dca465868a4e73a0173466b688f29dda", size = 195698 }, 191 | { url = "https://files.pythonhosted.org/packages/24/2e/dfdd9770664aae179a96561cc6952ff08f9a8cd09a908f259a9dfa063568/charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:22e14b5d70560b8dd51ec22863f370d1e595ac3d024cb8ad7d308b4cd95f8313", size = 140162 }, 192 | { url = "https://files.pythonhosted.org/packages/24/4e/f646b9093cff8fc86f2d60af2de4dc17c759de9d554f130b140ea4738ca6/charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8436c508b408b82d87dc5f62496973a1805cd46727c34440b0d29d8a2f50a6c9", size = 150263 }, 193 | { url = "https://files.pythonhosted.org/packages/5e/67/2937f8d548c3ef6e2f9aab0f6e21001056f692d43282b165e7c56023e6dd/charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2d074908e1aecee37a7635990b2c6d504cd4766c7bc9fc86d63f9c09af3fa11b", size = 142966 }, 194 | { url = "https://files.pythonhosted.org/packages/52/ed/b7f4f07de100bdb95c1756d3a4d17b90c1a3c53715c1a476f8738058e0fa/charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:955f8851919303c92343d2f66165294848d57e9bba6cf6e3625485a70a038d11", size = 144992 }, 195 | { url = "https://files.pythonhosted.org/packages/96/2c/d49710a6dbcd3776265f4c923bb73ebe83933dfbaa841c5da850fe0fd20b/charset_normalizer-3.4.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:44ecbf16649486d4aebafeaa7ec4c9fed8b88101f4dd612dcaf65d5e815f837f", size = 147162 }, 196 | { url = "https://files.pythonhosted.org/packages/b4/41/35ff1f9a6bd380303dea55e44c4933b4cc3c4850988927d4082ada230273/charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0924e81d3d5e70f8126529951dac65c1010cdf117bb75eb02dd12339b57749dd", size = 140972 }, 197 | { url = "https://files.pythonhosted.org/packages/fb/43/c6a0b685fe6910d08ba971f62cd9c3e862a85770395ba5d9cad4fede33ab/charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:2967f74ad52c3b98de4c3b32e1a44e32975e008a9cd2a8cc8966d6a5218c5cb2", size = 149095 }, 198 | { url = "https://files.pythonhosted.org/packages/4c/ff/a9a504662452e2d2878512115638966e75633519ec11f25fca3d2049a94a/charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:c75cb2a3e389853835e84a2d8fb2b81a10645b503eca9bcb98df6b5a43eb8886", size = 152668 }, 199 | { url = "https://files.pythonhosted.org/packages/6c/71/189996b6d9a4b932564701628af5cee6716733e9165af1d5e1b285c530ed/charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:09b26ae6b1abf0d27570633b2b078a2a20419c99d66fb2823173d73f188ce601", size = 150073 }, 200 | { url = "https://files.pythonhosted.org/packages/e4/93/946a86ce20790e11312c87c75ba68d5f6ad2208cfb52b2d6a2c32840d922/charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:fa88b843d6e211393a37219e6a1c1df99d35e8fd90446f1118f4216e307e48cd", size = 145732 }, 201 | { url = "https://files.pythonhosted.org/packages/cd/e5/131d2fb1b0dddafc37be4f3a2fa79aa4c037368be9423061dccadfd90091/charset_normalizer-3.4.1-cp313-cp313-win32.whl", hash = "sha256:eb8178fe3dba6450a3e024e95ac49ed3400e506fd4e9e5c32d30adda88cbd407", size = 95391 }, 202 | { url = "https://files.pythonhosted.org/packages/27/f2/4f9a69cc7712b9b5ad8fdb87039fd89abba997ad5cbe690d1835d40405b0/charset_normalizer-3.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:b1ac5992a838106edb89654e0aebfc24f5848ae2547d22c2c3f66454daa11971", size = 102702 }, 203 | { url = "https://files.pythonhosted.org/packages/0e/f6/65ecc6878a89bb1c23a086ea335ad4bf21a588990c3f535a227b9eea9108/charset_normalizer-3.4.1-py3-none-any.whl", hash = "sha256:d98b1668f06378c6dbefec3b92299716b931cd4e6061f3c875a71ced1780ab85", size = 49767 }, 204 | ] 205 | 206 | [[package]] 207 | name = "click" 208 | version = "8.1.8" 209 | source = { registry = "https://pypi.org/simple" } 210 | dependencies = [ 211 | { name = "colorama", marker = "sys_platform == 'win32'" }, 212 | ] 213 | sdist = { url = "https://files.pythonhosted.org/packages/b9/2e/0090cbf739cee7d23781ad4b89a9894a41538e4fcf4c31dcdd705b78eb8b/click-8.1.8.tar.gz", hash = "sha256:ed53c9d8990d83c2a27deae68e4ee337473f6330c040a31d4225c9574d16096a", size = 226593 } 214 | wheels = [ 215 | { url = "https://files.pythonhosted.org/packages/7e/d4/7ebdbd03970677812aac39c869717059dbb71a4cfc033ca6e5221787892c/click-8.1.8-py3-none-any.whl", hash = "sha256:63c132bbbed01578a06712a2d1f497bb62d9c1c0d329b7903a866228027263b2", size = 98188 }, 216 | ] 217 | 218 | [[package]] 219 | name = "colorama" 220 | version = "0.4.6" 221 | source = { registry = "https://pypi.org/simple" } 222 | sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697 } 223 | wheels = [ 224 | { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335 }, 225 | ] 226 | 227 | [[package]] 228 | name = "colorful" 229 | version = "0.5.6" 230 | source = { registry = "https://pypi.org/simple" } 231 | dependencies = [ 232 | { name = "colorama", marker = "sys_platform == 'win32'" }, 233 | ] 234 | sdist = { url = "https://files.pythonhosted.org/packages/fa/5f/38e40c3bc4107c39e4062d943026b8ee25154cb4b185b882f274a1ab65da/colorful-0.5.6.tar.gz", hash = "sha256:b56d5c01db1dac4898308ea889edcb113fbee3e6ec5df4bacffd61d5241b5b8d", size = 209280 } 235 | wheels = [ 236 | { url = "https://files.pythonhosted.org/packages/b3/61/39e7db0cb326c9c8f6a49fad4fc9c2f1241f05a4e10f0643fc31ce26a7e0/colorful-0.5.6-py2.py3-none-any.whl", hash = "sha256:eab8c1c809f5025ad2b5238a50bd691e26850da8cac8f90d660ede6ea1af9f1e", size = 201369 }, 237 | ] 238 | 239 | [[package]] 240 | name = "distlib" 241 | version = "0.3.9" 242 | source = { registry = "https://pypi.org/simple" } 243 | sdist = { url = "https://files.pythonhosted.org/packages/0d/dd/1bec4c5ddb504ca60fc29472f3d27e8d4da1257a854e1d96742f15c1d02d/distlib-0.3.9.tar.gz", hash = "sha256:a60f20dea646b8a33f3e7772f74dc0b2d0772d2837ee1342a00645c81edf9403", size = 613923 } 244 | wheels = [ 245 | { url = "https://files.pythonhosted.org/packages/91/a1/cf2472db20f7ce4a6be1253a81cfdf85ad9c7885ffbed7047fb72c24cf87/distlib-0.3.9-py2.py3-none-any.whl", hash = "sha256:47f8c22fd27c27e25a65601af709b38e4f0a45ea4fc2e710f65755fa8caaaf87", size = 468973 }, 246 | ] 247 | 248 | [[package]] 249 | name = "docutils" 250 | version = "0.21.2" 251 | source = { registry = "https://pypi.org/simple" } 252 | sdist = { url = "https://files.pythonhosted.org/packages/ae/ed/aefcc8cd0ba62a0560c3c18c33925362d46c6075480bfa4df87b28e169a9/docutils-0.21.2.tar.gz", hash = "sha256:3a6b18732edf182daa3cd12775bbb338cf5691468f91eeeb109deff6ebfa986f", size = 2204444 } 253 | wheels = [ 254 | { url = "https://files.pythonhosted.org/packages/8f/d7/9322c609343d929e75e7e5e6255e614fcc67572cfd083959cdef3b7aad79/docutils-0.21.2-py3-none-any.whl", hash = "sha256:dafca5b9e384f0e419294eb4d2ff9fa826435bf15f15b7bd45723e8ad76811b2", size = 587408 }, 255 | ] 256 | 257 | [[package]] 258 | name = "filelock" 259 | version = "3.16.1" 260 | source = { registry = "https://pypi.org/simple" } 261 | sdist = { url = "https://files.pythonhosted.org/packages/9d/db/3ef5bb276dae18d6ec2124224403d1d67bccdbefc17af4cc8f553e341ab1/filelock-3.16.1.tar.gz", hash = "sha256:c249fbfcd5db47e5e2d6d62198e565475ee65e4831e2561c8e313fa7eb961435", size = 18037 } 262 | wheels = [ 263 | { url = "https://files.pythonhosted.org/packages/b9/f8/feced7779d755758a52d1f6635d990b8d98dc0a29fa568bbe0625f18fdf3/filelock-3.16.1-py3-none-any.whl", hash = "sha256:2082e5703d51fbf98ea75855d9d5527e33d8ff23099bec374a134febee6946b0", size = 16163 }, 264 | ] 265 | 266 | [[package]] 267 | name = "h11" 268 | version = "0.14.0" 269 | source = { registry = "https://pypi.org/simple" } 270 | sdist = { url = "https://files.pythonhosted.org/packages/f5/38/3af3d3633a34a3316095b39c8e8fb4853a28a536e55d347bd8d8e9a14b03/h11-0.14.0.tar.gz", hash = "sha256:8f19fbbe99e72420ff35c00b27a34cb9937e902a8b810e2c88300c6f0a3b699d", size = 100418 } 271 | wheels = [ 272 | { url = "https://files.pythonhosted.org/packages/95/04/ff642e65ad6b90db43e668d70ffb6736436c7ce41fcc549f4e9472234127/h11-0.14.0-py3-none-any.whl", hash = "sha256:e3fe4ac4b851c468cc8363d500db52c2ead036020723024a109d37346efaa761", size = 58259 }, 273 | ] 274 | 275 | [[package]] 276 | name = "h2" 277 | version = "4.1.0" 278 | source = { registry = "https://pypi.org/simple" } 279 | dependencies = [ 280 | { name = "hpack" }, 281 | { name = "hyperframe" }, 282 | ] 283 | sdist = { url = "https://files.pythonhosted.org/packages/2a/32/fec683ddd10629ea4ea46d206752a95a2d8a48c22521edd70b142488efe1/h2-4.1.0.tar.gz", hash = "sha256:a83aca08fbe7aacb79fec788c9c0bac936343560ed9ec18b82a13a12c28d2abb", size = 2145593 } 284 | wheels = [ 285 | { url = "https://files.pythonhosted.org/packages/2a/e5/db6d438da759efbb488c4f3fbdab7764492ff3c3f953132efa6b9f0e9e53/h2-4.1.0-py3-none-any.whl", hash = "sha256:03a46bcf682256c95b5fd9e9a99c1323584c3eec6440d379b9903d709476bc6d", size = 57488 }, 286 | ] 287 | 288 | [[package]] 289 | name = "hpack" 290 | version = "4.0.0" 291 | source = { registry = "https://pypi.org/simple" } 292 | sdist = { url = "https://files.pythonhosted.org/packages/3e/9b/fda93fb4d957db19b0f6b370e79d586b3e8528b20252c729c476a2c02954/hpack-4.0.0.tar.gz", hash = "sha256:fc41de0c63e687ebffde81187a948221294896f6bdc0ae2312708df339430095", size = 49117 } 293 | wheels = [ 294 | { url = "https://files.pythonhosted.org/packages/d5/34/e8b383f35b77c402d28563d2b8f83159319b509bc5f760b15d60b0abf165/hpack-4.0.0-py3-none-any.whl", hash = "sha256:84a076fad3dc9a9f8063ccb8041ef100867b1878b25ef0ee63847a5d53818a6c", size = 32611 }, 295 | ] 296 | 297 | [[package]] 298 | name = "httpcore" 299 | version = "1.0.7" 300 | source = { registry = "https://pypi.org/simple" } 301 | dependencies = [ 302 | { name = "certifi" }, 303 | { name = "h11" }, 304 | ] 305 | sdist = { url = "https://files.pythonhosted.org/packages/6a/41/d7d0a89eb493922c37d343b607bc1b5da7f5be7e383740b4753ad8943e90/httpcore-1.0.7.tar.gz", hash = "sha256:8551cb62a169ec7162ac7be8d4817d561f60e08eaa485234898414bb5a8a0b4c", size = 85196 } 306 | wheels = [ 307 | { url = "https://files.pythonhosted.org/packages/87/f5/72347bc88306acb359581ac4d52f23c0ef445b57157adedb9aee0cd689d2/httpcore-1.0.7-py3-none-any.whl", hash = "sha256:a3fff8f43dc260d5bd363d9f9cf1830fa3a458b332856f34282de498ed420edd", size = 78551 }, 308 | ] 309 | 310 | [[package]] 311 | name = "httpx" 312 | version = "0.28.1" 313 | source = { registry = "https://pypi.org/simple" } 314 | dependencies = [ 315 | { name = "anyio" }, 316 | { name = "certifi" }, 317 | { name = "httpcore" }, 318 | { name = "idna" }, 319 | ] 320 | sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406 } 321 | wheels = [ 322 | { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517 }, 323 | ] 324 | 325 | [package.optional-dependencies] 326 | http2 = [ 327 | { name = "h2" }, 328 | ] 329 | 330 | [[package]] 331 | name = "hyperframe" 332 | version = "6.0.1" 333 | source = { registry = "https://pypi.org/simple" } 334 | sdist = { url = "https://files.pythonhosted.org/packages/5a/2a/4747bff0a17f7281abe73e955d60d80aae537a5d203f417fa1c2e7578ebb/hyperframe-6.0.1.tar.gz", hash = "sha256:ae510046231dc8e9ecb1a6586f63d2347bf4c8905914aa84ba585ae85f28a914", size = 25008 } 335 | wheels = [ 336 | { url = "https://files.pythonhosted.org/packages/d7/de/85a784bcc4a3779d1753a7ec2dee5de90e18c7bcf402e71b51fcf150b129/hyperframe-6.0.1-py3-none-any.whl", hash = "sha256:0ec6bafd80d8ad2195c4f03aacba3a8265e57bc4cff261e802bf39970ed02a15", size = 12389 }, 337 | ] 338 | 339 | [[package]] 340 | name = "idna" 341 | version = "3.10" 342 | source = { registry = "https://pypi.org/simple" } 343 | sdist = { url = "https://files.pythonhosted.org/packages/f1/70/7703c29685631f5a7590aa73f1f1d3fa9a380e654b86af429e0934a32f7d/idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9", size = 190490 } 344 | wheels = [ 345 | { url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442 }, 346 | ] 347 | 348 | [[package]] 349 | name = "imagesize" 350 | version = "1.4.1" 351 | source = { registry = "https://pypi.org/simple" } 352 | sdist = { url = "https://files.pythonhosted.org/packages/a7/84/62473fb57d61e31fef6e36d64a179c8781605429fd927b5dd608c997be31/imagesize-1.4.1.tar.gz", hash = "sha256:69150444affb9cb0d5cc5a92b3676f0b2fb7cd9ae39e947a5e11a36b4497cd4a", size = 1280026 } 353 | wheels = [ 354 | { url = "https://files.pythonhosted.org/packages/ff/62/85c4c919272577931d407be5ba5d71c20f0b616d31a0befe0ae45bb79abd/imagesize-1.4.1-py2.py3-none-any.whl", hash = "sha256:0d8d18d08f840c19d0ee7ca1fd82490fdc3729b7ac93f49870406ddde8ef8d8b", size = 8769 }, 355 | ] 356 | 357 | [[package]] 358 | name = "iniconfig" 359 | version = "2.0.0" 360 | source = { registry = "https://pypi.org/simple" } 361 | sdist = { url = "https://files.pythonhosted.org/packages/d7/4b/cbd8e699e64a6f16ca3a8220661b5f83792b3017d0f79807cb8708d33913/iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3", size = 4646 } 362 | wheels = [ 363 | { url = "https://files.pythonhosted.org/packages/ef/a6/62565a6e1cf69e10f5727360368e451d4b7f58beeac6173dc9db836a5b46/iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374", size = 5892 }, 364 | ] 365 | 366 | [[package]] 367 | name = "jinja2" 368 | version = "3.1.5" 369 | source = { registry = "https://pypi.org/simple" } 370 | dependencies = [ 371 | { name = "markupsafe" }, 372 | ] 373 | sdist = { url = "https://files.pythonhosted.org/packages/af/92/b3130cbbf5591acf9ade8708c365f3238046ac7cb8ccba6e81abccb0ccff/jinja2-3.1.5.tar.gz", hash = "sha256:8fefff8dc3034e27bb80d67c671eb8a9bc424c0ef4c0826edbff304cceff43bb", size = 244674 } 374 | wheels = [ 375 | { url = "https://files.pythonhosted.org/packages/bd/0f/2ba5fbcd631e3e88689309dbe978c5769e883e4b84ebfe7da30b43275c5a/jinja2-3.1.5-py3-none-any.whl", hash = "sha256:aba0f4dc9ed8013c424088f68a5c226f7d6097ed89b246d7749c2ec4175c6adb", size = 134596 }, 376 | ] 377 | 378 | [[package]] 379 | name = "lxml" 380 | version = "5.3.0" 381 | source = { registry = "https://pypi.org/simple" } 382 | sdist = { url = "https://files.pythonhosted.org/packages/e7/6b/20c3a4b24751377aaa6307eb230b66701024012c29dd374999cc92983269/lxml-5.3.0.tar.gz", hash = "sha256:4e109ca30d1edec1ac60cdbe341905dc3b8f55b16855e03a54aaf59e51ec8c6f", size = 3679318 } 383 | wheels = [ 384 | { url = "https://files.pythonhosted.org/packages/5c/a8/449faa2a3cbe6a99f8d38dcd51a3ee8844c17862841a6f769ea7c2a9cd0f/lxml-5.3.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:74bcb423462233bc5d6066e4e98b0264e7c1bed7541fff2f4e34fe6b21563c8b", size = 8141056 }, 385 | { url = "https://files.pythonhosted.org/packages/ac/8a/ae6325e994e2052de92f894363b038351c50ee38749d30cc6b6d96aaf90f/lxml-5.3.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a3d819eb6f9b8677f57f9664265d0a10dd6551d227afb4af2b9cd7bdc2ccbf18", size = 4425238 }, 386 | { url = "https://files.pythonhosted.org/packages/f8/fb/128dddb7f9086236bce0eeae2bfb316d138b49b159f50bc681d56c1bdd19/lxml-5.3.0-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5b8f5db71b28b8c404956ddf79575ea77aa8b1538e8b2ef9ec877945b3f46442", size = 5095197 }, 387 | { url = "https://files.pythonhosted.org/packages/b4/f9/a181a8ef106e41e3086629c8bdb2d21a942f14c84a0e77452c22d6b22091/lxml-5.3.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2c3406b63232fc7e9b8783ab0b765d7c59e7c59ff96759d8ef9632fca27c7ee4", size = 4809809 }, 388 | { url = "https://files.pythonhosted.org/packages/25/2f/b20565e808f7f6868aacea48ddcdd7e9e9fb4c799287f21f1a6c7c2e8b71/lxml-5.3.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2ecdd78ab768f844c7a1d4a03595038c166b609f6395e25af9b0f3f26ae1230f", size = 5407593 }, 389 | { url = "https://files.pythonhosted.org/packages/23/0e/caac672ec246d3189a16c4d364ed4f7d6bf856c080215382c06764058c08/lxml-5.3.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:168f2dfcfdedf611eb285efac1516c8454c8c99caf271dccda8943576b67552e", size = 4866657 }, 390 | { url = "https://files.pythonhosted.org/packages/67/a4/1f5fbd3f58d4069000522196b0b776a014f3feec1796da03e495cf23532d/lxml-5.3.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aa617107a410245b8660028a7483b68e7914304a6d4882b5ff3d2d3eb5948d8c", size = 4967017 }, 391 | { url = "https://files.pythonhosted.org/packages/ee/73/623ecea6ca3c530dd0a4ed0d00d9702e0e85cd5624e2d5b93b005fe00abd/lxml-5.3.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:69959bd3167b993e6e710b99051265654133a98f20cec1d9b493b931942e9c16", size = 4810730 }, 392 | { url = "https://files.pythonhosted.org/packages/1d/ce/fb84fb8e3c298f3a245ae3ea6221c2426f1bbaa82d10a88787412a498145/lxml-5.3.0-cp311-cp311-manylinux_2_28_ppc64le.whl", hash = "sha256:bd96517ef76c8654446fc3db9242d019a1bb5fe8b751ba414765d59f99210b79", size = 5455154 }, 393 | { url = "https://files.pythonhosted.org/packages/b1/72/4d1ad363748a72c7c0411c28be2b0dc7150d91e823eadad3b91a4514cbea/lxml-5.3.0-cp311-cp311-manylinux_2_28_s390x.whl", hash = "sha256:ab6dd83b970dc97c2d10bc71aa925b84788c7c05de30241b9e96f9b6d9ea3080", size = 4969416 }, 394 | { url = "https://files.pythonhosted.org/packages/42/07/b29571a58a3a80681722ea8ed0ba569211d9bb8531ad49b5cacf6d409185/lxml-5.3.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:eec1bb8cdbba2925bedc887bc0609a80e599c75b12d87ae42ac23fd199445654", size = 5013672 }, 395 | { url = "https://files.pythonhosted.org/packages/b9/93/bde740d5a58cf04cbd38e3dd93ad1e36c2f95553bbf7d57807bc6815d926/lxml-5.3.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:6a7095eeec6f89111d03dabfe5883a1fd54da319c94e0fb104ee8f23616b572d", size = 4878644 }, 396 | { url = "https://files.pythonhosted.org/packages/56/b5/645c8c02721d49927c93181de4017164ec0e141413577687c3df8ff0800f/lxml-5.3.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:6f651ebd0b21ec65dfca93aa629610a0dbc13dbc13554f19b0113da2e61a4763", size = 5511531 }, 397 | { url = "https://files.pythonhosted.org/packages/85/3f/6a99a12d9438316f4fc86ef88c5d4c8fb674247b17f3173ecadd8346b671/lxml-5.3.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:f422a209d2455c56849442ae42f25dbaaba1c6c3f501d58761c619c7836642ec", size = 5402065 }, 398 | { url = "https://files.pythonhosted.org/packages/80/8a/df47bff6ad5ac57335bf552babfb2408f9eb680c074ec1ba412a1a6af2c5/lxml-5.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:62f7fdb0d1ed2065451f086519865b4c90aa19aed51081979ecd05a21eb4d1be", size = 5069775 }, 399 | { url = "https://files.pythonhosted.org/packages/08/ae/e7ad0f0fbe4b6368c5ee1e3ef0c3365098d806d42379c46c1ba2802a52f7/lxml-5.3.0-cp311-cp311-win32.whl", hash = "sha256:c6379f35350b655fd817cd0d6cbeef7f265f3ae5fedb1caae2eb442bbeae9ab9", size = 3474226 }, 400 | { url = "https://files.pythonhosted.org/packages/c3/b5/91c2249bfac02ee514ab135e9304b89d55967be7e53e94a879b74eec7a5c/lxml-5.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:9c52100e2c2dbb0649b90467935c4b0de5528833c76a35ea1a2691ec9f1ee7a1", size = 3814971 }, 401 | { url = "https://files.pythonhosted.org/packages/eb/6d/d1f1c5e40c64bf62afd7a3f9b34ce18a586a1cccbf71e783cd0a6d8e8971/lxml-5.3.0-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:e99f5507401436fdcc85036a2e7dc2e28d962550afe1cbfc07c40e454256a859", size = 8171753 }, 402 | { url = "https://files.pythonhosted.org/packages/bd/83/26b1864921869784355459f374896dcf8b44d4af3b15d7697e9156cb2de9/lxml-5.3.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:384aacddf2e5813a36495233b64cb96b1949da72bef933918ba5c84e06af8f0e", size = 4441955 }, 403 | { url = "https://files.pythonhosted.org/packages/e0/d2/e9bff9fb359226c25cda3538f664f54f2804f4b37b0d7c944639e1a51f69/lxml-5.3.0-cp312-cp312-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:874a216bf6afaf97c263b56371434e47e2c652d215788396f60477540298218f", size = 5050778 }, 404 | { url = "https://files.pythonhosted.org/packages/88/69/6972bfafa8cd3ddc8562b126dd607011e218e17be313a8b1b9cc5a0ee876/lxml-5.3.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:65ab5685d56914b9a2a34d67dd5488b83213d680b0c5d10b47f81da5a16b0b0e", size = 4748628 }, 405 | { url = "https://files.pythonhosted.org/packages/5d/ea/a6523c7c7f6dc755a6eed3d2f6d6646617cad4d3d6d8ce4ed71bfd2362c8/lxml-5.3.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:aac0bbd3e8dd2d9c45ceb82249e8bdd3ac99131a32b4d35c8af3cc9db1657179", size = 5322215 }, 406 | { url = "https://files.pythonhosted.org/packages/99/37/396fbd24a70f62b31d988e4500f2068c7f3fd399d2fd45257d13eab51a6f/lxml-5.3.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b369d3db3c22ed14c75ccd5af429086f166a19627e84a8fdade3f8f31426e52a", size = 4813963 }, 407 | { url = "https://files.pythonhosted.org/packages/09/91/e6136f17459a11ce1757df864b213efbeab7adcb2efa63efb1b846ab6723/lxml-5.3.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c24037349665434f375645fa9d1f5304800cec574d0310f618490c871fd902b3", size = 4923353 }, 408 | { url = "https://files.pythonhosted.org/packages/1d/7c/2eeecf87c9a1fca4f84f991067c693e67340f2b7127fc3eca8fa29d75ee3/lxml-5.3.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:62d172f358f33a26d6b41b28c170c63886742f5b6772a42b59b4f0fa10526cb1", size = 4740541 }, 409 | { url = "https://files.pythonhosted.org/packages/3b/ed/4c38ba58defca84f5f0d0ac2480fdcd99fc7ae4b28fc417c93640a6949ae/lxml-5.3.0-cp312-cp312-manylinux_2_28_ppc64le.whl", hash = "sha256:c1f794c02903c2824fccce5b20c339a1a14b114e83b306ff11b597c5f71a1c8d", size = 5346504 }, 410 | { url = "https://files.pythonhosted.org/packages/a5/22/bbd3995437e5745cb4c2b5d89088d70ab19d4feabf8a27a24cecb9745464/lxml-5.3.0-cp312-cp312-manylinux_2_28_s390x.whl", hash = "sha256:5d6a6972b93c426ace71e0be9a6f4b2cfae9b1baed2eed2006076a746692288c", size = 4898077 }, 411 | { url = "https://files.pythonhosted.org/packages/0a/6e/94537acfb5b8f18235d13186d247bca478fea5e87d224644e0fe907df976/lxml-5.3.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:3879cc6ce938ff4eb4900d901ed63555c778731a96365e53fadb36437a131a99", size = 4946543 }, 412 | { url = "https://files.pythonhosted.org/packages/8d/e8/4b15df533fe8e8d53363b23a41df9be907330e1fa28c7ca36893fad338ee/lxml-5.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:74068c601baff6ff021c70f0935b0c7bc528baa8ea210c202e03757c68c5a4ff", size = 4816841 }, 413 | { url = "https://files.pythonhosted.org/packages/1a/e7/03f390ea37d1acda50bc538feb5b2bda6745b25731e4e76ab48fae7106bf/lxml-5.3.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:ecd4ad8453ac17bc7ba3868371bffb46f628161ad0eefbd0a855d2c8c32dd81a", size = 5417341 }, 414 | { url = "https://files.pythonhosted.org/packages/ea/99/d1133ab4c250da85a883c3b60249d3d3e7c64f24faff494cf0fd23f91e80/lxml-5.3.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:7e2f58095acc211eb9d8b5771bf04df9ff37d6b87618d1cbf85f92399c98dae8", size = 5327539 }, 415 | { url = "https://files.pythonhosted.org/packages/7d/ed/e6276c8d9668028213df01f598f385b05b55a4e1b4662ee12ef05dab35aa/lxml-5.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e63601ad5cd8f860aa99d109889b5ac34de571c7ee902d6812d5d9ddcc77fa7d", size = 5012542 }, 416 | { url = "https://files.pythonhosted.org/packages/36/88/684d4e800f5aa28df2a991a6a622783fb73cf0e46235cfa690f9776f032e/lxml-5.3.0-cp312-cp312-win32.whl", hash = "sha256:17e8d968d04a37c50ad9c456a286b525d78c4a1c15dd53aa46c1d8e06bf6fa30", size = 3486454 }, 417 | { url = "https://files.pythonhosted.org/packages/fc/82/ace5a5676051e60355bd8fb945df7b1ba4f4fb8447f2010fb816bfd57724/lxml-5.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:c1a69e58a6bb2de65902051d57fde951febad631a20a64572677a1052690482f", size = 3816857 }, 418 | { url = "https://files.pythonhosted.org/packages/94/6a/42141e4d373903bfea6f8e94b2f554d05506dfda522ada5343c651410dc8/lxml-5.3.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8c72e9563347c7395910de6a3100a4840a75a6f60e05af5e58566868d5eb2d6a", size = 8156284 }, 419 | { url = "https://files.pythonhosted.org/packages/91/5e/fa097f0f7d8b3d113fb7312c6308af702f2667f22644441715be961f2c7e/lxml-5.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e92ce66cd919d18d14b3856906a61d3f6b6a8500e0794142338da644260595cd", size = 4432407 }, 420 | { url = "https://files.pythonhosted.org/packages/2d/a1/b901988aa6d4ff937f2e5cfc114e4ec561901ff00660c3e56713642728da/lxml-5.3.0-cp313-cp313-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1d04f064bebdfef9240478f7a779e8c5dc32b8b7b0b2fc6a62e39b928d428e51", size = 5048331 }, 421 | { url = "https://files.pythonhosted.org/packages/30/0f/b2a54f48e52de578b71bbe2a2f8160672a8a5e103df3a78da53907e8c7ed/lxml-5.3.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5c2fb570d7823c2bbaf8b419ba6e5662137f8166e364a8b2b91051a1fb40ab8b", size = 4744835 }, 422 | { url = "https://files.pythonhosted.org/packages/82/9d/b000c15538b60934589e83826ecbc437a1586488d7c13f8ee5ff1f79a9b8/lxml-5.3.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0c120f43553ec759f8de1fee2f4794452b0946773299d44c36bfe18e83caf002", size = 5316649 }, 423 | { url = "https://files.pythonhosted.org/packages/e3/ee/ffbb9eaff5e541922611d2c56b175c45893d1c0b8b11e5a497708a6a3b3b/lxml-5.3.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:562e7494778a69086f0312ec9689f6b6ac1c6b65670ed7d0267e49f57ffa08c4", size = 4812046 }, 424 | { url = "https://files.pythonhosted.org/packages/15/ff/7ff89d567485c7b943cdac316087f16b2399a8b997007ed352a1248397e5/lxml-5.3.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:423b121f7e6fa514ba0c7918e56955a1d4470ed35faa03e3d9f0e3baa4c7e492", size = 4918597 }, 425 | { url = "https://files.pythonhosted.org/packages/c6/a3/535b6ed8c048412ff51268bdf4bf1cf052a37aa7e31d2e6518038a883b29/lxml-5.3.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:c00f323cc00576df6165cc9d21a4c21285fa6b9989c5c39830c3903dc4303ef3", size = 4738071 }, 426 | { url = "https://files.pythonhosted.org/packages/7a/8f/cbbfa59cb4d4fd677fe183725a76d8c956495d7a3c7f111ab8f5e13d2e83/lxml-5.3.0-cp313-cp313-manylinux_2_28_ppc64le.whl", hash = "sha256:1fdc9fae8dd4c763e8a31e7630afef517eab9f5d5d31a278df087f307bf601f4", size = 5342213 }, 427 | { url = "https://files.pythonhosted.org/packages/5c/fb/db4c10dd9958d4b52e34d1d1f7c1f434422aeaf6ae2bbaaff2264351d944/lxml-5.3.0-cp313-cp313-manylinux_2_28_s390x.whl", hash = "sha256:658f2aa69d31e09699705949b5fc4719cbecbd4a97f9656a232e7d6c7be1a367", size = 4893749 }, 428 | { url = "https://files.pythonhosted.org/packages/f2/38/bb4581c143957c47740de18a3281a0cab7722390a77cc6e610e8ebf2d736/lxml-5.3.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:1473427aff3d66a3fa2199004c3e601e6c4500ab86696edffdbc84954c72d832", size = 4945901 }, 429 | { url = "https://files.pythonhosted.org/packages/fc/d5/18b7de4960c731e98037bd48fa9f8e6e8f2558e6fbca4303d9b14d21ef3b/lxml-5.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a87de7dd873bf9a792bf1e58b1c3887b9264036629a5bf2d2e6579fe8e73edff", size = 4815447 }, 430 | { url = "https://files.pythonhosted.org/packages/97/a8/cd51ceaad6eb849246559a8ef60ae55065a3df550fc5fcd27014361c1bab/lxml-5.3.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:0d7b36afa46c97875303a94e8f3ad932bf78bace9e18e603f2085b652422edcd", size = 5411186 }, 431 | { url = "https://files.pythonhosted.org/packages/89/c3/1e3dabab519481ed7b1fdcba21dcfb8832f57000733ef0e71cf6d09a5e03/lxml-5.3.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:cf120cce539453ae086eacc0130a324e7026113510efa83ab42ef3fcfccac7fb", size = 5324481 }, 432 | { url = "https://files.pythonhosted.org/packages/b6/17/71e9984cf0570cd202ac0a1c9ed5c1b8889b0fc8dc736f5ef0ffb181c284/lxml-5.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:df5c7333167b9674aa8ae1d4008fa4bc17a313cc490b2cca27838bbdcc6bb15b", size = 5011053 }, 433 | { url = "https://files.pythonhosted.org/packages/69/68/9f7e6d3312a91e30829368c2b3217e750adef12a6f8eb10498249f4e8d72/lxml-5.3.0-cp313-cp313-win32.whl", hash = "sha256:c802e1c2ed9f0c06a65bc4ed0189d000ada8049312cfeab6ca635e39c9608957", size = 3485634 }, 434 | { url = "https://files.pythonhosted.org/packages/7d/db/214290d58ad68c587bd5d6af3d34e56830438733d0d0856c0275fde43652/lxml-5.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:406246b96d552e0503e17a1006fd27edac678b3fcc9f1be71a2f94b4ff61528d", size = 3814417 }, 435 | ] 436 | 437 | [[package]] 438 | name = "markdown-it-py" 439 | version = "3.0.0" 440 | source = { registry = "https://pypi.org/simple" } 441 | dependencies = [ 442 | { name = "mdurl" }, 443 | ] 444 | sdist = { url = "https://files.pythonhosted.org/packages/38/71/3b932df36c1a044d397a1f92d1cf91ee0a503d91e470cbd670aa66b07ed0/markdown-it-py-3.0.0.tar.gz", hash = "sha256:e3f60a94fa066dc52ec76661e37c851cb232d92f9886b15cb560aaada2df8feb", size = 74596 } 445 | wheels = [ 446 | { url = "https://files.pythonhosted.org/packages/42/d7/1ec15b46af6af88f19b8e5ffea08fa375d433c998b8a7639e76935c14f1f/markdown_it_py-3.0.0-py3-none-any.whl", hash = "sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1", size = 87528 }, 447 | ] 448 | 449 | [[package]] 450 | name = "markupsafe" 451 | version = "3.0.2" 452 | source = { registry = "https://pypi.org/simple" } 453 | sdist = { url = "https://files.pythonhosted.org/packages/b2/97/5d42485e71dfc078108a86d6de8fa46db44a1a9295e89c5d6d4a06e23a62/markupsafe-3.0.2.tar.gz", hash = "sha256:ee55d3edf80167e48ea11a923c7386f4669df67d7994554387f84e7d8b0a2bf0", size = 20537 } 454 | wheels = [ 455 | { url = "https://files.pythonhosted.org/packages/6b/28/bbf83e3f76936960b850435576dd5e67034e200469571be53f69174a2dfd/MarkupSafe-3.0.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:9025b4018f3a1314059769c7bf15441064b2207cb3f065e6ea1e7359cb46db9d", size = 14353 }, 456 | { url = "https://files.pythonhosted.org/packages/6c/30/316d194b093cde57d448a4c3209f22e3046c5bb2fb0820b118292b334be7/MarkupSafe-3.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:93335ca3812df2f366e80509ae119189886b0f3c2b81325d39efdb84a1e2ae93", size = 12392 }, 457 | { url = "https://files.pythonhosted.org/packages/f2/96/9cdafba8445d3a53cae530aaf83c38ec64c4d5427d975c974084af5bc5d2/MarkupSafe-3.0.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2cb8438c3cbb25e220c2ab33bb226559e7afb3baec11c4f218ffa7308603c832", size = 23984 }, 458 | { url = "https://files.pythonhosted.org/packages/f1/a4/aefb044a2cd8d7334c8a47d3fb2c9f328ac48cb349468cc31c20b539305f/MarkupSafe-3.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a123e330ef0853c6e822384873bef7507557d8e4a082961e1defa947aa59ba84", size = 23120 }, 459 | { url = "https://files.pythonhosted.org/packages/8d/21/5e4851379f88f3fad1de30361db501300d4f07bcad047d3cb0449fc51f8c/MarkupSafe-3.0.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1e084f686b92e5b83186b07e8a17fc09e38fff551f3602b249881fec658d3eca", size = 23032 }, 460 | { url = "https://files.pythonhosted.org/packages/00/7b/e92c64e079b2d0d7ddf69899c98842f3f9a60a1ae72657c89ce2655c999d/MarkupSafe-3.0.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d8213e09c917a951de9d09ecee036d5c7d36cb6cb7dbaece4c71a60d79fb9798", size = 24057 }, 461 | { url = "https://files.pythonhosted.org/packages/f9/ac/46f960ca323037caa0a10662ef97d0a4728e890334fc156b9f9e52bcc4ca/MarkupSafe-3.0.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:5b02fb34468b6aaa40dfc198d813a641e3a63b98c2b05a16b9f80b7ec314185e", size = 23359 }, 462 | { url = "https://files.pythonhosted.org/packages/69/84/83439e16197337b8b14b6a5b9c2105fff81d42c2a7c5b58ac7b62ee2c3b1/MarkupSafe-3.0.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:0bff5e0ae4ef2e1ae4fdf2dfd5b76c75e5c2fa4132d05fc1b0dabcd20c7e28c4", size = 23306 }, 463 | { url = "https://files.pythonhosted.org/packages/9a/34/a15aa69f01e2181ed8d2b685c0d2f6655d5cca2c4db0ddea775e631918cd/MarkupSafe-3.0.2-cp311-cp311-win32.whl", hash = "sha256:6c89876f41da747c8d3677a2b540fb32ef5715f97b66eeb0c6b66f5e3ef6f59d", size = 15094 }, 464 | { url = "https://files.pythonhosted.org/packages/da/b8/3a3bd761922d416f3dc5d00bfbed11f66b1ab89a0c2b6e887240a30b0f6b/MarkupSafe-3.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:70a87b411535ccad5ef2f1df5136506a10775d267e197e4cf531ced10537bd6b", size = 15521 }, 465 | { url = "https://files.pythonhosted.org/packages/22/09/d1f21434c97fc42f09d290cbb6350d44eb12f09cc62c9476effdb33a18aa/MarkupSafe-3.0.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:9778bd8ab0a994ebf6f84c2b949e65736d5575320a17ae8984a77fab08db94cf", size = 14274 }, 466 | { url = "https://files.pythonhosted.org/packages/6b/b0/18f76bba336fa5aecf79d45dcd6c806c280ec44538b3c13671d49099fdd0/MarkupSafe-3.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:846ade7b71e3536c4e56b386c2a47adf5741d2d8b94ec9dc3e92e5e1ee1e2225", size = 12348 }, 467 | { url = "https://files.pythonhosted.org/packages/e0/25/dd5c0f6ac1311e9b40f4af06c78efde0f3b5cbf02502f8ef9501294c425b/MarkupSafe-3.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1c99d261bd2d5f6b59325c92c73df481e05e57f19837bdca8413b9eac4bd8028", size = 24149 }, 468 | { url = "https://files.pythonhosted.org/packages/f3/f0/89e7aadfb3749d0f52234a0c8c7867877876e0a20b60e2188e9850794c17/MarkupSafe-3.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e17c96c14e19278594aa4841ec148115f9c7615a47382ecb6b82bd8fea3ab0c8", size = 23118 }, 469 | { url = "https://files.pythonhosted.org/packages/d5/da/f2eeb64c723f5e3777bc081da884b414671982008c47dcc1873d81f625b6/MarkupSafe-3.0.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:88416bd1e65dcea10bc7569faacb2c20ce071dd1f87539ca2ab364bf6231393c", size = 22993 }, 470 | { url = "https://files.pythonhosted.org/packages/da/0e/1f32af846df486dce7c227fe0f2398dc7e2e51d4a370508281f3c1c5cddc/MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2181e67807fc2fa785d0592dc2d6206c019b9502410671cc905d132a92866557", size = 24178 }, 471 | { url = "https://files.pythonhosted.org/packages/c4/f6/bb3ca0532de8086cbff5f06d137064c8410d10779c4c127e0e47d17c0b71/MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:52305740fe773d09cffb16f8ed0427942901f00adedac82ec8b67752f58a1b22", size = 23319 }, 472 | { url = "https://files.pythonhosted.org/packages/a2/82/8be4c96ffee03c5b4a034e60a31294daf481e12c7c43ab8e34a1453ee48b/MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ad10d3ded218f1039f11a75f8091880239651b52e9bb592ca27de44eed242a48", size = 23352 }, 473 | { url = "https://files.pythonhosted.org/packages/51/ae/97827349d3fcffee7e184bdf7f41cd6b88d9919c80f0263ba7acd1bbcb18/MarkupSafe-3.0.2-cp312-cp312-win32.whl", hash = "sha256:0f4ca02bea9a23221c0182836703cbf8930c5e9454bacce27e767509fa286a30", size = 15097 }, 474 | { url = "https://files.pythonhosted.org/packages/c1/80/a61f99dc3a936413c3ee4e1eecac96c0da5ed07ad56fd975f1a9da5bc630/MarkupSafe-3.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:8e06879fc22a25ca47312fbe7c8264eb0b662f6db27cb2d3bbbc74b1df4b9b87", size = 15601 }, 475 | { url = "https://files.pythonhosted.org/packages/83/0e/67eb10a7ecc77a0c2bbe2b0235765b98d164d81600746914bebada795e97/MarkupSafe-3.0.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ba9527cdd4c926ed0760bc301f6728ef34d841f405abf9d4f959c478421e4efd", size = 14274 }, 476 | { url = "https://files.pythonhosted.org/packages/2b/6d/9409f3684d3335375d04e5f05744dfe7e9f120062c9857df4ab490a1031a/MarkupSafe-3.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f8b3d067f2e40fe93e1ccdd6b2e1d16c43140e76f02fb1319a05cf2b79d99430", size = 12352 }, 477 | { url = "https://files.pythonhosted.org/packages/d2/f5/6eadfcd3885ea85fe2a7c128315cc1bb7241e1987443d78c8fe712d03091/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:569511d3b58c8791ab4c2e1285575265991e6d8f8700c7be0e88f86cb0672094", size = 24122 }, 478 | { url = "https://files.pythonhosted.org/packages/0c/91/96cf928db8236f1bfab6ce15ad070dfdd02ed88261c2afafd4b43575e9e9/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:15ab75ef81add55874e7ab7055e9c397312385bd9ced94920f2802310c930396", size = 23085 }, 479 | { url = "https://files.pythonhosted.org/packages/c2/cf/c9d56af24d56ea04daae7ac0940232d31d5a8354f2b457c6d856b2057d69/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f3818cb119498c0678015754eba762e0d61e5b52d34c8b13d770f0719f7b1d79", size = 22978 }, 480 | { url = "https://files.pythonhosted.org/packages/2a/9f/8619835cd6a711d6272d62abb78c033bda638fdc54c4e7f4272cf1c0962b/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:cdb82a876c47801bb54a690c5ae105a46b392ac6099881cdfb9f6e95e4014c6a", size = 24208 }, 481 | { url = "https://files.pythonhosted.org/packages/f9/bf/176950a1792b2cd2102b8ffeb5133e1ed984547b75db47c25a67d3359f77/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:cabc348d87e913db6ab4aa100f01b08f481097838bdddf7c7a84b7575b7309ca", size = 23357 }, 482 | { url = "https://files.pythonhosted.org/packages/ce/4f/9a02c1d335caabe5c4efb90e1b6e8ee944aa245c1aaaab8e8a618987d816/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:444dcda765c8a838eaae23112db52f1efaf750daddb2d9ca300bcae1039adc5c", size = 23344 }, 483 | { url = "https://files.pythonhosted.org/packages/ee/55/c271b57db36f748f0e04a759ace9f8f759ccf22b4960c270c78a394f58be/MarkupSafe-3.0.2-cp313-cp313-win32.whl", hash = "sha256:bcf3e58998965654fdaff38e58584d8937aa3096ab5354d493c77d1fdd66d7a1", size = 15101 }, 484 | { url = "https://files.pythonhosted.org/packages/29/88/07df22d2dd4df40aba9f3e402e6dc1b8ee86297dddbad4872bd5e7b0094f/MarkupSafe-3.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:e6a2a455bd412959b57a172ce6328d2dd1f01cb2135efda2e4576e8a23fa3b0f", size = 15603 }, 485 | { url = "https://files.pythonhosted.org/packages/62/6a/8b89d24db2d32d433dffcd6a8779159da109842434f1dd2f6e71f32f738c/MarkupSafe-3.0.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:b5a6b3ada725cea8a5e634536b1b01c30bcdcd7f9c6fff4151548d5bf6b3a36c", size = 14510 }, 486 | { url = "https://files.pythonhosted.org/packages/7a/06/a10f955f70a2e5a9bf78d11a161029d278eeacbd35ef806c3fd17b13060d/MarkupSafe-3.0.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:a904af0a6162c73e3edcb969eeeb53a63ceeb5d8cf642fade7d39e7963a22ddb", size = 12486 }, 487 | { url = "https://files.pythonhosted.org/packages/34/cf/65d4a571869a1a9078198ca28f39fba5fbb910f952f9dbc5220afff9f5e6/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4aa4e5faecf353ed117801a068ebab7b7e09ffb6e1d5e412dc852e0da018126c", size = 25480 }, 488 | { url = "https://files.pythonhosted.org/packages/0c/e3/90e9651924c430b885468b56b3d597cabf6d72be4b24a0acd1fa0e12af67/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c0ef13eaeee5b615fb07c9a7dadb38eac06a0608b41570d8ade51c56539e509d", size = 23914 }, 489 | { url = "https://files.pythonhosted.org/packages/66/8c/6c7cf61f95d63bb866db39085150df1f2a5bd3335298f14a66b48e92659c/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d16a81a06776313e817c951135cf7340a3e91e8c1ff2fac444cfd75fffa04afe", size = 23796 }, 490 | { url = "https://files.pythonhosted.org/packages/bb/35/cbe9238ec3f47ac9a7c8b3df7a808e7cb50fe149dc7039f5f454b3fba218/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:6381026f158fdb7c72a168278597a5e3a5222e83ea18f543112b2662a9b699c5", size = 25473 }, 491 | { url = "https://files.pythonhosted.org/packages/e6/32/7621a4382488aa283cc05e8984a9c219abad3bca087be9ec77e89939ded9/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:3d79d162e7be8f996986c064d1c7c817f6df3a77fe3d6859f6f9e7be4b8c213a", size = 24114 }, 492 | { url = "https://files.pythonhosted.org/packages/0d/80/0985960e4b89922cb5a0bac0ed39c5b96cbc1a536a99f30e8c220a996ed9/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:131a3c7689c85f5ad20f9f6fb1b866f402c445b220c19fe4308c0b147ccd2ad9", size = 24098 }, 493 | { url = "https://files.pythonhosted.org/packages/82/78/fedb03c7d5380df2427038ec8d973587e90561b2d90cd472ce9254cf348b/MarkupSafe-3.0.2-cp313-cp313t-win32.whl", hash = "sha256:ba8062ed2cf21c07a9e295d5b8a2a5ce678b913b45fdf68c32d95d6c1291e0b6", size = 15208 }, 494 | { url = "https://files.pythonhosted.org/packages/4f/65/6079a46068dfceaeabb5dcad6d674f5f5c61a6fa5673746f42a9f4c233b3/MarkupSafe-3.0.2-cp313-cp313t-win_amd64.whl", hash = "sha256:e444a31f8db13eb18ada366ab3cf45fd4b31e4db1236a4448f68778c1d1a5a2f", size = 15739 }, 495 | ] 496 | 497 | [[package]] 498 | name = "mdurl" 499 | version = "0.1.2" 500 | source = { registry = "https://pypi.org/simple" } 501 | sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729 } 502 | wheels = [ 503 | { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979 }, 504 | ] 505 | 506 | [[package]] 507 | name = "mypy" 508 | version = "1.14.1" 509 | source = { registry = "https://pypi.org/simple" } 510 | dependencies = [ 511 | { name = "mypy-extensions" }, 512 | { name = "typing-extensions" }, 513 | ] 514 | sdist = { url = "https://files.pythonhosted.org/packages/b9/eb/2c92d8ea1e684440f54fa49ac5d9a5f19967b7b472a281f419e69a8d228e/mypy-1.14.1.tar.gz", hash = "sha256:7ec88144fe9b510e8475ec2f5f251992690fcf89ccb4500b214b4226abcd32d6", size = 3216051 } 515 | wheels = [ 516 | { url = "https://files.pythonhosted.org/packages/da/11/a9422850fd506edbcdc7f6090682ecceaf1f87b9dd847f9df79942da8506/mypy-1.14.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f995e511de847791c3b11ed90084a7a0aafdc074ab88c5a9711622fe4751138c", size = 11120432 }, 517 | { url = "https://files.pythonhosted.org/packages/b6/9e/47e450fd39078d9c02d620545b2cb37993a8a8bdf7db3652ace2f80521ca/mypy-1.14.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d64169ec3b8461311f8ce2fd2eb5d33e2d0f2c7b49116259c51d0d96edee48d1", size = 10279515 }, 518 | { url = "https://files.pythonhosted.org/packages/01/b5/6c8d33bd0f851a7692a8bfe4ee75eb82b6983a3cf39e5e32a5d2a723f0c1/mypy-1.14.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ba24549de7b89b6381b91fbc068d798192b1b5201987070319889e93038967a8", size = 12025791 }, 519 | { url = "https://files.pythonhosted.org/packages/f0/4c/e10e2c46ea37cab5c471d0ddaaa9a434dc1d28650078ac1b56c2d7b9b2e4/mypy-1.14.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:183cf0a45457d28ff9d758730cd0210419ac27d4d3f285beda038c9083363b1f", size = 12749203 }, 520 | { url = "https://files.pythonhosted.org/packages/88/55/beacb0c69beab2153a0f57671ec07861d27d735a0faff135a494cd4f5020/mypy-1.14.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f2a0ecc86378f45347f586e4163d1769dd81c5a223d577fe351f26b179e148b1", size = 12885900 }, 521 | { url = "https://files.pythonhosted.org/packages/a2/75/8c93ff7f315c4d086a2dfcde02f713004357d70a163eddb6c56a6a5eff40/mypy-1.14.1-cp311-cp311-win_amd64.whl", hash = "sha256:ad3301ebebec9e8ee7135d8e3109ca76c23752bac1e717bc84cd3836b4bf3eae", size = 9777869 }, 522 | { url = "https://files.pythonhosted.org/packages/43/1b/b38c079609bb4627905b74fc6a49849835acf68547ac33d8ceb707de5f52/mypy-1.14.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:30ff5ef8519bbc2e18b3b54521ec319513a26f1bba19a7582e7b1f58a6e69f14", size = 11266668 }, 523 | { url = "https://files.pythonhosted.org/packages/6b/75/2ed0d2964c1ffc9971c729f7a544e9cd34b2cdabbe2d11afd148d7838aa2/mypy-1.14.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:cb9f255c18052343c70234907e2e532bc7e55a62565d64536dbc7706a20b78b9", size = 10254060 }, 524 | { url = "https://files.pythonhosted.org/packages/a1/5f/7b8051552d4da3c51bbe8fcafffd76a6823779101a2b198d80886cd8f08e/mypy-1.14.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8b4e3413e0bddea671012b063e27591b953d653209e7a4fa5e48759cda77ca11", size = 11933167 }, 525 | { url = "https://files.pythonhosted.org/packages/04/90/f53971d3ac39d8b68bbaab9a4c6c58c8caa4d5fd3d587d16f5927eeeabe1/mypy-1.14.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:553c293b1fbdebb6c3c4030589dab9fafb6dfa768995a453d8a5d3b23784af2e", size = 12864341 }, 526 | { url = "https://files.pythonhosted.org/packages/03/d2/8bc0aeaaf2e88c977db41583559319f1821c069e943ada2701e86d0430b7/mypy-1.14.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fad79bfe3b65fe6a1efaed97b445c3d37f7be9fdc348bdb2d7cac75579607c89", size = 12972991 }, 527 | { url = "https://files.pythonhosted.org/packages/6f/17/07815114b903b49b0f2cf7499f1c130e5aa459411596668267535fe9243c/mypy-1.14.1-cp312-cp312-win_amd64.whl", hash = "sha256:8fa2220e54d2946e94ab6dbb3ba0a992795bd68b16dc852db33028df2b00191b", size = 9879016 }, 528 | { url = "https://files.pythonhosted.org/packages/9e/15/bb6a686901f59222275ab228453de741185f9d54fecbaacec041679496c6/mypy-1.14.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:92c3ed5afb06c3a8e188cb5da4984cab9ec9a77ba956ee419c68a388b4595255", size = 11252097 }, 529 | { url = "https://files.pythonhosted.org/packages/f8/b3/8b0f74dfd072c802b7fa368829defdf3ee1566ba74c32a2cb2403f68024c/mypy-1.14.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:dbec574648b3e25f43d23577309b16534431db4ddc09fda50841f1e34e64ed34", size = 10239728 }, 530 | { url = "https://files.pythonhosted.org/packages/c5/9b/4fd95ab20c52bb5b8c03cc49169be5905d931de17edfe4d9d2986800b52e/mypy-1.14.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8c6d94b16d62eb3e947281aa7347d78236688e21081f11de976376cf010eb31a", size = 11924965 }, 531 | { url = "https://files.pythonhosted.org/packages/56/9d/4a236b9c57f5d8f08ed346914b3f091a62dd7e19336b2b2a0d85485f82ff/mypy-1.14.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d4b19b03fdf54f3c5b2fa474c56b4c13c9dbfb9a2db4370ede7ec11a2c5927d9", size = 12867660 }, 532 | { url = "https://files.pythonhosted.org/packages/40/88/a61a5497e2f68d9027de2bb139c7bb9abaeb1be1584649fa9d807f80a338/mypy-1.14.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0c911fde686394753fff899c409fd4e16e9b294c24bfd5e1ea4675deae1ac6fd", size = 12969198 }, 533 | { url = "https://files.pythonhosted.org/packages/54/da/3d6fc5d92d324701b0c23fb413c853892bfe0e1dbe06c9138037d459756b/mypy-1.14.1-cp313-cp313-win_amd64.whl", hash = "sha256:8b21525cb51671219f5307be85f7e646a153e5acc656e5cebf64bfa076c50107", size = 9885276 }, 534 | { url = "https://files.pythonhosted.org/packages/a0/b5/32dd67b69a16d088e533962e5044e51004176a9952419de0370cdaead0f8/mypy-1.14.1-py3-none-any.whl", hash = "sha256:b66a60cc4073aeb8ae00057f9c1f64d49e90f918fbcef9a977eb121da8b8f1d1", size = 2752905 }, 535 | ] 536 | 537 | [[package]] 538 | name = "mypy-extensions" 539 | version = "1.0.0" 540 | source = { registry = "https://pypi.org/simple" } 541 | sdist = { url = "https://files.pythonhosted.org/packages/98/a4/1ab47638b92648243faf97a5aeb6ea83059cc3624972ab6b8d2316078d3f/mypy_extensions-1.0.0.tar.gz", hash = "sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782", size = 4433 } 542 | wheels = [ 543 | { url = "https://files.pythonhosted.org/packages/2a/e2/5d3f6ada4297caebe1a2add3b126fe800c96f56dbe5d1988a2cbe0b267aa/mypy_extensions-1.0.0-py3-none-any.whl", hash = "sha256:4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d", size = 4695 }, 544 | ] 545 | 546 | [[package]] 547 | name = "packaging" 548 | version = "24.2" 549 | source = { registry = "https://pypi.org/simple" } 550 | sdist = { url = "https://files.pythonhosted.org/packages/d0/63/68dbb6eb2de9cb10ee4c9c14a0148804425e13c4fb20d61cce69f53106da/packaging-24.2.tar.gz", hash = "sha256:c228a6dc5e932d346bc5739379109d49e8853dd8223571c7c5b55260edc0b97f", size = 163950 } 551 | wheels = [ 552 | { url = "https://files.pythonhosted.org/packages/88/ef/eb23f262cca3c0c4eb7ab1933c3b1f03d021f2c48f54763065b6f0e321be/packaging-24.2-py3-none-any.whl", hash = "sha256:09abb1bccd265c01f4a3aa3f7a7db064b36514d2cba19a2f694fe6150451a759", size = 65451 }, 553 | ] 554 | 555 | [[package]] 556 | name = "pathspec" 557 | version = "0.12.1" 558 | source = { registry = "https://pypi.org/simple" } 559 | sdist = { url = "https://files.pythonhosted.org/packages/ca/bc/f35b8446f4531a7cb215605d100cd88b7ac6f44ab3fc94870c120ab3adbf/pathspec-0.12.1.tar.gz", hash = "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712", size = 51043 } 560 | wheels = [ 561 | { url = "https://files.pythonhosted.org/packages/cc/20/ff623b09d963f88bfde16306a54e12ee5ea43e9b597108672ff3a408aad6/pathspec-0.12.1-py3-none-any.whl", hash = "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08", size = 31191 }, 562 | ] 563 | 564 | [[package]] 565 | name = "phx-class-registry" 566 | version = "5.1.1" 567 | source = { editable = "." } 568 | 569 | [package.dev-dependencies] 570 | ci = [ 571 | { name = "mypy" }, 572 | { name = "pytest" }, 573 | { name = "sphinx" }, 574 | { name = "sphinx-rtd-theme" }, 575 | { name = "tox-uv" }, 576 | ] 577 | dev = [ 578 | { name = "autohooks" }, 579 | { name = "autohooks-plugin-black" }, 580 | { name = "autohooks-plugin-mypy" }, 581 | { name = "autohooks-plugin-pytest" }, 582 | { name = "autohooks-plugin-ruff" }, 583 | { name = "mypy" }, 584 | { name = "pytest" }, 585 | { name = "sphinx" }, 586 | { name = "sphinx-rtd-theme" }, 587 | { name = "tox" }, 588 | { name = "tox-uv" }, 589 | ] 590 | 591 | [package.metadata] 592 | 593 | [package.metadata.requires-dev] 594 | ci = [ 595 | { name = "mypy", specifier = ">=1,<2" }, 596 | { name = "pytest", specifier = ">=8,<9" }, 597 | { name = "sphinx", specifier = ">=8,<9" }, 598 | { name = "sphinx-rtd-theme", specifier = ">=3,<4" }, 599 | { name = "tox-uv", specifier = ">=1,<2" }, 600 | ] 601 | dev = [ 602 | { name = "autohooks", specifier = ">=25,<26" }, 603 | { name = "autohooks-plugin-black", specifier = ">=23,<24" }, 604 | { name = "autohooks-plugin-mypy", specifier = ">=23,<24" }, 605 | { name = "autohooks-plugin-pytest", specifier = ">=23,<24" }, 606 | { name = "autohooks-plugin-ruff", specifier = ">=25,<26" }, 607 | { name = "mypy", specifier = ">=1,<2" }, 608 | { name = "pytest", specifier = ">=8,<9" }, 609 | { name = "sphinx", specifier = ">=8,<9" }, 610 | { name = "sphinx-rtd-theme", specifier = ">=3,<4" }, 611 | { name = "tox", specifier = ">=4,<5" }, 612 | { name = "tox-uv", specifier = ">=1,<2" }, 613 | ] 614 | 615 | [[package]] 616 | name = "platformdirs" 617 | version = "4.3.6" 618 | source = { registry = "https://pypi.org/simple" } 619 | sdist = { url = "https://files.pythonhosted.org/packages/13/fc/128cc9cb8f03208bdbf93d3aa862e16d376844a14f9a0ce5cf4507372de4/platformdirs-4.3.6.tar.gz", hash = "sha256:357fb2acbc885b0419afd3ce3ed34564c13c9b95c89360cd9563f73aa5e2b907", size = 21302 } 620 | wheels = [ 621 | { url = "https://files.pythonhosted.org/packages/3c/a6/bc1012356d8ece4d66dd75c4b9fc6c1f6650ddd5991e421177d9f8f671be/platformdirs-4.3.6-py3-none-any.whl", hash = "sha256:73e575e1408ab8103900836b97580d5307456908a03e92031bab39e4554cc3fb", size = 18439 }, 622 | ] 623 | 624 | [[package]] 625 | name = "pluggy" 626 | version = "1.5.0" 627 | source = { registry = "https://pypi.org/simple" } 628 | sdist = { url = "https://files.pythonhosted.org/packages/96/2d/02d4312c973c6050a18b314a5ad0b3210edb65a906f868e31c111dede4a6/pluggy-1.5.0.tar.gz", hash = "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1", size = 67955 } 629 | wheels = [ 630 | { url = "https://files.pythonhosted.org/packages/88/5f/e351af9a41f866ac3f1fac4ca0613908d9a41741cfcf2228f4ad853b697d/pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669", size = 20556 }, 631 | ] 632 | 633 | [[package]] 634 | name = "pontos" 635 | version = "24.12.4" 636 | source = { registry = "https://pypi.org/simple" } 637 | dependencies = [ 638 | { name = "colorful" }, 639 | { name = "httpx", extra = ["http2"] }, 640 | { name = "lxml" }, 641 | { name = "packaging" }, 642 | { name = "python-dateutil" }, 643 | { name = "rich" }, 644 | { name = "semver" }, 645 | { name = "shtab" }, 646 | { name = "tomlkit" }, 647 | ] 648 | sdist = { url = "https://files.pythonhosted.org/packages/32/45/89dc282d81db0abacf851a115059bae80d9e7b20c710af3ef5c82e5ace42/pontos-24.12.4.tar.gz", hash = "sha256:87db5110a235d5bdb6cb400f8b45aaf29ee07de25a680d46122f38d5ed628b47", size = 337464 } 649 | wheels = [ 650 | { url = "https://files.pythonhosted.org/packages/81/9e/17876aee09901054be8b316c65d04f4068df3475c3e2fd47bf18d5cca6fa/pontos-24.12.4-py3-none-any.whl", hash = "sha256:ff2b810697b64d80e9526b216db1d477e6d1110b2c8af4f88697668dfbe73190", size = 244450 }, 651 | ] 652 | 653 | [[package]] 654 | name = "pygments" 655 | version = "2.18.0" 656 | source = { registry = "https://pypi.org/simple" } 657 | sdist = { url = "https://files.pythonhosted.org/packages/8e/62/8336eff65bcbc8e4cb5d05b55faf041285951b6e80f33e2bff2024788f31/pygments-2.18.0.tar.gz", hash = "sha256:786ff802f32e91311bff3889f6e9a86e81505fe99f2735bb6d60ae0c5004f199", size = 4891905 } 658 | wheels = [ 659 | { url = "https://files.pythonhosted.org/packages/f7/3f/01c8b82017c199075f8f788d0d906b9ffbbc5a47dc9918a945e13d5a2bda/pygments-2.18.0-py3-none-any.whl", hash = "sha256:b8e6aca0523f3ab76fee51799c488e38782ac06eafcf95e7ba832985c8e7b13a", size = 1205513 }, 660 | ] 661 | 662 | [[package]] 663 | name = "pyproject-api" 664 | version = "1.8.0" 665 | source = { registry = "https://pypi.org/simple" } 666 | dependencies = [ 667 | { name = "packaging" }, 668 | ] 669 | sdist = { url = "https://files.pythonhosted.org/packages/bb/19/441e0624a8afedd15bbcce96df1b80479dd0ff0d965f5ce8fde4f2f6ffad/pyproject_api-1.8.0.tar.gz", hash = "sha256:77b8049f2feb5d33eefcc21b57f1e279636277a8ac8ad6b5871037b243778496", size = 22340 } 670 | wheels = [ 671 | { url = "https://files.pythonhosted.org/packages/ba/f4/3c4ddfcc0c19c217c6de513842d286de8021af2f2ab79bbb86c00342d778/pyproject_api-1.8.0-py3-none-any.whl", hash = "sha256:3d7d347a047afe796fd5d1885b1e391ba29be7169bd2f102fcd378f04273d228", size = 13100 }, 672 | ] 673 | 674 | [[package]] 675 | name = "pytest" 676 | version = "8.3.4" 677 | source = { registry = "https://pypi.org/simple" } 678 | dependencies = [ 679 | { name = "colorama", marker = "sys_platform == 'win32'" }, 680 | { name = "iniconfig" }, 681 | { name = "packaging" }, 682 | { name = "pluggy" }, 683 | ] 684 | sdist = { url = "https://files.pythonhosted.org/packages/05/35/30e0d83068951d90a01852cb1cef56e5d8a09d20c7f511634cc2f7e0372a/pytest-8.3.4.tar.gz", hash = "sha256:965370d062bce11e73868e0335abac31b4d3de0e82f4007408d242b4f8610761", size = 1445919 } 685 | wheels = [ 686 | { url = "https://files.pythonhosted.org/packages/11/92/76a1c94d3afee238333bc0a42b82935dd8f9cf8ce9e336ff87ee14d9e1cf/pytest-8.3.4-py3-none-any.whl", hash = "sha256:50e16d954148559c9a74109af1eaf0c945ba2d8f30f0a3d3335edde19788b6f6", size = 343083 }, 687 | ] 688 | 689 | [[package]] 690 | name = "python-dateutil" 691 | version = "2.9.0.post0" 692 | source = { registry = "https://pypi.org/simple" } 693 | dependencies = [ 694 | { name = "six" }, 695 | ] 696 | sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432 } 697 | wheels = [ 698 | { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892 }, 699 | ] 700 | 701 | [[package]] 702 | name = "requests" 703 | version = "2.32.3" 704 | source = { registry = "https://pypi.org/simple" } 705 | dependencies = [ 706 | { name = "certifi" }, 707 | { name = "charset-normalizer" }, 708 | { name = "idna" }, 709 | { name = "urllib3" }, 710 | ] 711 | sdist = { url = "https://files.pythonhosted.org/packages/63/70/2bf7780ad2d390a8d301ad0b550f1581eadbd9a20f896afe06353c2a2913/requests-2.32.3.tar.gz", hash = "sha256:55365417734eb18255590a9ff9eb97e9e1da868d4ccd6402399eaf68af20a760", size = 131218 } 712 | wheels = [ 713 | { url = "https://files.pythonhosted.org/packages/f9/9b/335f9764261e915ed497fcdeb11df5dfd6f7bf257d4a6a2a686d80da4d54/requests-2.32.3-py3-none-any.whl", hash = "sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6", size = 64928 }, 714 | ] 715 | 716 | [[package]] 717 | name = "rich" 718 | version = "13.9.4" 719 | source = { registry = "https://pypi.org/simple" } 720 | dependencies = [ 721 | { name = "markdown-it-py" }, 722 | { name = "pygments" }, 723 | ] 724 | sdist = { url = "https://files.pythonhosted.org/packages/ab/3a/0316b28d0761c6734d6bc14e770d85506c986c85ffb239e688eeaab2c2bc/rich-13.9.4.tar.gz", hash = "sha256:439594978a49a09530cff7ebc4b5c7103ef57baf48d5ea3184f21d9a2befa098", size = 223149 } 725 | wheels = [ 726 | { url = "https://files.pythonhosted.org/packages/19/71/39c7c0d87f8d4e6c020a393182060eaefeeae6c01dab6a84ec346f2567df/rich-13.9.4-py3-none-any.whl", hash = "sha256:6049d5e6ec054bf2779ab3358186963bac2ea89175919d699e378b99738c2a90", size = 242424 }, 727 | ] 728 | 729 | [[package]] 730 | name = "ruff" 731 | version = "0.8.4" 732 | source = { registry = "https://pypi.org/simple" } 733 | sdist = { url = "https://files.pythonhosted.org/packages/34/37/9c02181ef38d55b77d97c68b78e705fd14c0de0e5d085202bb2b52ce5be9/ruff-0.8.4.tar.gz", hash = "sha256:0d5f89f254836799af1615798caa5f80b7f935d7a670fad66c5007928e57ace8", size = 3402103 } 734 | wheels = [ 735 | { url = "https://files.pythonhosted.org/packages/05/67/f480bf2f2723b2e49af38ed2be75ccdb2798fca7d56279b585c8f553aaab/ruff-0.8.4-py3-none-linux_armv6l.whl", hash = "sha256:58072f0c06080276804c6a4e21a9045a706584a958e644353603d36ca1eb8a60", size = 10546415 }, 736 | { url = "https://files.pythonhosted.org/packages/eb/7a/5aba20312c73f1ce61814e520d1920edf68ca3b9c507bd84d8546a8ecaa8/ruff-0.8.4-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:ffb60904651c00a1e0b8df594591770018a0f04587f7deeb3838344fe3adabac", size = 10346113 }, 737 | { url = "https://files.pythonhosted.org/packages/76/f4/c41de22b3728486f0aa95383a44c42657b2db4062f3234ca36fc8cf52d8b/ruff-0.8.4-py3-none-macosx_11_0_arm64.whl", hash = "sha256:6ddf5d654ac0d44389f6bf05cee4caeefc3132a64b58ea46738111d687352296", size = 9943564 }, 738 | { url = "https://files.pythonhosted.org/packages/0e/f0/afa0d2191af495ac82d4cbbfd7a94e3df6f62a04ca412033e073b871fc6d/ruff-0.8.4-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e248b1f0fa2749edd3350a2a342b67b43a2627434c059a063418e3d375cfe643", size = 10805522 }, 739 | { url = "https://files.pythonhosted.org/packages/12/57/5d1e9a0fd0c228e663894e8e3a8e7063e5ee90f8e8e60cf2085f362bfa1a/ruff-0.8.4-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bf197b98ed86e417412ee3b6c893f44c8864f816451441483253d5ff22c0e81e", size = 10306763 }, 740 | { url = "https://files.pythonhosted.org/packages/04/df/f069fdb02e408be8aac6853583572a2873f87f866fe8515de65873caf6b8/ruff-0.8.4-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c41319b85faa3aadd4d30cb1cffdd9ac6b89704ff79f7664b853785b48eccdf3", size = 11359574 }, 741 | { url = "https://files.pythonhosted.org/packages/d3/04/37c27494cd02e4a8315680debfc6dfabcb97e597c07cce0044db1f9dfbe2/ruff-0.8.4-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:9f8402b7c4f96463f135e936d9ab77b65711fcd5d72e5d67597b543bbb43cf3f", size = 12094851 }, 742 | { url = "https://files.pythonhosted.org/packages/81/b1/c5d7fb68506cab9832d208d03ea4668da9a9887a4a392f4f328b1bf734ad/ruff-0.8.4-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e4e56b3baa9c23d324ead112a4fdf20db9a3f8f29eeabff1355114dd96014604", size = 11655539 }, 743 | { url = "https://files.pythonhosted.org/packages/ef/38/8f8f2c8898dc8a7a49bc340cf6f00226917f0f5cb489e37075bcb2ce3671/ruff-0.8.4-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:736272574e97157f7edbbb43b1d046125fce9e7d8d583d5d65d0c9bf2c15addf", size = 12912805 }, 744 | { url = "https://files.pythonhosted.org/packages/06/dd/fa6660c279f4eb320788876d0cff4ea18d9af7d9ed7216d7bd66877468d0/ruff-0.8.4-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e5fe710ab6061592521f902fca7ebcb9fabd27bc7c57c764298b1c1f15fff720", size = 11205976 }, 745 | { url = "https://files.pythonhosted.org/packages/a8/d7/de94cc89833b5de455750686c17c9e10f4e1ab7ccdc5521b8fe911d1477e/ruff-0.8.4-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:13e9ec6d6b55f6da412d59953d65d66e760d583dd3c1c72bf1f26435b5bfdbae", size = 10792039 }, 746 | { url = "https://files.pythonhosted.org/packages/6d/15/3e4906559248bdbb74854af684314608297a05b996062c9d72e0ef7c7097/ruff-0.8.4-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:97d9aefef725348ad77d6db98b726cfdb075a40b936c7984088804dfd38268a7", size = 10400088 }, 747 | { url = "https://files.pythonhosted.org/packages/a2/21/9ed4c0e8133cb4a87a18d470f534ad1a8a66d7bec493bcb8bda2d1a5d5be/ruff-0.8.4-py3-none-musllinux_1_2_i686.whl", hash = "sha256:ab78e33325a6f5374e04c2ab924a3367d69a0da36f8c9cb6b894a62017506111", size = 10900814 }, 748 | { url = "https://files.pythonhosted.org/packages/0d/5d/122a65a18955bd9da2616b69bc839351f8baf23b2805b543aa2f0aed72b5/ruff-0.8.4-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:8ef06f66f4a05c3ddbc9121a8b0cecccd92c5bf3dd43b5472ffe40b8ca10f0f8", size = 11268828 }, 749 | { url = "https://files.pythonhosted.org/packages/43/a9/1676ee9106995381e3d34bccac5bb28df70194167337ed4854c20f27c7ba/ruff-0.8.4-py3-none-win32.whl", hash = "sha256:552fb6d861320958ca5e15f28b20a3d071aa83b93caee33a87b471f99a6c0835", size = 8805621 }, 750 | { url = "https://files.pythonhosted.org/packages/10/98/ed6b56a30ee76771c193ff7ceeaf1d2acc98d33a1a27b8479cbdb5c17a23/ruff-0.8.4-py3-none-win_amd64.whl", hash = "sha256:f21a1143776f8656d7f364bd264a9d60f01b7f52243fbe90e7670c0dfe0cf65d", size = 9660086 }, 751 | { url = "https://files.pythonhosted.org/packages/13/9f/026e18ca7d7766783d779dae5e9c656746c6ede36ef73c6d934aaf4a6dec/ruff-0.8.4-py3-none-win_arm64.whl", hash = "sha256:9183dd615d8df50defa8b1d9a074053891ba39025cf5ae88e8bcb52edcc4bf08", size = 9074500 }, 752 | ] 753 | 754 | [[package]] 755 | name = "semver" 756 | version = "3.0.2" 757 | source = { registry = "https://pypi.org/simple" } 758 | sdist = { url = "https://files.pythonhosted.org/packages/41/6c/a536cc008f38fd83b3c1b98ce19ead13b746b5588c9a0cb9dd9f6ea434bc/semver-3.0.2.tar.gz", hash = "sha256:6253adb39c70f6e51afed2fa7152bcd414c411286088fb4b9effb133885ab4cc", size = 214988 } 759 | wheels = [ 760 | { url = "https://files.pythonhosted.org/packages/9a/77/0cc7a8a3bc7e53d07e8f47f147b92b0960e902b8254859f4aee5c4d7866b/semver-3.0.2-py3-none-any.whl", hash = "sha256:b1ea4686fe70b981f85359eda33199d60c53964284e0cfb4977d243e37cf4bf4", size = 17099 }, 761 | ] 762 | 763 | [[package]] 764 | name = "shtab" 765 | version = "1.7.1" 766 | source = { registry = "https://pypi.org/simple" } 767 | sdist = { url = "https://files.pythonhosted.org/packages/a9/e4/13bf30c7c30ab86a7bc4104b1c943ff2f56c1a07c6d82a71ad034bcef1dc/shtab-1.7.1.tar.gz", hash = "sha256:4e4bcb02eeb82ec45920a5d0add92eac9c9b63b2804c9196c1f1fdc2d039243c", size = 45410 } 768 | wheels = [ 769 | { url = "https://files.pythonhosted.org/packages/e2/d1/a1d3189e7873408b9dc396aef0d7926c198b0df2aa3ddb5b539d3e89a70f/shtab-1.7.1-py3-none-any.whl", hash = "sha256:32d3d2ff9022d4c77a62492b6ec875527883891e33c6b479ba4d41a51e259983", size = 14095 }, 770 | ] 771 | 772 | [[package]] 773 | name = "six" 774 | version = "1.17.0" 775 | source = { registry = "https://pypi.org/simple" } 776 | sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031 } 777 | wheels = [ 778 | { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050 }, 779 | ] 780 | 781 | [[package]] 782 | name = "sniffio" 783 | version = "1.3.1" 784 | source = { registry = "https://pypi.org/simple" } 785 | sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372 } 786 | wheels = [ 787 | { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235 }, 788 | ] 789 | 790 | [[package]] 791 | name = "snowballstemmer" 792 | version = "2.2.0" 793 | source = { registry = "https://pypi.org/simple" } 794 | sdist = { url = "https://files.pythonhosted.org/packages/44/7b/af302bebf22c749c56c9c3e8ae13190b5b5db37a33d9068652e8f73b7089/snowballstemmer-2.2.0.tar.gz", hash = "sha256:09b16deb8547d3412ad7b590689584cd0fe25ec8db3be37788be3810cbf19cb1", size = 86699 } 795 | wheels = [ 796 | { url = "https://files.pythonhosted.org/packages/ed/dc/c02e01294f7265e63a7315fe086dd1df7dacb9f840a804da846b96d01b96/snowballstemmer-2.2.0-py2.py3-none-any.whl", hash = "sha256:c8e1716e83cc398ae16824e5572ae04e0d9fc2c6b985fb0f900f5f0c96ecba1a", size = 93002 }, 797 | ] 798 | 799 | [[package]] 800 | name = "sphinx" 801 | version = "8.1.3" 802 | source = { registry = "https://pypi.org/simple" } 803 | dependencies = [ 804 | { name = "alabaster" }, 805 | { name = "babel" }, 806 | { name = "colorama", marker = "sys_platform == 'win32'" }, 807 | { name = "docutils" }, 808 | { name = "imagesize" }, 809 | { name = "jinja2" }, 810 | { name = "packaging" }, 811 | { name = "pygments" }, 812 | { name = "requests" }, 813 | { name = "snowballstemmer" }, 814 | { name = "sphinxcontrib-applehelp" }, 815 | { name = "sphinxcontrib-devhelp" }, 816 | { name = "sphinxcontrib-htmlhelp" }, 817 | { name = "sphinxcontrib-jsmath" }, 818 | { name = "sphinxcontrib-qthelp" }, 819 | { name = "sphinxcontrib-serializinghtml" }, 820 | ] 821 | sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/be0b61178fe2cdcb67e2a92fc9ebb488e3c51c4f74a36a7824c0adf23425/sphinx-8.1.3.tar.gz", hash = "sha256:43c1911eecb0d3e161ad78611bc905d1ad0e523e4ddc202a58a821773dc4c927", size = 8184611 } 822 | wheels = [ 823 | { url = "https://files.pythonhosted.org/packages/26/60/1ddff83a56d33aaf6f10ec8ce84b4c007d9368b21008876fceda7e7381ef/sphinx-8.1.3-py3-none-any.whl", hash = "sha256:09719015511837b76bf6e03e42eb7595ac8c2e41eeb9c29c5b755c6b677992a2", size = 3487125 }, 824 | ] 825 | 826 | [[package]] 827 | name = "sphinx-rtd-theme" 828 | version = "3.0.2" 829 | source = { registry = "https://pypi.org/simple" } 830 | dependencies = [ 831 | { name = "docutils" }, 832 | { name = "sphinx" }, 833 | { name = "sphinxcontrib-jquery" }, 834 | ] 835 | sdist = { url = "https://files.pythonhosted.org/packages/91/44/c97faec644d29a5ceddd3020ae2edffa69e7d00054a8c7a6021e82f20335/sphinx_rtd_theme-3.0.2.tar.gz", hash = "sha256:b7457bc25dda723b20b086a670b9953c859eab60a2a03ee8eb2bb23e176e5f85", size = 7620463 } 836 | wheels = [ 837 | { url = "https://files.pythonhosted.org/packages/85/77/46e3bac77b82b4df5bb5b61f2de98637724f246b4966cfc34bc5895d852a/sphinx_rtd_theme-3.0.2-py2.py3-none-any.whl", hash = "sha256:422ccc750c3a3a311de4ae327e82affdaf59eb695ba4936538552f3b00f4ee13", size = 7655561 }, 838 | ] 839 | 840 | [[package]] 841 | name = "sphinxcontrib-applehelp" 842 | version = "2.0.0" 843 | source = { registry = "https://pypi.org/simple" } 844 | sdist = { url = "https://files.pythonhosted.org/packages/ba/6e/b837e84a1a704953c62ef8776d45c3e8d759876b4a84fe14eba2859106fe/sphinxcontrib_applehelp-2.0.0.tar.gz", hash = "sha256:2f29ef331735ce958efa4734873f084941970894c6090408b079c61b2e1c06d1", size = 20053 } 845 | wheels = [ 846 | { url = "https://files.pythonhosted.org/packages/5d/85/9ebeae2f76e9e77b952f4b274c27238156eae7979c5421fba91a28f4970d/sphinxcontrib_applehelp-2.0.0-py3-none-any.whl", hash = "sha256:4cd3f0ec4ac5dd9c17ec65e9ab272c9b867ea77425228e68ecf08d6b28ddbdb5", size = 119300 }, 847 | ] 848 | 849 | [[package]] 850 | name = "sphinxcontrib-devhelp" 851 | version = "2.0.0" 852 | source = { registry = "https://pypi.org/simple" } 853 | sdist = { url = "https://files.pythonhosted.org/packages/f6/d2/5beee64d3e4e747f316bae86b55943f51e82bb86ecd325883ef65741e7da/sphinxcontrib_devhelp-2.0.0.tar.gz", hash = "sha256:411f5d96d445d1d73bb5d52133377b4248ec79db5c793ce7dbe59e074b4dd1ad", size = 12967 } 854 | wheels = [ 855 | { url = "https://files.pythonhosted.org/packages/35/7a/987e583882f985fe4d7323774889ec58049171828b58c2217e7f79cdf44e/sphinxcontrib_devhelp-2.0.0-py3-none-any.whl", hash = "sha256:aefb8b83854e4b0998877524d1029fd3e6879210422ee3780459e28a1f03a8a2", size = 82530 }, 856 | ] 857 | 858 | [[package]] 859 | name = "sphinxcontrib-htmlhelp" 860 | version = "2.1.0" 861 | source = { registry = "https://pypi.org/simple" } 862 | sdist = { url = "https://files.pythonhosted.org/packages/43/93/983afd9aa001e5201eab16b5a444ed5b9b0a7a010541e0ddfbbfd0b2470c/sphinxcontrib_htmlhelp-2.1.0.tar.gz", hash = "sha256:c9e2916ace8aad64cc13a0d233ee22317f2b9025b9cf3295249fa985cc7082e9", size = 22617 } 863 | wheels = [ 864 | { url = "https://files.pythonhosted.org/packages/0a/7b/18a8c0bcec9182c05a0b3ec2a776bba4ead82750a55ff798e8d406dae604/sphinxcontrib_htmlhelp-2.1.0-py3-none-any.whl", hash = "sha256:166759820b47002d22914d64a075ce08f4c46818e17cfc9470a9786b759b19f8", size = 98705 }, 865 | ] 866 | 867 | [[package]] 868 | name = "sphinxcontrib-jquery" 869 | version = "4.1" 870 | source = { registry = "https://pypi.org/simple" } 871 | dependencies = [ 872 | { name = "sphinx" }, 873 | ] 874 | sdist = { url = "https://files.pythonhosted.org/packages/de/f3/aa67467e051df70a6330fe7770894b3e4f09436dea6881ae0b4f3d87cad8/sphinxcontrib-jquery-4.1.tar.gz", hash = "sha256:1620739f04e36a2c779f1a131a2dfd49b2fd07351bf1968ced074365933abc7a", size = 122331 } 875 | wheels = [ 876 | { url = "https://files.pythonhosted.org/packages/76/85/749bd22d1a68db7291c89e2ebca53f4306c3f205853cf31e9de279034c3c/sphinxcontrib_jquery-4.1-py2.py3-none-any.whl", hash = "sha256:f936030d7d0147dd026a4f2b5a57343d233f1fc7b363f68b3d4f1cb0993878ae", size = 121104 }, 877 | ] 878 | 879 | [[package]] 880 | name = "sphinxcontrib-jsmath" 881 | version = "1.0.1" 882 | source = { registry = "https://pypi.org/simple" } 883 | sdist = { url = "https://files.pythonhosted.org/packages/b2/e8/9ed3830aeed71f17c026a07a5097edcf44b692850ef215b161b8ad875729/sphinxcontrib-jsmath-1.0.1.tar.gz", hash = "sha256:a9925e4a4587247ed2191a22df5f6970656cb8ca2bd6284309578f2153e0c4b8", size = 5787 } 884 | wheels = [ 885 | { url = "https://files.pythonhosted.org/packages/c2/42/4c8646762ee83602e3fb3fbe774c2fac12f317deb0b5dbeeedd2d3ba4b77/sphinxcontrib_jsmath-1.0.1-py2.py3-none-any.whl", hash = "sha256:2ec2eaebfb78f3f2078e73666b1415417a116cc848b72e5172e596c871103178", size = 5071 }, 886 | ] 887 | 888 | [[package]] 889 | name = "sphinxcontrib-qthelp" 890 | version = "2.0.0" 891 | source = { registry = "https://pypi.org/simple" } 892 | sdist = { url = "https://files.pythonhosted.org/packages/68/bc/9104308fc285eb3e0b31b67688235db556cd5b0ef31d96f30e45f2e51cae/sphinxcontrib_qthelp-2.0.0.tar.gz", hash = "sha256:4fe7d0ac8fc171045be623aba3e2a8f613f8682731f9153bb2e40ece16b9bbab", size = 17165 } 893 | wheels = [ 894 | { url = "https://files.pythonhosted.org/packages/27/83/859ecdd180cacc13b1f7e857abf8582a64552ea7a061057a6c716e790fce/sphinxcontrib_qthelp-2.0.0-py3-none-any.whl", hash = "sha256:b18a828cdba941ccd6ee8445dbe72ffa3ef8cbe7505d8cd1fa0d42d3f2d5f3eb", size = 88743 }, 895 | ] 896 | 897 | [[package]] 898 | name = "sphinxcontrib-serializinghtml" 899 | version = "2.0.0" 900 | source = { registry = "https://pypi.org/simple" } 901 | sdist = { url = "https://files.pythonhosted.org/packages/3b/44/6716b257b0aa6bfd51a1b31665d1c205fb12cb5ad56de752dfa15657de2f/sphinxcontrib_serializinghtml-2.0.0.tar.gz", hash = "sha256:e9d912827f872c029017a53f0ef2180b327c3f7fd23c87229f7a8e8b70031d4d", size = 16080 } 902 | wheels = [ 903 | { url = "https://files.pythonhosted.org/packages/52/a7/d2782e4e3f77c8450f727ba74a8f12756d5ba823d81b941f1b04da9d033a/sphinxcontrib_serializinghtml-2.0.0-py3-none-any.whl", hash = "sha256:6e2cb0eef194e10c27ec0023bfeb25badbbb5868244cf5bc5bdc04e4464bf331", size = 92072 }, 904 | ] 905 | 906 | [[package]] 907 | name = "tomlkit" 908 | version = "0.13.2" 909 | source = { registry = "https://pypi.org/simple" } 910 | sdist = { url = "https://files.pythonhosted.org/packages/b1/09/a439bec5888f00a54b8b9f05fa94d7f901d6735ef4e55dcec9bc37b5d8fa/tomlkit-0.13.2.tar.gz", hash = "sha256:fff5fe59a87295b278abd31bec92c15d9bc4a06885ab12bcea52c71119392e79", size = 192885 } 911 | wheels = [ 912 | { url = "https://files.pythonhosted.org/packages/f9/b6/a447b5e4ec71e13871be01ba81f5dfc9d0af7e473da256ff46bc0e24026f/tomlkit-0.13.2-py3-none-any.whl", hash = "sha256:7a974427f6e119197f670fbbbeae7bef749a6c14e793db934baefc1b5f03efde", size = 37955 }, 913 | ] 914 | 915 | [[package]] 916 | name = "tox" 917 | version = "4.24.1" 918 | source = { registry = "https://pypi.org/simple" } 919 | dependencies = [ 920 | { name = "cachetools" }, 921 | { name = "chardet" }, 922 | { name = "colorama" }, 923 | { name = "filelock" }, 924 | { name = "packaging" }, 925 | { name = "platformdirs" }, 926 | { name = "pluggy" }, 927 | { name = "pyproject-api" }, 928 | { name = "virtualenv" }, 929 | ] 930 | sdist = { url = "https://files.pythonhosted.org/packages/cf/7b/97f757e159983737bdd8fb513f4c263cd411a846684814ed5433434a1fa9/tox-4.24.1.tar.gz", hash = "sha256:083a720adbc6166fff0b7d1df9d154f9d00bfccb9403b8abf6bc0ee435d6a62e", size = 194742 } 931 | wheels = [ 932 | { url = "https://files.pythonhosted.org/packages/ab/04/b0d1c1b44c98583cab9eabb4acdba964fdf6b6c597c53cfb8870fd08cbbf/tox-4.24.1-py3-none-any.whl", hash = "sha256:57ba7df7d199002c6df8c2db9e6484f3de6ca8f42013c083ea2d4d1e5c6bdc75", size = 171829 }, 933 | ] 934 | 935 | [[package]] 936 | name = "tox-uv" 937 | version = "1.22.1" 938 | source = { registry = "https://pypi.org/simple" } 939 | dependencies = [ 940 | { name = "packaging" }, 941 | { name = "tox" }, 942 | { name = "uv" }, 943 | ] 944 | sdist = { url = "https://files.pythonhosted.org/packages/cb/ed/50e143cb7adabf2f777be639cb8fabec88c4bc4c896e805461c842765c00/tox_uv-1.22.1.tar.gz", hash = "sha256:1267e09b533ce41e86bf8335d7a60f381a9779fc72c6f45fded0337bc8348812", size = 19235 } 945 | wheels = [ 946 | { url = "https://files.pythonhosted.org/packages/ae/75/ce3617b183518f0b3f5fb8ebb6f265ea8bbf309678dc09e9025863e539eb/tox_uv-1.22.1-py3-none-any.whl", hash = "sha256:f326d2b06ec0e40fc035f7c27f9bfac41a3d5d9df18056552700acfe73a26146", size = 14783 }, 947 | ] 948 | 949 | [[package]] 950 | name = "typing-extensions" 951 | version = "4.12.2" 952 | source = { registry = "https://pypi.org/simple" } 953 | sdist = { url = "https://files.pythonhosted.org/packages/df/db/f35a00659bc03fec321ba8bce9420de607a1d37f8342eee1863174c69557/typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8", size = 85321 } 954 | wheels = [ 955 | { url = "https://files.pythonhosted.org/packages/26/9f/ad63fc0248c5379346306f8668cda6e2e2e9c95e01216d2b8ffd9ff037d0/typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d", size = 37438 }, 956 | ] 957 | 958 | [[package]] 959 | name = "urllib3" 960 | version = "2.3.0" 961 | source = { registry = "https://pypi.org/simple" } 962 | sdist = { url = "https://files.pythonhosted.org/packages/aa/63/e53da845320b757bf29ef6a9062f5c669fe997973f966045cb019c3f4b66/urllib3-2.3.0.tar.gz", hash = "sha256:f8c5449b3cf0861679ce7e0503c7b44b5ec981bec0d1d3795a07f1ba96f0204d", size = 307268 } 963 | wheels = [ 964 | { url = "https://files.pythonhosted.org/packages/c8/19/4ec628951a74043532ca2cf5d97b7b14863931476d117c471e8e2b1eb39f/urllib3-2.3.0-py3-none-any.whl", hash = "sha256:1cee9ad369867bfdbbb48b7dd50374c0967a0bb7710050facf0dd6911440e3df", size = 128369 }, 965 | ] 966 | 967 | [[package]] 968 | name = "uv" 969 | version = "0.5.29" 970 | source = { registry = "https://pypi.org/simple" } 971 | sdist = { url = "https://files.pythonhosted.org/packages/66/7c/1b7673d336f6d5f5d8ce568638cf9961303322a35d3d491ab3eb368302ef/uv-0.5.29.tar.gz", hash = "sha256:05b6c8132b4054a83596aa0d85720649c6c8029188ea03f014c4bcfa77003c74", size = 2726211 } 972 | wheels = [ 973 | { url = "https://files.pythonhosted.org/packages/2e/4a/db8f6f994fdfcf2239f5c03c783a90ac916ef233611389f12f2c3410b1a8/uv-0.5.29-py3-none-linux_armv6l.whl", hash = "sha256:9f5fc05f3848e16a90fc9cebe2897d3d4de42f8cf2ec312b4efef45a520d54e9", size = 15399105 }, 974 | { url = "https://files.pythonhosted.org/packages/e7/f7/a2d80425b22b424de0d363b0dabc137446d46297a1f7e549d7683739bc38/uv-0.5.29-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:b307438a5e2df313a9ea5446d6e5af99a9b57a363fc5e68a434ef2d89cde083b", size = 15612341 }, 975 | { url = "https://files.pythonhosted.org/packages/0b/9e/fcdd09fff5372322ee8d1d17a1bc4ea63808d1b026b4508b9ecbbdc9cc95/uv-0.5.29-py3-none-macosx_11_0_arm64.whl", hash = "sha256:25f12457e0898313aed2705effb53118af542bd9825a4de2214a324ddd9bf8d7", size = 14489154 }, 976 | { url = "https://files.pythonhosted.org/packages/6b/9e/0f095eb42a647b88604675e26ff15adb95ec2de8e0023cc44ce75408b91e/uv-0.5.29-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:345f14af3944b67f1622b080fc037fa1276f921b1a8ffbe19d4c5b5e9a19a3b0", size = 14919084 }, 977 | { url = "https://files.pythonhosted.org/packages/31/a5/09fdedd70882683a03bd30bb577aa7b956eac2585196d1ee148d9102d418/uv-0.5.29-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8fe93da5e7a087607396f905bf7d704e9a2073245a357871484c9281dc969be9", size = 15154592 }, 978 | { url = "https://files.pythonhosted.org/packages/4e/ab/ba7c24caadba6498b09f8c715b8601fc53436ef8d0b9d03f777be17c7d43/uv-0.5.29-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e5165111121acb6b4924b0b2e740115706fb9ecfd3335def7c5afa8cce307996", size = 15861185 }, 979 | { url = "https://files.pythonhosted.org/packages/2c/09/110089bbb381a9dc6f0ca3b27a98705ec3af6bfd07aa102af6342fdfe26c/uv-0.5.29-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:6fbd1354d15fadff723b1eed171dab917dffa81091c12d5aedd6ff87b72f95df", size = 16869327 }, 980 | { url = "https://files.pythonhosted.org/packages/2c/4a/e4d6bea2ac417557da5a6cd4b77f6ff15fbcaacc614fdf666d060f4948a2/uv-0.5.29-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:aeb4a5698d1f09e8eab2495f77fc5fba25876b749d2dbef2f9e196f2471f86ba", size = 16576955 }, 981 | { url = "https://files.pythonhosted.org/packages/a7/55/6bfe17bf7c65c6e89200f1971ea3697e6795bb9b8b2b658b95e40b265371/uv-0.5.29-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1631bd3269149501e851d2120a77c32430709540d44e24c9e82de1fe5ee71057", size = 20936345 }, 982 | { url = "https://files.pythonhosted.org/packages/9f/e6/8f3889348a753ec986eafbadd45e4587a119ebdf35f659e5e8478a46e0dd/uv-0.5.29-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:02ceda14892816f7db3d7d1a4a69b299817b54749036af1125ec73d8f570b52a", size = 16253140 }, 983 | { url = "https://files.pythonhosted.org/packages/df/0e/d7b3d93dc9668fcd0e79e434888d77d5b7713d76b530f304032fce62b525/uv-0.5.29-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:ea6b05abfc025cb42ec27c9c8ac738909b1229e271b421f0c339eecc61df13a6", size = 15191674 }, 984 | { url = "https://files.pythonhosted.org/packages/c2/0f/06d0cee28a2b28a55a3a9a0d112f0ecf883590b92ed843a4d7a27a7ff686/uv-0.5.29-py3-none-musllinux_1_1_armv7l.whl", hash = "sha256:0e4fc5cc036afdccd8b539f826e8c4bac7acf51436c6216e81895ce5532141ac", size = 15138403 }, 985 | { url = "https://files.pythonhosted.org/packages/cc/6a/775df99fb7ab996db13965cc4c0c4fbed3bee73f890b043e7f66f5263bf7/uv-0.5.29-py3-none-musllinux_1_1_i686.whl", hash = "sha256:49f1bb38033ca49bb73cc33de06eff537b8a25cd201a29a4a4c2559535356907", size = 15547352 }, 986 | { url = "https://files.pythonhosted.org/packages/82/0f/365bfd0fa53ab04ec8258abf0a552bcdc7f3827ffc3ffac8679ea558f7ed/uv-0.5.29-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:ba16016199938f44b16ee74941bb7d95eb8e84301db7c7aad9d1f4861bb10b1c", size = 16401513 }, 987 | { url = "https://files.pythonhosted.org/packages/56/83/49ef68d21f9924b6efadbd68bece223d238a32aef939d382a3bde9fdb3b4/uv-0.5.29-py3-none-win32.whl", hash = "sha256:25e7f1850a71846b52aa8ed58640aa2082e16bc84995e8ff438e4bb916968159", size = 15571908 }, 988 | { url = "https://files.pythonhosted.org/packages/a1/25/304b5b400e612c9262fb5d06568ec0c813f40afe10908faeb9bafbf7d0f6/uv-0.5.29-py3-none-win_amd64.whl", hash = "sha256:d19ecc416fc069fbf767b2fd022789b2d93832b8d45702e34daf714dea1e5851", size = 16923471 }, 989 | { url = "https://files.pythonhosted.org/packages/94/5e/0132a00365066b058d096c008f8ef7dc36bb00a5676efae97dc919669c78/uv-0.5.29-py3-none-win_arm64.whl", hash = "sha256:e8a5e18487c33a0c29867da123ef0f035ee1ba880640fcbf8743ca80d7158ed0", size = 15693104 }, 990 | ] 991 | 992 | [[package]] 993 | name = "virtualenv" 994 | version = "20.28.0" 995 | source = { registry = "https://pypi.org/simple" } 996 | dependencies = [ 997 | { name = "distlib" }, 998 | { name = "filelock" }, 999 | { name = "platformdirs" }, 1000 | ] 1001 | sdist = { url = "https://files.pythonhosted.org/packages/bf/75/53316a5a8050069228a2f6d11f32046cfa94fbb6cc3f08703f59b873de2e/virtualenv-20.28.0.tar.gz", hash = "sha256:2c9c3262bb8e7b87ea801d715fae4495e6032450c71d2309be9550e7364049aa", size = 7650368 } 1002 | wheels = [ 1003 | { url = "https://files.pythonhosted.org/packages/10/f9/0919cf6f1432a8c4baa62511f8f8da8225432d22e83e3476f5be1a1edc6e/virtualenv-20.28.0-py3-none-any.whl", hash = "sha256:23eae1b4516ecd610481eda647f3a7c09aea295055337331bb4e6892ecce47b0", size = 4276702 }, 1004 | ] 1005 | --------------------------------------------------------------------------------