├── .github └── workflows │ ├── publish-to-pypi.yml │ └── testing-and-quality.yml ├── .gitignore ├── .pre-commit-config.yaml ├── README.md ├── entitled ├── __init__.py ├── client.py ├── exceptions.py ├── policies.py ├── py.typed ├── response.py └── rules.py ├── example ├── pyproject.toml └── src │ ├── __init__.py │ ├── main.py │ ├── models.py │ └── policies │ └── list_policy.py ├── pyproject.toml ├── tests ├── __init__.py ├── conftest.py ├── data │ ├── __init__.py │ ├── factories.py │ ├── models.py │ └── policies.py ├── fixtures.py ├── test_client.py ├── test_policies.py └── test_rules.py └── uv.lock /.github/workflows/publish-to-pypi.yml: -------------------------------------------------------------------------------- 1 | name: Publish Python Package 2 | 3 | on: 4 | release: 5 | types: [published] 6 | 7 | permissions: 8 | contents: read 9 | 10 | jobs: 11 | publish: 12 | name: Build and publish package 13 | strategy: 14 | fail-fast: false 15 | matrix: 16 | os: [ubuntu-latest] 17 | runs-on: ${{ matrix.os }} 18 | environment: release 19 | permissions: 20 | id-token: write 21 | steps: 22 | - uses: actions/checkout@v4 23 | - name: Install uv 24 | uses: astral-sh/setup-uv@v5 25 | with: 26 | version: "0.6.11" 27 | python-version-file: "pyproject.toml" 28 | - name: Building package distribution 29 | run: uv build 30 | - name: Publish to PyPI 31 | uses: pypa/gh-action-pypi-publish@release/v1 32 | -------------------------------------------------------------------------------- /.github/workflows/testing-and-quality.yml: -------------------------------------------------------------------------------- 1 | name: Testing and quality control 2 | 3 | on: 4 | push: 5 | pull_request: 6 | 7 | permissions: 8 | contents: read 9 | 10 | jobs: 11 | code-quality: 12 | name: "Code QA" 13 | strategy: 14 | fail-fast: false 15 | matrix: 16 | os: [ubuntu-latest] 17 | runs-on: ${{ matrix.os }} 18 | steps: 19 | - uses: actions/checkout@v4 20 | - name: Install uv 21 | uses: astral-sh/setup-uv@v5 22 | with: 23 | version: "0.6.11" 24 | python-version-file: "pyproject.toml" 25 | - name: Install dependencies 26 | run: uv sync --all-extras --dev 27 | - name: Run ruff linter 28 | run: uv run ruff check entitled 29 | - name: Run ruff formater 30 | run: uv run ruff format entitled 31 | - name: Run bandit 32 | run: uv run bandit entitled 33 | 34 | test: 35 | name: "Run test suite" 36 | strategy: 37 | fail-fast: false 38 | matrix: 39 | os: [ubuntu-latest] 40 | runs-on: ${{ matrix.os }} 41 | steps: 42 | - uses: actions/checkout@v4 43 | - name: Install uv 44 | uses: astral-sh/setup-uv@v5 45 | with: 46 | version: "0.6.11" 47 | python-version-file: "pyproject.toml" 48 | - name: Install dependencies 49 | run: uv sync --all-extras --dev 50 | - name: Run tests 51 | run: uv run pytest --cov=entitled --cov-report=xml tests/ 52 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__ 2 | .mypy-cache 3 | *.pyc 4 | .python-version 5 | poetry.lock 6 | .coverage 7 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | # See https://pre-commit.com for more information 2 | # See https://pre-commit.com/hooks.html for more hooks 3 | repos: 4 | - repo: https://github.com/pre-commit/pre-commit-hooks 5 | rev: v3.2.0 6 | hooks: 7 | - id: trailing-whitespace 8 | - id: end-of-file-fixer 9 | - id: check-toml 10 | - id: check-merge-conflict 11 | - id: check-yaml 12 | - id: check-added-large-files 13 | 14 | - repo: https://github.com/psf/black 15 | rev: 24.1.0 16 | hooks: 17 | - id: black 18 | 19 | - repo: https://github.com/pycqa/isort 20 | rev: 5.13.2 21 | hooks: 22 | - id: isort 23 | 24 | - repo: https://github.com/pre-commit/mirrors-mypy 25 | rev: v1.9.0 26 | hooks: 27 | - id: mypy 28 | 29 | - repo: local 30 | hooks: 31 | - id: pylint 32 | exclude: ^tests 33 | name: pylint 34 | entry: pylint 35 | language: system 36 | types: [python] 37 | args: 38 | [ 39 | "--rcfile=pyproject.toml", 40 | ] 41 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![versions](https://img.shields.io/badge/python-3.12-blue.svg) 2 | [![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/psf/black) 3 | 4 | An authorization library for Python. 5 | 6 | Aims to provide the tools to organize, enforce and audit your authorization layer as easily as possible, letting you focus on the essential part: the actual rules. 7 | 8 | ## Documentation 9 | 10 | For the full documentation, go [here](https://python-entitled.xefi.com/) 11 | 12 | ## A sneak peek... 13 | 14 | ```py 15 | from entitled import Policy, Client 16 | 17 | # Some actors and resources of your application... 18 | class User: 19 | def __init__(self, id: int, role: str): 20 | self.id: str = id 21 | self.role: str = role 22 | 23 | class Resource: 24 | def __init__(self, id: int, user: User): 25 | self.id: str = id 26 | self.owner: User = user 27 | 28 | my_policy = Policy[Resource]("resource") # Defining a policy for your resource 29 | 30 | @my_policy.rule("edit")# Declaring a rule on the resource 31 | def can_edit( 32 | actor: User, resource: Resource, context = None 33 | ) -> bool: 34 | return actor == resource.owner or actor.role == "admin" 35 | 36 | client = Client() 37 | client.register(my_policy) # Registering a policy 38 | 39 | user1 = User(1, "user") 40 | resource1 = Resource(1, user1) 41 | if client.allows("edit", user1, resource1): # Using the client to make auth decisions 42 | ... 43 | ``` 44 | ## Support us 45 | 46 |

