├── tests ├── __init__.py ├── test_timeout_decorator.py ├── test_gitleaks_config.py └── test_masked_logger.py ├── maskerlogger ├── __init__.py ├── utils.py ├── ahocorasick_regex_match.py └── masker_formatter.py ├── .github ├── SECURITY.md ├── dependabot.yml ├── workflows │ ├── lint.yml │ ├── publish.yml │ ├── run-tests.yml │ └── quality.yml ├── PULL_REQUEST_TEMPLATE.md ├── ISSUE_TEMPLATE │ ├── feature_request.yml │ └── bug_report.yml ├── CONTRIBUTING.md └── CODE_OF_CONDUCT.md ├── .gitignore ├── examples └── secrets_in_logs_example.py ├── .pre-commit-config.yaml ├── LICENSE ├── pyproject.toml ├── README.md └── poetry.lock /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /maskerlogger/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Init file for maskerlogger package. 3 | """ 4 | 5 | from maskerlogger.masker_formatter import MaskerFormatter, MaskerFormatterJson, SKIP_MASK # noqa 6 | 7 | __version__ = "1.1.1" 8 | -------------------------------------------------------------------------------- /.github/SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | ## Reporting a Vulnerability 4 | 5 | We take security issues seriously and appreciate your efforts to responsibly disclose any findings. 6 | 7 | **Please do not report security vulnerabilities through public GitHub issues.** 8 | 9 | If you discover a security vulnerability, please email us at: 10 | 11 | **security@ox.security** 12 | 13 | We welcome all security reports and will respond to legitimate issues appropriately. 14 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__/ 2 | *.py[cod] 3 | *$py.class 4 | build/ 5 | develop-eggs/ 6 | dist/ 7 | downloads/ 8 | eggs/ 9 | .eggs/ 10 | lib/ 11 | lib64/ 12 | parts/ 13 | sdist/ 14 | var/ 15 | wheels/ 16 | *.egg-info/ 17 | .installed.cfg 18 | *.egg 19 | .tox/ 20 | .coverage 21 | .coverage.* 22 | .cache 23 | nosetests.xml 24 | coverage.xml 25 | *.cover 26 | .hypothesis/ 27 | .pytest_cache/ 28 | *.log 29 | .env 30 | .venv 31 | env/ 32 | venv/ 33 | /site 34 | .idea/ 35 | .vscode/ 36 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | # GitHub Actions 4 | - package-ecosystem: "github-actions" 5 | directory: "/" 6 | schedule: 7 | interval: "weekly" 8 | day: "monday" 9 | open-pull-requests-limit: 10 10 | labels: 11 | - "dependencies" 12 | commit-message: 13 | prefix: "chore" 14 | include: "scope" 15 | 16 | # Python dependencies 17 | - package-ecosystem: "pip" 18 | directory: "/" 19 | schedule: 20 | interval: "weekly" 21 | day: "monday" 22 | open-pull-requests-limit: 10 23 | labels: 24 | - "dependencies" 25 | commit-message: 26 | prefix: "chore" 27 | include: "scope" 28 | ignore: 29 | # Ignore patch updates for stable dependencies 30 | - dependency-name: "*" 31 | update-types: ["version-update:semver-patch"] 32 | -------------------------------------------------------------------------------- /examples/secrets_in_logs_example.py: -------------------------------------------------------------------------------- 1 | """ 2 | This module demonstrates handling secrets in logs with maskerlogger. 3 | """ 4 | 5 | import logging 6 | 7 | from maskerlogger import SKIP_MASK, MaskerFormatter 8 | 9 | 10 | def main(): 11 | """ 12 | Main function to demonstrate logging with secrets. 13 | """ 14 | logger = logging.getLogger("mylogger") 15 | logger.setLevel(logging.DEBUG) 16 | handler = logging.StreamHandler() 17 | handler.setFormatter( 18 | MaskerFormatter("%(asctime)s %(name)s %(levelname)s %(message)s", redact=50) 19 | ) 20 | logger.addHandler(handler) 21 | 22 | logger.info('"current_key": "AIzaSOHbouG6DDa6DOcRGEgOMayAXYXcw6la3c"', extra=SKIP_MASK) # noqa 23 | logger.info('"AKIAI44QH8DHBEXAMPLE" and then more text.') 24 | logger.info("Datadog access token: 'abcdef1234567890abcdef1234567890'") 25 | logger.info('"password": "password123"') 26 | 27 | 28 | if __name__ == "__main__": 29 | main() 30 | -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | name: Lint 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | branches: 9 | - '**' 10 | 11 | jobs: 12 | lint: 13 | runs-on: ubuntu-latest 14 | strategy: 15 | matrix: 16 | python-version: ["3.13"] 17 | 18 | steps: 19 | - name: Checkout repository 20 | uses: actions/checkout@v6 21 | 22 | - name: Set up Python ${{ matrix.python-version }} 23 | uses: actions/setup-python@v6 24 | with: 25 | python-version: ${{ matrix.python-version }} 26 | 27 | - name: Install dependencies 28 | run: | 29 | python -m pip install --upgrade pip 30 | pip install ruff 31 | 32 | - name: Run Ruff linter 33 | run: ruff check . 34 | 35 | - name: Run Ruff formatter check 36 | run: ruff format --check . 37 | 38 | - name: Install mypy 39 | run: pip install mypy 40 | 41 | - name: Run mypy type checker 42 | run: mypy maskerlogger/ 43 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/pre-commit/pre-commit-hooks 3 | rev: v6.0.0 4 | hooks: 5 | - id: trailing-whitespace 6 | - id: end-of-file-fixer 7 | - id: check-yaml 8 | - id: check-toml 9 | - id: check-added-large-files 10 | args: ['--maxkb=1000'] 11 | - id: check-merge-conflict 12 | - id: check-case-conflict 13 | - id: detect-private-key 14 | exclude: ^tests/test_gitleaks_config\.py$ 15 | - id: mixed-line-ending 16 | args: ['--fix=lf'] 17 | 18 | - repo: https://github.com/astral-sh/ruff-pre-commit 19 | rev: v0.14.3 20 | hooks: 21 | - id: ruff 22 | args: [--fix, --exit-non-zero-on-fix] 23 | - id: ruff-format 24 | 25 | - repo: https://github.com/pre-commit/mirrors-mypy 26 | rev: v1.18.2 27 | hooks: 28 | - id: mypy 29 | files: ^maskerlogger/.*$ 30 | 31 | - repo: https://github.com/python-poetry/poetry 32 | rev: 2.2.1 33 | hooks: 34 | - id: poetry-check 35 | - id: poetry-lock 36 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 tamar-ox 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish to PyPI 2 | 3 | on: 4 | release: 5 | types: [published] 6 | 7 | concurrency: 8 | group: ${{ github.workflow }}-${{ github.ref }} 9 | cancel-in-progress: false 10 | 11 | jobs: 12 | pypi-publish: 13 | name: Upload release to PyPI 14 | runs-on: ubuntu-latest 15 | environment: release 16 | permissions: 17 | id-token: write 18 | contents: read 19 | 20 | steps: 21 | - name: Checkout repository 22 | uses: actions/checkout@v6 23 | 24 | - name: Set up Python 25 | uses: actions/setup-python@v6 26 | with: 27 | python-version: '3.11' 28 | 29 | - name: Install dependencies 30 | run: | 31 | python -m pip install --upgrade pip 32 | pip install poetry 33 | 34 | - name: Verify version matches tag 35 | run: | 36 | TAG_VERSION=${GITHUB_REF#refs/tags/v} 37 | PACKAGE_VERSION=$(poetry version -s) 38 | if [ "$TAG_VERSION" != "$PACKAGE_VERSION" ]; then 39 | echo "Tag version ($TAG_VERSION) does not match package version ($PACKAGE_VERSION)" 40 | exit 1 41 | fi 42 | shell: bash 43 | 44 | - name: Build package 45 | run: poetry build 46 | 47 | - name: Publish package to PyPI 48 | uses: pypa/gh-action-pypi-publish@release/v1 49 | with: 50 | print-hash: true 51 | -------------------------------------------------------------------------------- /.github/workflows/run-tests.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | branches: 9 | - '**' 10 | 11 | jobs: 12 | test: 13 | name: Test Python ${{ matrix.python-version }} on ubuntu-latest 14 | runs-on: ubuntu-latest 15 | 16 | strategy: 17 | fail-fast: false 18 | matrix: 19 | python-version: ["3.10", "3.11", "3.12", "3.13"] 20 | 21 | steps: 22 | - name: Checkout repository 23 | uses: actions/checkout@v6 24 | 25 | - name: Set up Python ${{ matrix.python-version }} 26 | uses: actions/setup-python@v6 27 | with: 28 | python-version: ${{ matrix.python-version }} 29 | cache: 'pip' 30 | 31 | - name: Install dependencies 32 | run: | 33 | python -m pip install --upgrade pip 34 | pip install poetry 35 | poetry install 36 | 37 | - name: Run tests with coverage 38 | run: | 39 | poetry run pytest tests/ --cov=maskerlogger --cov-report=xml --cov-report=term 40 | 41 | - name: Upload coverage to Codecov 42 | if: matrix.python-version == '3.10' 43 | uses: codecov/codecov-action@v5 44 | with: 45 | files: ./coverage.xml # Changed from 'file' to 'files' 46 | flags: unittests 47 | name: codecov-umbrella 48 | fail_ci_if_error: false 49 | token: ${{ secrets.CODECOV_TOKEN }} # OX Agent: Sensitive Data Exposure prevented by using GitHub secrets instead of hardcoded tokens 50 | -------------------------------------------------------------------------------- /.github/workflows/quality.yml: -------------------------------------------------------------------------------- 1 | name: Quality Checks 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | branches: 9 | - '**' 10 | 11 | jobs: 12 | file-checks: 13 | name: File Quality Checks 14 | runs-on: ubuntu-latest 15 | 16 | steps: 17 | - name: Checkout repository 18 | uses: actions/checkout@v6 19 | 20 | - name: Set up Python 21 | uses: actions/setup-python@v6 22 | with: 23 | python-version: '3.10' 24 | 25 | - name: Install pre-commit 26 | run: pip install pre-commit 27 | 28 | - name: Run file quality checks 29 | run: | 30 | # Run only the pre-commit-hooks (file checks) 31 | pre-commit run --all-files trailing-whitespace 32 | pre-commit run --all-files end-of-file-fixer 33 | pre-commit run --all-files check-yaml 34 | pre-commit run --all-files check-toml 35 | pre-commit run --all-files check-added-large-files 36 | pre-commit run --all-files check-merge-conflict 37 | pre-commit run --all-files check-case-conflict 38 | pre-commit run --all-files detect-private-key 39 | pre-commit run --all-files mixed-line-ending 40 | 41 | poetry-validation: 42 | name: Poetry Validation 43 | runs-on: ubuntu-latest 44 | 45 | steps: 46 | - name: Checkout repository 47 | uses: actions/checkout@v6 48 | 49 | - name: Set up Python 50 | uses: actions/setup-python@v6 51 | with: 52 | python-version: '3.10' 53 | 54 | - name: Install Poetry 55 | run: pip install poetry 56 | 57 | - name: Validate Poetry configuration 58 | run: poetry check 59 | 60 | - name: Verify lock file is up to date 61 | run: poetry check --lock 62 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | ## Description 2 | 3 | 4 | 5 | ## Related Issue 6 | 7 | 8 | Fixes # 9 | 10 | ## Type of Change 11 | 12 | 13 | 14 | - [ ] Bug fix (non-breaking change which fixes an issue) 15 | - [ ] New feature (non-breaking change which adds functionality) 16 | - [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected) 17 | - [ ] Documentation update 18 | - [ ] Code refactoring 19 | - [ ] Performance improvement 20 | - [ ] Test improvements 21 | 22 | ## Changes Made 23 | 24 | 25 | 26 | - 27 | - 28 | - 29 | 30 | ## Testing 31 | 32 | 33 | 34 | - [ ] All existing tests pass 35 | - [ ] Added new tests for the changes 36 | - [ ] Tested manually (describe below) 37 | 38 | ### Manual Testing Steps 39 | 40 | 41 | 42 | 1. 43 | 2. 44 | 3. 45 | 46 | ## Checklist 47 | 48 | - [ ] My code follows the project's style guidelines 49 | - [ ] I have performed a self-review of my code 50 | - [ ] I have commented my code where necessary 51 | - [ ] I have added/updated docstrings for all functions and classes 52 | - [ ] I have added type annotations to all functions and classes 53 | - [ ] My changes generate no new linting errors 54 | - [ ] I have added tests that prove my fix is effective or that my feature works 55 | - [ ] New and existing unit tests pass locally with my changes 56 | 57 | ## Screenshots (if applicable) 58 | 59 | 60 | 61 | ## Additional Context 62 | 63 | 64 | -------------------------------------------------------------------------------- /maskerlogger/utils.py: -------------------------------------------------------------------------------- 1 | import functools 2 | import threading 3 | from collections.abc import Callable 4 | from typing import Any, TypeVar 5 | 6 | F = TypeVar("F", bound=Callable[..., Any]) 7 | 8 | 9 | class TimeoutException(Exception): 10 | pass 11 | 12 | 13 | def timeout(seconds: int | float | Callable[..., int | float]) -> Callable[[F], F]: 14 | """ 15 | Decorator to enforce a timeout on function execution. 16 | 17 | The function runs in a daemon thread to prevent process exit issues. 18 | Note: The function will continue executing in the background even after 19 | timeout, but as a daemon thread it won't prevent process termination. 20 | """ 21 | 22 | def decorator(func: F) -> F: 23 | @functools.wraps(func) 24 | def wrapper(*args: Any, **kwargs: Any) -> Any: 25 | result: list[Any] = [None] 26 | exception: list[Exception | None] = [None] 27 | timeout_value: int | float = seconds(*args, **kwargs) if callable(seconds) else seconds 28 | if timeout_value <= 0: 29 | raise ValueError(f"Timeout value must be positive, got {timeout_value}") 30 | 31 | def target() -> None: 32 | try: 33 | result[0] = func(*args, **kwargs) 34 | except Exception as e: 35 | exception[0] = e 36 | 37 | thread = threading.Thread( 38 | target=target, daemon=True, name=f"timeout-thread-{func.__name__}" 39 | ) 40 | thread.start() 41 | thread.join(timeout=timeout_value) 42 | if thread.is_alive(): 43 | # Note: Daemon thread will be cleaned up by the OS when the main process exits 44 | raise TimeoutException(f"Function call exceeded {timeout_value} seconds") 45 | if exception[0]: 46 | raise exception[0] 47 | return result[0] 48 | 49 | return wrapper # type: ignore[return-value] 50 | 51 | return decorator 52 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.yml: -------------------------------------------------------------------------------- 1 | name: Feature Request 2 | description: Suggest a new feature or enhancement 3 | title: "[Feature]: " 4 | labels: ["enhancement"] 5 | body: 6 | - type: markdown 7 | attributes: 8 | value: | 9 | Thanks for suggesting a new feature! Please fill out the form below. 10 | 11 | - type: textarea 12 | id: problem 13 | attributes: 14 | label: Is your feature request related to a problem? 15 | description: A clear and concise description of what the problem is. 16 | placeholder: I'm always frustrated when... 17 | validations: 18 | required: true 19 | 20 | - type: textarea 21 | id: solution 22 | attributes: 23 | label: Describe the solution you'd like 24 | description: A clear and concise description of what you want to happen. 25 | placeholder: I would like to be able to... 26 | validations: 27 | required: true 28 | 29 | - type: textarea 30 | id: alternatives 31 | attributes: 32 | label: Describe alternatives you've considered 33 | description: A clear and concise description of any alternative solutions or features you've considered. 34 | placeholder: I've considered... 35 | validations: 36 | required: false 37 | 38 | - type: textarea 39 | id: example 40 | attributes: 41 | label: Code example 42 | description: If applicable, provide a code example of how you envision using this feature 43 | render: python 44 | placeholder: | 45 | from maskerlogger import MaskerFormatter 46 | 47 | # Example of how the feature would work 48 | 49 | - type: dropdown 50 | id: priority 51 | attributes: 52 | label: How important is this feature to you? 53 | options: 54 | - Nice to have 55 | - Important 56 | - Critical 57 | validations: 58 | required: true 59 | 60 | - type: textarea 61 | id: additional 62 | attributes: 63 | label: Additional context 64 | description: Add any other context, screenshots, or examples about the feature request here. 65 | placeholder: Any additional information 66 | validations: 67 | required: false 68 | 69 | - type: checkboxes 70 | id: checklist 71 | attributes: 72 | label: Checklist 73 | description: Please confirm the following 74 | options: 75 | - label: I have searched existing issues to ensure this feature hasn't been requested 76 | required: true 77 | - label: This feature aligns with the project's goals (masking sensitive data in logs) 78 | required: true 79 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "maskerlogger" 3 | version = "1.1.1" 4 | description = "mask your secrets from your logs" 5 | authors = [ 6 | {name = "Tamar Galer", email = "tamar@ox.security"}, 7 | {name = "Aviad Levy", email = "aviad@ox.security"} 8 | ] 9 | readme = "README.md" 10 | requires-python = ">=3.10" 11 | license = {text = "MIT"} 12 | classifiers = [ 13 | "Development Status :: 5 - Production/Stable", 14 | "Intended Audience :: Developers", 15 | "License :: OSI Approved :: MIT License", 16 | "Operating System :: OS Independent", 17 | "Programming Language :: Python :: 3", 18 | "Programming Language :: Python :: 3.10", 19 | "Programming Language :: Python :: 3.11", 20 | "Programming Language :: Python :: 3.12", 21 | "Programming Language :: Python :: 3.13", 22 | "Topic :: Software Development :: Libraries :: Python Modules", 23 | "Topic :: System :: Logging", 24 | ] 25 | dependencies = [ 26 | "pyahocorasick>=2.1.0,<3.0.0", 27 | "python-json-logger>=2.0.7,<4.0.0", 28 | "tomli>=2.0.0,<3.0.0", 29 | ] 30 | 31 | [project.urls] 32 | Source = "https://github.com/oxsecurity/MaskerLogger" 33 | Tracker = "https://github.com/oxsecurity/MaskerLogger/issues" 34 | 35 | [tool.poetry] 36 | packages = [{include = "maskerlogger"}] 37 | 38 | [tool.poetry.group.dev.dependencies] 39 | pytest = "^9.0.0" 40 | pytest-cov = "^7.0.0" 41 | ruff = "^0.14.4" 42 | pre-commit = "^4.5.0" 43 | mypy = "^1.7.0" 44 | 45 | [build-system] 46 | requires = ["poetry-core"] 47 | build-backend = "poetry.core.masonry.api" 48 | 49 | [tool.ruff] 50 | line-length = 100 51 | target-version = "py310" 52 | 53 | [tool.ruff.lint] 54 | select = [ 55 | "E", # pycodestyle errors 56 | "W", # pycodestyle warnings 57 | "F", # pyflakes 58 | "I", # isort 59 | "B", # flake8-bugbear 60 | "C4", # flake8-comprehensions 61 | "UP", # pyupgrade 62 | ] 63 | ignore = [ 64 | "E501", # line too long, handled by formatter 65 | ] 66 | 67 | [tool.ruff.format] 68 | quote-style = "double" 69 | indent-style = "space" 70 | 71 | [tool.coverage.run] 72 | source = ["maskerlogger"] 73 | omit = ["tests/*", "*/secrets_in_logs_example.py"] 74 | 75 | [tool.coverage.report] 76 | exclude_lines = [ 77 | "pragma: no cover", 78 | "def __repr__", 79 | "raise AssertionError", 80 | "raise NotImplementedError", 81 | "if __name__ == .__main__.:", 82 | ] 83 | 84 | [tool.mypy] 85 | python_version = "3.10" 86 | warn_return_any = true 87 | warn_unused_configs = true 88 | disallow_untyped_defs = true 89 | disallow_incomplete_defs = true 90 | check_untyped_defs = true 91 | disallow_untyped_decorators = false 92 | no_implicit_optional = true 93 | warn_redundant_casts = true 94 | warn_unused_ignores = true 95 | warn_no_return = true 96 | strict_equality = true 97 | ignore_missing_imports = true 98 | -------------------------------------------------------------------------------- /tests/test_timeout_decorator.py: -------------------------------------------------------------------------------- 1 | import threading 2 | import time 3 | 4 | import pytest 5 | 6 | from maskerlogger.utils import TimeoutException, timeout 7 | 8 | 9 | def test_timeout_creates_daemon_thread(): 10 | thread_refs = [] 11 | 12 | @timeout(1) 13 | def slow_function(): 14 | thread_refs.append(threading.current_thread()) 15 | time.sleep(0.1) 16 | return "completed" 17 | 18 | result = slow_function() 19 | assert result == "completed" 20 | assert len(thread_refs) == 1 21 | assert thread_refs[0].daemon is True 22 | 23 | 24 | def test_timeout_raises_exception_when_exceeded(): 25 | @timeout(1) 26 | def very_slow_function(): 27 | time.sleep(5) 28 | return "should not reach here" 29 | 30 | with pytest.raises(TimeoutException) as exc_info: 31 | very_slow_function() 32 | assert "exceeded 1 seconds" in str(exc_info.value) 33 | 34 | 35 | def test_timeout_with_callable_seconds(): 36 | @timeout(lambda: 2) 37 | def function_with_dynamic_timeout(): 38 | time.sleep(0.1) 39 | return "completed" 40 | 41 | result = function_with_dynamic_timeout() 42 | assert result == "completed" 43 | 44 | 45 | def test_timeout_with_callable_seconds_exceeds(): 46 | @timeout(lambda: 1) 47 | def slow_function_with_dynamic_timeout(): 48 | time.sleep(5) 49 | return "should not reach here" 50 | 51 | with pytest.raises(TimeoutException): 52 | slow_function_with_dynamic_timeout() 53 | 54 | 55 | def test_timeout_propagates_exceptions(): 56 | @timeout(2) 57 | def function_that_raises(): 58 | raise ValueError("Test exception") 59 | 60 | with pytest.raises(ValueError) as exc_info: 61 | function_that_raises() 62 | assert str(exc_info.value) == "Test exception" 63 | 64 | 65 | def test_timeout_with_method_timeout(): 66 | class TestClass: 67 | def __init__(self, timeout_value): 68 | self.timeout_value = timeout_value 69 | 70 | @timeout(lambda self: self.timeout_value) 71 | def method_with_timeout(self): 72 | time.sleep(0.1) 73 | return "completed" 74 | 75 | obj = TestClass(2) 76 | result = obj.method_with_timeout() 77 | assert result == "completed" 78 | 79 | 80 | def test_timeout_creates_daemon_threads_that_dont_block_exit(): 81 | active_threads_before = set(threading.enumerate()) 82 | 83 | @timeout(1) 84 | def timed_out_function(): 85 | time.sleep(5) 86 | 87 | for _ in range(3): 88 | try: 89 | timed_out_function() 90 | except TimeoutException: 91 | pass 92 | 93 | active_threads_after = set(threading.enumerate()) 94 | new_threads = active_threads_after - active_threads_before 95 | 96 | for thread in new_threads: 97 | assert thread.daemon is True 98 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.yml: -------------------------------------------------------------------------------- 1 | name: Bug Report 2 | description: Report a bug or unexpected behavior 3 | title: "[Bug]: " 4 | labels: ["bug"] 5 | body: 6 | - type: markdown 7 | attributes: 8 | value: | 9 | Thanks for taking the time to report this bug! Please fill out the form below. 10 | 11 | - type: textarea 12 | id: description 13 | attributes: 14 | label: Describe the bug 15 | description: A clear and concise description of what the bug is. 16 | placeholder: What happened? 17 | validations: 18 | required: true 19 | 20 | - type: textarea 21 | id: reproduction 22 | attributes: 23 | label: Steps to reproduce 24 | description: Steps to reproduce the behavior 25 | placeholder: | 26 | 1. Initialize MaskerFormatter with... 27 | 2. Log a message with... 28 | 3. Observe that... 29 | validations: 30 | required: true 31 | 32 | - type: textarea 33 | id: expected 34 | attributes: 35 | label: Expected behavior 36 | description: A clear and concise description of what you expected to happen. 37 | placeholder: What should have happened? 38 | validations: 39 | required: true 40 | 41 | - type: textarea 42 | id: actual 43 | attributes: 44 | label: Actual behavior 45 | description: What actually happened instead 46 | placeholder: What actually happened? 47 | validations: 48 | required: true 49 | 50 | - type: textarea 51 | id: code 52 | attributes: 53 | label: Code example 54 | description: Please provide a minimal code example that reproduces the issue 55 | render: python 56 | placeholder: | 57 | from maskerlogger import MaskerFormatter 58 | import logging 59 | 60 | # Your code here 61 | 62 | - type: input 63 | id: version 64 | attributes: 65 | label: MaskerLogger version 66 | description: What version of MaskerLogger are you using? 67 | placeholder: "0.4.0" 68 | validations: 69 | required: true 70 | 71 | - type: input 72 | id: python-version 73 | attributes: 74 | label: Python version 75 | description: What version of Python are you using? 76 | placeholder: "3.11.0" 77 | validations: 78 | required: true 79 | 80 | - type: input 81 | id: os 82 | attributes: 83 | label: Operating System 84 | description: What operating system are you using? 85 | placeholder: "Ubuntu 22.04, macOS 14, Windows 11, etc." 86 | validations: 87 | required: true 88 | 89 | - type: textarea 90 | id: additional 91 | attributes: 92 | label: Additional context 93 | description: Add any other context about the problem here (logs, screenshots, etc.) 94 | placeholder: Any additional information that might help 95 | validations: 96 | required: false 97 | 98 | - type: checkboxes 99 | id: checklist 100 | attributes: 101 | label: Checklist 102 | description: Please confirm the following 103 | options: 104 | - label: I have searched existing issues to ensure this is not a duplicate 105 | required: true 106 | - label: I have provided all requested information 107 | required: true 108 | -------------------------------------------------------------------------------- /.github/CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to MaskerLogger 2 | 3 | Thank you for your interest in contributing to MaskerLogger! We welcome contributions from the community. 4 | 5 | ## Getting Started 6 | 7 | 1. Fork the repository 8 | 2. Clone your fork: `git clone https://github.com/your-username/MaskerLogger.git` 9 | 3. Create a feature branch: `git checkout -b feature-branch` 10 | 4. Set up your development environment (see below) 11 | 12 | ## Development Setup 13 | 14 | MaskerLogger uses Poetry for dependency management. We recommend using UV for faster installations. 15 | 16 | ```bash 17 | # Install dependencies 18 | poetry install 19 | 20 | # Activate virtual environment 21 | poetry shell 22 | 23 | # Or use uv (if available) 24 | uv pip install -e ".[dev]" 25 | ``` 26 | 27 | ### Pre-commit Hooks 28 | 29 | We use pre-commit to ensure code quality before commits. Install the hooks: 30 | 31 | ```bash 32 | # Install pre-commit hooks 33 | poetry run pre-commit install 34 | 35 | # Run hooks manually on all files 36 | poetry run pre-commit run --all-files 37 | ``` 38 | 39 | The pre-commit hooks will automatically: 40 | - Run Ruff linting and formatting 41 | - Run mypy type checking 42 | - Check for trailing whitespace 43 | - Validate YAML and TOML files 44 | - Detect potential issues (large files, merge conflicts, private keys) 45 | - Verify Poetry configuration 46 | 47 | ## Code Style 48 | 49 | We use [Ruff](https://github.com/astral-sh/ruff) for linting and formatting, and [mypy](https://mypy.readthedocs.io/) for type checking: 50 | 51 | ```bash 52 | # Run linting 53 | poetry run ruff check . 54 | 55 | # Run formatting 56 | poetry run ruff format . 57 | 58 | # Run type checking 59 | poetry run mypy maskerlogger/ 60 | ``` 61 | 62 | ### Python Style Guidelines 63 | 64 | - Use type annotations for all functions and classes 65 | - Add docstrings to all functions and classes (PEP 257 convention) 66 | - Files should be concise (typically < 250 lines) 67 | - Functions should do one thing and be short 68 | - Use meaningful names that reveal intent 69 | - Follow the "return early" pattern instead of nested if/else 70 | - Don't repeat yourself - abstract and reuse code 71 | 72 | ## Testing 73 | 74 | We use pytest for testing. All tests must pass before submitting a PR. 75 | 76 | ```bash 77 | # Run tests 78 | poetry run pytest tests/ 79 | 80 | # Run tests with coverage 81 | poetry run pytest tests/ --cov=maskerlogger --cov-report=html 82 | ``` 83 | 84 | ### Test Guidelines 85 | 86 | - Use pytest (NOT unittest module) 87 | - Add type annotations to test functions 88 | - Test names should be self-explanatory (no docstrings needed) 89 | - All tests should be in the `./tests` directory 90 | - Create `__init__.py` files as needed 91 | 92 | ## Submitting Changes 93 | 94 | 1. Ensure all tests pass 95 | 2. Pre-commit hooks will automatically run on commit (or run manually: `poetry run pre-commit run --all-files`) 96 | 3. If you haven't set up pre-commit, ensure code passes linting: `poetry run ruff check .` 97 | 4. Format your code: `poetry run ruff format .` 98 | 5. Commit your changes with a descriptive message and push 99 | 6. Open a Pull Request 100 | 101 | ## Pull Request Process 102 | 103 | 1. Fill out the PR template completely 104 | 2. Link any related issues 105 | 3. Ensure all CI checks pass 106 | 4. Wait for review from maintainers 107 | 5. Address any feedback 108 | 6. Once approved, a maintainer will merge your PR 109 | 110 | ## Questions? 111 | 112 | If you have questions, feel free to: 113 | - Open an issue for discussion 114 | - Check existing issues and discussions 115 | - Reach out to the maintainers 116 | 117 | Thank you for contributing! 🎉 118 | -------------------------------------------------------------------------------- /.github/CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, sex characteristics, gender identity and expression, 9 | level of experience, education, socio-economic status, nationality, personal 10 | appearance, race, religion, or sexual identity and orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | * Using welcoming and inclusive language 18 | * Being respectful of differing viewpoints and experiences 19 | * Gracefully accepting constructive criticism 20 | * Focusing on what is best for the community 21 | * Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | * The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | * Trolling, insulting/derogatory comments, and personal or political attacks 28 | * Public or private harassment 29 | * Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | * Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that aren't aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies both within project spaces and in public spaces 49 | when an individual is representing the project or its community. Examples of 50 | representing a project or community include using an official project e-mail 51 | address, posting via an official social media account, or acting as an appointed 52 | representative at an online or offline event. Representation of a project may be 53 | further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of excessive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team at . All 59 | complaints will be reviewed and investigated and will result in a response that 60 | is deemed necessary and appropriate to the circumstances. The project team is 61 | obligated to maintain confidentiality with regard to the reporter of an incident. 62 | Further details of specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who don't follow or enforce the Code of Conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. 67 | 68 | ## Attribution 69 | 70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 71 | available at [https://www.contributor-covenant.org/version/1/4/code-of-conduct.html](https://www.contributor-covenant.org/version/1/4/code-of-conduct.html) 72 | 73 | [homepage]: https://www.contributor-covenant.org 74 | 75 | For answers to common questions about this code of conduct, see 76 | [https://www.contributor-covenant.org/faq](https://www.contributor-covenant.org/faq) 77 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Tests](https://github.com/oxsecurity/maskerlogger/actions/workflows/run-tests.yml/badge.svg)](https://github.com/oxsecurity/maskerlogger/actions/workflows/run-tests.yml) 2 | [![Lint](https://github.com/oxsecurity/maskerlogger/actions/workflows/lint.yml/badge.svg)](https://github.com/oxsecurity/maskerlogger/actions/workflows/lint.yml) 3 | [![Quality](https://github.com/oxsecurity/maskerlogger/actions/workflows/quality.yml/badge.svg)](https://github.com/oxsecurity/maskerlogger/actions/workflows/quality.yml) 4 | [![codecov](https://codecov.io/gh/oxsecurity/maskerlogger/branch/main/graph/badge.svg)](https://codecov.io/gh/oxsecurity/maskerlogger) 5 | [![PyPI version](https://badge.fury.io/py/maskerlogger.svg)](https://badge.fury.io/py/maskerlogger) 6 | [![Python](https://img.shields.io/pypi/pyversions/maskerlogger.svg)](https://pypi.org/project/maskerlogger/) 7 | [![Platform](https://img.shields.io/badge/platform-Linux%20%7C%20macOS%20%7C%20Windows-blue)](https://github.com/oxsecurity/maskerlogger) 8 | [![License](https://img.shields.io/github/license/oxsecurity/maskerlogger)](https://github.com/oxsecurity/maskerlogger/blob/main/LICENSE) 9 | [![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen.svg)](https://makeapullrequest.com) 10 | 11 | 12 | ![MaskerLoggerTitle](https://github.com/oxsecurity/MaskerLogger/assets/140309297/ae8ec8a7-9ec8-42f6-9640-6f9cd91e986e) 13 | 14 | # Masker Logger 15 | 16 | Keep Your logs safe! 17 | This formatter ensures the security of your logs and prevents sensitive data leaks. 18 | For example - 19 | Using this Formatter will print this line: 20 | `logger.info(f'Dont Give Your {secrets} away')` 21 | like this: 22 | `Dont Give Your ****** away` 23 | 24 | ## Getting started 25 | This formatter utilizes the standard `logging.Formatter` module. 26 | Before printing each record to any destination (file, stdout, etc.), it ensures sensitive data is masked with asterisks to prevent leaks. 27 | 28 | ### Requirements 29 | 30 | | MaskerLogger Version | Python Version | 31 | |---------------------|----------------| 32 | | 1.0.0+ | 3.10 - 3.13 | 33 | | < 1.0.0 | 3.9 - 3.13 | 34 | 35 | ### Install the library 36 | 37 | ``` 38 | pip install maskerlogger 39 | ``` 40 | 41 | ### Basic Usage 42 | 43 | Like any formatter - just init your logger handler with the MaskerLogger formatter. 44 | ``` 45 | from maskerlogger import MaskerFormatter 46 | logger = logging.getLogger('logger') 47 | logger.setLevel(logging.DEBUG) 48 | handler = logging.StreamHandler() 49 | handler.setFormatter( 50 | MaskerFormatter("%(asctime)s %(name)s %(levelname)s %(message)s")) 51 | logger.addHandler(handler) 52 | ``` 53 | #### skip masking 54 | If, for some reason, you want to disable masking on a specific log line, use the `SKIP_MASK` mechanism. 55 | ``` 56 | from maskerlogger import MaskerFormatter, SKIP_MASK 57 | ... 58 | ... 59 | logger.info('Line you want to skip', extra=SKIP_MASK) 60 | ``` 61 | 62 | #### redact 63 | Here’s a rewritten version suitable for inclusion in a README.md file: 64 | 65 | --- 66 | 67 | ### Partial Masking of Secrets 68 | If you prefer to mask only a portion of a secret (rather than its entire length), you can set the `redact` parameter in the formatter. The `redact` parameter specifies the percentage of the secret to be masked. 69 | 70 | Here’s an example of how to use it: 71 | 72 | ``` 73 | handler.setFormatter( 74 | MaskerFormatter("%(asctime)s %(name)s %(levelname)s %(message)s", 75 | redact=30)) 76 | ``` 77 | 78 | In this example, 30% of the secret will be masked. Adjust the `redact` value as needed to suit your requirements. 79 | 80 | ## The Config File 81 | 82 | Here's where the magic happens! 83 | Our tool is built upon the powerful Gitleaks tool, 84 | leveraging its default configuration to scan for sensitive data leaks in repositories. 85 | You can find the default configuration [here](https://github.com/gitleaks/gitleaks/blob/master/config/gitleaks.toml) 86 | 87 | #### Use custom config file 88 | 89 | To create and use your own config file, set the path when initializing the formatter: 90 | ``` 91 | handler.setFormatter( 92 | MaskerFormatter("%(asctime)s %(name)s %(levelname)s %(message)s", 93 | regex_config_path="your/config/gitleaks.toml")) 94 | ``` 95 | 96 | Good luck! 97 | 98 | 99 | ##### Brought to you by [OX Security](https://www.ox.security/) 100 | -------------------------------------------------------------------------------- /maskerlogger/ahocorasick_regex_match.py: -------------------------------------------------------------------------------- 1 | import re 2 | from typing import Any 3 | 4 | import ahocorasick 5 | import tomli as toml 6 | 7 | from maskerlogger.utils import timeout 8 | 9 | RULES_KEY = "rules" 10 | KEYWORDS_KEY = "keywords" 11 | REGEX_KEY = "regex" 12 | 13 | 14 | class RegexMatcher: 15 | """Efficient regex matcher using Aho-Corasick algorithm for keyword detection. 16 | 17 | This class loads regex patterns from a TOML configuration file and uses the 18 | Aho-Corasick algorithm to efficiently detect keywords before applying regex matching. 19 | This two-stage approach significantly improves performance for large pattern sets. 20 | """ 21 | 22 | def __init__(self, config_path: str, timeout_seconds: int = 3) -> None: 23 | """Initialize the RegexMatcher. 24 | 25 | Args: 26 | config_path: Path to the TOML configuration file. 27 | timeout_seconds: Timeout for individual regex operations. 28 | 29 | Raises: 30 | FileNotFoundError: If config file doesn't exist. 31 | ValueError: If config is malformed or contains invalid regex patterns. 32 | """ 33 | config = self._load_config(config_path) 34 | self.keyword_to_patterns = self._extract_keywords_and_patterns(config) 35 | self.automaton = self._initialize_automaton() 36 | self.timeout_seconds = timeout_seconds 37 | 38 | def _initialize_automaton(self) -> ahocorasick.Automaton: 39 | keyword_automaton = ahocorasick.Automaton() 40 | for keyword, regexs in self.keyword_to_patterns.items(): 41 | keyword_automaton.add_word(keyword, (regexs)) 42 | keyword_automaton.make_automaton() 43 | return keyword_automaton 44 | 45 | @staticmethod 46 | def _load_config(config_path: str) -> dict[str, Any]: 47 | try: 48 | with open(config_path, "rb") as f: 49 | return toml.load(f) # type: ignore[no-any-return] 50 | except FileNotFoundError as e: 51 | raise FileNotFoundError(f"Configuration file not found: {config_path}") from e 52 | except Exception as e: 53 | raise ValueError(f"Failed to load configuration from {config_path}: {e}") from e 54 | 55 | def _extract_keywords_and_patterns( 56 | self, config: dict[str, Any] 57 | ) -> dict[str, list[re.Pattern[str]]]: 58 | if RULES_KEY not in config: 59 | raise ValueError(f"Configuration must contain a '{RULES_KEY}' key") 60 | 61 | keyword_to_patterns: dict[str, list[re.Pattern[str]]] = {} 62 | for rule in config[RULES_KEY]: 63 | for keyword in rule.get(KEYWORDS_KEY, []): 64 | if keyword not in keyword_to_patterns: 65 | keyword_to_patterns[keyword] = [] 66 | 67 | keyword_to_patterns[keyword].append(self._get_compiled_regex(rule[REGEX_KEY])) 68 | 69 | return keyword_to_patterns 70 | 71 | def safe_compile(self, pattern: str, flags: int = 0) -> re.Pattern[str]: 72 | """ 73 | Safely compile Gitleaks (Go-style) regex for Python. 74 | Handles bad escapes like \\z. 75 | Preserves valid Python regex anchors like \\A, \\Z. 76 | Preserves regex escape sequences like \b, \\w, \\d, etc. 77 | """ 78 | # Replace PCRE/Go-only tokens with Python equivalents 79 | pattern = pattern.replace(r"\z", r"\Z") 80 | 81 | return re.compile(pattern, flags) 82 | 83 | def _get_compiled_regex(self, regex: str) -> re.Pattern[str]: 84 | try: 85 | if "(?i)" in regex: 86 | regex = regex.replace("(?i)", "") 87 | return self.safe_compile(regex, re.IGNORECASE) 88 | return self.safe_compile(regex) 89 | except re.error as e: 90 | raise ValueError(f"Invalid regex pattern '{regex}': {e}") from e 91 | 92 | def _filter_by_keywords(self, line: str) -> set[re.Pattern[str]]: 93 | matched_regexes: set[re.Pattern[str]] = set() 94 | for _end_index, regex_values in self.automaton.iter(line): 95 | matched_regexes.update(regex_values) 96 | return matched_regexes 97 | 98 | @timeout(lambda self, *args, **kwargs: self.timeout_seconds) 99 | def _get_match_regex( 100 | self, line: str, matched_regex: list[re.Pattern[str]] 101 | ) -> list[re.Match[str]]: 102 | matches: list[re.Match[str]] = [] 103 | for regex in matched_regex: 104 | matches.extend(regex.finditer(line)) 105 | return matches 106 | 107 | def match_regex_to_line(self, line: str) -> list[re.Match[str]] | None: 108 | lower_case_line = line.lower() 109 | if matched_regxes := self._filter_by_keywords(lower_case_line): 110 | return self._get_match_regex(line, list(matched_regxes)) 111 | return None 112 | -------------------------------------------------------------------------------- /maskerlogger/masker_formatter.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import os 3 | import re 4 | from abc import ABC 5 | 6 | try: 7 | from pythonjsonlogger import json as jsonlogger 8 | except ImportError: 9 | from pythonjsonlogger import jsonlogger 10 | 11 | from maskerlogger.ahocorasick_regex_match import RegexMatcher 12 | from maskerlogger.utils import TimeoutException 13 | 14 | DEFAULT_SECRETS_CONFIG_PATH = os.path.join(os.path.dirname(__file__), "config/gitleaks.toml") 15 | _APPLY_MASK = "apply_mask" 16 | SKIP_MASK = {_APPLY_MASK: False} 17 | 18 | 19 | class AbstractMaskedLogger(ABC): # noqa B024 20 | """Abstract base class for loggers that mask sensitive data in log messages. 21 | 22 | This class provides the core functionality for detecting and masking sensitive 23 | information in log messages using regex patterns configured in a TOML file. 24 | """ 25 | 26 | def __init__( 27 | self, 28 | regex_config_path: str = DEFAULT_SECRETS_CONFIG_PATH, 29 | redact: int = 100, 30 | timeout_seconds: int = 3, 31 | ) -> None: 32 | """Initialize the AbstractMaskedLogger. 33 | 34 | Args: 35 | regex_config_path: Path to the TOML configuration file containing regex patterns. 36 | redact: Percentage of sensitive data to redact (0-100). 100 means full masking. 37 | timeout_seconds: Timeout in seconds for regex matching operations to prevent hangs. 38 | 39 | Raises: 40 | FileNotFoundError: If the configuration file is not found. 41 | ValueError: If redact percentage is invalid or configuration is malformed. 42 | """ 43 | self.regex_matcher = RegexMatcher(regex_config_path, timeout_seconds) 44 | self.redact = self._validate_redact(redact) 45 | 46 | @staticmethod 47 | def _validate_redact(redact: int | str) -> int: 48 | try: 49 | redact_int = int(redact) 50 | except (ValueError, TypeError) as e: 51 | raise ValueError( 52 | f"Redact value must be a number, got {type(redact).__name__}: {redact}" 53 | ) from e 54 | 55 | if not (0 <= redact_int <= 100): 56 | raise ValueError("Redact value must be between 0 and 100") 57 | 58 | return redact_int 59 | 60 | def _mask_secret(self, msg: str, matches: list[re.Match]) -> str: 61 | """Masks the sensitive data in the log message.""" 62 | if not matches: 63 | return msg 64 | 65 | # Create a character array to track which positions should be masked 66 | # Each element will be True if that character should be masked 67 | mask_positions = [False] * len(msg) 68 | 69 | # Process all matches and mark positions for masking 70 | for match in matches: 71 | masked_something = False 72 | 73 | # If there are capture groups, try to process each group (starting from 1) 74 | if match.groups(): 75 | for group_index in range(1, len(match.groups()) + 1): 76 | group = match.group(group_index) 77 | if group: # Process non-empty groups 78 | group_start = match.start(group_index) 79 | group_end = match.end(group_index) 80 | redact_length = int((len(group) / 100) * self.redact) 81 | 82 | # Mark positions for masking (only the first redact_length characters) 83 | for pos in range(group_start, min(group_start + redact_length, group_end)): 84 | mask_positions[pos] = True 85 | masked_something = True 86 | 87 | # If no capture groups exist, or all capture groups were None/empty, 88 | # fall back to masking the entire match (group 0) 89 | if not masked_something: 90 | full_match = match.group(0) 91 | if full_match: 92 | group_start = match.start(0) 93 | group_end = match.end(0) 94 | redact_length = int((len(full_match) / 100) * self.redact) 95 | 96 | # Mark positions for masking (only the first redact_length characters) 97 | for pos in range(group_start, min(group_start + redact_length, group_end)): 98 | mask_positions[pos] = True 99 | 100 | # Build the masked string by replacing marked positions with asterisks 101 | result = [] 102 | for i, char in enumerate(msg): 103 | if mask_positions[i]: 104 | result.append("*") 105 | else: 106 | result.append(char) 107 | 108 | return "".join(result) 109 | 110 | def _mask_sensitive_data(self, record: logging.LogRecord) -> None: 111 | """Applies masking to the sensitive data in the log message and traceback.""" 112 | try: 113 | # Mask the main log message 114 | if found_matching_regex := self.regex_matcher.match_regex_to_line(record.msg): 115 | record.msg = self._mask_secret(record.msg, found_matching_regex) 116 | 117 | # Mask exc_text if present 118 | if hasattr(record, "exc_text") and record.exc_text: 119 | if found_matching_regex := self.regex_matcher.match_regex_to_line(record.exc_text): 120 | record.exc_text = self._mask_secret(record.exc_text, found_matching_regex) 121 | 122 | # Mask exc_info (traceback) if present and exc_text not present 123 | elif hasattr(record, "exc_info") and record.exc_info: 124 | import traceback 125 | 126 | exc_type, exc_value, exc_tb = record.exc_info 127 | tb_str = "".join(traceback.format_exception(exc_type, exc_value, exc_tb)) 128 | if found_matching_regex := self.regex_matcher.match_regex_to_line(tb_str): 129 | masked_tb_str = self._mask_secret(tb_str, found_matching_regex) 130 | record.exc_text = masked_tb_str # exc_text is used by logging.Formatter 131 | except TimeoutException: 132 | pass 133 | 134 | 135 | # Normal Masked Logger - Text-Based Log Formatter 136 | class MaskerFormatter(logging.Formatter, AbstractMaskedLogger): 137 | def __init__( 138 | self, 139 | fmt: str, 140 | regex_config_path: str = DEFAULT_SECRETS_CONFIG_PATH, 141 | redact: int = 100, 142 | timeout_seconds: int = 3, 143 | ) -> None: 144 | """Initializes the MaskerFormatter. 145 | 146 | Args: 147 | fmt (str): Format string for the logger. 148 | regex_config_path (str): Path to the configuration file for regex patterns. 149 | redact (int): Percentage of the sensitive data to redact. 150 | timeout_seconds (int): Timeout in seconds for regex matching operations. 151 | """ 152 | logging.Formatter.__init__(self, fmt) 153 | AbstractMaskedLogger.__init__(self, regex_config_path, redact, timeout_seconds) 154 | 155 | def format(self, record: logging.LogRecord) -> str: 156 | """Formats the log record as text and applies masking.""" 157 | if getattr(record, _APPLY_MASK, True): 158 | self._mask_sensitive_data(record) 159 | 160 | return super().format(record) 161 | 162 | 163 | # JSON Masked Logger - JSON-Based Log Formatter 164 | class MaskerFormatterJson(jsonlogger.JsonFormatter, AbstractMaskedLogger): 165 | def __init__( 166 | self, 167 | fmt: str, 168 | regex_config_path: str = DEFAULT_SECRETS_CONFIG_PATH, 169 | redact: int = 100, 170 | timeout_seconds: int = 3, 171 | ) -> None: 172 | """Initializes the MaskerFormatterJson. 173 | 174 | Args: 175 | fmt (str): Format string for the logger. 176 | regex_config_path (str): Path to the configuration file for regex patterns. 177 | redact (int): Percentage of the sensitive data to redact. 178 | timeout_seconds (int): Timeout in seconds for regex matching operations. 179 | """ 180 | jsonlogger.JsonFormatter.__init__(self, fmt) 181 | AbstractMaskedLogger.__init__(self, regex_config_path, redact, timeout_seconds) 182 | 183 | def format(self, record: logging.LogRecord) -> str: 184 | """Formats the log record as JSON and applies masking.""" 185 | if getattr(record, _APPLY_MASK, True): 186 | self._mask_sensitive_data(record) 187 | 188 | return str(super().format(record)) 189 | 190 | def formatException(self, exc_info: tuple[type, BaseException, object]) -> str: 191 | # Get the formatted exception string from the base class 192 | formatted = super().formatException(exc_info) 193 | # Mask sensitive data in the formatted exception string 194 | if found_matching_regex := self.regex_matcher.match_regex_to_line(formatted): 195 | return self._mask_secret(formatted, found_matching_regex) 196 | return str(formatted) 197 | -------------------------------------------------------------------------------- /tests/test_gitleaks_config.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | import pytest 4 | import tomli 5 | 6 | from maskerlogger.ahocorasick_regex_match import RegexMatcher 7 | 8 | GITLEAKS_CONFIG_PATH = os.path.join( 9 | os.path.dirname(__file__), "..", "maskerlogger", "config", "gitleaks.toml" 10 | ) 11 | 12 | 13 | @pytest.fixture 14 | def config_path() -> str: 15 | return GITLEAKS_CONFIG_PATH 16 | 17 | 18 | @pytest.fixture 19 | def regex_matcher(config_path: str) -> RegexMatcher: 20 | return RegexMatcher(config_path) 21 | 22 | 23 | def test_config_file_exists(config_path: str) -> None: 24 | assert os.path.exists(config_path), f"Config file not found at {config_path}" 25 | assert os.path.isfile(config_path), f"Config path is not a file: {config_path}" 26 | 27 | 28 | def test_config_file_loads_successfully(config_path: str) -> None: 29 | matcher = RegexMatcher(config_path) 30 | assert matcher is not None 31 | assert matcher.keyword_to_patterns is not None 32 | assert len(matcher.keyword_to_patterns) > 0 33 | 34 | 35 | def test_config_contains_rules(regex_matcher: RegexMatcher) -> None: 36 | assert len(regex_matcher.keyword_to_patterns) > 0 37 | 38 | 39 | def test_all_regex_patterns_compile(regex_matcher: RegexMatcher) -> None: 40 | for keyword, patterns in regex_matcher.keyword_to_patterns.items(): 41 | assert len(patterns) > 0, f"Keyword '{keyword}' has no patterns" 42 | for pattern in patterns: 43 | assert pattern is not None, f"Pattern for keyword '{keyword}' is None" 44 | 45 | 46 | def test_automaton_initializes_successfully(regex_matcher: RegexMatcher) -> None: 47 | assert regex_matcher.automaton is not None 48 | assert len(regex_matcher.automaton) > 0 49 | 50 | 51 | @pytest.mark.parametrize( 52 | "test_case", 53 | [ 54 | "ghp_123456789012345678901234567890123456", 55 | "token=ghp_abcdefghijklmnopqrstuvwxyz123456", 56 | "GITHUB_TOKEN=ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZ123456", 57 | ], 58 | ) 59 | def test_github_pat_detection(regex_matcher: RegexMatcher, test_case: str) -> None: 60 | matches = regex_matcher.match_regex_to_line(test_case) 61 | assert matches is not None, f"Failed to detect GitHub PAT in: {test_case}" 62 | assert len(matches) > 0, f"No matches found for: {test_case}" 63 | 64 | 65 | @pytest.mark.parametrize( 66 | "test_case", 67 | [ 68 | "AKIAIOSFODNN7EXAMPLE", 69 | "aws_access_key_id=AKIAIOSFODNN7EXAMPLE", 70 | "ASIAIOSFODNN7EXAMPLE", 71 | ], 72 | ) 73 | def test_aws_access_key_detection(regex_matcher: RegexMatcher, test_case: str) -> None: 74 | matches = regex_matcher.match_regex_to_line(test_case) 75 | assert matches is not None, f"Failed to detect AWS key in: {test_case}" 76 | assert len(matches) > 0, f"No matches found for: {test_case}" 77 | 78 | 79 | @pytest.mark.parametrize( 80 | "test_case", 81 | [ 82 | "xoxb-1234567890-1234567890123-abcdefghijklmnopqrstuvwx", 83 | "slack_token=xoxb-1234567890-1234567890123-abcdefghijklmnopqrstuvwx", 84 | "xoxp-1234567890123-1234567890123-1234567890123-abcdefghijklmnopqrstuvwxyz1234", 85 | ], 86 | ) 87 | def test_slack_token_detection(regex_matcher: RegexMatcher, test_case: str) -> None: 88 | matches = regex_matcher.match_regex_to_line(test_case) 89 | assert matches is not None, f"Failed to detect Slack token in: {test_case}" 90 | assert len(matches) > 0, f"No matches found for: {test_case}" 91 | 92 | 93 | @pytest.mark.parametrize( 94 | "test_case", 95 | [ 96 | "sk_test_1234567890123456789012345678901234567890", 97 | "STRIPE_KEY=sk_live_abcdefghijklmnopqrstuvwxyz1234567890", 98 | "rk_test_1234567890123456789012345678901234567890", 99 | ], 100 | ) 101 | def test_stripe_key_detection(regex_matcher: RegexMatcher, test_case: str) -> None: 102 | matches = regex_matcher.match_regex_to_line(test_case) 103 | assert matches is not None, f"Failed to detect Stripe key in: {test_case}" 104 | assert len(matches) > 0, f"No matches found for: {test_case}" 105 | 106 | 107 | @pytest.mark.parametrize( 108 | "test_case", 109 | [ 110 | "sk-12345678901234567890T3BlbkFJ12345678901234567890", 111 | "OPENAI_API_KEY=sk-12345678901234567890T3BlbkFJ12345678901234567890", 112 | ], 113 | ) 114 | def test_openai_api_key_detection(regex_matcher: RegexMatcher, test_case: str) -> None: 115 | matches = regex_matcher.match_regex_to_line(test_case) 116 | assert matches is not None, f"Failed to detect OpenAI key in: {test_case}" 117 | assert len(matches) > 0, f"No matches found for: {test_case}" 118 | 119 | 120 | @pytest.mark.parametrize( 121 | "test_case", 122 | [ 123 | "-----BEGIN RSA PRIVATE KEY-----\nMIIEpAIBAAKCAQEA0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOP\n-----END RSA PRIVATE KEY-----", 124 | "-----BEGIN RSA PRIVATE KEY-----\nMIIBOgIBAAJBAKj34GkxFhD90vcNLYLInFEX6Ppy1tPf9Cnzj4p4WGeKLs1Pt8Qu\nKUpRKfFLfRYC9AIKjbJTWit+CqvjWYzvQwECAwEAAQJAIJLixBy2qpFoS4DSmoEm\no3qGy0t6z09AIJtH+5OeRV1be+N4cDYJKffGzDa88vQENZiRm0GRq6a+HPGQMd2k\nTQIhAKMSvzIBnni7ot/OSie2TmJLY4SwTQAevXysE2RbFDYdAiEBCUEaRQnMnbp7\n9mxDXDf6AU0cN/RPBjb9qSHDcWZHGzUCIG2Es59z8ugGrDY+pxLQnwfotadxd+Uy\nv/Ow5T0q5gIJAiEAyS4RaI9YG8EWx/2w0T67ZUVAw8eOMB6BIUg0Xcu+3okCIBOs\n/5OiPgoTdSy7bcF9IGpSE8ZgGKzgYQVZeN97YE00\n-----END RSA PRIVATE KEY-----", # noqa 125 | ], 126 | ) 127 | def test_private_key_detection(regex_matcher: RegexMatcher, test_case: str) -> None: 128 | matches = regex_matcher.match_regex_to_line(test_case) 129 | assert matches is not None, f"Failed to detect private key in: {test_case}" 130 | assert len(matches) > 0, f"No matches found for: {test_case}" 131 | 132 | 133 | @pytest.mark.parametrize( 134 | "test_case", 135 | [ 136 | "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c", 137 | "token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c", 138 | ], 139 | ) 140 | def test_jwt_token_detection(regex_matcher: RegexMatcher, test_case: str) -> None: 141 | matches = regex_matcher.match_regex_to_line(test_case) 142 | assert matches is not None, f"Failed to detect JWT in: {test_case}" 143 | assert len(matches) > 0, f"No matches found for: {test_case}" 144 | 145 | 146 | @pytest.mark.parametrize( 147 | "test_case", 148 | [ 149 | "api_key=123456789012345678901234567890", 150 | "API_TOKEN=abcdefghijklmnopqrstuvwxyz123456", 151 | "secret=test_secret_key_12345678901234567890", 152 | ], 153 | ) 154 | def test_generic_api_key_detection(regex_matcher: RegexMatcher, test_case: str) -> None: 155 | matches = regex_matcher.match_regex_to_line(test_case) 156 | assert matches is not None, f"Failed to detect generic API key in: {test_case}" 157 | assert len(matches) > 0, f"No matches found for: {test_case}" 158 | 159 | 160 | @pytest.mark.parametrize( 161 | "test_case", 162 | [ 163 | "This is a regular log message", 164 | "User logged in successfully", 165 | "Processing request 12345", 166 | "Error occurred at line 42", 167 | ], 168 | ) 169 | def test_non_sensitive_data_not_detected(regex_matcher: RegexMatcher, test_case: str) -> None: 170 | matches = regex_matcher.match_regex_to_line(test_case) 171 | assert matches is None, f"False positive detected in: {test_case}" 172 | 173 | 174 | @pytest.mark.parametrize( 175 | "test_case", 176 | [ 177 | "GITHUB_TOKEN=ghp_123456789012345678901234567890123456", 178 | "github_token=ghp_123456789012345678901234567890123456", 179 | "GitHub_Token=ghp_123456789012345678901234567890123456", 180 | ], 181 | ) 182 | def test_keyword_case_insensitivity(regex_matcher: RegexMatcher, test_case: str) -> None: 183 | matches = regex_matcher.match_regex_to_line(test_case) 184 | assert matches is not None, f"Failed to detect token with case variation: {test_case}" 185 | assert len(matches) > 0 186 | 187 | 188 | def test_multiple_secrets_in_one_line(regex_matcher: RegexMatcher) -> None: 189 | test_line = ( 190 | "github_token=ghp_123456789012345678901234567890123456 and aws_key=AKIAIOSFODNN7EXAMPLE" 191 | ) 192 | matches = regex_matcher.match_regex_to_line(test_line) 193 | assert matches is not None 194 | assert len(matches) > 0 195 | 196 | 197 | def test_keyword_to_patterns_structure(regex_matcher: RegexMatcher) -> None: 198 | for keyword, patterns in regex_matcher.keyword_to_patterns.items(): 199 | assert isinstance(keyword, str), f"Keyword must be string, got {type(keyword)}" 200 | assert len(keyword) > 0, "Keyword cannot be empty" 201 | assert isinstance(patterns, list), f"Patterns must be list, got {type(patterns)}" 202 | assert len(patterns) > 0, f"Keyword '{keyword}' must have at least one pattern" 203 | 204 | 205 | def test_automaton_keyword_matching(regex_matcher: RegexMatcher) -> None: 206 | test_keywords = ["github", "aws", "slack", "stripe", "api", "token"] 207 | for keyword in test_keywords: 208 | if keyword.lower() in regex_matcher.keyword_to_patterns: 209 | test_line = f"test_{keyword}_value=some_secret_123" 210 | matches = regex_matcher.match_regex_to_line(test_line) 211 | assert matches is not None or keyword not in ["github", "aws", "slack", "stripe"] 212 | 213 | 214 | def test_config_file_is_valid_toml(config_path: str) -> None: 215 | with open(config_path, "rb") as f: 216 | config = tomli.load(f) 217 | assert "rules" in config, "Config must contain 'rules' key" 218 | assert isinstance(config["rules"], list), "Rules must be a list" 219 | assert len(config["rules"]) > 0, "Config must contain at least one rule" 220 | 221 | 222 | def test_all_rules_have_required_fields(config_path: str) -> None: 223 | with open(config_path, "rb") as f: 224 | config = tomli.load(f) 225 | for rule in config["rules"]: 226 | assert "id" in rule, f"Rule missing 'id' field: {rule}" 227 | assert "regex" in rule, f"Rule missing 'regex' field: {rule.get('id', 'unknown')}" 228 | assert isinstance(rule["regex"], str), ( 229 | f"Rule regex must be string: {rule.get('id', 'unknown')}" 230 | ) 231 | assert len(rule["regex"]) > 0, ( 232 | f"Rule regex cannot be empty: {rule.get('id', 'unknown')}" 233 | ) 234 | 235 | 236 | def test_keywords_extraction_from_config(config_path: str) -> None: 237 | with open(config_path, "rb") as f: 238 | config = tomli.load(f) 239 | rules_with_keywords = [ 240 | r for r in config["rules"] if "keywords" in r and len(r["keywords"]) > 0 241 | ] 242 | assert len(rules_with_keywords) > 0, "At least some rules should have keywords" 243 | 244 | 245 | def test_regex_patterns_are_valid(regex_matcher: RegexMatcher) -> None: 246 | for keyword, patterns in regex_matcher.keyword_to_patterns.items(): 247 | for pattern in patterns: 248 | try: 249 | test_string = "test_string_for_pattern_validation" 250 | pattern.search(test_string) 251 | except Exception as e: 252 | pytest.fail(f"Pattern for keyword '{keyword}' failed validation: {e}") 253 | 254 | 255 | def test_timeout_configuration(regex_matcher: RegexMatcher) -> None: 256 | assert regex_matcher.timeout_seconds > 0 257 | assert isinstance(regex_matcher.timeout_seconds, int) 258 | 259 | 260 | def test_specific_rule_ids_exist(config_path: str) -> None: 261 | with open(config_path, "rb") as f: 262 | config = tomli.load(f) 263 | rule_ids = [rule["id"] for rule in config["rules"]] 264 | expected_rules = [ 265 | "github-pat", 266 | "aws-access-token", 267 | "slack-bot-token", 268 | "stripe-access-token", 269 | "openai-api-key", 270 | "private-key", 271 | "jwt", 272 | "generic-api-key", 273 | ] 274 | for expected_rule in expected_rules: 275 | assert expected_rule in rule_ids, f"Expected rule '{expected_rule}' not found in config" 276 | -------------------------------------------------------------------------------- /tests/test_masked_logger.py: -------------------------------------------------------------------------------- 1 | import json 2 | import logging 3 | from io import StringIO 4 | from unittest.mock import patch 5 | 6 | import pytest 7 | 8 | from maskerlogger import MaskerFormatter, MaskerFormatterJson 9 | from maskerlogger.utils import TimeoutException 10 | 11 | 12 | @pytest.fixture 13 | def logger_and_log_stream(): 14 | """ 15 | Pytest fixture to set up the logger and a StringIO stream for capturing log output. 16 | 17 | Returns: 18 | tuple: A logger instance and a StringIO object to capture the log output. 19 | """ 20 | logger = logging.getLogger("test_logger") 21 | logger.setLevel(logging.DEBUG) 22 | logger.handlers.clear() 23 | log_stream = StringIO() 24 | 25 | # Create console handler and set formatter 26 | console_handler = logging.StreamHandler(log_stream) 27 | logger.addHandler(console_handler) 28 | 29 | return logger, log_stream 30 | 31 | 32 | @pytest.fixture 33 | def log_format(): 34 | return "%(asctime)s %(name)s %(levelname)s %(message)s" 35 | 36 | 37 | def test_masked_logger_text(logger_and_log_stream, log_format): 38 | """ 39 | Test the functionality of MaskerFormatter, ensuring it formats logs in plain text 40 | and masks sensitive data correctly. 41 | 42 | Args: 43 | logger_and_log_stream (tuple): A tuple containing the logger and log stream. 44 | """ 45 | logger, log_stream = logger_and_log_stream 46 | 47 | # Set the MaskerFormatter formatter 48 | formatter = MaskerFormatter(fmt=log_format) 49 | logger.handlers[0].setFormatter(formatter) 50 | 51 | # Log a sensitive message 52 | logger.info("User login with password=secretpassword") 53 | 54 | # Read and parse the log output 55 | log_output = log_stream.getvalue().strip() 56 | 57 | # Validate that the password is masked in the text log output 58 | assert "password=*****" in log_output 59 | assert "secretpassword" not in log_output 60 | 61 | 62 | def test_masked_logger_json(logger_and_log_stream, log_format): 63 | """ 64 | Test the functionality of MaskerFormatterJson, ensuring it formats logs in JSON format 65 | and masks sensitive data correctly. 66 | 67 | Args: 68 | logger_and_log_stream (tuple): A tuple containing the logger and log stream. 69 | """ 70 | logger, log_stream = logger_and_log_stream 71 | 72 | # Set the MaskerFormatterJson formatter 73 | formatter = MaskerFormatterJson(fmt=log_format) 74 | logger.handlers[0].setFormatter(formatter) 75 | 76 | # Log a sensitive message 77 | logger.info("User login with password=secretpassword") 78 | 79 | # Read and parse the log output 80 | log_output = log_stream.getvalue().strip() 81 | log_json = json.loads(log_output) # Parse the JSON log output 82 | 83 | # Validate that the password is masked in the JSON log output 84 | assert "password=*****" in log_json["message"] 85 | assert "secretpassword" not in log_json["message"] 86 | 87 | 88 | def test_masked_logger_text_format_after_masking(logger_and_log_stream, log_format): 89 | """ 90 | Test that MaskerFormatter outputs correctly formatted text logs after applying data masking. 91 | Ensures that sensitive data is masked and log format remains valid. 92 | 93 | Args: 94 | logger_and_log_stream (tuple): A tuple containing the logger and log stream. 95 | """ 96 | logger, log_stream = logger_and_log_stream 97 | 98 | # Set the MaskerFormatter formatter 99 | formatter = MaskerFormatter(fmt=log_format) 100 | logger.handlers[0].setFormatter(formatter) 101 | 102 | # Log a sensitive message 103 | logger.info("Sensitive data: password=secretpassword and other info") 104 | 105 | # Read and parse the log output 106 | log_output = log_stream.getvalue().strip() 107 | 108 | # Validate that the password is masked and the log format is correct 109 | assert "password=*****" in log_output 110 | assert "secretpassword" not in log_output 111 | 112 | 113 | def test_masked_logger_json_format_after_masking(logger_and_log_stream, log_format): 114 | """ 115 | Test that MaskerFormatterJson outputs correctly formatted JSON logs after applying data masking. 116 | Ensures that sensitive data is masked and log format remains valid. 117 | 118 | Args: 119 | logger_and_log_stream (tuple): A tuple containing the logger and log stream. 120 | """ 121 | logger, log_stream = logger_and_log_stream 122 | 123 | # Set the MaskerFormatterJson formatter 124 | formatter = MaskerFormatterJson(fmt=log_format) 125 | 126 | logger.handlers[0].setFormatter(formatter) 127 | 128 | # Log a sensitive message 129 | logger.info("Sensitive data: password=secretpassword and other info") 130 | 131 | # Read and parse the log output 132 | log_output = log_stream.getvalue().strip() 133 | log_json = json.loads(log_output) # Parse the JSON log output 134 | 135 | # Validate that the password is masked and the JSON log format is correct 136 | assert "password=*****" in log_json["message"] 137 | assert "secretpassword" not in log_json["message"] 138 | 139 | 140 | def test_masked_logger_non_sensitive_data(logger_and_log_stream, log_format): 141 | """ 142 | Test that non-sensitive log messages are logged without modification, 143 | ensuring they are formatted correctly in both text and JSON formats. 144 | 145 | Args: 146 | logger_and_log_stream (tuple): A tuple containing the logger and log stream. 147 | """ 148 | logger, log_stream = logger_and_log_stream 149 | 150 | # Set the MaskerFormatter formatter for testing non-sensitive data 151 | formatter = MaskerFormatter(fmt=log_format) 152 | logger.handlers[0].setFormatter(formatter) 153 | 154 | # Log a non-sensitive message 155 | non_sensitive_msg = "This is a regular log message." 156 | logger.info(non_sensitive_msg) 157 | 158 | # Read and parse the log output 159 | log_output = log_stream.getvalue().strip() 160 | 161 | # Ensure the non-sensitive message is logged without any masking 162 | assert non_sensitive_msg in log_output 163 | 164 | 165 | def test_masked_logger_handles_timeout_gracefully(logger_and_log_stream, log_format): 166 | logger, log_stream = logger_and_log_stream 167 | formatter = MaskerFormatter(fmt=log_format) 168 | logger.handlers[0].setFormatter(formatter) 169 | 170 | with patch.object( 171 | formatter.regex_matcher, 172 | "match_regex_to_line", 173 | side_effect=TimeoutException("Regex matching timeout"), 174 | ): 175 | sensitive_msg = "User login with password=secretpassword" 176 | logger.info(sensitive_msg) 177 | 178 | log_output = log_stream.getvalue().strip() 179 | 180 | assert sensitive_msg in log_output 181 | assert log_output is not None 182 | 183 | 184 | def test_redact_validation_valid_values(): 185 | """Test that valid redact values (0-100) are accepted.""" 186 | # Test boundary values 187 | MaskerFormatter(fmt="%(message)s", redact=0) 188 | MaskerFormatter(fmt="%(message)s", redact=50) 189 | MaskerFormatter(fmt="%(message)s", redact=100) 190 | 191 | # Test valid integer values 192 | MaskerFormatter(fmt="%(message)s", redact=25) 193 | MaskerFormatter(fmt="%(message)s", redact=75) 194 | 195 | 196 | def test_redact_validation_invalid_values(): 197 | """Test that invalid redact values raise ValueError.""" 198 | # Test negative values 199 | with pytest.raises(ValueError, match="Redact value must be between 0 and 100"): 200 | MaskerFormatter(fmt="%(message)s", redact=-1) 201 | 202 | with pytest.raises(ValueError, match="Redact value must be between 0 and 100"): 203 | MaskerFormatter(fmt="%(message)s", redact=-50) 204 | 205 | # Test values greater than 100 206 | with pytest.raises(ValueError, match="Redact value must be between 0 and 100"): 207 | MaskerFormatter(fmt="%(message)s", redact=101) 208 | 209 | with pytest.raises(ValueError, match="Redact value must be between 0 and 100"): 210 | MaskerFormatter(fmt="%(message)s", redact=150) 211 | 212 | 213 | def test_redact_validation_type_conversion(): 214 | """Test that string numbers are properly converted to integers.""" 215 | # Test string representations of valid values 216 | formatter = MaskerFormatter(fmt="%(message)s", redact="50") 217 | assert formatter.redact == 50 218 | assert isinstance(formatter.redact, int) 219 | 220 | formatter = MaskerFormatter(fmt="%(message)s", redact="0") 221 | assert formatter.redact == 0 222 | assert isinstance(formatter.redact, int) 223 | 224 | # Test invalid string values 225 | with pytest.raises(ValueError, match="Redact value must be between 0 and 100"): 226 | MaskerFormatter(fmt="%(message)s", redact="150") 227 | 228 | 229 | def test_masked_logger_multiple_leaks_same_string(logger_and_log_stream, log_format): 230 | """ 231 | Test that multiple occurrences of the same leak in a single string are all masked. 232 | This verifies the fix for catching more than 1 leak in the same string. 233 | 234 | Args: 235 | logger_and_log_stream (tuple): A tuple containing the logger and log stream. 236 | """ 237 | logger, log_stream = logger_and_log_stream 238 | 239 | # Set the MaskerFormatter formatter 240 | formatter = MaskerFormatter(fmt=log_format) 241 | logger.handlers[0].setFormatter(formatter) 242 | 243 | # Log a message with multiple instances of the same secret (using 10+ char passwords) 244 | logger.info( 245 | "First password=secretpassword and second password=anothersecret and third password=secretpassword" 246 | ) 247 | 248 | # Read and parse the log output 249 | log_output = log_stream.getvalue().strip() 250 | 251 | # Validate that all password instances are masked 252 | assert "password=" in log_output 253 | assert "secretpassword" not in log_output 254 | assert "anothersecret" not in log_output 255 | 256 | # Count the number of password= occurrences to ensure all are processed 257 | password_count = log_output.count("password=") 258 | assert password_count == 3, f"Expected 3 password fields, found {password_count}" 259 | 260 | 261 | def test_masked_logger_multiple_different_leaks_same_string(logger_and_log_stream, log_format): 262 | """ 263 | Test that multiple different types of leaks in a single string are all masked. 264 | 265 | Args: 266 | logger_and_log_stream (tuple): A tuple containing the logger and log stream. 267 | """ 268 | logger, log_stream = logger_and_log_stream 269 | 270 | # Set the MaskerFormatter formatter 271 | formatter = MaskerFormatter(fmt=log_format) 272 | logger.handlers[0].setFormatter(formatter) 273 | 274 | # Log a message with multiple different sensitive patterns (using 10+ char secrets) 275 | logger.info( 276 | "User data: password=mysecretpassword and token=abc123tokenlong and password=anothersecret" 277 | ) 278 | 279 | # Read and parse the log output 280 | log_output = log_stream.getvalue().strip() 281 | 282 | # Validate that both password instances and token are masked 283 | assert "password=" in log_output 284 | assert "token=" in log_output 285 | assert "mysecretpassword" not in log_output 286 | assert "anothersecret" not in log_output 287 | assert "abc123tokenlong" not in log_output 288 | 289 | 290 | def test_masked_logger_overlapping_matches(logger_and_log_stream, log_format): 291 | """ 292 | Test that overlapping matches from different regex patterns are handled correctly. 293 | This verifies that the character-array approach properly handles complex scenarios 294 | where multiple patterns might match overlapping text spans. 295 | 296 | Args: 297 | logger_and_log_stream (tuple): A tuple containing the logger and log stream. 298 | """ 299 | logger, log_stream = logger_and_log_stream 300 | 301 | # Set the MaskerFormatter formatter 302 | formatter = MaskerFormatter(fmt=log_format) 303 | logger.handlers[0].setFormatter(formatter) 304 | 305 | # Log a message that might trigger overlapping regex patterns 306 | logger.info("Auth data: token=secrettoken123456 and password=overlappingsecretkey") 307 | 308 | # Read and parse the log output 309 | log_output = log_stream.getvalue().strip() 310 | 311 | # Validate that all sensitive data is masked, even with potential overlaps 312 | assert "secrettoken123456" not in log_output 313 | assert "overlappingsecretkey" not in log_output 314 | # Note: Different patterns capture differently - some include key=, others don't 315 | # The important thing is that the secret values are masked 316 | assert "password=" in log_output # This pattern captures only the value 317 | 318 | 319 | def test_masked_logger_empty_capture_groups(logger_and_log_stream, log_format): 320 | """ 321 | Test that patterns with capture groups that are all None/empty still get masked. 322 | This verifies the fix for the edge case where regex patterns have optional groups 323 | that don't match, leaving all capture groups as None/empty. 324 | 325 | Args: 326 | logger_and_log_stream (tuple): A tuple containing the logger and log stream. 327 | """ 328 | logger, log_stream = logger_and_log_stream 329 | 330 | # Set the MaskerFormatter formatter 331 | formatter = MaskerFormatter(fmt=log_format) 332 | logger.handlers[0].setFormatter(formatter) 333 | 334 | # Create a scenario that might result in None capture groups 335 | # This could happen with complex regex patterns that have optional groups 336 | logger.info("API key: AKIAIOSFODNN7EXAMPLE") # AWS access key pattern 337 | 338 | # Read and parse the log output 339 | log_output = log_stream.getvalue().strip() 340 | 341 | # The key should be masked even if capture groups are None/empty 342 | assert "AKIAIOSFODNN7EXAMPLE" not in log_output 343 | # Some part of the message should be masked (asterisks should appear) 344 | assert "*" in log_output 345 | 346 | 347 | def test_masked_logger_no_capture_groups_fallback(logger_and_log_stream, log_format): 348 | """ 349 | Test that patterns with no capture groups fall back to masking the entire match (group 0). 350 | This covers the fallback code path when masked_something remains False. 351 | """ 352 | logger, log_stream = logger_and_log_stream 353 | 354 | # Set the MaskerFormatter formatter 355 | formatter = MaskerFormatter(fmt=log_format) 356 | logger.handlers[0].setFormatter(formatter) 357 | 358 | # Use a pattern that we know doesn't have capture groups but matches secrets 359 | # The JWT pattern should match without capture groups in some cases 360 | logger.info( 361 | "JWT: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c" 362 | ) 363 | 364 | log_output = log_stream.getvalue().strip() 365 | 366 | # The entire JWT should be masked since there are no capture groups 367 | assert "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9" not in log_output 368 | assert "*" in log_output 369 | 370 | 371 | def test_masked_logger_all_capture_groups_none(logger_and_log_stream, log_format): 372 | """ 373 | Test the fallback to group 0 when all capture groups are None. 374 | This specifically tests the case where match.groups() returns a tuple with None values. 375 | """ 376 | import re 377 | from unittest.mock import Mock 378 | 379 | logger, log_stream = logger_and_log_stream 380 | 381 | formatter = MaskerFormatter(fmt=log_format) 382 | 383 | # Mock a match object that has capture groups but they're all None 384 | mock_match = Mock(spec=re.Match) 385 | mock_match.groups.return_value = (None, None) # Two capture groups, both None 386 | mock_match.group.side_effect = lambda i=0: "sensitivedata12345" if i == 0 else None 387 | mock_match.start.side_effect = lambda i=0: 10 if i == 0 else -1 388 | mock_match.end.side_effect = lambda i=0: 27 if i == 0 else -1 389 | 390 | # Test the _mask_secret method directly with our mock match 391 | test_message = "Some text sensitivedata12345 more text" 392 | result = formatter._mask_secret(test_message, [mock_match]) 393 | 394 | # Should fall back to masking the entire match since all capture groups are None 395 | assert "sensitivedata12345" not in result 396 | assert "*" in result 397 | 398 | 399 | def test_masked_logger_all_capture_groups_empty(logger_and_log_stream, log_format): 400 | """ 401 | Test the fallback to group 0 when all capture groups are empty strings. 402 | """ 403 | import re 404 | from unittest.mock import Mock 405 | 406 | logger, log_stream = logger_and_log_stream 407 | 408 | formatter = MaskerFormatter(fmt=log_format) 409 | 410 | # Mock a match object that has capture groups, but they're all empty strings 411 | mock_match = Mock(spec=re.Match) 412 | mock_match.groups.return_value = ("", "") # Two capture groups, both empty 413 | mock_match.group.side_effect = lambda i=0: "anothersecret123" if i == 0 else "" 414 | mock_match.start.side_effect = lambda i=0: 5 if i == 0 else -1 415 | mock_match.end.side_effect = lambda i=0: 21 if i == 0 else -1 416 | 417 | # Test the _mask_secret method directly 418 | test_message = "Data anothersecret123 end" 419 | result = formatter._mask_secret(test_message, [mock_match]) 420 | 421 | # Should fall back to masking the entire match since all capture groups are empty 422 | assert "anothersecret123" not in result 423 | assert "*" in result 424 | 425 | 426 | def test_masked_logger_fallback_with_different_redact_percentages(): 427 | """ 428 | Test the fallback masking with different redact percentages to ensure 429 | the redact_length calculation works correctly in the fallback code path. 430 | """ 431 | import re 432 | from unittest.mock import Mock 433 | 434 | test_cases = [ 435 | (0, "testsecret1234", "testsecret1234"), # 0% should not mask anything 436 | (50, "testsecret1234", "*******cret1234"), # 50% should mask half 437 | (100, "testsecret1234", "**************"), # 100% should mask everything 438 | ] 439 | 440 | for redact_percent, secret, _ in test_cases: 441 | formatter = MaskerFormatter(fmt="%(message)s", redact=redact_percent) 442 | 443 | # Mock a match with no valid capture groups 444 | mock_match = Mock(spec=re.Match) 445 | mock_match.groups.return_value = (None,) 446 | mock_match.group.side_effect = lambda i=0, s=secret: s if i == 0 else None 447 | mock_match.start.side_effect = lambda i=0: 0 if i == 0 else -1 448 | mock_match.end.side_effect = lambda i=0, s=secret: len(s) if i == 0 else -1 449 | 450 | result = formatter._mask_secret(secret, [mock_match]) 451 | 452 | if redact_percent == 0: 453 | # 0% redaction should leave the original text 454 | assert result == secret 455 | else: 456 | # Other percentages should mask appropriately 457 | expected_mask_length = int((len(secret) / 100) * redact_percent) 458 | expected_asterisks = "*" * expected_mask_length 459 | expected_remaining = secret[expected_mask_length:] 460 | expected_result = expected_asterisks + expected_remaining 461 | assert result == expected_result, ( 462 | f"Redact {redact_percent}%: expected {expected_result}, got {result}" 463 | ) 464 | 465 | 466 | def test_masked_logger_mixed_capture_groups_fallback(): 467 | """ 468 | Test a scenario where some matches have valid capture groups and others need fallback. 469 | This ensures both code paths work together correctly. 470 | """ 471 | import re 472 | from unittest.mock import Mock 473 | 474 | formatter = MaskerFormatter(fmt="%(message)s") 475 | 476 | # First match: has a valid capture group 477 | mock_match1 = Mock(spec=re.Match) 478 | mock_match1.groups.return_value = ("validgroup123",) 479 | mock_match1.group.side_effect = lambda i=0: "key=validgroup123" if i == 0 else "validgroup123" 480 | mock_match1.start.side_effect = lambda i=0: 0 if i == 0 else 4 481 | mock_match1.end.side_effect = lambda i=0: 17 if i == 0 else 17 482 | 483 | # Second match: has capture groups, but they're all None (needs fallback) 484 | mock_match2 = Mock(spec=re.Match) 485 | mock_match2.groups.return_value = (None, None) 486 | mock_match2.group.side_effect = lambda i=0: "fallbacksecret" if i == 0 else None 487 | mock_match2.start.side_effect = lambda i=0: 20 if i == 0 else -1 488 | mock_match2.end.side_effect = lambda i=0: 34 if i == 0 else -1 489 | 490 | test_message = "key=validgroup123 : fallbacksecret end" 491 | result = formatter._mask_secret(test_message, [mock_match1, mock_match2]) 492 | 493 | # Both secrets should be masked 494 | assert "validgroup123" not in result 495 | assert "fallbacksecret" not in result 496 | assert "*" in result 497 | 498 | 499 | def test_masked_logger_masks_secrets_in_traceback_text(logger_and_log_stream, log_format): 500 | """ 501 | Test that MaskerFormatter masks secrets in exception tracebacks (exc_info) in text logs. 502 | """ 503 | logger, log_stream = logger_and_log_stream 504 | formatter = MaskerFormatter(fmt=log_format) 505 | logger.handlers[0].setFormatter(formatter) 506 | 507 | secret = "supersecretpassword" 508 | try: 509 | raise ValueError(f"This is a test error with password={secret}") 510 | except Exception: 511 | logger.error("Exception occurred", exc_info=True) 512 | 513 | log_output = log_stream.getvalue() 514 | # The secret should be masked in the traceback 515 | assert "password=" in log_output 516 | assert secret not in log_output 517 | assert "*****" in log_output 518 | 519 | 520 | def test_masked_logger_masks_secrets_in_traceback_json(logger_and_log_stream, log_format): 521 | """ 522 | Test that MaskerFormatterJson masks secrets in exception tracebacks (exc_info) in JSON logs. 523 | """ 524 | logger, log_stream = logger_and_log_stream 525 | formatter = MaskerFormatterJson(fmt=log_format) 526 | logger.handlers[0].setFormatter(formatter) 527 | 528 | secret = "supersecretpassword" 529 | try: 530 | raise ValueError(f"This is a test error with password={secret}") 531 | except Exception: 532 | logger.error("Exception occurred", exc_info=True) 533 | 534 | log_output = log_stream.getvalue() 535 | log_json = json.loads(log_output) 536 | # The secret should be masked in the traceback (exc_info field) 537 | assert "password=" in log_json.get("exc_info", "") 538 | assert secret not in log_json.get("exc_info", "") 539 | assert "*****" in log_json.get("exc_info", "") 540 | -------------------------------------------------------------------------------- /poetry.lock: -------------------------------------------------------------------------------- 1 | # This file is automatically @generated by Poetry 2.2.1 and should not be changed by hand. 2 | 3 | [[package]] 4 | name = "cfgv" 5 | version = "3.4.0" 6 | description = "Validate configuration and produce human readable error messages." 7 | optional = false 8 | python-versions = ">=3.8" 9 | groups = ["dev"] 10 | files = [ 11 | {file = "cfgv-3.4.0-py2.py3-none-any.whl", hash = "sha256:b7265b1f29fd3316bfcd2b330d63d024f2bfd8bcb8b0272f8e19a504856c48f9"}, 12 | {file = "cfgv-3.4.0.tar.gz", hash = "sha256:e52591d4c5f5dead8e0f673fb16db7949d2cfb3f7da4582893288f0ded8fe560"}, 13 | ] 14 | 15 | [[package]] 16 | name = "colorama" 17 | version = "0.4.6" 18 | description = "Cross-platform colored terminal text." 19 | optional = false 20 | python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" 21 | groups = ["dev"] 22 | markers = "sys_platform == \"win32\"" 23 | files = [ 24 | {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, 25 | {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, 26 | ] 27 | 28 | [[package]] 29 | name = "coverage" 30 | version = "7.10.7" 31 | description = "Code coverage measurement for Python" 32 | optional = false 33 | python-versions = ">=3.9" 34 | groups = ["dev"] 35 | files = [ 36 | {file = "coverage-7.10.7-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:fc04cc7a3db33664e0c2d10eb8990ff6b3536f6842c9590ae8da4c614b9ed05a"}, 37 | {file = "coverage-7.10.7-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e201e015644e207139f7e2351980feb7040e6f4b2c2978892f3e3789d1c125e5"}, 38 | {file = "coverage-7.10.7-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:240af60539987ced2c399809bd34f7c78e8abe0736af91c3d7d0e795df633d17"}, 39 | {file = "coverage-7.10.7-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8421e088bc051361b01c4b3a50fd39a4b9133079a2229978d9d30511fd05231b"}, 40 | {file = "coverage-7.10.7-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6be8ed3039ae7f7ac5ce058c308484787c86e8437e72b30bf5e88b8ea10f3c87"}, 41 | {file = "coverage-7.10.7-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e28299d9f2e889e6d51b1f043f58d5f997c373cc12e6403b90df95b8b047c13e"}, 42 | {file = "coverage-7.10.7-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:c4e16bd7761c5e454f4efd36f345286d6f7c5fa111623c355691e2755cae3b9e"}, 43 | {file = "coverage-7.10.7-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:b1c81d0e5e160651879755c9c675b974276f135558cf4ba79fee7b8413a515df"}, 44 | {file = "coverage-7.10.7-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:606cc265adc9aaedcc84f1f064f0e8736bc45814f15a357e30fca7ecc01504e0"}, 45 | {file = "coverage-7.10.7-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:10b24412692df990dbc34f8fb1b6b13d236ace9dfdd68df5b28c2e39cafbba13"}, 46 | {file = "coverage-7.10.7-cp310-cp310-win32.whl", hash = "sha256:b51dcd060f18c19290d9b8a9dd1e0181538df2ce0717f562fff6cf74d9fc0b5b"}, 47 | {file = "coverage-7.10.7-cp310-cp310-win_amd64.whl", hash = "sha256:3a622ac801b17198020f09af3eaf45666b344a0d69fc2a6ffe2ea83aeef1d807"}, 48 | {file = "coverage-7.10.7-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a609f9c93113be646f44c2a0256d6ea375ad047005d7f57a5c15f614dc1b2f59"}, 49 | {file = "coverage-7.10.7-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:65646bb0359386e07639c367a22cf9b5bf6304e8630b565d0626e2bdf329227a"}, 50 | {file = "coverage-7.10.7-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5f33166f0dfcce728191f520bd2692914ec70fac2713f6bf3ce59c3deacb4699"}, 51 | {file = "coverage-7.10.7-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:35f5e3f9e455bb17831876048355dca0f758b6df22f49258cb5a91da23ef437d"}, 52 | {file = "coverage-7.10.7-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4da86b6d62a496e908ac2898243920c7992499c1712ff7c2b6d837cc69d9467e"}, 53 | {file = "coverage-7.10.7-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6b8b09c1fad947c84bbbc95eca841350fad9cbfa5a2d7ca88ac9f8d836c92e23"}, 54 | {file = "coverage-7.10.7-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:4376538f36b533b46f8971d3a3e63464f2c7905c9800db97361c43a2b14792ab"}, 55 | {file = "coverage-7.10.7-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:121da30abb574f6ce6ae09840dae322bef734480ceafe410117627aa54f76d82"}, 56 | {file = "coverage-7.10.7-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:88127d40df529336a9836870436fc2751c339fbaed3a836d42c93f3e4bd1d0a2"}, 57 | {file = "coverage-7.10.7-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ba58bbcd1b72f136080c0bccc2400d66cc6115f3f906c499013d065ac33a4b61"}, 58 | {file = "coverage-7.10.7-cp311-cp311-win32.whl", hash = "sha256:972b9e3a4094b053a4e46832b4bc829fc8a8d347160eb39d03f1690316a99c14"}, 59 | {file = "coverage-7.10.7-cp311-cp311-win_amd64.whl", hash = "sha256:a7b55a944a7f43892e28ad4bc0561dfd5f0d73e605d1aa5c3c976b52aea121d2"}, 60 | {file = "coverage-7.10.7-cp311-cp311-win_arm64.whl", hash = "sha256:736f227fb490f03c6488f9b6d45855f8e0fd749c007f9303ad30efab0e73c05a"}, 61 | {file = "coverage-7.10.7-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7bb3b9ddb87ef7725056572368040c32775036472d5a033679d1fa6c8dc08417"}, 62 | {file = "coverage-7.10.7-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:18afb24843cbc175687225cab1138c95d262337f5473512010e46831aa0c2973"}, 63 | {file = "coverage-7.10.7-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:399a0b6347bcd3822be369392932884b8216d0944049ae22925631a9b3d4ba4c"}, 64 | {file = "coverage-7.10.7-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:314f2c326ded3f4b09be11bc282eb2fc861184bc95748ae67b360ac962770be7"}, 65 | {file = "coverage-7.10.7-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c41e71c9cfb854789dee6fc51e46743a6d138b1803fab6cb860af43265b42ea6"}, 66 | {file = "coverage-7.10.7-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc01f57ca26269c2c706e838f6422e2a8788e41b3e3c65e2f41148212e57cd59"}, 67 | {file = "coverage-7.10.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a6442c59a8ac8b85812ce33bc4d05bde3fb22321fa8294e2a5b487c3505f611b"}, 68 | {file = "coverage-7.10.7-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:78a384e49f46b80fb4c901d52d92abe098e78768ed829c673fbb53c498bef73a"}, 69 | {file = "coverage-7.10.7-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:5e1e9802121405ede4b0133aa4340ad8186a1d2526de5b7c3eca519db7bb89fb"}, 70 | {file = "coverage-7.10.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:d41213ea25a86f69efd1575073d34ea11aabe075604ddf3d148ecfec9e1e96a1"}, 71 | {file = "coverage-7.10.7-cp312-cp312-win32.whl", hash = "sha256:77eb4c747061a6af8d0f7bdb31f1e108d172762ef579166ec84542f711d90256"}, 72 | {file = "coverage-7.10.7-cp312-cp312-win_amd64.whl", hash = "sha256:f51328ffe987aecf6d09f3cd9d979face89a617eacdaea43e7b3080777f647ba"}, 73 | {file = "coverage-7.10.7-cp312-cp312-win_arm64.whl", hash = "sha256:bda5e34f8a75721c96085903c6f2197dc398c20ffd98df33f866a9c8fd95f4bf"}, 74 | {file = "coverage-7.10.7-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:981a651f543f2854abd3b5fcb3263aac581b18209be49863ba575de6edf4c14d"}, 75 | {file = "coverage-7.10.7-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:73ab1601f84dc804f7812dc297e93cd99381162da39c47040a827d4e8dafe63b"}, 76 | {file = "coverage-7.10.7-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:a8b6f03672aa6734e700bbcd65ff050fd19cddfec4b031cc8cf1c6967de5a68e"}, 77 | {file = "coverage-7.10.7-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:10b6ba00ab1132a0ce4428ff68cf50a25efd6840a42cdf4239c9b99aad83be8b"}, 78 | {file = "coverage-7.10.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c79124f70465a150e89340de5963f936ee97097d2ef76c869708c4248c63ca49"}, 79 | {file = "coverage-7.10.7-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:69212fbccdbd5b0e39eac4067e20a4a5256609e209547d86f740d68ad4f04911"}, 80 | {file = "coverage-7.10.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7ea7c6c9d0d286d04ed3541747e6597cbe4971f22648b68248f7ddcd329207f0"}, 81 | {file = "coverage-7.10.7-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:b9be91986841a75042b3e3243d0b3cb0b2434252b977baaf0cd56e960fe1e46f"}, 82 | {file = "coverage-7.10.7-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:b281d5eca50189325cfe1f365fafade89b14b4a78d9b40b05ddd1fc7d2a10a9c"}, 83 | {file = "coverage-7.10.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:99e4aa63097ab1118e75a848a28e40d68b08a5e19ce587891ab7fd04475e780f"}, 84 | {file = "coverage-7.10.7-cp313-cp313-win32.whl", hash = "sha256:dc7c389dce432500273eaf48f410b37886be9208b2dd5710aaf7c57fd442c698"}, 85 | {file = "coverage-7.10.7-cp313-cp313-win_amd64.whl", hash = "sha256:cac0fdca17b036af3881a9d2729a850b76553f3f716ccb0360ad4dbc06b3b843"}, 86 | {file = "coverage-7.10.7-cp313-cp313-win_arm64.whl", hash = "sha256:4b6f236edf6e2f9ae8fcd1332da4e791c1b6ba0dc16a2dc94590ceccb482e546"}, 87 | {file = "coverage-7.10.7-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:a0ec07fd264d0745ee396b666d47cef20875f4ff2375d7c4f58235886cc1ef0c"}, 88 | {file = "coverage-7.10.7-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:dd5e856ebb7bfb7672b0086846db5afb4567a7b9714b8a0ebafd211ec7ce6a15"}, 89 | {file = "coverage-7.10.7-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:f57b2a3c8353d3e04acf75b3fed57ba41f5c0646bbf1d10c7c282291c97936b4"}, 90 | {file = "coverage-7.10.7-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:1ef2319dd15a0b009667301a3f84452a4dc6fddfd06b0c5c53ea472d3989fbf0"}, 91 | {file = "coverage-7.10.7-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:83082a57783239717ceb0ad584de3c69cf581b2a95ed6bf81ea66034f00401c0"}, 92 | {file = "coverage-7.10.7-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:50aa94fb1fb9a397eaa19c0d5ec15a5edd03a47bf1a3a6111a16b36e190cff65"}, 93 | {file = "coverage-7.10.7-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:2120043f147bebb41c85b97ac45dd173595ff14f2a584f2963891cbcc3091541"}, 94 | {file = "coverage-7.10.7-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:2fafd773231dd0378fdba66d339f84904a8e57a262f583530f4f156ab83863e6"}, 95 | {file = "coverage-7.10.7-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:0b944ee8459f515f28b851728ad224fa2d068f1513ef6b7ff1efafeb2185f999"}, 96 | {file = "coverage-7.10.7-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4b583b97ab2e3efe1b3e75248a9b333bd3f8b0b1b8e5b45578e05e5850dfb2c2"}, 97 | {file = "coverage-7.10.7-cp313-cp313t-win32.whl", hash = "sha256:2a78cd46550081a7909b3329e2266204d584866e8d97b898cd7fb5ac8d888b1a"}, 98 | {file = "coverage-7.10.7-cp313-cp313t-win_amd64.whl", hash = "sha256:33a5e6396ab684cb43dc7befa386258acb2d7fae7f67330ebb85ba4ea27938eb"}, 99 | {file = "coverage-7.10.7-cp313-cp313t-win_arm64.whl", hash = "sha256:86b0e7308289ddde73d863b7683f596d8d21c7d8664ce1dee061d0bcf3fbb4bb"}, 100 | {file = "coverage-7.10.7-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:b06f260b16ead11643a5a9f955bd4b5fd76c1a4c6796aeade8520095b75de520"}, 101 | {file = "coverage-7.10.7-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:212f8f2e0612778f09c55dd4872cb1f64a1f2b074393d139278ce902064d5b32"}, 102 | {file = "coverage-7.10.7-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3445258bcded7d4aa630ab8296dea4d3f15a255588dd535f980c193ab6b95f3f"}, 103 | {file = "coverage-7.10.7-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:bb45474711ba385c46a0bfe696c695a929ae69ac636cda8f532be9e8c93d720a"}, 104 | {file = "coverage-7.10.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:813922f35bd800dca9994c5971883cbc0d291128a5de6b167c7aa697fcf59360"}, 105 | {file = "coverage-7.10.7-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:93c1b03552081b2a4423091d6fb3787265b8f86af404cff98d1b5342713bdd69"}, 106 | {file = "coverage-7.10.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:cc87dd1b6eaf0b848eebb1c86469b9f72a1891cb42ac7adcfbce75eadb13dd14"}, 107 | {file = "coverage-7.10.7-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:39508ffda4f343c35f3236fe8d1a6634a51f4581226a1262769d7f970e73bffe"}, 108 | {file = "coverage-7.10.7-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:925a1edf3d810537c5a3abe78ec5530160c5f9a26b1f4270b40e62cc79304a1e"}, 109 | {file = "coverage-7.10.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2c8b9a0636f94c43cd3576811e05b89aa9bc2d0a85137affc544ae5cb0e4bfbd"}, 110 | {file = "coverage-7.10.7-cp314-cp314-win32.whl", hash = "sha256:b7b8288eb7cdd268b0304632da8cb0bb93fadcfec2fe5712f7b9cc8f4d487be2"}, 111 | {file = "coverage-7.10.7-cp314-cp314-win_amd64.whl", hash = "sha256:1ca6db7c8807fb9e755d0379ccc39017ce0a84dcd26d14b5a03b78563776f681"}, 112 | {file = "coverage-7.10.7-cp314-cp314-win_arm64.whl", hash = "sha256:097c1591f5af4496226d5783d036bf6fd6cd0cbc132e071b33861de756efb880"}, 113 | {file = "coverage-7.10.7-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:a62c6ef0d50e6de320c270ff91d9dd0a05e7250cac2a800b7784bae474506e63"}, 114 | {file = "coverage-7.10.7-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:9fa6e4dd51fe15d8738708a973470f67a855ca50002294852e9571cdbd9433f2"}, 115 | {file = "coverage-7.10.7-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:8fb190658865565c549b6b4706856d6a7b09302c797eb2cf8e7fe9dabb043f0d"}, 116 | {file = "coverage-7.10.7-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:affef7c76a9ef259187ef31599a9260330e0335a3011732c4b9effa01e1cd6e0"}, 117 | {file = "coverage-7.10.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e16e07d85ca0cf8bafe5f5d23a0b850064e8e945d5677492b06bbe6f09cc699"}, 118 | {file = "coverage-7.10.7-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:03ffc58aacdf65d2a82bbeb1ffe4d01ead4017a21bfd0454983b88ca73af94b9"}, 119 | {file = "coverage-7.10.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:1b4fd784344d4e52647fd7857b2af5b3fbe6c239b0b5fa63e94eb67320770e0f"}, 120 | {file = "coverage-7.10.7-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:0ebbaddb2c19b71912c6f2518e791aa8b9f054985a0769bdb3a53ebbc765c6a1"}, 121 | {file = "coverage-7.10.7-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:a2d9a3b260cc1d1dbdb1c582e63ddcf5363426a1a68faa0f5da28d8ee3c722a0"}, 122 | {file = "coverage-7.10.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a3cc8638b2480865eaa3926d192e64ce6c51e3d29c849e09d5b4ad95efae5399"}, 123 | {file = "coverage-7.10.7-cp314-cp314t-win32.whl", hash = "sha256:67f8c5cbcd3deb7a60b3345dffc89a961a484ed0af1f6f73de91705cc6e31235"}, 124 | {file = "coverage-7.10.7-cp314-cp314t-win_amd64.whl", hash = "sha256:e1ed71194ef6dea7ed2d5cb5f7243d4bcd334bfb63e59878519be558078f848d"}, 125 | {file = "coverage-7.10.7-cp314-cp314t-win_arm64.whl", hash = "sha256:7fe650342addd8524ca63d77b2362b02345e5f1a093266787d210c70a50b471a"}, 126 | {file = "coverage-7.10.7-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:fff7b9c3f19957020cac546c70025331113d2e61537f6e2441bc7657913de7d3"}, 127 | {file = "coverage-7.10.7-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:bc91b314cef27742da486d6839b677b3f2793dfe52b51bbbb7cf736d5c29281c"}, 128 | {file = "coverage-7.10.7-cp39-cp39-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:567f5c155eda8df1d3d439d40a45a6a5f029b429b06648235f1e7e51b522b396"}, 129 | {file = "coverage-7.10.7-cp39-cp39-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2af88deffcc8a4d5974cf2d502251bc3b2db8461f0b66d80a449c33757aa9f40"}, 130 | {file = "coverage-7.10.7-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7315339eae3b24c2d2fa1ed7d7a38654cba34a13ef19fbcb9425da46d3dc594"}, 131 | {file = "coverage-7.10.7-cp39-cp39-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:912e6ebc7a6e4adfdbb1aec371ad04c68854cd3bf3608b3514e7ff9062931d8a"}, 132 | {file = "coverage-7.10.7-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:f49a05acd3dfe1ce9715b657e28d138578bc40126760efb962322c56e9ca344b"}, 133 | {file = "coverage-7.10.7-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:cce2109b6219f22ece99db7644b9622f54a4e915dad65660ec435e89a3ea7cc3"}, 134 | {file = "coverage-7.10.7-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:f3c887f96407cea3916294046fc7dab611c2552beadbed4ea901cbc6a40cc7a0"}, 135 | {file = "coverage-7.10.7-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:635adb9a4507c9fd2ed65f39693fa31c9a3ee3a8e6dc64df033e8fdf52a7003f"}, 136 | {file = "coverage-7.10.7-cp39-cp39-win32.whl", hash = "sha256:5a02d5a850e2979b0a014c412573953995174743a3f7fa4ea5a6e9a3c5617431"}, 137 | {file = "coverage-7.10.7-cp39-cp39-win_amd64.whl", hash = "sha256:c134869d5ffe34547d14e174c866fd8fe2254918cc0a95e99052903bc1543e07"}, 138 | {file = "coverage-7.10.7-py3-none-any.whl", hash = "sha256:f7941f6f2fe6dd6807a1208737b8a0cbcf1cc6d7b07d24998ad2d63590868260"}, 139 | {file = "coverage-7.10.7.tar.gz", hash = "sha256:f4ab143ab113be368a3e9b795f9cd7906c5ef407d6173fe9675a902e1fffc239"}, 140 | ] 141 | 142 | [package.dependencies] 143 | tomli = {version = "*", optional = true, markers = "python_full_version <= \"3.11.0a6\" and extra == \"toml\""} 144 | 145 | [package.extras] 146 | toml = ["tomli ; python_full_version <= \"3.11.0a6\""] 147 | 148 | [[package]] 149 | name = "distlib" 150 | version = "0.4.0" 151 | description = "Distribution utilities" 152 | optional = false 153 | python-versions = "*" 154 | groups = ["dev"] 155 | files = [ 156 | {file = "distlib-0.4.0-py2.py3-none-any.whl", hash = "sha256:9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16"}, 157 | {file = "distlib-0.4.0.tar.gz", hash = "sha256:feec40075be03a04501a973d81f633735b4b69f98b05450592310c0f401a4e0d"}, 158 | ] 159 | 160 | [[package]] 161 | name = "exceptiongroup" 162 | version = "1.3.0" 163 | description = "Backport of PEP 654 (exception groups)" 164 | optional = false 165 | python-versions = ">=3.7" 166 | groups = ["dev"] 167 | markers = "python_version == \"3.10\"" 168 | files = [ 169 | {file = "exceptiongroup-1.3.0-py3-none-any.whl", hash = "sha256:4d111e6e0c13d0644cad6ddaa7ed0261a0b36971f6d23e7ec9b4b9097da78a10"}, 170 | {file = "exceptiongroup-1.3.0.tar.gz", hash = "sha256:b241f5885f560bc56a59ee63ca4c6a8bfa46ae4ad651af316d4e81817bb9fd88"}, 171 | ] 172 | 173 | [package.dependencies] 174 | typing-extensions = {version = ">=4.6.0", markers = "python_version < \"3.13\""} 175 | 176 | [package.extras] 177 | test = ["pytest (>=6)"] 178 | 179 | [[package]] 180 | name = "filelock" 181 | version = "3.19.1" 182 | description = "A platform independent file lock." 183 | optional = false 184 | python-versions = ">=3.9" 185 | groups = ["dev"] 186 | files = [ 187 | {file = "filelock-3.19.1-py3-none-any.whl", hash = "sha256:d38e30481def20772f5baf097c122c3babc4fcdb7e14e57049eb9d88c6dc017d"}, 188 | {file = "filelock-3.19.1.tar.gz", hash = "sha256:66eda1888b0171c998b35be2bcc0f6d75c388a7ce20c3f3f37aa8e96c2dddf58"}, 189 | ] 190 | 191 | [[package]] 192 | name = "identify" 193 | version = "2.6.15" 194 | description = "File identification library for Python" 195 | optional = false 196 | python-versions = ">=3.9" 197 | groups = ["dev"] 198 | files = [ 199 | {file = "identify-2.6.15-py2.py3-none-any.whl", hash = "sha256:1181ef7608e00704db228516541eb83a88a9f94433a8c80bb9b5bd54b1d81757"}, 200 | {file = "identify-2.6.15.tar.gz", hash = "sha256:e4f4864b96c6557ef2a1e1c951771838f4edc9df3a72ec7118b338801b11c7bf"}, 201 | ] 202 | 203 | [package.extras] 204 | license = ["ukkonen"] 205 | 206 | [[package]] 207 | name = "iniconfig" 208 | version = "2.1.0" 209 | description = "brain-dead simple config-ini parsing" 210 | optional = false 211 | python-versions = ">=3.8" 212 | groups = ["dev"] 213 | files = [ 214 | {file = "iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760"}, 215 | {file = "iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7"}, 216 | ] 217 | 218 | [[package]] 219 | name = "mypy" 220 | version = "1.18.2" 221 | description = "Optional static typing for Python" 222 | optional = false 223 | python-versions = ">=3.9" 224 | groups = ["dev"] 225 | files = [ 226 | {file = "mypy-1.18.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:c1eab0cf6294dafe397c261a75f96dc2c31bffe3b944faa24db5def4e2b0f77c"}, 227 | {file = "mypy-1.18.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:7a780ca61fc239e4865968ebc5240bb3bf610ef59ac398de9a7421b54e4a207e"}, 228 | {file = "mypy-1.18.2-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:448acd386266989ef11662ce3c8011fd2a7b632e0ec7d61a98edd8e27472225b"}, 229 | {file = "mypy-1.18.2-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f9e171c465ad3901dc652643ee4bffa8e9fef4d7d0eece23b428908c77a76a66"}, 230 | {file = "mypy-1.18.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:592ec214750bc00741af1f80cbf96b5013d81486b7bb24cb052382c19e40b428"}, 231 | {file = "mypy-1.18.2-cp310-cp310-win_amd64.whl", hash = "sha256:7fb95f97199ea11769ebe3638c29b550b5221e997c63b14ef93d2e971606ebed"}, 232 | {file = "mypy-1.18.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:807d9315ab9d464125aa9fcf6d84fde6e1dc67da0b6f80e7405506b8ac72bc7f"}, 233 | {file = "mypy-1.18.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:776bb00de1778caf4db739c6e83919c1d85a448f71979b6a0edd774ea8399341"}, 234 | {file = "mypy-1.18.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1379451880512ffce14505493bd9fe469e0697543717298242574882cf8cdb8d"}, 235 | {file = "mypy-1.18.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1331eb7fd110d60c24999893320967594ff84c38ac6d19e0a76c5fd809a84c86"}, 236 | {file = "mypy-1.18.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3ca30b50a51e7ba93b00422e486cbb124f1c56a535e20eff7b2d6ab72b3b2e37"}, 237 | {file = "mypy-1.18.2-cp311-cp311-win_amd64.whl", hash = "sha256:664dc726e67fa54e14536f6e1224bcfce1d9e5ac02426d2326e2bb4e081d1ce8"}, 238 | {file = "mypy-1.18.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:33eca32dd124b29400c31d7cf784e795b050ace0e1f91b8dc035672725617e34"}, 239 | {file = "mypy-1.18.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a3c47adf30d65e89b2dcd2fa32f3aeb5e94ca970d2c15fcb25e297871c8e4764"}, 240 | {file = "mypy-1.18.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5d6c838e831a062f5f29d11c9057c6009f60cb294fea33a98422688181fe2893"}, 241 | {file = "mypy-1.18.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01199871b6110a2ce984bde85acd481232d17413868c9807e95c1b0739a58914"}, 242 | {file = "mypy-1.18.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a2afc0fa0b0e91b4599ddfe0f91e2c26c2b5a5ab263737e998d6817874c5f7c8"}, 243 | {file = "mypy-1.18.2-cp312-cp312-win_amd64.whl", hash = "sha256:d8068d0afe682c7c4897c0f7ce84ea77f6de953262b12d07038f4d296d547074"}, 244 | {file = "mypy-1.18.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:07b8b0f580ca6d289e69209ec9d3911b4a26e5abfde32228a288eb79df129fcc"}, 245 | {file = "mypy-1.18.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:ed4482847168439651d3feee5833ccedbf6657e964572706a2adb1f7fa4dfe2e"}, 246 | {file = "mypy-1.18.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c3ad2afadd1e9fea5cf99a45a822346971ede8685cc581ed9cd4d42eaf940986"}, 247 | {file = "mypy-1.18.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a431a6f1ef14cf8c144c6b14793a23ec4eae3db28277c358136e79d7d062f62d"}, 248 | {file = "mypy-1.18.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7ab28cc197f1dd77a67e1c6f35cd1f8e8b73ed2217e4fc005f9e6a504e46e7ba"}, 249 | {file = "mypy-1.18.2-cp313-cp313-win_amd64.whl", hash = "sha256:0e2785a84b34a72ba55fb5daf079a1003a34c05b22238da94fcae2bbe46f3544"}, 250 | {file = "mypy-1.18.2-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:62f0e1e988ad41c2a110edde6c398383a889d95b36b3e60bcf155f5164c4fdce"}, 251 | {file = "mypy-1.18.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:8795a039bab805ff0c1dfdb8cd3344642c2b99b8e439d057aba30850b8d3423d"}, 252 | {file = "mypy-1.18.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6ca1e64b24a700ab5ce10133f7ccd956a04715463d30498e64ea8715236f9c9c"}, 253 | {file = "mypy-1.18.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d924eef3795cc89fecf6bedc6ed32b33ac13e8321344f6ddbf8ee89f706c05cb"}, 254 | {file = "mypy-1.18.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:20c02215a080e3a2be3aa50506c67242df1c151eaba0dcbc1e4e557922a26075"}, 255 | {file = "mypy-1.18.2-cp314-cp314-win_amd64.whl", hash = "sha256:749b5f83198f1ca64345603118a6f01a4e99ad4bf9d103ddc5a3200cc4614adf"}, 256 | {file = "mypy-1.18.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:25a9c8fb67b00599f839cf472713f54249a62efd53a54b565eb61956a7e3296b"}, 257 | {file = "mypy-1.18.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:c2b9c7e284ee20e7598d6f42e13ca40b4928e6957ed6813d1ab6348aa3f47133"}, 258 | {file = "mypy-1.18.2-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d6985ed057513e344e43a26cc1cd815c7a94602fb6a3130a34798625bc2f07b6"}, 259 | {file = "mypy-1.18.2-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:22f27105f1525ec024b5c630c0b9f36d5c1cc4d447d61fe51ff4bd60633f47ac"}, 260 | {file = "mypy-1.18.2-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:030c52d0ea8144e721e49b1f68391e39553d7451f0c3f8a7565b59e19fcb608b"}, 261 | {file = "mypy-1.18.2-cp39-cp39-win_amd64.whl", hash = "sha256:aa5e07ac1a60a253445797e42b8b2963c9675563a94f11291ab40718b016a7a0"}, 262 | {file = "mypy-1.18.2-py3-none-any.whl", hash = "sha256:22a1748707dd62b58d2ae53562ffc4d7f8bcc727e8ac7cbc69c053ddc874d47e"}, 263 | {file = "mypy-1.18.2.tar.gz", hash = "sha256:06a398102a5f203d7477b2923dda3634c36727fa5c237d8f859ef90c42a9924b"}, 264 | ] 265 | 266 | [package.dependencies] 267 | mypy_extensions = ">=1.0.0" 268 | pathspec = ">=0.9.0" 269 | tomli = {version = ">=1.1.0", markers = "python_version < \"3.11\""} 270 | typing_extensions = ">=4.6.0" 271 | 272 | [package.extras] 273 | dmypy = ["psutil (>=4.0)"] 274 | faster-cache = ["orjson"] 275 | install-types = ["pip"] 276 | mypyc = ["setuptools (>=50)"] 277 | reports = ["lxml"] 278 | 279 | [[package]] 280 | name = "mypy-extensions" 281 | version = "1.1.0" 282 | description = "Type system extensions for programs checked with the mypy type checker." 283 | optional = false 284 | python-versions = ">=3.8" 285 | groups = ["dev"] 286 | files = [ 287 | {file = "mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505"}, 288 | {file = "mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558"}, 289 | ] 290 | 291 | [[package]] 292 | name = "nodeenv" 293 | version = "1.9.1" 294 | description = "Node.js virtual environment builder" 295 | optional = false 296 | python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" 297 | groups = ["dev"] 298 | files = [ 299 | {file = "nodeenv-1.9.1-py2.py3-none-any.whl", hash = "sha256:ba11c9782d29c27c70ffbdda2d7415098754709be8a7056d79a737cd901155c9"}, 300 | {file = "nodeenv-1.9.1.tar.gz", hash = "sha256:6ec12890a2dab7946721edbfbcd91f3319c6ccc9aec47be7c7e6b7011ee6645f"}, 301 | ] 302 | 303 | [[package]] 304 | name = "packaging" 305 | version = "25.0" 306 | description = "Core utilities for Python packages" 307 | optional = false 308 | python-versions = ">=3.8" 309 | groups = ["dev"] 310 | files = [ 311 | {file = "packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484"}, 312 | {file = "packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f"}, 313 | ] 314 | 315 | [[package]] 316 | name = "pathspec" 317 | version = "0.12.1" 318 | description = "Utility library for gitignore style pattern matching of file paths." 319 | optional = false 320 | python-versions = ">=3.8" 321 | groups = ["dev"] 322 | files = [ 323 | {file = "pathspec-0.12.1-py3-none-any.whl", hash = "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08"}, 324 | {file = "pathspec-0.12.1.tar.gz", hash = "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712"}, 325 | ] 326 | 327 | [[package]] 328 | name = "platformdirs" 329 | version = "4.4.0" 330 | description = "A small Python package for determining appropriate platform-specific dirs, e.g. a `user data dir`." 331 | optional = false 332 | python-versions = ">=3.9" 333 | groups = ["dev"] 334 | files = [ 335 | {file = "platformdirs-4.4.0-py3-none-any.whl", hash = "sha256:abd01743f24e5287cd7a5db3752faf1a2d65353f38ec26d98e25a6db65958c85"}, 336 | {file = "platformdirs-4.4.0.tar.gz", hash = "sha256:ca753cf4d81dc309bc67b0ea38fd15dc97bc30ce419a7f58d13eb3bf14c4febf"}, 337 | ] 338 | 339 | [package.extras] 340 | docs = ["furo (>=2024.8.6)", "proselint (>=0.14)", "sphinx (>=8.1.3)", "sphinx-autodoc-typehints (>=3)"] 341 | test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=8.3.4)", "pytest-cov (>=6)", "pytest-mock (>=3.14)"] 342 | type = ["mypy (>=1.14.1)"] 343 | 344 | [[package]] 345 | name = "pluggy" 346 | version = "1.6.0" 347 | description = "plugin and hook calling mechanisms for python" 348 | optional = false 349 | python-versions = ">=3.9" 350 | groups = ["dev"] 351 | files = [ 352 | {file = "pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746"}, 353 | {file = "pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3"}, 354 | ] 355 | 356 | [package.extras] 357 | dev = ["pre-commit", "tox"] 358 | testing = ["coverage", "pytest", "pytest-benchmark"] 359 | 360 | [[package]] 361 | name = "pre-commit" 362 | version = "4.5.0" 363 | description = "A framework for managing and maintaining multi-language pre-commit hooks." 364 | optional = false 365 | python-versions = ">=3.10" 366 | groups = ["dev"] 367 | files = [ 368 | {file = "pre_commit-4.5.0-py2.py3-none-any.whl", hash = "sha256:25e2ce09595174d9c97860a95609f9f852c0614ba602de3561e267547f2335e1"}, 369 | {file = "pre_commit-4.5.0.tar.gz", hash = "sha256:dc5a065e932b19fc1d4c653c6939068fe54325af8e741e74e88db4d28a4dd66b"}, 370 | ] 371 | 372 | [package.dependencies] 373 | cfgv = ">=2.0.0" 374 | identify = ">=1.0.0" 375 | nodeenv = ">=0.11.1" 376 | pyyaml = ">=5.1" 377 | virtualenv = ">=20.10.0" 378 | 379 | [[package]] 380 | name = "pyahocorasick" 381 | version = "2.2.0" 382 | description = "pyahocorasick is a fast and memory efficient library for exact or approximate multi-pattern string search. With the ``ahocorasick.Automaton`` class, you can find multiple key string occurrences at once in some input text. You can use it as a plain dict-like Trie or convert a Trie to an automaton for efficient Aho-Corasick search. And pickle to disk for easy reuse of large automatons. Implemented in C and tested on Python 3.6+. Works on Linux, macOS and Windows. BSD-3-Cause license." 383 | optional = false 384 | python-versions = ">=3.9" 385 | groups = ["main"] 386 | files = [ 387 | {file = "pyahocorasick-2.2.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:779f1bb63644655d6001f5b1c5f864ec1284cf1b622ac24774f8444ab92f4f84"}, 388 | {file = "pyahocorasick-2.2.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:6e9e082ffc2b240017357aeccaedc7aaccba530cb9e64945e23e999ef98b19c5"}, 389 | {file = "pyahocorasick-2.2.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:9b82717334794ee1bf50ab574c2b990179fc5bfedf1ff40875f18f011f5f7d5d"}, 390 | {file = "pyahocorasick-2.2.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:f6be205779ba8e58670356a8cc5fbbbcf9255bfe24569c736d45f036fce9f2af"}, 391 | {file = "pyahocorasick-2.2.0-cp310-cp310-win_amd64.whl", hash = "sha256:43a2f3302a1c45d54fb24cd988629908b11e70da32fed0042e3558f1a6603b00"}, 392 | {file = "pyahocorasick-2.2.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:a20d05f965ba3d5d38fd26b80d087fb59b8945d3dab3571ff9d64cef6d7edf01"}, 393 | {file = "pyahocorasick-2.2.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4352ade48042067eae16c9c049351cd037078fdf1885c6befe44c7fd38ec7bc9"}, 394 | {file = "pyahocorasick-2.2.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3463d65232e93ddbdab22be8c22ebc9246419d9be738da07af2bccf800c57107"}, 395 | {file = "pyahocorasick-2.2.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:9850cc8fc3071c239965ba1ca2114de990493025381582176af5951a64ff11cb"}, 396 | {file = "pyahocorasick-2.2.0-cp311-cp311-win_amd64.whl", hash = "sha256:e55347e2b884ec87c972e5f7706625f5bc4e07e703fbc1fb51a6f3bb3087d650"}, 397 | {file = "pyahocorasick-2.2.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:744f63790fc4d337e129c80d28f57e6ba4d22a4b7e065825c72e98f92a77e16b"}, 398 | {file = "pyahocorasick-2.2.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:73bb94c5621565c5ad22d2f44d45edc7e568de5bd629d22a435e76d7023dae4e"}, 399 | {file = "pyahocorasick-2.2.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4c3fb6406b3311319bef625f0269af276b75f834e5cba33b81f2e8c35a9c6c91"}, 400 | {file = "pyahocorasick-2.2.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:346f92c0086589e44c279d1519187bd3421d94836875033b27b7730f11bc923e"}, 401 | {file = "pyahocorasick-2.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:1dbc761cdf8c9a1b85f065fb2442c234b742203df8e3cd2f38fc45e4838b02d3"}, 402 | {file = "pyahocorasick-2.2.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:54c9604d73051f96c2d5a6c267f404f3d2d02790a2680a0c0ee7069ef7660d8b"}, 403 | {file = "pyahocorasick-2.2.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:57932f7e894107d5ddf011051feb081b0ff7fdd6ab94462ead0c4c716ffdbd47"}, 404 | {file = "pyahocorasick-2.2.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:67c4489c2615fc4a25824d1f12c9e775d84c2207eecedde273bbffd479d82e71"}, 405 | {file = "pyahocorasick-2.2.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:3f388b66e8973e8ac7cd7db7f90c56b2aaec4b5563b6da7bfc3e973b7ea34e1d"}, 406 | {file = "pyahocorasick-2.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:8eabbd6fcd65595d36dadc3fc57d536aa302833991cd6b0b872aae60c5eac3e9"}, 407 | {file = "pyahocorasick-2.2.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:9c964af712aa57216575d1d42afed9a9b1df296794739654ed1359a2c4a6074f"}, 408 | {file = "pyahocorasick-2.2.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:179fb28f3bd9865ec175ed47283feb68af99d9ca1c63a4f25282d6575f29cdbd"}, 409 | {file = "pyahocorasick-2.2.0-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:08e7125b4baa5e6e293c06a994e7d11462c5bd4f08b708ab97ba5edddf07c5ff"}, 410 | {file = "pyahocorasick-2.2.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:cfb8d47b5d709342c6f65770d266a5f608f7e2736f161427146ff504bc698bc6"}, 411 | {file = "pyahocorasick-2.2.0-cp39-cp39-win_amd64.whl", hash = "sha256:a54abc9f24ec9578769ce6ae24fce438e92171932d400c77b4ff5564e5be3b97"}, 412 | {file = "pyahocorasick-2.2.0.tar.gz", hash = "sha256:817f302088400a1402bf2f8631fdb21cf5a2666888e0d6a7d5a3ad556212e9da"}, 413 | ] 414 | 415 | [package.extras] 416 | testing = ["pytest", "setuptools", "twine", "wheel"] 417 | 418 | [[package]] 419 | name = "pygments" 420 | version = "2.19.2" 421 | description = "Pygments is a syntax highlighting package written in Python." 422 | optional = false 423 | python-versions = ">=3.8" 424 | groups = ["dev"] 425 | files = [ 426 | {file = "pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b"}, 427 | {file = "pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887"}, 428 | ] 429 | 430 | [package.extras] 431 | windows-terminal = ["colorama (>=0.4.6)"] 432 | 433 | [[package]] 434 | name = "pytest" 435 | version = "9.0.0" 436 | description = "pytest: simple powerful testing with Python" 437 | optional = false 438 | python-versions = ">=3.10" 439 | groups = ["dev"] 440 | files = [ 441 | {file = "pytest-9.0.0-py3-none-any.whl", hash = "sha256:e5ccdf10b0bac554970ee88fc1a4ad0ee5d221f8ef22321f9b7e4584e19d7f96"}, 442 | {file = "pytest-9.0.0.tar.gz", hash = "sha256:8f44522eafe4137b0f35c9ce3072931a788a21ee40a2ed279e817d3cc16ed21e"}, 443 | ] 444 | 445 | [package.dependencies] 446 | colorama = {version = ">=0.4", markers = "sys_platform == \"win32\""} 447 | exceptiongroup = {version = ">=1", markers = "python_version < \"3.11\""} 448 | iniconfig = ">=1.0.1" 449 | packaging = ">=22" 450 | pluggy = ">=1.5,<2" 451 | pygments = ">=2.7.2" 452 | tomli = {version = ">=1", markers = "python_version < \"3.11\""} 453 | 454 | [package.extras] 455 | dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "requests", "setuptools", "xmlschema"] 456 | 457 | [[package]] 458 | name = "pytest-cov" 459 | version = "7.0.0" 460 | description = "Pytest plugin for measuring coverage." 461 | optional = false 462 | python-versions = ">=3.9" 463 | groups = ["dev"] 464 | files = [ 465 | {file = "pytest_cov-7.0.0-py3-none-any.whl", hash = "sha256:3b8e9558b16cc1479da72058bdecf8073661c7f57f7d3c5f22a1c23507f2d861"}, 466 | {file = "pytest_cov-7.0.0.tar.gz", hash = "sha256:33c97eda2e049a0c5298e91f519302a1334c26ac65c1a483d6206fd458361af1"}, 467 | ] 468 | 469 | [package.dependencies] 470 | coverage = {version = ">=7.10.6", extras = ["toml"]} 471 | pluggy = ">=1.2" 472 | pytest = ">=7" 473 | 474 | [package.extras] 475 | testing = ["process-tests", "pytest-xdist", "virtualenv"] 476 | 477 | [[package]] 478 | name = "python-json-logger" 479 | version = "3.3.0" 480 | description = "JSON Log Formatter for the Python Logging Package" 481 | optional = false 482 | python-versions = ">=3.8" 483 | groups = ["main"] 484 | files = [ 485 | {file = "python_json_logger-3.3.0-py3-none-any.whl", hash = "sha256:dd980fae8cffb24c13caf6e158d3d61c0d6d22342f932cb6e9deedab3d35eec7"}, 486 | {file = "python_json_logger-3.3.0.tar.gz", hash = "sha256:12b7e74b17775e7d565129296105bbe3910842d9d0eb083fc83a6a617aa8df84"}, 487 | ] 488 | 489 | [package.extras] 490 | dev = ["backports.zoneinfo ; python_version < \"3.9\"", "black", "build", "freezegun", "mdx_truly_sane_lists", "mike", "mkdocs", "mkdocs-awesome-pages-plugin", "mkdocs-gen-files", "mkdocs-literate-nav", "mkdocs-material (>=8.5)", "mkdocstrings[python]", "msgspec ; implementation_name != \"pypy\"", "mypy", "orjson ; implementation_name != \"pypy\"", "pylint", "pytest", "tzdata", "validate-pyproject[all]"] 491 | 492 | [[package]] 493 | name = "pyyaml" 494 | version = "6.0.3" 495 | description = "YAML parser and emitter for Python" 496 | optional = false 497 | python-versions = ">=3.8" 498 | groups = ["dev"] 499 | files = [ 500 | {file = "PyYAML-6.0.3-cp38-cp38-macosx_10_13_x86_64.whl", hash = "sha256:c2514fceb77bc5e7a2f7adfaa1feb2fb311607c9cb518dbc378688ec73d8292f"}, 501 | {file = "PyYAML-6.0.3-cp38-cp38-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9c57bb8c96f6d1808c030b1687b9b5fb476abaa47f0db9c0101f5e9f394e97f4"}, 502 | {file = "PyYAML-6.0.3-cp38-cp38-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:efd7b85f94a6f21e4932043973a7ba2613b059c4a000551892ac9f1d11f5baf3"}, 503 | {file = "PyYAML-6.0.3-cp38-cp38-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:22ba7cfcad58ef3ecddc7ed1db3409af68d023b7f940da23c6c2a1890976eda6"}, 504 | {file = "PyYAML-6.0.3-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:6344df0d5755a2c9a276d4473ae6b90647e216ab4757f8426893b5dd2ac3f369"}, 505 | {file = "PyYAML-6.0.3-cp38-cp38-win32.whl", hash = "sha256:3ff07ec89bae51176c0549bc4c63aa6202991da2d9a6129d7aef7f1407d3f295"}, 506 | {file = "PyYAML-6.0.3-cp38-cp38-win_amd64.whl", hash = "sha256:5cf4e27da7e3fbed4d6c3d8e797387aaad68102272f8f9752883bc32d61cb87b"}, 507 | {file = "pyyaml-6.0.3-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b"}, 508 | {file = "pyyaml-6.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956"}, 509 | {file = "pyyaml-6.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8"}, 510 | {file = "pyyaml-6.0.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198"}, 511 | {file = "pyyaml-6.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b"}, 512 | {file = "pyyaml-6.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0"}, 513 | {file = "pyyaml-6.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69"}, 514 | {file = "pyyaml-6.0.3-cp310-cp310-win32.whl", hash = "sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e"}, 515 | {file = "pyyaml-6.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c"}, 516 | {file = "pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e"}, 517 | {file = "pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824"}, 518 | {file = "pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c"}, 519 | {file = "pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00"}, 520 | {file = "pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d"}, 521 | {file = "pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a"}, 522 | {file = "pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4"}, 523 | {file = "pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b"}, 524 | {file = "pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf"}, 525 | {file = "pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196"}, 526 | {file = "pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0"}, 527 | {file = "pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28"}, 528 | {file = "pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c"}, 529 | {file = "pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc"}, 530 | {file = "pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e"}, 531 | {file = "pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea"}, 532 | {file = "pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5"}, 533 | {file = "pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b"}, 534 | {file = "pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd"}, 535 | {file = "pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8"}, 536 | {file = "pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1"}, 537 | {file = "pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c"}, 538 | {file = "pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5"}, 539 | {file = "pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6"}, 540 | {file = "pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6"}, 541 | {file = "pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be"}, 542 | {file = "pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26"}, 543 | {file = "pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c"}, 544 | {file = "pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb"}, 545 | {file = "pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac"}, 546 | {file = "pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310"}, 547 | {file = "pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7"}, 548 | {file = "pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788"}, 549 | {file = "pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5"}, 550 | {file = "pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764"}, 551 | {file = "pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35"}, 552 | {file = "pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac"}, 553 | {file = "pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3"}, 554 | {file = "pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3"}, 555 | {file = "pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba"}, 556 | {file = "pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c"}, 557 | {file = "pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702"}, 558 | {file = "pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c"}, 559 | {file = "pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065"}, 560 | {file = "pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65"}, 561 | {file = "pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9"}, 562 | {file = "pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b"}, 563 | {file = "pyyaml-6.0.3-cp39-cp39-macosx_10_13_x86_64.whl", hash = "sha256:b865addae83924361678b652338317d1bd7e79b1f4596f96b96c77a5a34b34da"}, 564 | {file = "pyyaml-6.0.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:c3355370a2c156cffb25e876646f149d5d68f5e0a3ce86a5084dd0b64a994917"}, 565 | {file = "pyyaml-6.0.3-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3c5677e12444c15717b902a5798264fa7909e41153cdf9ef7ad571b704a63dd9"}, 566 | {file = "pyyaml-6.0.3-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5ed875a24292240029e4483f9d4a4b8a1ae08843b9c54f43fcc11e404532a8a5"}, 567 | {file = "pyyaml-6.0.3-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0150219816b6a1fa26fb4699fb7daa9caf09eb1999f3b70fb6e786805e80375a"}, 568 | {file = "pyyaml-6.0.3-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:fa160448684b4e94d80416c0fa4aac48967a969efe22931448d853ada8baf926"}, 569 | {file = "pyyaml-6.0.3-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:27c0abcb4a5dac13684a37f76e701e054692a9b2d3064b70f5e4eb54810553d7"}, 570 | {file = "pyyaml-6.0.3-cp39-cp39-win32.whl", hash = "sha256:1ebe39cb5fc479422b83de611d14e2c0d3bb2a18bbcb01f229ab3cfbd8fee7a0"}, 571 | {file = "pyyaml-6.0.3-cp39-cp39-win_amd64.whl", hash = "sha256:2e71d11abed7344e42a8849600193d15b6def118602c4c176f748e4583246007"}, 572 | {file = "pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f"}, 573 | ] 574 | 575 | [[package]] 576 | name = "ruff" 577 | version = "0.14.4" 578 | description = "An extremely fast Python linter and code formatter, written in Rust." 579 | optional = false 580 | python-versions = ">=3.7" 581 | groups = ["dev"] 582 | files = [ 583 | {file = "ruff-0.14.4-py3-none-linux_armv6l.whl", hash = "sha256:e6604613ffbcf2297cd5dcba0e0ac9bd0c11dc026442dfbb614504e87c349518"}, 584 | {file = "ruff-0.14.4-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:d99c0b52b6f0598acede45ee78288e5e9b4409d1ce7f661f0fa36d4cbeadf9a4"}, 585 | {file = "ruff-0.14.4-py3-none-macosx_11_0_arm64.whl", hash = "sha256:9358d490ec030f1b51d048a7fd6ead418ed0826daf6149e95e30aa67c168af33"}, 586 | {file = "ruff-0.14.4-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:81b40d27924f1f02dfa827b9c0712a13c0e4b108421665322218fc38caf615c2"}, 587 | {file = "ruff-0.14.4-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f5e649052a294fe00818650712083cddc6cc02744afaf37202c65df9ea52efa5"}, 588 | {file = "ruff-0.14.4-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:aa082a8f878deeba955531f975881828fd6afd90dfa757c2b0808aadb437136e"}, 589 | {file = "ruff-0.14.4-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:1043c6811c2419e39011890f14d0a30470f19d47d197c4858b2787dfa698f6c8"}, 590 | {file = "ruff-0.14.4-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a9f3a936ac27fb7c2a93e4f4b943a662775879ac579a433291a6f69428722649"}, 591 | {file = "ruff-0.14.4-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:95643ffd209ce78bc113266b88fba3d39e0461f0cbc8b55fb92505030fb4a850"}, 592 | {file = "ruff-0.14.4-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:456daa2fa1021bc86ca857f43fe29d5d8b3f0e55e9f90c58c317c1dcc2afc7b5"}, 593 | {file = "ruff-0.14.4-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:f911bba769e4a9f51af6e70037bb72b70b45a16db5ce73e1f72aefe6f6d62132"}, 594 | {file = "ruff-0.14.4-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:76158a7369b3979fa878612c623a7e5430c18b2fd1c73b214945c2d06337db67"}, 595 | {file = "ruff-0.14.4-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:f3b8f3b442d2b14c246e7aeca2e75915159e06a3540e2f4bed9f50d062d24469"}, 596 | {file = "ruff-0.14.4-py3-none-musllinux_1_2_i686.whl", hash = "sha256:c62da9a06779deecf4d17ed04939ae8b31b517643b26370c3be1d26f3ef7dbde"}, 597 | {file = "ruff-0.14.4-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:5a443a83a1506c684e98acb8cb55abaf3ef725078be40237463dae4463366349"}, 598 | {file = "ruff-0.14.4-py3-none-win32.whl", hash = "sha256:643b69cb63cd996f1fc7229da726d07ac307eae442dd8974dbc7cf22c1e18fff"}, 599 | {file = "ruff-0.14.4-py3-none-win_amd64.whl", hash = "sha256:26673da283b96fe35fa0c939bf8411abec47111644aa9f7cfbd3c573fb125d2c"}, 600 | {file = "ruff-0.14.4-py3-none-win_arm64.whl", hash = "sha256:dd09c292479596b0e6fec8cd95c65c3a6dc68e9ad17b8f2382130f87ff6a75bb"}, 601 | {file = "ruff-0.14.4.tar.gz", hash = "sha256:f459a49fe1085a749f15414ca76f61595f1a2cc8778ed7c279b6ca2e1fd19df3"}, 602 | ] 603 | 604 | [[package]] 605 | name = "tomli" 606 | version = "2.3.0" 607 | description = "A lil' TOML parser" 608 | optional = false 609 | python-versions = ">=3.8" 610 | groups = ["main", "dev"] 611 | files = [ 612 | {file = "tomli-2.3.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:88bd15eb972f3664f5ed4b57c1634a97153b4bac4479dcb6a495f41921eb7f45"}, 613 | {file = "tomli-2.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:883b1c0d6398a6a9d29b508c331fa56adbcdff647f6ace4dfca0f50e90dfd0ba"}, 614 | {file = "tomli-2.3.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d1381caf13ab9f300e30dd8feadb3de072aeb86f1d34a8569453ff32a7dea4bf"}, 615 | {file = "tomli-2.3.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a0e285d2649b78c0d9027570d4da3425bdb49830a6156121360b3f8511ea3441"}, 616 | {file = "tomli-2.3.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0a154a9ae14bfcf5d8917a59b51ffd5a3ac1fd149b71b47a3a104ca4edcfa845"}, 617 | {file = "tomli-2.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:74bf8464ff93e413514fefd2be591c3b0b23231a77f901db1eb30d6f712fc42c"}, 618 | {file = "tomli-2.3.0-cp311-cp311-win32.whl", hash = "sha256:00b5f5d95bbfc7d12f91ad8c593a1659b6387b43f054104cda404be6bda62456"}, 619 | {file = "tomli-2.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:4dc4ce8483a5d429ab602f111a93a6ab1ed425eae3122032db7e9acf449451be"}, 620 | {file = "tomli-2.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d7d86942e56ded512a594786a5ba0a5e521d02529b3826e7761a05138341a2ac"}, 621 | {file = "tomli-2.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:73ee0b47d4dad1c5e996e3cd33b8a76a50167ae5f96a2607cbe8cc773506ab22"}, 622 | {file = "tomli-2.3.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:792262b94d5d0a466afb5bc63c7daa9d75520110971ee269152083270998316f"}, 623 | {file = "tomli-2.3.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4f195fe57ecceac95a66a75ac24d9d5fbc98ef0962e09b2eddec5d39375aae52"}, 624 | {file = "tomli-2.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e31d432427dcbf4d86958c184b9bfd1e96b5b71f8eb17e6d02531f434fd335b8"}, 625 | {file = "tomli-2.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7b0882799624980785240ab732537fcfc372601015c00f7fc367c55308c186f6"}, 626 | {file = "tomli-2.3.0-cp312-cp312-win32.whl", hash = "sha256:ff72b71b5d10d22ecb084d345fc26f42b5143c5533db5e2eaba7d2d335358876"}, 627 | {file = "tomli-2.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:1cb4ed918939151a03f33d4242ccd0aa5f11b3547d0cf30f7c74a408a5b99878"}, 628 | {file = "tomli-2.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5192f562738228945d7b13d4930baffda67b69425a7f0da96d360b0a3888136b"}, 629 | {file = "tomli-2.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:be71c93a63d738597996be9528f4abe628d1adf5e6eb11607bc8fe1a510b5dae"}, 630 | {file = "tomli-2.3.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c4665508bcbac83a31ff8ab08f424b665200c0e1e645d2bd9ab3d3e557b6185b"}, 631 | {file = "tomli-2.3.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4021923f97266babc6ccab9f5068642a0095faa0a51a246a6a02fccbb3514eaf"}, 632 | {file = "tomli-2.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4ea38c40145a357d513bffad0ed869f13c1773716cf71ccaa83b0fa0cc4e42f"}, 633 | {file = "tomli-2.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ad805ea85eda330dbad64c7ea7a4556259665bdf9d2672f5dccc740eb9d3ca05"}, 634 | {file = "tomli-2.3.0-cp313-cp313-win32.whl", hash = "sha256:97d5eec30149fd3294270e889b4234023f2c69747e555a27bd708828353ab606"}, 635 | {file = "tomli-2.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:0c95ca56fbe89e065c6ead5b593ee64b84a26fca063b5d71a1122bf26e533999"}, 636 | {file = "tomli-2.3.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:cebc6fe843e0733ee827a282aca4999b596241195f43b4cc371d64fc6639da9e"}, 637 | {file = "tomli-2.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4c2ef0244c75aba9355561272009d934953817c49f47d768070c3c94355c2aa3"}, 638 | {file = "tomli-2.3.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c22a8bf253bacc0cf11f35ad9808b6cb75ada2631c2d97c971122583b129afbc"}, 639 | {file = "tomli-2.3.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0eea8cc5c5e9f89c9b90c4896a8deefc74f518db5927d0e0e8d4a80953d774d0"}, 640 | {file = "tomli-2.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b74a0e59ec5d15127acdabd75ea17726ac4c5178ae51b85bfe39c4f8a278e879"}, 641 | {file = "tomli-2.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b5870b50c9db823c595983571d1296a6ff3e1b88f734a4c8f6fc6188397de005"}, 642 | {file = "tomli-2.3.0-cp314-cp314-win32.whl", hash = "sha256:feb0dacc61170ed7ab602d3d972a58f14ee3ee60494292d384649a3dc38ef463"}, 643 | {file = "tomli-2.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:b273fcbd7fc64dc3600c098e39136522650c49bca95df2d11cf3b626422392c8"}, 644 | {file = "tomli-2.3.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:940d56ee0410fa17ee1f12b817b37a4d4e4dc4d27340863cc67236c74f582e77"}, 645 | {file = "tomli-2.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f85209946d1fe94416debbb88d00eb92ce9cd5266775424ff81bc959e001acaf"}, 646 | {file = "tomli-2.3.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a56212bdcce682e56b0aaf79e869ba5d15a6163f88d5451cbde388d48b13f530"}, 647 | {file = "tomli-2.3.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c5f3ffd1e098dfc032d4d3af5c0ac64f6d286d98bc148698356847b80fa4de1b"}, 648 | {file = "tomli-2.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5e01decd096b1530d97d5d85cb4dff4af2d8347bd35686654a004f8dea20fc67"}, 649 | {file = "tomli-2.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:8a35dd0e643bb2610f156cca8db95d213a90015c11fee76c946aa62b7ae7e02f"}, 650 | {file = "tomli-2.3.0-cp314-cp314t-win32.whl", hash = "sha256:a1f7f282fe248311650081faafa5f4732bdbfef5d45fe3f2e702fbc6f2d496e0"}, 651 | {file = "tomli-2.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:70a251f8d4ba2d9ac2542eecf008b3c8a9fc5c3f9f02c56a9d7952612be2fdba"}, 652 | {file = "tomli-2.3.0-py3-none-any.whl", hash = "sha256:e95b1af3c5b07d9e643909b5abbec77cd9f1217e6d0bca72b0234736b9fb1f1b"}, 653 | {file = "tomli-2.3.0.tar.gz", hash = "sha256:64be704a875d2a59753d80ee8a533c3fe183e3f06807ff7dc2232938ccb01549"}, 654 | ] 655 | 656 | [[package]] 657 | name = "typing-extensions" 658 | version = "4.15.0" 659 | description = "Backported and Experimental Type Hints for Python 3.9+" 660 | optional = false 661 | python-versions = ">=3.9" 662 | groups = ["dev"] 663 | files = [ 664 | {file = "typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548"}, 665 | {file = "typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466"}, 666 | ] 667 | 668 | [[package]] 669 | name = "virtualenv" 670 | version = "20.35.4" 671 | description = "Virtual Python Environment builder" 672 | optional = false 673 | python-versions = ">=3.8" 674 | groups = ["dev"] 675 | files = [ 676 | {file = "virtualenv-20.35.4-py3-none-any.whl", hash = "sha256:c21c9cede36c9753eeade68ba7d523529f228a403463376cf821eaae2b650f1b"}, 677 | {file = "virtualenv-20.35.4.tar.gz", hash = "sha256:643d3914d73d3eeb0c552cbb12d7e82adf0e504dbf86a3182f8771a153a1971c"}, 678 | ] 679 | 680 | [package.dependencies] 681 | distlib = ">=0.3.7,<1" 682 | filelock = ">=3.12.2,<4" 683 | platformdirs = ">=3.9.1,<5" 684 | typing-extensions = {version = ">=4.13.2", markers = "python_version < \"3.11\""} 685 | 686 | [package.extras] 687 | docs = ["furo (>=2023.7.26)", "proselint (>=0.13)", "sphinx (>=7.1.2,!=7.3)", "sphinx-argparse (>=0.4)", "sphinxcontrib-towncrier (>=0.2.1a0)", "towncrier (>=23.6)"] 688 | test = ["covdefaults (>=2.3)", "coverage (>=7.2.7)", "coverage-enable-subprocess (>=1)", "flaky (>=3.7)", "packaging (>=23.1)", "pytest (>=7.4)", "pytest-env (>=0.8.2)", "pytest-freezer (>=0.4.8) ; platform_python_implementation == \"PyPy\" or platform_python_implementation == \"GraalVM\" or platform_python_implementation == \"CPython\" and sys_platform == \"win32\" and python_version >= \"3.13\"", "pytest-mock (>=3.11.1)", "pytest-randomly (>=3.12)", "pytest-timeout (>=2.1)", "setuptools (>=68)", "time-machine (>=2.10) ; platform_python_implementation == \"CPython\""] 689 | 690 | [metadata] 691 | lock-version = "2.1" 692 | python-versions = ">=3.10" 693 | content-hash = "27b2fc1c9e1ce2655181a207c6048bbda9a3dd1cece9e5c7ff772d04d7d88b36" 694 | --------------------------------------------------------------------------------