47 | 48 | Since 1997, XEFI is a leader in IT performance support for small and medium-sized businesses through its nearly 200 local agencies based in France, Belgium, Switzerland and Spain. 49 | A one-stop shop for IT, office automation, software, [digitalization](https://www.xefi.com/solutions-software/), print and cloud needs. 50 | [Want to work with us ?](https://carriere.xefi.fr/metiers-software) 51 | -------------------------------------------------------------------------------- /entitled/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xefi/python-entitled/2c734abfb0c1dfd21cff29db3249ee09a6f9ce8c/entitled/__init__.py -------------------------------------------------------------------------------- /entitled/client.py: -------------------------------------------------------------------------------- 1 | from importlib import util 2 | from pathlib import Path 3 | import types 4 | from typing import Any, Literal 5 | 6 | from entitled.exceptions import AuthorizationException 7 | from entitled.policies import Policy 8 | from entitled.response import Err, Response 9 | 10 | 11 | class Client: 12 | load_path: Path | None = None 13 | _policy_registry: dict[type, Policy[Any]] 14 | 15 | def __init__(self, path: str | None = None): 16 | self._policy_registry = dict() 17 | if path is not None: 18 | self.load_path = Path(path) 19 | self.load_policies() 20 | 21 | def register_policy(self, policy: Policy[Any]): 22 | resource_type = getattr(policy, "__orig_class__").__args__[0] 23 | self._policy_registry[resource_type] = policy 24 | 25 | def load_policies(self, path: Path | None = None): 26 | path = path if path is not None else self.load_path 27 | if path is None: 28 | return 29 | for file in path.glob("*.py"): 30 | mod_name = file.stem 31 | full_mod_name = ".".join(file.parts[:-1] + (mod_name,)) 32 | spec = util.spec_from_file_location(full_mod_name, file) 33 | if spec is not None: 34 | mod = util.module_from_spec(spec) 35 | if spec.loader: 36 | try: 37 | spec.loader.exec_module(mod) 38 | self._register_from_module(mod) 39 | except Exception as e: 40 | raise e 41 | 42 | def _register_from_module(self, mod: types.ModuleType): 43 | for attr_name in dir(mod): 44 | attr = getattr(mod, attr_name) 45 | if isinstance(attr, Policy): 46 | try: 47 | self.register_policy(attr) 48 | except (ValueError, AttributeError): 49 | pass 50 | 51 | def _resolve_policy(self, resource: Any) -> Policy[Any] | None: 52 | lookup_key = resource if isinstance(resource, type) else type(resource) 53 | policy = self._policy_registry.get(lookup_key, None) 54 | return policy 55 | 56 | async def inspect( 57 | self, 58 | name: str, 59 | actor: Any, 60 | resource: Any, 61 | *args: Any, 62 | **kwargs: Any, 63 | ) -> Response: 64 | policy = self._resolve_policy(resource) 65 | if policy is not None: 66 | return await policy.inspect(name, actor, resource, *args, **kwargs) 67 | return Err(f"No policy found with name '{name}'") 68 | 69 | async def allows( 70 | self, 71 | name: str, 72 | actor: Any, 73 | resource: Any, 74 | *args: Any, 75 | **kwargs: Any, 76 | ) -> bool: 77 | return (await self.inspect(name, actor, resource, *args, **kwargs)).allowed() 78 | 79 | async def denies( 80 | self, 81 | name: str, 82 | actor: Any, 83 | resource: Any, 84 | *args: Any, 85 | **kwargs: Any, 86 | ) -> bool: 87 | return not await self.allows(name, actor, resource, *args, **kwargs) 88 | 89 | async def authorize( 90 | self, 91 | name: str, 92 | actor: Any, 93 | resource: Any, 94 | *args: Any, 95 | **kwargs: Any, 96 | ) -> Literal[True]: 97 | result = await self.inspect(name, actor, resource, *args, **kwargs) 98 | if not result.allowed(): 99 | raise AuthorizationException(result.message()) 100 | return True 101 | 102 | async def grants( 103 | self, 104 | actor: Any, 105 | resource: Any, 106 | *args: Any, 107 | **kwargs: Any, 108 | ) -> dict[str, bool]: 109 | policy = self._resolve_policy(resource) 110 | if policy is None: 111 | return {} 112 | return await policy.grants(actor, resource, *args, **kwargs) 113 | -------------------------------------------------------------------------------- /entitled/exceptions.py: -------------------------------------------------------------------------------- 1 | """Authorization related exceptions""" 2 | 3 | 4 | class AuthorizationException(Exception): 5 | """Raised when an authorization is denied""" 6 | 7 | 8 | class UndefinedAction(AuthorizationException): 9 | """Raised when attempting to authorize an undefined action""" 10 | -------------------------------------------------------------------------------- /entitled/policies.py: -------------------------------------------------------------------------------- 1 | """Grouping of authorization rules around a particular resource type""" 2 | 3 | from typing import Any, TypeVar 4 | 5 | from entitled import exceptions 6 | from entitled.response import Err, Response 7 | from entitled.rules import Rule, RuleProto 8 | 9 | T = TypeVar("T") 10 | 11 | 12 | class Policy[T]: 13 | """A grouping of rules refering the given resource type.""" 14 | 15 | _registry: dict[str, Rule[Any]] 16 | 17 | def __init__( 18 | self, 19 | rules: dict[str, Rule[Any]] | None = None, 20 | ): 21 | self._registry = {} 22 | 23 | if not rules: 24 | rules = {} 25 | 26 | for action, rule in rules.items(): 27 | self.register(action, *rule) 28 | 29 | def rule(self, func: RuleProto[Any]): 30 | rule_name = f"{func.__name__}" 31 | new_rule = Rule[T](rule_name, func) 32 | self.register(rule_name, new_rule) 33 | return func 34 | 35 | def register(self, action: str, rule: Rule[T]): 36 | self._registry[action] = rule 37 | 38 | async def inspect( 39 | self, 40 | action: str, 41 | actor: Any, 42 | *args: Any, 43 | **kwargs: Any, 44 | ) -> Response: 45 | if action not in self._registry: 46 | return Err(f"Action <{action}> undefined for this policy") 47 | return await self._registry[action].inspect(actor, *args, **kwargs) 48 | 49 | async def allows( 50 | self, 51 | action: str, 52 | actor: Any, 53 | *args: Any, 54 | **kwargs: Any, 55 | ) -> bool: 56 | return (await self.inspect(action, actor, *args, **kwargs)).allowed() 57 | 58 | async def denies( 59 | self, 60 | action: str, 61 | actor: Any, 62 | *args: Any, 63 | **kwargs: Any, 64 | ) -> bool: 65 | return not (await self.inspect(action, actor, *args, **kwargs)).allowed() 66 | 67 | async def authorize( 68 | self, 69 | action: str, 70 | actor: Any, 71 | *args: Any, 72 | **kwargs: Any, 73 | ) -> bool: 74 | res = await self.inspect(action, actor, *args, **kwargs) 75 | if not res.allowed(): 76 | raise exceptions.AuthorizationException(res.message()) 77 | return True 78 | 79 | async def grants( 80 | self, 81 | actor: Any, 82 | *args: Any, 83 | **kwargs: Any, 84 | ) -> dict[str, bool]: 85 | return { 86 | action: await self.allows( 87 | action, 88 | actor, 89 | *args, 90 | **kwargs, 91 | ) 92 | for action in self._registry 93 | } 94 | -------------------------------------------------------------------------------- /entitled/py.typed: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xefi/python-entitled/2c734abfb0c1dfd21cff29db3249ee09a6f9ce8c/entitled/py.typed -------------------------------------------------------------------------------- /entitled/response.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | from typing import TypeVar 3 | 4 | T = TypeVar("T") 5 | 6 | 7 | @dataclass(frozen=True) 8 | class Ok: 9 | def allowed(self): 10 | return True 11 | 12 | def message(self): 13 | return None 14 | 15 | 16 | @dataclass(frozen=True) 17 | class Err: 18 | msg: str = "" 19 | 20 | def allowed(self): 21 | return False 22 | 23 | def message(self): 24 | return self.msg 25 | 26 | 27 | Response = Ok | Err 28 | -------------------------------------------------------------------------------- /entitled/rules.py: -------------------------------------------------------------------------------- 1 | import inspect 2 | from typing import Any, Literal, Protocol, TypeVar 3 | 4 | from entitled.exceptions import AuthorizationException 5 | from entitled.response import Err, Ok, Response 6 | 7 | 8 | Actor = TypeVar("Actor", contravariant=True) 9 | 10 | 11 | class RuleProto(Protocol[Actor]): 12 | async def __call__( 13 | self, actor: Actor, *args: Any, **kwargs: Any 14 | ) -> Response | bool: ... 15 | 16 | 17 | class Rule[Actor]: 18 | name: str 19 | callable: RuleProto[Actor] 20 | 21 | def __init__(self, name: str, callable: RuleProto[Actor]) -> None: 22 | self.name = name 23 | self.callable = callable 24 | 25 | async def __call__( 26 | self, 27 | actor: Actor, 28 | *args: Any, 29 | **kwargs: Any, 30 | ) -> Response | bool: 31 | sig = inspect.signature(self.callable) 32 | args_count = len( 33 | [ 34 | p 35 | for p in sig.parameters.values() 36 | if p.kind in (p.POSITIONAL_ONLY, p.POSITIONAL_OR_KEYWORD) 37 | ] 38 | ) 39 | valid_positionals = (actor,) + args[: args_count - 1] 40 | valid_kwargs = { 41 | k: v 42 | for k, v in kwargs.items() 43 | if k in sig.parameters 44 | and sig.parameters[k].kind 45 | in (inspect.Parameter.KEYWORD_ONLY, inspect.Parameter.POSITIONAL_OR_KEYWORD) 46 | } 47 | bound = sig.bind_partial(*valid_positionals, **valid_kwargs) 48 | 49 | return await self.callable(*bound.args, **bound.kwargs) 50 | 51 | async def inspect( 52 | self, 53 | actor: Actor, 54 | *args: Any, 55 | **kwargs: Any, 56 | ) -> Response: 57 | result = await self(actor, *args, **kwargs) 58 | match result: 59 | case True: 60 | return Ok() 61 | case False: 62 | return Err("Unauthorized") 63 | case _: 64 | return result 65 | 66 | async def allows( 67 | self, 68 | actor: Actor, 69 | *args: Any, 70 | **kwargs: Any, 71 | ) -> bool: 72 | return (await self.inspect(actor, *args, **kwargs)).allowed() 73 | 74 | async def denies( 75 | self, 76 | actor: Actor, 77 | *args: Any, 78 | **kwargs: Any, 79 | ) -> bool: 80 | return not await self.allows(actor, *args, **kwargs) 81 | 82 | async def authorize( 83 | self, 84 | actor: Actor, 85 | *args: Any, 86 | **kwargs: Any, 87 | ) -> Literal[True]: 88 | res = await self.inspect(actor, *args, **kwargs) 89 | if not res.allowed(): 90 | raise AuthorizationException(res.message()) 91 | return True 92 | -------------------------------------------------------------------------------- /example/pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "entitled-example" 3 | version = "0.1.0" 4 | description = "" 5 | authors = ["Mathias Bigaignon "] 6 | readme = "README.md" 7 | 8 | [tool.poetry.dependencies] 9 | python = "^3.12" 10 | fastapi = {extras = ["all"], version = "^0.112.2"} 11 | entitled = "^0.3.0" 12 | 13 | 14 | [build-system] 15 | requires = ["poetry-core"] 16 | build-backend = "poetry.core.masonry.api" 17 | -------------------------------------------------------------------------------- /example/src/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xefi/python-entitled/2c734abfb0c1dfd21cff29db3249ee09a6f9ce8c/example/src/__init__.py -------------------------------------------------------------------------------- /example/src/main.py: -------------------------------------------------------------------------------- 1 | import entitled 2 | from fastapi import FastAPI, HTTPException, status 3 | import pydantic 4 | 5 | from src import models 6 | 7 | 8 | users: dict[pydantic.EmailStr, models.User] = {} 9 | lists = models.ListsRegistry() 10 | 11 | auth_client = entitled.Client("entitled_example/policies") 12 | 13 | app = FastAPI() 14 | 15 | 16 | @app.post("/users", tags=["users"]) 17 | async def create_user(email: pydantic.EmailStr): 18 | if email not in users.keys(): 19 | new_user = models.User(email=email) 20 | users[email] = new_user 21 | return new_user 22 | 23 | 24 | @app.post("/lists", tags=["lists"]) 25 | async def create_list(list_title: str, current_user: str): 26 | user = users.get(current_user) 27 | print(user) 28 | if user: 29 | new_list = models.TodoList(id=lists.max_id + 1, title=list_title, owner=user) 30 | lists.add(new_list) 31 | return new_list 32 | raise HTTPException(status.HTTP_401_UNAUTHORIZED) 33 | 34 | 35 | @app.get("/lists/{id}", tags=["lists"]) 36 | async def get_list(id: int, current_user: str): 37 | user = users.get(current_user) 38 | if user is None: 39 | raise HTTPException(status.HTTP_401_UNAUTHORIZED) 40 | 41 | result = lists.get(id) 42 | 43 | if not auth_client.allows("read", user, result): 44 | raise HTTPException(status.HTTP_403_FORBIDDEN) 45 | 46 | return result 47 | 48 | 49 | @app.post("/lists/{id}/tasks", tags=["tasks"]) 50 | async def add_task(id: int, current_user: str, task_data: models.TaskCreateSchema): 51 | user = users.get(current_user) 52 | if user is None: 53 | raise HTTPException(status.HTTP_401_UNAUTHORIZED) 54 | 55 | parent_list = lists.get(id) 56 | 57 | if parent_list is None: 58 | raise HTTPException(status.HTTP_404_NOT_FOUND) 59 | 60 | if not auth_client.allows("edit", user, parent_list): 61 | raise HTTPException(status.HTTP_403_FORBIDDEN) 62 | 63 | new_task = models.Task( 64 | title=task_data.title, 65 | description=task_data.description, 66 | parent_list=parent_list, 67 | ) 68 | 69 | return new_task 70 | 71 | 72 | @app.get("/lists/{id}/permissions", tags=["lists"]) 73 | async def inspect_perms(id: int, current_user: str): 74 | user = users.get(current_user) 75 | if user is None: 76 | raise HTTPException(status.HTTP_401_UNAUTHORIZED) 77 | 78 | target_list = lists.get(id) 79 | 80 | if target_list is None: 81 | raise HTTPException(status.HTTP_404_NOT_FOUND) 82 | return auth_client.grants(user, target_list) 83 | -------------------------------------------------------------------------------- /example/src/models.py: -------------------------------------------------------------------------------- 1 | import pydantic 2 | 3 | 4 | class User(pydantic.BaseModel): 5 | email: pydantic.EmailStr 6 | 7 | 8 | class Task(pydantic.BaseModel): 9 | title: str 10 | description: str = "" 11 | done: bool = False 12 | parent_list: "TodoList" 13 | 14 | 15 | class TaskCreateSchema(pydantic.BaseModel): 16 | title: str 17 | description: str = "" 18 | 19 | 20 | class TodoList(pydantic.BaseModel): 21 | id: int 22 | title: str 23 | tasks: list[Task] = [] 24 | owner: User 25 | watchers: list[User] = [] 26 | 27 | 28 | class ListsRegistry(pydantic.BaseModel): 29 | max_id: int = 0 30 | lists: dict[int, TodoList] = {} 31 | 32 | def get(self, id: int) -> TodoList | None: 33 | return self.lists.get(id) 34 | 35 | def add(self, list: TodoList): 36 | if list.id not in self.lists.keys(): 37 | self.lists[list.id] = list 38 | self.max_id = max(self.max_id, list.id) 39 | -------------------------------------------------------------------------------- /example/src/policies/list_policy.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | 3 | import entitled 4 | 5 | from src.models import TodoList, User 6 | 7 | list_policy = entitled.Policy[TodoList]() 8 | 9 | 10 | @list_policy.rule("read") 11 | def can_see_list( 12 | actor: User, resource: TodoList, context: dict[str, Any] | None = None 13 | ) -> bool: 14 | return resource.owner == actor or actor in resource.watchers 15 | 16 | 17 | @list_policy.rule("edit") 18 | def can_edit_list( 19 | actor: User, resource: TodoList, context: dict[str, Any] | None = None 20 | ) -> bool: 21 | return resource.owner == actor 22 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "entitled" 3 | version = "1.0.0" 4 | description = "A library to make writing, enforcing and auditing your authorization policies a breeze" 5 | requires-python = ">=3.12,<3.13" 6 | authors = [{ name = "Mathias Bigaignon", email = "m.bigaignon@xefi.fr" }] 7 | readme = "README.md" 8 | dependencies = [] 9 | 10 | [dependency-groups] 11 | dev = [ 12 | "coverage == 7.6.1", 13 | "mypy == 1.11.2", 14 | "pre-commit == 3.8.0", 15 | "pytest == 8.3.3", 16 | "bandit == 1.7.10", 17 | "ruff==0.8.0", 18 | "factory-boy == 3.3.1", 19 | "pytest-cov == 5.0.0", 20 | "pydantic[email]>=2.10.6", 21 | "anyio>=4.8.0", 22 | "uvloop>=0.21.0", 23 | ] 24 | 25 | [build-system] 26 | requires = ["hatchling"] 27 | build-backend = "hatchling.build" 28 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xefi/python-entitled/2c734abfb0c1dfd21cff29db3249ee09a6f9ce8c/tests/__init__.py -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | pytest_plugins = ["tests.fixtures"] 2 | -------------------------------------------------------------------------------- /tests/data/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xefi/python-entitled/2c734abfb0c1dfd21cff29db3249ee09a6f9ce8c/tests/data/__init__.py -------------------------------------------------------------------------------- /tests/data/factories.py: -------------------------------------------------------------------------------- 1 | import factory 2 | 3 | from tests.data import models 4 | 5 | 6 | class TenantFactory(factory.Factory): 7 | class Meta: 8 | model = models.Tenant 9 | 10 | name = factory.Faker("company") 11 | 12 | 13 | class UserFactory(factory.Factory): 14 | class Meta: 15 | model = models.User 16 | 17 | name = factory.Faker("name") 18 | -------------------------------------------------------------------------------- /tests/data/models.py: -------------------------------------------------------------------------------- 1 | import dataclasses 2 | 3 | 4 | @dataclasses.dataclass 5 | class Tenant: 6 | name: str 7 | owner: "User | None" = None 8 | 9 | 10 | @dataclasses.dataclass 11 | class User: 12 | name: str 13 | tenant: Tenant | None = None 14 | roles: set[str] = dataclasses.field(default_factory=set) 15 | 16 | 17 | @dataclasses.dataclass 18 | class Resource: 19 | name: str 20 | owner: User 21 | tenant: Tenant 22 | -------------------------------------------------------------------------------- /tests/data/policies.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | from entitled.old_policies import Policy 3 | from tests.data.models import Resource, Tenant, User 4 | 5 | tenant_policy = Policy[Tenant]("tenant") 6 | 7 | 8 | @tenant_policy.rule("member") 9 | def is_member( 10 | actor: User, 11 | resource: Tenant | type[Tenant], 12 | context: dict[str, Any] | None = None, 13 | ) -> bool: 14 | return actor.tenant == resource 15 | 16 | 17 | @tenant_policy.rule("admin_role") 18 | def has_admin_role( 19 | actor: User, 20 | resource: Tenant | type[Tenant], 21 | context: dict[str, Any] | None = None, 22 | ) -> bool: 23 | return is_member(actor, resource) and "admin" in actor.roles 24 | 25 | 26 | resource_policy = Policy[Resource]("node") 27 | 28 | 29 | @resource_policy.rule("edit") 30 | def can_edit( 31 | actor: User, 32 | resource: Resource | type[Resource], 33 | context: dict[str, Any] | None = None, 34 | ) -> bool: 35 | return has_admin_role(actor, resource.tenant) or resource.owner == actor 36 | 37 | 38 | @resource_policy.rule("view") 39 | def can_view( 40 | actor: User, 41 | resource: Resource | type[Resource], 42 | context: dict[str, Any] | None = None, 43 | ) -> bool: 44 | return is_member(actor, resource.tenant) 45 | -------------------------------------------------------------------------------- /tests/fixtures.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from _pytest import fixtures 4 | 5 | pytestmark = pytest.mark.anyio 6 | 7 | 8 | @pytest.fixture( 9 | params=[pytest.param(("asyncio", {"use_uvloop": True}), id="asyncio+uvloop")], 10 | ) 11 | def anyio_backend(request: fixtures.SubRequest): 12 | return request.param 13 | -------------------------------------------------------------------------------- /tests/test_client.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from entitled.client import Client 3 | from entitled.exceptions import AuthorizationException 4 | from entitled.policies import Policy 5 | from entitled.response import Err, Ok, Response 6 | from tests.data.factories import TenantFactory, UserFactory 7 | from tests.data.models import Tenant, User 8 | 9 | pytestmark = pytest.mark.anyio 10 | 11 | client = Client() 12 | policy = Policy[Tenant]() 13 | 14 | 15 | @policy.rule 16 | async def is_member( 17 | actor: User, 18 | resource: Tenant, 19 | ) -> bool: 20 | return actor.tenant == resource 21 | 22 | 23 | @policy.rule 24 | async def is_owner( 25 | actor: User, 26 | resource: Tenant, 27 | context: str, 28 | ) -> Response: 29 | return ( 30 | Ok() 31 | if len(context) > 0 and resource.owner == actor 32 | else Err("Not owner on the tenant") 33 | ) 34 | 35 | 36 | tenant1 = TenantFactory() 37 | tenant2 = TenantFactory() 38 | user1 = UserFactory(tenant=tenant1) 39 | user2 = UserFactory(tenant=tenant2) 40 | tenant2.owner = user2 41 | client.register_policy(policy) 42 | 43 | 44 | async def test_inspect(): 45 | res = await client.inspect( 46 | "is_member", 47 | user1, 48 | tenant1, 49 | ) 50 | assert res.allowed() 51 | res = await client.inspect( 52 | "is_member", 53 | user2, 54 | tenant1, 55 | ) 56 | assert res.message() == "Unauthorized" 57 | 58 | 59 | async def test_allows(): 60 | assert await client.allows( 61 | "is_member", 62 | user1, 63 | tenant1, 64 | ) 65 | assert await client.denies( 66 | "is_member", 67 | user2, 68 | tenant1, 69 | ) 70 | 71 | 72 | async def test_authorize(): 73 | assert await client.authorize( 74 | "is_member", 75 | user1, 76 | tenant1, 77 | ) 78 | with pytest.raises(AuthorizationException): 79 | _ = await client.authorize( 80 | "is_member", 81 | user2, 82 | tenant1, 83 | ) 84 | 85 | 86 | async def test_grants(): 87 | res = await client.grants(user1, tenant1, context="ok") 88 | assert res["is_member"] 89 | assert not res["is_owner"] 90 | -------------------------------------------------------------------------------- /tests/test_policies.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from entitled.exceptions import AuthorizationException 4 | from entitled.policies import Policy 5 | from entitled.response import Err, Ok, Response 6 | from tests.data.factories import TenantFactory, UserFactory 7 | from tests.data.models import Tenant, User 8 | 9 | pytestmark = pytest.mark.anyio 10 | 11 | 12 | policy = Policy[Tenant]() 13 | 14 | 15 | @policy.rule 16 | async def is_member( 17 | actor: User, 18 | resource: Tenant, 19 | ) -> bool: 20 | return actor.tenant == resource 21 | 22 | 23 | @policy.rule 24 | async def is_owner( 25 | actor: User, 26 | resource: Tenant, 27 | context: str, 28 | ) -> Response: 29 | return ( 30 | Ok() 31 | if len(context) > 0 and resource.owner == actor 32 | else Err("Not owner on the tenant") 33 | ) 34 | 35 | 36 | tenant1 = TenantFactory() 37 | tenant2 = TenantFactory() 38 | user1 = UserFactory(tenant=tenant1) 39 | user2 = UserFactory(tenant=tenant2) 40 | tenant2.owner = user2 41 | 42 | 43 | async def test_inspect(): 44 | res = await policy.inspect("is_member", user1, tenant1) 45 | assert res.allowed() 46 | res = await policy.inspect("is_member", user2, tenant1) 47 | assert not res.allowed() 48 | res = await policy.inspect("is_owner", user1, tenant2, context="") 49 | assert not res.allowed() 50 | assert res.message() == "Not owner on the tenant" 51 | 52 | 53 | async def test_allows(): 54 | assert await policy.allows("is_member", user1, tenant1, context="ok") 55 | assert await policy.denies("is_member", user2, tenant1) 56 | assert await policy.allows("is_owner", user2, tenant2, context="ok") 57 | assert await policy.denies("is_owner", user1, tenant2, context="ok") 58 | 59 | 60 | async def test_authorize(): 61 | assert await policy.authorize("is_member", user1, tenant1) 62 | with pytest.raises(AuthorizationException): 63 | _ = await policy.authorize("is_member", user2, tenant1) 64 | 65 | 66 | async def test_grants(): 67 | res = await policy.grants(user1, tenant1, context="ok") 68 | assert res["is_member"] 69 | assert not res["is_owner"] 70 | -------------------------------------------------------------------------------- /tests/test_rules.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from entitled.exceptions import AuthorizationException 4 | from entitled.response import Err, Ok, Response 5 | from entitled.rules import Rule 6 | from tests.data.factories import TenantFactory, UserFactory 7 | from tests.data.models import User, Tenant 8 | 9 | pytestmark = pytest.mark.anyio 10 | 11 | 12 | async def is_member( 13 | actor: User, 14 | resource: Tenant, 15 | ) -> bool: 16 | return actor.tenant == resource 17 | 18 | 19 | async def is_owner( 20 | actor: User, 21 | resource: Tenant, 22 | ) -> Response: 23 | return Ok() if resource.owner == actor else Err("Not owner on the tenant") 24 | 25 | 26 | def test_define(): 27 | rule = Rule[User]("is_member", is_member) 28 | assert rule.callable == is_member 29 | 30 | 31 | async def test_allows(): 32 | tenant1 = TenantFactory() 33 | tenant2 = TenantFactory() 34 | user1 = UserFactory(tenant=tenant1) 35 | user2 = UserFactory(tenant=tenant2) 36 | tenant2.owner = user2 37 | 38 | rule1 = Rule[User]("is_member", is_member) 39 | rule2 = Rule[User]("is_owner", is_owner) 40 | 41 | assert await rule1.allows(user1, tenant1) 42 | assert not await rule1.denies(user1, tenant1) 43 | assert not await rule1.allows(user2, tenant1) 44 | assert await rule1.denies(user2, tenant1) 45 | 46 | assert not await rule2.allows(user1, tenant2) 47 | assert await rule2.denies(user1, tenant2) 48 | assert await rule2.allows(user2, tenant2) 49 | assert not await rule2.denies(user2, tenant2) 50 | 51 | 52 | async def test_authorize(): 53 | tenant1 = TenantFactory() 54 | tenant2 = TenantFactory() 55 | user1 = UserFactory(tenant=tenant1) 56 | user2 = UserFactory(tenant=tenant2) 57 | tenant2.owner = user2 58 | 59 | rule1 = Rule[User]("is_member", is_member) 60 | rule2 = Rule[User]("is_owner", is_owner) 61 | 62 | assert await rule1.authorize(user1, tenant1) 63 | with pytest.raises(AuthorizationException): 64 | _ = await rule1.authorize(user2, tenant1) 65 | with pytest.raises(AuthorizationException): 66 | _ = await rule2.authorize(user1, tenant2) 67 | assert await rule2.authorize(user2, tenant2) 68 | 69 | 70 | async def test_inspect(): 71 | tenant1 = TenantFactory() 72 | tenant2 = TenantFactory() 73 | user1 = UserFactory(tenant=tenant1) 74 | user2 = UserFactory(tenant=tenant2) 75 | tenant2.owner = user2 76 | 77 | rule1 = Rule[User]("is_member", is_member) 78 | rule2 = Rule[User]("is_owner", is_owner) 79 | 80 | assert (await rule1.inspect(user1, tenant1, "blbl")).allowed() 81 | assert ( 82 | await rule1.inspect(user2, tenant1, {"test": 1}) 83 | ).message() == "Unauthorized" 84 | assert (await rule2.inspect(user2, tenant2)).allowed() 85 | assert (await rule2.inspect(user1, tenant2)).message() == "Not owner on the tenant" 86 | -------------------------------------------------------------------------------- /uv.lock: -------------------------------------------------------------------------------- 1 | version = 1 2 | requires-python = "==3.12.*" 3 | 4 | [[package]] 5 | name = "annotated-types" 6 | version = "0.7.0" 7 | source = { registry = "https://pypi.org/simple" } 8 | sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081 } 9 | wheels = [ 10 | { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643 }, 11 | ] 12 | 13 | [[package]] 14 | name = "anyio" 15 | version = "4.8.0" 16 | source = { registry = "https://pypi.org/simple" } 17 | dependencies = [ 18 | { name = "idna" }, 19 | { name = "sniffio" }, 20 | { name = "typing-extensions" }, 21 | ] 22 | sdist = { url = "https://files.pythonhosted.org/packages/a3/73/199a98fc2dae33535d6b8e8e6ec01f8c1d76c9adb096c6b7d64823038cde/anyio-4.8.0.tar.gz", hash = "sha256:1d9fe889df5212298c0c0723fa20479d1b94883a2df44bd3897aa91083316f7a", size = 181126 } 23 | wheels = [ 24 | { url = "https://files.pythonhosted.org/packages/46/eb/e7f063ad1fec6b3178a3cd82d1a3c4de82cccf283fc42746168188e1cdd5/anyio-4.8.0-py3-none-any.whl", hash = "sha256:b5011f270ab5eb0abf13385f851315585cc37ef330dd88e27ec3d34d651fd47a", size = 96041 }, 25 | ] 26 | 27 | [[package]] 28 | name = "bandit" 29 | version = "1.7.10" 30 | source = { registry = "https://pypi.org/simple" } 31 | dependencies = [ 32 | { name = "colorama", marker = "platform_system == 'Windows'" }, 33 | { name = "pyyaml" }, 34 | { name = "rich" }, 35 | { name = "stevedore" }, 36 | ] 37 | sdist = { url = "https://files.pythonhosted.org/packages/38/26/bdd962d6ee781f6229c3fb83483cf9e09d87959150a9000789806d750f3c/bandit-1.7.10.tar.gz", hash = "sha256:59ed5caf5d92b6ada4bf65bc6437feea4a9da1093384445fed4d472acc6cff7b", size = 4228540 } 38 | wheels = [ 39 | { url = "https://files.pythonhosted.org/packages/9e/9c/491231d973d54f6465002812b4cadc663f208436407745be473254725f55/bandit-1.7.10-py3-none-any.whl", hash = "sha256:665721d7bebbb4485a339c55161ac0eedde27d51e638000d91c8c2d68343ad02", size = 130756 }, 40 | ] 41 | 42 | [[package]] 43 | name = "cfgv" 44 | version = "3.4.0" 45 | source = { registry = "https://pypi.org/simple" } 46 | sdist = { url = "https://files.pythonhosted.org/packages/11/74/539e56497d9bd1d484fd863dd69cbbfa653cd2aa27abfe35653494d85e94/cfgv-3.4.0.tar.gz", hash = "sha256:e52591d4c5f5dead8e0f673fb16db7949d2cfb3f7da4582893288f0ded8fe560", size = 7114 } 47 | wheels = [ 48 | { url = "https://files.pythonhosted.org/packages/c5/55/51844dd50c4fc7a33b653bfaba4c2456f06955289ca770a5dbd5fd267374/cfgv-3.4.0-py2.py3-none-any.whl", hash = "sha256:b7265b1f29fd3316bfcd2b330d63d024f2bfd8bcb8b0272f8e19a504856c48f9", size = 7249 }, 49 | ] 50 | 51 | [[package]] 52 | name = "colorama" 53 | version = "0.4.6" 54 | source = { registry = "https://pypi.org/simple" } 55 | sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697 } 56 | wheels = [ 57 | { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335 }, 58 | ] 59 | 60 | [[package]] 61 | name = "coverage" 62 | version = "7.6.1" 63 | source = { registry = "https://pypi.org/simple" } 64 | sdist = { url = "https://files.pythonhosted.org/packages/f7/08/7e37f82e4d1aead42a7443ff06a1e406aabf7302c4f00a546e4b320b994c/coverage-7.6.1.tar.gz", hash = "sha256:953510dfb7b12ab69d20135a0662397f077c59b1e6379a768e97c59d852ee51d", size = 798791 } 65 | wheels = [ 66 | { url = "https://files.pythonhosted.org/packages/7e/d4/300fc921dff243cd518c7db3a4c614b7e4b2431b0d1145c1e274fd99bd70/coverage-7.6.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:95cae0efeb032af8458fc27d191f85d1717b1d4e49f7cb226cf526ff28179778", size = 206983 }, 67 | { url = "https://files.pythonhosted.org/packages/e1/ab/6bf00de5327ecb8db205f9ae596885417a31535eeda6e7b99463108782e1/coverage-7.6.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5621a9175cf9d0b0c84c2ef2b12e9f5f5071357c4d2ea6ca1cf01814f45d2391", size = 207221 }, 68 | { url = "https://files.pythonhosted.org/packages/92/8f/2ead05e735022d1a7f3a0a683ac7f737de14850395a826192f0288703472/coverage-7.6.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:260933720fdcd75340e7dbe9060655aff3af1f0c5d20f46b57f262ab6c86a5e8", size = 240342 }, 69 | { url = "https://files.pythonhosted.org/packages/0f/ef/94043e478201ffa85b8ae2d2c79b4081e5a1b73438aafafccf3e9bafb6b5/coverage-7.6.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:07e2ca0ad381b91350c0ed49d52699b625aab2b44b65e1b4e02fa9df0e92ad2d", size = 237371 }, 70 | { url = "https://files.pythonhosted.org/packages/1f/0f/c890339dd605f3ebc269543247bdd43b703cce6825b5ed42ff5f2d6122c7/coverage-7.6.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c44fee9975f04b33331cb8eb272827111efc8930cfd582e0320613263ca849ca", size = 239455 }, 71 | { url = "https://files.pythonhosted.org/packages/d1/04/7fd7b39ec7372a04efb0f70c70e35857a99b6a9188b5205efb4c77d6a57a/coverage-7.6.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:877abb17e6339d96bf08e7a622d05095e72b71f8afd8a9fefc82cf30ed944163", size = 238924 }, 72 | { url = "https://files.pythonhosted.org/packages/ed/bf/73ce346a9d32a09cf369f14d2a06651329c984e106f5992c89579d25b27e/coverage-7.6.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:3e0cadcf6733c09154b461f1ca72d5416635e5e4ec4e536192180d34ec160f8a", size = 237252 }, 73 | { url = "https://files.pythonhosted.org/packages/86/74/1dc7a20969725e917b1e07fe71a955eb34bc606b938316bcc799f228374b/coverage-7.6.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c3c02d12f837d9683e5ab2f3d9844dc57655b92c74e286c262e0fc54213c216d", size = 238897 }, 74 | { url = "https://files.pythonhosted.org/packages/b6/e9/d9cc3deceb361c491b81005c668578b0dfa51eed02cd081620e9a62f24ec/coverage-7.6.1-cp312-cp312-win32.whl", hash = "sha256:e05882b70b87a18d937ca6768ff33cc3f72847cbc4de4491c8e73880766718e5", size = 209606 }, 75 | { url = "https://files.pythonhosted.org/packages/47/c8/5a2e41922ea6740f77d555c4d47544acd7dc3f251fe14199c09c0f5958d3/coverage-7.6.1-cp312-cp312-win_amd64.whl", hash = "sha256:b5d7b556859dd85f3a541db6a4e0167b86e7273e1cdc973e5b175166bb634fdb", size = 210373 }, 76 | ] 77 | 78 | [[package]] 79 | name = "distlib" 80 | version = "0.3.9" 81 | source = { registry = "https://pypi.org/simple" } 82 | sdist = { url = "https://files.pythonhosted.org/packages/0d/dd/1bec4c5ddb504ca60fc29472f3d27e8d4da1257a854e1d96742f15c1d02d/distlib-0.3.9.tar.gz", hash = "sha256:a60f20dea646b8a33f3e7772f74dc0b2d0772d2837ee1342a00645c81edf9403", size = 613923 } 83 | wheels = [ 84 | { url = "https://files.pythonhosted.org/packages/91/a1/cf2472db20f7ce4a6be1253a81cfdf85ad9c7885ffbed7047fb72c24cf87/distlib-0.3.9-py2.py3-none-any.whl", hash = "sha256:47f8c22fd27c27e25a65601af709b38e4f0a45ea4fc2e710f65755fa8caaaf87", size = 468973 }, 85 | ] 86 | 87 | [[package]] 88 | name = "dnspython" 89 | version = "2.7.0" 90 | source = { registry = "https://pypi.org/simple" } 91 | sdist = { url = "https://files.pythonhosted.org/packages/b5/4a/263763cb2ba3816dd94b08ad3a33d5fdae34ecb856678773cc40a3605829/dnspython-2.7.0.tar.gz", hash = "sha256:ce9c432eda0dc91cf618a5cedf1a4e142651196bbcd2c80e89ed5a907e5cfaf1", size = 345197 } 92 | wheels = [ 93 | { url = "https://files.pythonhosted.org/packages/68/1b/e0a87d256e40e8c888847551b20a017a6b98139178505dc7ffb96f04e954/dnspython-2.7.0-py3-none-any.whl", hash = "sha256:b4c34b7d10b51bcc3a5071e7b8dee77939f1e878477eeecc965e9835f63c6c86", size = 313632 }, 94 | ] 95 | 96 | [[package]] 97 | name = "email-validator" 98 | version = "2.2.0" 99 | source = { registry = "https://pypi.org/simple" } 100 | dependencies = [ 101 | { name = "dnspython" }, 102 | { name = "idna" }, 103 | ] 104 | sdist = { url = "https://files.pythonhosted.org/packages/48/ce/13508a1ec3f8bb981ae4ca79ea40384becc868bfae97fd1c942bb3a001b1/email_validator-2.2.0.tar.gz", hash = "sha256:cb690f344c617a714f22e66ae771445a1ceb46821152df8e165c5f9a364582b7", size = 48967 } 105 | wheels = [ 106 | { url = "https://files.pythonhosted.org/packages/d7/ee/bf0adb559ad3c786f12bcbc9296b3f5675f529199bef03e2df281fa1fadb/email_validator-2.2.0-py3-none-any.whl", hash = "sha256:561977c2d73ce3611850a06fa56b414621e0c8faa9d66f2611407d87465da631", size = 33521 }, 107 | ] 108 | 109 | [[package]] 110 | name = "entitled" 111 | version = "1.0.0" 112 | source = { editable = "." } 113 | 114 | [package.dependency-groups] 115 | dev = [ 116 | { name = "anyio" }, 117 | { name = "bandit" }, 118 | { name = "coverage" }, 119 | { name = "factory-boy" }, 120 | { name = "mypy" }, 121 | { name = "pre-commit" }, 122 | { name = "pydantic", extra = ["email"] }, 123 | { name = "pytest" }, 124 | { name = "pytest-cov" }, 125 | { name = "ruff" }, 126 | { name = "uvloop" }, 127 | ] 128 | 129 | [package.metadata] 130 | 131 | [package.metadata.dependency-groups] 132 | dev = [ 133 | { name = "anyio", specifier = ">=4.8.0" }, 134 | { name = "bandit", specifier = "==1.7.10" }, 135 | { name = "coverage", specifier = "==7.6.1" }, 136 | { name = "factory-boy", specifier = "==3.3.1" }, 137 | { name = "mypy", specifier = "==1.11.2" }, 138 | { name = "pre-commit", specifier = "==3.8.0" }, 139 | { name = "pydantic", extras = ["email"], specifier = ">=2.10.6" }, 140 | { name = "pytest", specifier = "==8.3.3" }, 141 | { name = "pytest-cov", specifier = "==5.0.0" }, 142 | { name = "ruff", specifier = "==0.8.0" }, 143 | { name = "uvloop", specifier = ">=0.21.0" }, 144 | ] 145 | 146 | [[package]] 147 | name = "factory-boy" 148 | version = "3.3.1" 149 | source = { registry = "https://pypi.org/simple" } 150 | dependencies = [ 151 | { name = "faker" }, 152 | ] 153 | sdist = { url = "https://files.pythonhosted.org/packages/99/3d/8070dde623341401b1c80156583d4c793058fe250450178218bb6e45526c/factory_boy-3.3.1.tar.gz", hash = "sha256:8317aa5289cdfc45f9cae570feb07a6177316c82e34d14df3c2e1f22f26abef0", size = 163924 } 154 | wheels = [ 155 | { url = "https://files.pythonhosted.org/packages/33/cf/44ec67152f3129d0114c1499dd34f0a0a0faf43d9c2af05bc535746ca482/factory_boy-3.3.1-py2.py3-none-any.whl", hash = "sha256:7b1113c49736e1e9995bc2a18f4dbf2c52cf0f841103517010b1d825712ce3ca", size = 36878 }, 156 | ] 157 | 158 | [[package]] 159 | name = "faker" 160 | version = "36.2.2" 161 | source = { registry = "https://pypi.org/simple" } 162 | dependencies = [ 163 | { name = "tzdata" }, 164 | ] 165 | sdist = { url = "https://files.pythonhosted.org/packages/ee/6c/412b064e33d11b351ef8945e4cc0ab56aa156e107c71610c4af96bd5d72c/faker-36.2.2.tar.gz", hash = "sha256:758bc63a26dc878fa0d76aa7639b8b65327927980ed0c3683b23bd8a5182f33f", size = 1874990 } 166 | wheels = [ 167 | { url = "https://files.pythonhosted.org/packages/28/30/3e81fdb631115c37ef81ad8bd342bf3fa52e66366bbed65a367a9137f8b9/faker-36.2.2-py3-none-any.whl", hash = "sha256:14adc340dc8abed5264142ffafe6f1a0f99cf7a7525bc6863755efd5fbbd0692", size = 1918206 }, 168 | ] 169 | 170 | [[package]] 171 | name = "filelock" 172 | version = "3.17.0" 173 | source = { registry = "https://pypi.org/simple" } 174 | sdist = { url = "https://files.pythonhosted.org/packages/dc/9c/0b15fb47b464e1b663b1acd1253a062aa5feecb07d4e597daea542ebd2b5/filelock-3.17.0.tar.gz", hash = "sha256:ee4e77401ef576ebb38cd7f13b9b28893194acc20a8e68e18730ba9c0e54660e", size = 18027 } 175 | wheels = [ 176 | { url = "https://files.pythonhosted.org/packages/89/ec/00d68c4ddfedfe64159999e5f8a98fb8442729a63e2077eb9dcd89623d27/filelock-3.17.0-py3-none-any.whl", hash = "sha256:533dc2f7ba78dc2f0f531fc6c4940addf7b70a481e269a5a3b93be94ffbe8338", size = 16164 }, 177 | ] 178 | 179 | [[package]] 180 | name = "identify" 181 | version = "2.6.8" 182 | source = { registry = "https://pypi.org/simple" } 183 | sdist = { url = "https://files.pythonhosted.org/packages/f9/fa/5eb460539e6f5252a7c5a931b53426e49258cde17e3d50685031c300a8fd/identify-2.6.8.tar.gz", hash = "sha256:61491417ea2c0c5c670484fd8abbb34de34cdae1e5f39a73ee65e48e4bb663fc", size = 99249 } 184 | wheels = [ 185 | { url = "https://files.pythonhosted.org/packages/78/8c/4bfcab2d8286473b8d83ea742716f4b79290172e75f91142bc1534b05b9a/identify-2.6.8-py2.py3-none-any.whl", hash = "sha256:83657f0f766a3c8d0eaea16d4ef42494b39b34629a4b3192a9d020d349b3e255", size = 99109 }, 186 | ] 187 | 188 | [[package]] 189 | name = "idna" 190 | version = "3.10" 191 | source = { registry = "https://pypi.org/simple" } 192 | sdist = { url = "https://files.pythonhosted.org/packages/f1/70/7703c29685631f5a7590aa73f1f1d3fa9a380e654b86af429e0934a32f7d/idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9", size = 190490 } 193 | wheels = [ 194 | { url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442 }, 195 | ] 196 | 197 | [[package]] 198 | name = "iniconfig" 199 | version = "2.0.0" 200 | source = { registry = "https://pypi.org/simple" } 201 | sdist = { url = "https://files.pythonhosted.org/packages/d7/4b/cbd8e699e64a6f16ca3a8220661b5f83792b3017d0f79807cb8708d33913/iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3", size = 4646 } 202 | wheels = [ 203 | { url = "https://files.pythonhosted.org/packages/ef/a6/62565a6e1cf69e10f5727360368e451d4b7f58beeac6173dc9db836a5b46/iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374", size = 5892 }, 204 | ] 205 | 206 | [[package]] 207 | name = "markdown-it-py" 208 | version = "3.0.0" 209 | source = { registry = "https://pypi.org/simple" } 210 | dependencies = [ 211 | { name = "mdurl" }, 212 | ] 213 | sdist = { url = "https://files.pythonhosted.org/packages/38/71/3b932df36c1a044d397a1f92d1cf91ee0a503d91e470cbd670aa66b07ed0/markdown-it-py-3.0.0.tar.gz", hash = "sha256:e3f60a94fa066dc52ec76661e37c851cb232d92f9886b15cb560aaada2df8feb", size = 74596 } 214 | wheels = [ 215 | { url = "https://files.pythonhosted.org/packages/42/d7/1ec15b46af6af88f19b8e5ffea08fa375d433c998b8a7639e76935c14f1f/markdown_it_py-3.0.0-py3-none-any.whl", hash = "sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1", size = 87528 }, 216 | ] 217 | 218 | [[package]] 219 | name = "mdurl" 220 | version = "0.1.2" 221 | source = { registry = "https://pypi.org/simple" } 222 | sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729 } 223 | wheels = [ 224 | { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979 }, 225 | ] 226 | 227 | [[package]] 228 | name = "mypy" 229 | version = "1.11.2" 230 | source = { registry = "https://pypi.org/simple" } 231 | dependencies = [ 232 | { name = "mypy-extensions" }, 233 | { name = "typing-extensions" }, 234 | ] 235 | sdist = { url = "https://files.pythonhosted.org/packages/5c/86/5d7cbc4974fd564550b80fbb8103c05501ea11aa7835edf3351d90095896/mypy-1.11.2.tar.gz", hash = "sha256:7f9993ad3e0ffdc95c2a14b66dee63729f021968bff8ad911867579c65d13a79", size = 3078806 } 236 | wheels = [ 237 | { url = "https://files.pythonhosted.org/packages/35/3a/ed7b12ecc3f6db2f664ccf85cb2e004d3e90bec928e9d7be6aa2f16b7cdf/mypy-1.11.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:e8960dbbbf36906c5c0b7f4fbf2f0c7ffb20f4898e6a879fcf56a41a08b0d318", size = 10990335 }, 238 | { url = "https://files.pythonhosted.org/packages/04/e4/1a9051e2ef10296d206519f1df13d2cc896aea39e8683302f89bf5792a59/mypy-1.11.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:06d26c277962f3fb50e13044674aa10553981ae514288cb7d0a738f495550b36", size = 10007119 }, 239 | { url = "https://files.pythonhosted.org/packages/f3/3c/350a9da895f8a7e87ade0028b962be0252d152e0c2fbaafa6f0658b4d0d4/mypy-1.11.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6e7184632d89d677973a14d00ae4d03214c8bc301ceefcdaf5c474866814c987", size = 12506856 }, 240 | { url = "https://files.pythonhosted.org/packages/b6/49/ee5adf6a49ff13f4202d949544d3d08abb0ea1f3e7f2a6d5b4c10ba0360a/mypy-1.11.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:3a66169b92452f72117e2da3a576087025449018afc2d8e9bfe5ffab865709ca", size = 12952066 }, 241 | { url = "https://files.pythonhosted.org/packages/27/c0/b19d709a42b24004d720db37446a42abadf844d5c46a2c442e2a074d70d9/mypy-1.11.2-cp312-cp312-win_amd64.whl", hash = "sha256:969ea3ef09617aff826885a22ece0ddef69d95852cdad2f60c8bb06bf1f71f70", size = 9664000 }, 242 | { url = "https://files.pythonhosted.org/packages/42/3a/bdf730640ac523229dd6578e8a581795720a9321399de494374afc437ec5/mypy-1.11.2-py3-none-any.whl", hash = "sha256:b499bc07dbdcd3de92b0a8b29fdf592c111276f6a12fe29c30f6c417dd546d12", size = 2619625 }, 243 | ] 244 | 245 | [[package]] 246 | name = "mypy-extensions" 247 | version = "1.0.0" 248 | source = { registry = "https://pypi.org/simple" } 249 | sdist = { url = "https://files.pythonhosted.org/packages/98/a4/1ab47638b92648243faf97a5aeb6ea83059cc3624972ab6b8d2316078d3f/mypy_extensions-1.0.0.tar.gz", hash = "sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782", size = 4433 } 250 | wheels = [ 251 | { url = "https://files.pythonhosted.org/packages/2a/e2/5d3f6ada4297caebe1a2add3b126fe800c96f56dbe5d1988a2cbe0b267aa/mypy_extensions-1.0.0-py3-none-any.whl", hash = "sha256:4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d", size = 4695 }, 252 | ] 253 | 254 | [[package]] 255 | name = "nodeenv" 256 | version = "1.9.1" 257 | source = { registry = "https://pypi.org/simple" } 258 | sdist = { url = "https://files.pythonhosted.org/packages/43/16/fc88b08840de0e0a72a2f9d8c6bae36be573e475a6326ae854bcc549fc45/nodeenv-1.9.1.tar.gz", hash = "sha256:6ec12890a2dab7946721edbfbcd91f3319c6ccc9aec47be7c7e6b7011ee6645f", size = 47437 } 259 | wheels = [ 260 | { url = "https://files.pythonhosted.org/packages/d2/1d/1b658dbd2b9fa9c4c9f32accbfc0205d532c8c6194dc0f2a4c0428e7128a/nodeenv-1.9.1-py2.py3-none-any.whl", hash = "sha256:ba11c9782d29c27c70ffbdda2d7415098754709be8a7056d79a737cd901155c9", size = 22314 }, 261 | ] 262 | 263 | [[package]] 264 | name = "packaging" 265 | version = "24.2" 266 | source = { registry = "https://pypi.org/simple" } 267 | sdist = { url = "https://files.pythonhosted.org/packages/d0/63/68dbb6eb2de9cb10ee4c9c14a0148804425e13c4fb20d61cce69f53106da/packaging-24.2.tar.gz", hash = "sha256:c228a6dc5e932d346bc5739379109d49e8853dd8223571c7c5b55260edc0b97f", size = 163950 } 268 | wheels = [ 269 | { url = "https://files.pythonhosted.org/packages/88/ef/eb23f262cca3c0c4eb7ab1933c3b1f03d021f2c48f54763065b6f0e321be/packaging-24.2-py3-none-any.whl", hash = "sha256:09abb1bccd265c01f4a3aa3f7a7db064b36514d2cba19a2f694fe6150451a759", size = 65451 }, 270 | ] 271 | 272 | [[package]] 273 | name = "pbr" 274 | version = "6.1.1" 275 | source = { registry = "https://pypi.org/simple" } 276 | dependencies = [ 277 | { name = "setuptools" }, 278 | ] 279 | sdist = { url = "https://files.pythonhosted.org/packages/01/d2/510cc0d218e753ba62a1bc1434651db3cd797a9716a0a66cc714cb4f0935/pbr-6.1.1.tar.gz", hash = "sha256:93ea72ce6989eb2eed99d0f75721474f69ad88128afdef5ac377eb797c4bf76b", size = 125702 } 280 | wheels = [ 281 | { url = "https://files.pythonhosted.org/packages/47/ac/684d71315abc7b1214d59304e23a982472967f6bf4bde5a98f1503f648dc/pbr-6.1.1-py2.py3-none-any.whl", hash = "sha256:38d4daea5d9fa63b3f626131b9d34947fd0c8be9b05a29276870580050a25a76", size = 108997 }, 282 | ] 283 | 284 | [[package]] 285 | name = "platformdirs" 286 | version = "4.3.6" 287 | source = { registry = "https://pypi.org/simple" } 288 | sdist = { url = "https://files.pythonhosted.org/packages/13/fc/128cc9cb8f03208bdbf93d3aa862e16d376844a14f9a0ce5cf4507372de4/platformdirs-4.3.6.tar.gz", hash = "sha256:357fb2acbc885b0419afd3ce3ed34564c13c9b95c89360cd9563f73aa5e2b907", size = 21302 } 289 | wheels = [ 290 | { url = "https://files.pythonhosted.org/packages/3c/a6/bc1012356d8ece4d66dd75c4b9fc6c1f6650ddd5991e421177d9f8f671be/platformdirs-4.3.6-py3-none-any.whl", hash = "sha256:73e575e1408ab8103900836b97580d5307456908a03e92031bab39e4554cc3fb", size = 18439 }, 291 | ] 292 | 293 | [[package]] 294 | name = "pluggy" 295 | version = "1.5.0" 296 | source = { registry = "https://pypi.org/simple" } 297 | sdist = { url = "https://files.pythonhosted.org/packages/96/2d/02d4312c973c6050a18b314a5ad0b3210edb65a906f868e31c111dede4a6/pluggy-1.5.0.tar.gz", hash = "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1", size = 67955 } 298 | wheels = [ 299 | { url = "https://files.pythonhosted.org/packages/88/5f/e351af9a41f866ac3f1fac4ca0613908d9a41741cfcf2228f4ad853b697d/pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669", size = 20556 }, 300 | ] 301 | 302 | [[package]] 303 | name = "pre-commit" 304 | version = "3.8.0" 305 | source = { registry = "https://pypi.org/simple" } 306 | dependencies = [ 307 | { name = "cfgv" }, 308 | { name = "identify" }, 309 | { name = "nodeenv" }, 310 | { name = "pyyaml" }, 311 | { name = "virtualenv" }, 312 | ] 313 | sdist = { url = "https://files.pythonhosted.org/packages/64/10/97ee2fa54dff1e9da9badbc5e35d0bbaef0776271ea5907eccf64140f72f/pre_commit-3.8.0.tar.gz", hash = "sha256:8bb6494d4a20423842e198980c9ecf9f96607a07ea29549e180eef9ae80fe7af", size = 177815 } 314 | wheels = [ 315 | { url = "https://files.pythonhosted.org/packages/07/92/caae8c86e94681b42c246f0bca35c059a2f0529e5b92619f6aba4cf7e7b6/pre_commit-3.8.0-py2.py3-none-any.whl", hash = "sha256:9a90a53bf82fdd8778d58085faf8d83df56e40dfe18f45b19446e26bf1b3a63f", size = 204643 }, 316 | ] 317 | 318 | [[package]] 319 | name = "pydantic" 320 | version = "2.10.6" 321 | source = { registry = "https://pypi.org/simple" } 322 | dependencies = [ 323 | { name = "annotated-types" }, 324 | { name = "pydantic-core" }, 325 | { name = "typing-extensions" }, 326 | ] 327 | sdist = { url = "https://files.pythonhosted.org/packages/b7/ae/d5220c5c52b158b1de7ca89fc5edb72f304a70a4c540c84c8844bf4008de/pydantic-2.10.6.tar.gz", hash = "sha256:ca5daa827cce33de7a42be142548b0096bf05a7e7b365aebfa5f8eeec7128236", size = 761681 } 328 | wheels = [ 329 | { url = "https://files.pythonhosted.org/packages/f4/3c/8cc1cc84deffa6e25d2d0c688ebb80635dfdbf1dbea3e30c541c8cf4d860/pydantic-2.10.6-py3-none-any.whl", hash = "sha256:427d664bf0b8a2b34ff5dd0f5a18df00591adcee7198fbd71981054cef37b584", size = 431696 }, 330 | ] 331 | 332 | [package.optional-dependencies] 333 | email = [ 334 | { name = "email-validator" }, 335 | ] 336 | 337 | [[package]] 338 | name = "pydantic-core" 339 | version = "2.27.2" 340 | source = { registry = "https://pypi.org/simple" } 341 | dependencies = [ 342 | { name = "typing-extensions" }, 343 | ] 344 | sdist = { url = "https://files.pythonhosted.org/packages/fc/01/f3e5ac5e7c25833db5eb555f7b7ab24cd6f8c322d3a3ad2d67a952dc0abc/pydantic_core-2.27.2.tar.gz", hash = "sha256:eb026e5a4c1fee05726072337ff51d1efb6f59090b7da90d30ea58625b1ffb39", size = 413443 } 345 | wheels = [ 346 | { url = "https://files.pythonhosted.org/packages/d6/74/51c8a5482ca447871c93e142d9d4a92ead74de6c8dc5e66733e22c9bba89/pydantic_core-2.27.2-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:9e0c8cfefa0ef83b4da9588448b6d8d2a2bf1a53c3f1ae5fca39eb3061e2f0b0", size = 1893127 }, 347 | { url = "https://files.pythonhosted.org/packages/d3/f3/c97e80721735868313c58b89d2de85fa80fe8dfeeed84dc51598b92a135e/pydantic_core-2.27.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:83097677b8e3bd7eaa6775720ec8e0405f1575015a463285a92bfdfe254529ef", size = 1811340 }, 348 | { url = "https://files.pythonhosted.org/packages/9e/91/840ec1375e686dbae1bd80a9e46c26a1e0083e1186abc610efa3d9a36180/pydantic_core-2.27.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:172fce187655fece0c90d90a678424b013f8fbb0ca8b036ac266749c09438cb7", size = 1822900 }, 349 | { url = "https://files.pythonhosted.org/packages/f6/31/4240bc96025035500c18adc149aa6ffdf1a0062a4b525c932065ceb4d868/pydantic_core-2.27.2-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:519f29f5213271eeeeb3093f662ba2fd512b91c5f188f3bb7b27bc5973816934", size = 1869177 }, 350 | { url = "https://files.pythonhosted.org/packages/fa/20/02fbaadb7808be578317015c462655c317a77a7c8f0ef274bc016a784c54/pydantic_core-2.27.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:05e3a55d124407fffba0dd6b0c0cd056d10e983ceb4e5dbd10dda135c31071d6", size = 2038046 }, 351 | { url = "https://files.pythonhosted.org/packages/06/86/7f306b904e6c9eccf0668248b3f272090e49c275bc488a7b88b0823444a4/pydantic_core-2.27.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9c3ed807c7b91de05e63930188f19e921d1fe90de6b4f5cd43ee7fcc3525cb8c", size = 2685386 }, 352 | { url = "https://files.pythonhosted.org/packages/8d/f0/49129b27c43396581a635d8710dae54a791b17dfc50c70164866bbf865e3/pydantic_core-2.27.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6fb4aadc0b9a0c063206846d603b92030eb6f03069151a625667f982887153e2", size = 1997060 }, 353 | { url = "https://files.pythonhosted.org/packages/0d/0f/943b4af7cd416c477fd40b187036c4f89b416a33d3cc0ab7b82708a667aa/pydantic_core-2.27.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:28ccb213807e037460326424ceb8b5245acb88f32f3d2777427476e1b32c48c4", size = 2004870 }, 354 | { url = "https://files.pythonhosted.org/packages/35/40/aea70b5b1a63911c53a4c8117c0a828d6790483f858041f47bab0b779f44/pydantic_core-2.27.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:de3cd1899e2c279b140adde9357c4495ed9d47131b4a4eaff9052f23398076b3", size = 1999822 }, 355 | { url = "https://files.pythonhosted.org/packages/f2/b3/807b94fd337d58effc5498fd1a7a4d9d59af4133e83e32ae39a96fddec9d/pydantic_core-2.27.2-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:220f892729375e2d736b97d0e51466252ad84c51857d4d15f5e9692f9ef12be4", size = 2130364 }, 356 | { url = "https://files.pythonhosted.org/packages/fc/df/791c827cd4ee6efd59248dca9369fb35e80a9484462c33c6649a8d02b565/pydantic_core-2.27.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:a0fcd29cd6b4e74fe8ddd2c90330fd8edf2e30cb52acda47f06dd615ae72da57", size = 2158303 }, 357 | { url = "https://files.pythonhosted.org/packages/9b/67/4e197c300976af185b7cef4c02203e175fb127e414125916bf1128b639a9/pydantic_core-2.27.2-cp312-cp312-win32.whl", hash = "sha256:1e2cb691ed9834cd6a8be61228471d0a503731abfb42f82458ff27be7b2186fc", size = 1834064 }, 358 | { url = "https://files.pythonhosted.org/packages/1f/ea/cd7209a889163b8dcca139fe32b9687dd05249161a3edda62860430457a5/pydantic_core-2.27.2-cp312-cp312-win_amd64.whl", hash = "sha256:cc3f1a99a4f4f9dd1de4fe0312c114e740b5ddead65bb4102884b384c15d8bc9", size = 1989046 }, 359 | { url = "https://files.pythonhosted.org/packages/bc/49/c54baab2f4658c26ac633d798dab66b4c3a9bbf47cff5284e9c182f4137a/pydantic_core-2.27.2-cp312-cp312-win_arm64.whl", hash = "sha256:3911ac9284cd8a1792d3cb26a2da18f3ca26c6908cc434a18f730dc0db7bfa3b", size = 1885092 }, 360 | ] 361 | 362 | [[package]] 363 | name = "pygments" 364 | version = "2.19.1" 365 | source = { registry = "https://pypi.org/simple" } 366 | sdist = { url = "https://files.pythonhosted.org/packages/7c/2d/c3338d48ea6cc0feb8446d8e6937e1408088a72a39937982cc6111d17f84/pygments-2.19.1.tar.gz", hash = "sha256:61c16d2a8576dc0649d9f39e089b5f02bcd27fba10d8fb4dcc28173f7a45151f", size = 4968581 } 367 | wheels = [ 368 | { url = "https://files.pythonhosted.org/packages/8a/0b/9fcc47d19c48b59121088dd6da2488a49d5f72dacf8262e2790a1d2c7d15/pygments-2.19.1-py3-none-any.whl", hash = "sha256:9ea1544ad55cecf4b8242fab6dd35a93bbce657034b0611ee383099054ab6d8c", size = 1225293 }, 369 | ] 370 | 371 | [[package]] 372 | name = "pytest" 373 | version = "8.3.3" 374 | source = { registry = "https://pypi.org/simple" } 375 | dependencies = [ 376 | { name = "colorama", marker = "sys_platform == 'win32'" }, 377 | { name = "iniconfig" }, 378 | { name = "packaging" }, 379 | { name = "pluggy" }, 380 | ] 381 | sdist = { url = "https://files.pythonhosted.org/packages/8b/6c/62bbd536103af674e227c41a8f3dcd022d591f6eed5facb5a0f31ee33bbc/pytest-8.3.3.tar.gz", hash = "sha256:70b98107bd648308a7952b06e6ca9a50bc660be218d53c257cc1fc94fda10181", size = 1442487 } 382 | wheels = [ 383 | { url = "https://files.pythonhosted.org/packages/6b/77/7440a06a8ead44c7757a64362dd22df5760f9b12dc5f11b6188cd2fc27a0/pytest-8.3.3-py3-none-any.whl", hash = "sha256:a6853c7375b2663155079443d2e45de913a911a11d669df02a50814944db57b2", size = 342341 }, 384 | ] 385 | 386 | [[package]] 387 | name = "pytest-cov" 388 | version = "5.0.0" 389 | source = { registry = "https://pypi.org/simple" } 390 | dependencies = [ 391 | { name = "coverage" }, 392 | { name = "pytest" }, 393 | ] 394 | sdist = { url = "https://files.pythonhosted.org/packages/74/67/00efc8d11b630c56f15f4ad9c7f9223f1e5ec275aaae3fa9118c6a223ad2/pytest-cov-5.0.0.tar.gz", hash = "sha256:5837b58e9f6ebd335b0f8060eecce69b662415b16dc503883a02f45dfeb14857", size = 63042 } 395 | wheels = [ 396 | { url = "https://files.pythonhosted.org/packages/78/3a/af5b4fa5961d9a1e6237b530eb87dd04aea6eb83da09d2a4073d81b54ccf/pytest_cov-5.0.0-py3-none-any.whl", hash = "sha256:4f0764a1219df53214206bf1feea4633c3b558a2925c8b59f144f682861ce652", size = 21990 }, 397 | ] 398 | 399 | [[package]] 400 | name = "pyyaml" 401 | version = "6.0.2" 402 | source = { registry = "https://pypi.org/simple" } 403 | sdist = { url = "https://files.pythonhosted.org/packages/54/ed/79a089b6be93607fa5cdaedf301d7dfb23af5f25c398d5ead2525b063e17/pyyaml-6.0.2.tar.gz", hash = "sha256:d584d9ec91ad65861cc08d42e834324ef890a082e591037abe114850ff7bbc3e", size = 130631 } 404 | wheels = [ 405 | { url = "https://files.pythonhosted.org/packages/86/0c/c581167fc46d6d6d7ddcfb8c843a4de25bdd27e4466938109ca68492292c/PyYAML-6.0.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:c70c95198c015b85feafc136515252a261a84561b7b1d51e3384e0655ddf25ab", size = 183873 }, 406 | { url = "https://files.pythonhosted.org/packages/a8/0c/38374f5bb272c051e2a69281d71cba6fdb983413e6758b84482905e29a5d/PyYAML-6.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ce826d6ef20b1bc864f0a68340c8b3287705cae2f8b4b1d932177dcc76721725", size = 173302 }, 407 | { url = "https://files.pythonhosted.org/packages/c3/93/9916574aa8c00aa06bbac729972eb1071d002b8e158bd0e83a3b9a20a1f7/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1f71ea527786de97d1a0cc0eacd1defc0985dcf6b3f17bb77dcfc8c34bec4dc5", size = 739154 }, 408 | { url = "https://files.pythonhosted.org/packages/95/0f/b8938f1cbd09739c6da569d172531567dbcc9789e0029aa070856f123984/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9b22676e8097e9e22e36d6b7bda33190d0d400f345f23d4065d48f4ca7ae0425", size = 766223 }, 409 | { url = "https://files.pythonhosted.org/packages/b9/2b/614b4752f2e127db5cc206abc23a8c19678e92b23c3db30fc86ab731d3bd/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:80bab7bfc629882493af4aa31a4cfa43a4c57c83813253626916b8c7ada83476", size = 767542 }, 410 | { url = "https://files.pythonhosted.org/packages/d4/00/dd137d5bcc7efea1836d6264f049359861cf548469d18da90cd8216cf05f/PyYAML-6.0.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:0833f8694549e586547b576dcfaba4a6b55b9e96098b36cdc7ebefe667dfed48", size = 731164 }, 411 | { url = "https://files.pythonhosted.org/packages/c9/1f/4f998c900485e5c0ef43838363ba4a9723ac0ad73a9dc42068b12aaba4e4/PyYAML-6.0.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8b9c7197f7cb2738065c481a0461e50ad02f18c78cd75775628afb4d7137fb3b", size = 756611 }, 412 | { url = "https://files.pythonhosted.org/packages/df/d1/f5a275fdb252768b7a11ec63585bc38d0e87c9e05668a139fea92b80634c/PyYAML-6.0.2-cp312-cp312-win32.whl", hash = "sha256:ef6107725bd54b262d6dedcc2af448a266975032bc85ef0172c5f059da6325b4", size = 140591 }, 413 | { url = "https://files.pythonhosted.org/packages/0c/e8/4f648c598b17c3d06e8753d7d13d57542b30d56e6c2dedf9c331ae56312e/PyYAML-6.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:7e7401d0de89a9a855c839bc697c079a4af81cf878373abd7dc625847d25cbd8", size = 156338 }, 414 | ] 415 | 416 | [[package]] 417 | name = "rich" 418 | version = "13.9.4" 419 | source = { registry = "https://pypi.org/simple" } 420 | dependencies = [ 421 | { name = "markdown-it-py" }, 422 | { name = "pygments" }, 423 | ] 424 | sdist = { url = "https://files.pythonhosted.org/packages/ab/3a/0316b28d0761c6734d6bc14e770d85506c986c85ffb239e688eeaab2c2bc/rich-13.9.4.tar.gz", hash = "sha256:439594978a49a09530cff7ebc4b5c7103ef57baf48d5ea3184f21d9a2befa098", size = 223149 } 425 | wheels = [ 426 | { url = "https://files.pythonhosted.org/packages/19/71/39c7c0d87f8d4e6c020a393182060eaefeeae6c01dab6a84ec346f2567df/rich-13.9.4-py3-none-any.whl", hash = "sha256:6049d5e6ec054bf2779ab3358186963bac2ea89175919d699e378b99738c2a90", size = 242424 }, 427 | ] 428 | 429 | [[package]] 430 | name = "ruff" 431 | version = "0.8.0" 432 | source = { registry = "https://pypi.org/simple" } 433 | sdist = { url = "https://files.pythonhosted.org/packages/b2/d6/a2373f3ba7180ddb44420d2a9d1f1510e1a4d162b3d27282bedcb09c8da9/ruff-0.8.0.tar.gz", hash = "sha256:a7ccfe6331bf8c8dad715753e157457faf7351c2b69f62f32c165c2dbcbacd44", size = 3276537 } 434 | wheels = [ 435 | { url = "https://files.pythonhosted.org/packages/ec/77/e889ee3ce7fd8baa3ed1b77a03b9fb8ec1be68be1418261522fd6a5405e0/ruff-0.8.0-py3-none-linux_armv6l.whl", hash = "sha256:fcb1bf2cc6706adae9d79c8d86478677e3bbd4ced796ccad106fd4776d395fea", size = 10518283 }, 436 | { url = "https://files.pythonhosted.org/packages/da/c8/0a47de01edf19fb22f5f9b7964f46a68d0bdff20144d134556ffd1ba9154/ruff-0.8.0-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:295bb4c02d58ff2ef4378a1870c20af30723013f441c9d1637a008baaf928c8b", size = 10317691 }, 437 | { url = "https://files.pythonhosted.org/packages/41/17/9885e4a0eeae07abd2a4ebabc3246f556719f24efa477ba2739146c4635a/ruff-0.8.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:7b1f1c76b47c18fa92ee78b60d2d20d7e866c55ee603e7d19c1e991fad933a9a", size = 9940999 }, 438 | { url = "https://files.pythonhosted.org/packages/3e/cd/46b6f7043597eb318b5f5482c8ae8f5491cccce771e85f59d23106f2d179/ruff-0.8.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:eb0d4f250a7711b67ad513fde67e8870109e5ce590a801c3722580fe98c33a99", size = 10772437 }, 439 | { url = "https://files.pythonhosted.org/packages/5d/87/afc95aeb8bc78b1d8a3461717a4419c05aa8aa943d4c9cbd441630f85584/ruff-0.8.0-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0e55cce9aa93c5d0d4e3937e47b169035c7e91c8655b0974e61bb79cf398d49c", size = 10299156 }, 440 | { url = "https://files.pythonhosted.org/packages/65/fa/04c647bb809c4d65e8eae1ed1c654d9481b21dd942e743cd33511687b9f9/ruff-0.8.0-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3f4cd64916d8e732ce6b87f3f5296a8942d285bbbc161acee7fe561134af64f9", size = 11325819 }, 441 | { url = "https://files.pythonhosted.org/packages/90/26/7dad6e7d833d391a8a1afe4ee70ca6f36c4a297d3cca83ef10e83e9aacf3/ruff-0.8.0-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:c5c1466be2a2ebdf7c5450dd5d980cc87c8ba6976fb82582fea18823da6fa362", size = 12023927 }, 442 | { url = "https://files.pythonhosted.org/packages/24/a0/be5296dda6428ba8a13bda8d09fbc0e14c810b485478733886e61597ae2b/ruff-0.8.0-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2dabfd05b96b7b8f2da00d53c514eea842bff83e41e1cceb08ae1966254a51df", size = 11589702 }, 443 | { url = "https://files.pythonhosted.org/packages/26/3f/7602eb11d2886db545834182a9dbe500b8211fcbc9b4064bf9d358bbbbb4/ruff-0.8.0-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:facebdfe5a5af6b1588a1d26d170635ead6892d0e314477e80256ef4a8470cf3", size = 12782936 }, 444 | { url = "https://files.pythonhosted.org/packages/4c/5d/083181bdec4ec92a431c1291d3fff65eef3ded630a4b55eb735000ef5f3b/ruff-0.8.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:87a8e86bae0dbd749c815211ca11e3a7bd559b9710746c559ed63106d382bd9c", size = 11138488 }, 445 | { url = "https://files.pythonhosted.org/packages/b7/23/c12cdef58413cee2436d6a177aa06f7a366ebbca916cf10820706f632459/ruff-0.8.0-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:85e654f0ded7befe2d61eeaf3d3b1e4ef3894469cd664ffa85006c7720f1e4a2", size = 10744474 }, 446 | { url = "https://files.pythonhosted.org/packages/29/61/a12f3b81520083cd7c5caa24ba61bb99fd1060256482eff0ef04cc5ccd1b/ruff-0.8.0-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:83a55679c4cb449fa527b8497cadf54f076603cc36779b2170b24f704171ce70", size = 10369029 }, 447 | { url = "https://files.pythonhosted.org/packages/08/2a/c013f4f3e4a54596c369cee74c24870ed1d534f31a35504908b1fc97017a/ruff-0.8.0-py3-none-musllinux_1_2_i686.whl", hash = "sha256:812e2052121634cf13cd6fddf0c1871d0ead1aad40a1a258753c04c18bb71bbd", size = 10867481 }, 448 | { url = "https://files.pythonhosted.org/packages/d5/f7/685b1e1d42a3e94ceb25eab23c70bdd8c0ab66a43121ef83fe6db5a58756/ruff-0.8.0-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:780d5d8523c04202184405e60c98d7595bdb498c3c6abba3b6d4cdf2ca2af426", size = 11237117 }, 449 | { url = "https://files.pythonhosted.org/packages/03/20/401132c0908e8837625e3b7e32df9962e7cd681a4df1e16a10e2a5b4ecda/ruff-0.8.0-py3-none-win32.whl", hash = "sha256:5fdb6efecc3eb60bba5819679466471fd7d13c53487df7248d6e27146e985468", size = 8783511 }, 450 | { url = "https://files.pythonhosted.org/packages/1d/5c/4d800fca7854f62ad77f2c0d99b4b585f03e2d87a6ec1ecea85543a14a3c/ruff-0.8.0-py3-none-win_amd64.whl", hash = "sha256:582891c57b96228d146725975fbb942e1f30a0c4ba19722e692ca3eb25cc9b4f", size = 9559876 }, 451 | { url = "https://files.pythonhosted.org/packages/5b/bc/cc8a6a5ca4960b226dc15dd8fb511dd11f2014ff89d325c0b9b9faa9871f/ruff-0.8.0-py3-none-win_arm64.whl", hash = "sha256:ba93e6294e9a737cd726b74b09a6972e36bb511f9a102f1d9a7e1ce94dd206a6", size = 8939733 }, 452 | ] 453 | 454 | [[package]] 455 | name = "setuptools" 456 | version = "75.8.2" 457 | source = { registry = "https://pypi.org/simple" } 458 | sdist = { url = "https://files.pythonhosted.org/packages/d1/53/43d99d7687e8cdef5ab5f9ec5eaf2c0423c2b35133a2b7e7bc276fc32b21/setuptools-75.8.2.tar.gz", hash = "sha256:4880473a969e5f23f2a2be3646b2dfd84af9028716d398e46192f84bc36900d2", size = 1344083 } 459 | wheels = [ 460 | { url = "https://files.pythonhosted.org/packages/a9/38/7d7362e031bd6dc121e5081d8cb6aa6f6fedf2b67bf889962134c6da4705/setuptools-75.8.2-py3-none-any.whl", hash = "sha256:558e47c15f1811c1fa7adbd0096669bf76c1d3f433f58324df69f3f5ecac4e8f", size = 1229385 }, 461 | ] 462 | 463 | [[package]] 464 | name = "sniffio" 465 | version = "1.3.1" 466 | source = { registry = "https://pypi.org/simple" } 467 | sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372 } 468 | wheels = [ 469 | { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235 }, 470 | ] 471 | 472 | [[package]] 473 | name = "stevedore" 474 | version = "5.4.1" 475 | source = { registry = "https://pypi.org/simple" } 476 | dependencies = [ 477 | { name = "pbr" }, 478 | ] 479 | sdist = { url = "https://files.pythonhosted.org/packages/28/3f/13cacea96900bbd31bb05c6b74135f85d15564fc583802be56976c940470/stevedore-5.4.1.tar.gz", hash = "sha256:3135b5ae50fe12816ef291baff420acb727fcd356106e3e9cbfa9e5985cd6f4b", size = 513858 } 480 | wheels = [ 481 | { url = "https://files.pythonhosted.org/packages/f7/45/8c4ebc0c460e6ec38e62ab245ad3c7fc10b210116cea7c16d61602aa9558/stevedore-5.4.1-py3-none-any.whl", hash = "sha256:d10a31c7b86cba16c1f6e8d15416955fc797052351a56af15e608ad20811fcfe", size = 49533 }, 482 | ] 483 | 484 | [[package]] 485 | name = "typing-extensions" 486 | version = "4.12.2" 487 | source = { registry = "https://pypi.org/simple" } 488 | sdist = { url = "https://files.pythonhosted.org/packages/df/db/f35a00659bc03fec321ba8bce9420de607a1d37f8342eee1863174c69557/typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8", size = 85321 } 489 | wheels = [ 490 | { url = "https://files.pythonhosted.org/packages/26/9f/ad63fc0248c5379346306f8668cda6e2e2e9c95e01216d2b8ffd9ff037d0/typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d", size = 37438 }, 491 | ] 492 | 493 | [[package]] 494 | name = "tzdata" 495 | version = "2025.1" 496 | source = { registry = "https://pypi.org/simple" } 497 | sdist = { url = "https://files.pythonhosted.org/packages/43/0f/fa4723f22942480be4ca9527bbde8d43f6c3f2fe8412f00e7f5f6746bc8b/tzdata-2025.1.tar.gz", hash = "sha256:24894909e88cdb28bd1636c6887801df64cb485bd593f2fd83ef29075a81d694", size = 194950 } 498 | wheels = [ 499 | { url = "https://files.pythonhosted.org/packages/0f/dd/84f10e23edd882c6f968c21c2434fe67bd4a528967067515feca9e611e5e/tzdata-2025.1-py2.py3-none-any.whl", hash = "sha256:7e127113816800496f027041c570f50bcd464a020098a3b6b199517772303639", size = 346762 }, 500 | ] 501 | 502 | [[package]] 503 | name = "uvloop" 504 | version = "0.21.0" 505 | source = { registry = "https://pypi.org/simple" } 506 | sdist = { url = "https://files.pythonhosted.org/packages/af/c0/854216d09d33c543f12a44b393c402e89a920b1a0a7dc634c42de91b9cf6/uvloop-0.21.0.tar.gz", hash = "sha256:3bf12b0fda68447806a7ad847bfa591613177275d35b6724b1ee573faa3704e3", size = 2492741 } 507 | wheels = [ 508 | { url = "https://files.pythonhosted.org/packages/8c/4c/03f93178830dc7ce8b4cdee1d36770d2f5ebb6f3d37d354e061eefc73545/uvloop-0.21.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:359ec2c888397b9e592a889c4d72ba3d6befba8b2bb01743f72fffbde663b59c", size = 1471284 }, 509 | { url = "https://files.pythonhosted.org/packages/43/3e/92c03f4d05e50f09251bd8b2b2b584a2a7f8fe600008bcc4523337abe676/uvloop-0.21.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:f7089d2dc73179ce5ac255bdf37c236a9f914b264825fdaacaded6990a7fb4c2", size = 821349 }, 510 | { url = "https://files.pythonhosted.org/packages/a6/ef/a02ec5da49909dbbfb1fd205a9a1ac4e88ea92dcae885e7c961847cd51e2/uvloop-0.21.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:baa4dcdbd9ae0a372f2167a207cd98c9f9a1ea1188a8a526431eef2f8116cc8d", size = 4580089 }, 511 | { url = "https://files.pythonhosted.org/packages/06/a7/b4e6a19925c900be9f98bec0a75e6e8f79bb53bdeb891916609ab3958967/uvloop-0.21.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:86975dca1c773a2c9864f4c52c5a55631038e387b47eaf56210f873887b6c8dc", size = 4693770 }, 512 | { url = "https://files.pythonhosted.org/packages/ce/0c/f07435a18a4b94ce6bd0677d8319cd3de61f3a9eeb1e5f8ab4e8b5edfcb3/uvloop-0.21.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:461d9ae6660fbbafedd07559c6a2e57cd553b34b0065b6550685f6653a98c1cb", size = 4451321 }, 513 | { url = "https://files.pythonhosted.org/packages/8f/eb/f7032be105877bcf924709c97b1bf3b90255b4ec251f9340cef912559f28/uvloop-0.21.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:183aef7c8730e54c9a3ee3227464daed66e37ba13040bb3f350bc2ddc040f22f", size = 4659022 }, 514 | ] 515 | 516 | [[package]] 517 | name = "virtualenv" 518 | version = "20.29.2" 519 | source = { registry = "https://pypi.org/simple" } 520 | dependencies = [ 521 | { name = "distlib" }, 522 | { name = "filelock" }, 523 | { name = "platformdirs" }, 524 | ] 525 | sdist = { url = "https://files.pythonhosted.org/packages/f1/88/dacc875dd54a8acadb4bcbfd4e3e86df8be75527116c91d8f9784f5e9cab/virtualenv-20.29.2.tar.gz", hash = "sha256:fdaabebf6d03b5ba83ae0a02cfe96f48a716f4fae556461d180825866f75b728", size = 4320272 } 526 | wheels = [ 527 | { url = "https://files.pythonhosted.org/packages/93/fa/849483d56773ae29740ae70043ad88e068f98a6401aa819b5d6bee604683/virtualenv-20.29.2-py3-none-any.whl", hash = "sha256:febddfc3d1ea571bdb1dc0f98d7b45d24def7428214d4fb73cc486c9568cce6a", size = 4301478 }, 528 | ] 529 | --------------------------------------------------------------------------------