diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml new file mode 100644 index 0000000..e593893 --- /dev/null +++ b/.github/workflows/main.yml @@ -0,0 +1,58 @@ +name: CI + +on: + push: + # Use ["main", "master"] for CI only on the default branch. + # Use ["**"] for CI on all branches. + branches: ["main", "master"] + pull_request: + branches: ["main", "master"] + workflow_dispatch: # Enable manual trigger. + +permissions: + contents: read + +jobs: + build: + strategy: + matrix: + os: ["ubuntu-latest", "macos-latest", "windows-latest"] + python-version: ["3.12", "3.13"] + + runs-on: ${{ matrix.os }} + timeout-minutes: 5 + + steps: + # Generally following uv docs: + # https://docs.astral.sh/uv/guides/integration/github/ + + - name: Checkout (official GitHub action) + uses: actions/checkout@v5 + with: + # Important for versioning plugins: + fetch-depth: 0 + + - name: Install uv (official Astral action) + uses: astral-sh/setup-uv@v5 + with: + # Update this as needed: + version: "0.9.5" + enable-cache: true + python-version: ${{ matrix.python-version }} + + - name: Set up Python (using uv) + run: uv python install + + - name: Install all dependencies + run: uv sync --all-extras + + - name: Run linting + run: | + uv run ruff check . + uv run ruff format --check . + + - name: Run type checking + run: uv run pyright + + - name: Run tests + run: uv run pytest diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..6f25230 --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,41 @@ +name: Publish to PyPI + +on: + release: + types: [published] + workflow_dispatch: # Enable manual trigger. + +jobs: + build-and-publish: + runs-on: ubuntu-latest + permissions: + id-token: write # Mandatory for OIDC. + contents: read + steps: + - name: Checkout (official GitHub action) + uses: actions/checkout@v4 + with: + # Important for versioning plugins: + fetch-depth: 0 + + - name: Install uv (official Astral action) + uses: astral-sh/setup-uv@v5 + with: + version: "0.9.5" + enable-cache: true + python-version: "3.12" + + - name: Set up Python (using uv) + run: uv python install + + - name: Install all dependencies + run: uv sync --all-extras + + - name: Run tests + run: uv run pytest + + - name: Build package + run: uv build + + - name: Publish to PyPI + run: uv publish --trusted-publishing always diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..68bc17f --- /dev/null +++ b/.gitignore @@ -0,0 +1,160 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +#poetry.lock + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +#pdm.lock +# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it +# in version control. +# https://pdm.fming.dev/#use-with-ide +.pdm.toml + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# PyCharm +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +#.idea/ diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..fca2e9b --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,10 @@ +{ + "[python]": { + "editor.formatOnSave": true, + }, + "python.testing.pytestArgs": [ + "tests" + ], + "python.testing.unittestEnabled": false, + "python.testing.pytestEnabled": true +} \ No newline at end of file diff --git a/README.md b/README.md index fea27e0..0351f21 100644 --- a/README.md +++ b/README.md @@ -1,16 +1,28 @@ -keyhunter -========= +# keyhunter -A tool to recover lost bitcoin private keys from dead harddrives. +A tool to recover lost bitcoin private keys from dead hard drives. -chmod +x keyhunter.py +## Usage -./keyhunter.py /dev/sdc +```bash +python3 keyhunter.py -i /dev/sdX --log ./sdX_log.log -o ./sdX_found_keys_list.txt +``` -output should list found private keys in base58 key import format. +The output file lists found private keys, in base58 key WIF (wallet import format). -bitcoind importprivkey 5K????????????? yay +To import into bitcoind, use the following command for each key: +```bash +bitcoind importprivkey 5KXXXXXXXXXXXX bitcoind getbalance +``` -DONATIONS --> 1YAyBtCwvZqNF9umZTUmfQ6vvLQRTG9qG +## Features and Limitations + +* Supports both pre-2012 and post-2012 wallet keys. +* Supports logging to a file. +* Cannot find encrypted wallets. + +## Credits + +This project is a maintained fork of [pierce403/keyhunter](https://github.com/pierce403/keyhunter). diff --git a/keyhunter.py b/keyhunter.py deleted file mode 100755 index 0e3b1e6..0000000 --- a/keyhunter.py +++ /dev/null @@ -1,91 +0,0 @@ -#!/usr/bin/python - -import binascii -import os -import hashlib -import sys - -# bytes to read at a time from file (10meg) -readlength=10*1024*1024 - -magic = '\x01\x30\x82\x01\x13\x02\x01\x01\x04\x20' -magiclen = len(magic) - - -##### start code from pywallet.py ############# - -__b58chars = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz' -__b58base = len(__b58chars) - -def b58encode(v): - """ encode v, which is a string of bytes, to base58. - """ - - long_value = 0L - for (i, c) in enumerate(v[::-1]): - long_value += (256**i) * ord(c) - - result = '' - while long_value >= __b58base: - div, mod = divmod(long_value, __b58base) - result = __b58chars[mod] + result - long_value = div - result = __b58chars[long_value] + result - - # Bitcoin does a little leading-zero-compression: - # leading 0-bytes in the input become leading-1s - nPad = 0 - for c in v: - if c != '\0': - break - nPad += 1 - - return (__b58chars[0]*nPad) + result - -def Hash(data): - return hashlib.sha256(hashlib.sha256(data).digest()).digest() - -def EncodeBase58Check(secret): - hash = Hash(secret) - return b58encode(secret + hash[0:4]) - -########## end code from pywallet.py ############ - -def find_keys(filename): - keys = set() - with open(filename, "rb") as f: - # read through target file one block at a time - while True: - data = f.read(readlength) - if not data: - break - - # look in this block for keys - pos = 0 - while True: - # find the magic number - pos = data.find(magic, pos) - if pos == -1: - break - key_offset = pos + magiclen - key_data = "\x80" + data[key_offset:key_offset + 32] - keys.add(EncodeBase58Check(key_data)) - pos += 1 - - # are we at the end of the file? - if len(data) == readlength: - # make sure we didn't miss any keys at the end of the block - f.seek(f.tell() - (32 + magiclen)) - return keys - -def main(): - if len(sys.argv) != 2: - print "./{0} ".format(sys.argv[0]) - exit() - - keys = find_keys(sys.argv[1]) - for key in keys: - print key - -if __name__ == "__main__": - main() \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..350e399 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,33 @@ +[project] +name = "keyhunter" +version = "0.1.0" +description = "A tool to recover lost bitcoin private keys from dead hard drives." +readme = "README.md" +requires-python = ">=3.12" +dependencies = [ + "loguru>=0.7.3", +] + +[dependency-groups] +dev = [ + "pyright>=1.1.408", + "pytest>=9.0.2", + "ruff>=0.14.14", +] + +[project.urls] +Homepage = "https://github.com/RecRanger/keyhunter" +Issues = "https://github.com/RecRanger/keyhunter/issues" + +[project.scripts] +keyhunter = "keyhunter.keyhunter:main_cli" + +[tool.pyright] +typeCheckingMode = "standard" + +[tool.ruff.lint] +select = ["ALL"] +ignore = ["D", "S", "TD", "FIX", "COM812"] + +[tool.uv] +package = true diff --git a/src/keyhunter/__init__.py b/src/keyhunter/__init__.py new file mode 100644 index 0000000..286de97 --- /dev/null +++ b/src/keyhunter/__init__.py @@ -0,0 +1,4 @@ +from keyhunter import keyhunter + +if __name__ == "__main__": + keyhunter.main_cli() diff --git a/src/keyhunter/__main__.py b/src/keyhunter/__main__.py new file mode 100644 index 0000000..286de97 --- /dev/null +++ b/src/keyhunter/__main__.py @@ -0,0 +1,4 @@ +from keyhunter import keyhunter + +if __name__ == "__main__": + keyhunter.main_cli() diff --git a/src/keyhunter/keyhunter.py b/src/keyhunter/keyhunter.py new file mode 100644 index 0000000..5fa049d --- /dev/null +++ b/src/keyhunter/keyhunter.py @@ -0,0 +1,173 @@ +"""A tool to recover lost bitcoin private keys from dead hard drives.""" + +import argparse +import hashlib +from pathlib import Path + +from loguru import logger + +# Bytes to read at a time from file (10 MiB). +READ_BLOCK_SIZE = 10 * 1024 * 1024 + +# Magic bytes reference: https://bitcointalk.org/index.php?topic=2745783.msg28084524#msg28084524 +MAGIC_BYTES_LIST = [ + bytes.fromhex("01308201130201010420"), # Old (uncompressed), <2012 + bytes.fromhex("01d63081d30201010420"), # New (compressed), >2012 +] +MAGIC_BYTES_LEN = 10 # Length of each element in MAGIC_BYTES_LIST. +assert all(len(magic_bytes) == MAGIC_BYTES_LEN for magic_bytes in MAGIC_BYTES_LIST) + + +B58_CHARS = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz" +B58_BASE = len(B58_CHARS) # Literally 58. + + +def b58encode(v: bytes) -> str: + """Encode v, which is a string of bytes, to base58.""" + + long_value = 0 + for i, c in enumerate(v[::-1]): + long_value += (256**i) * c + + result = "" + while long_value >= B58_BASE: + div, mod = divmod(long_value, B58_BASE) + result = B58_CHARS[mod] + result + long_value = div + result = B58_CHARS[long_value] + result + + # Bitcoin does a little leading-zero-compression: + # leading 0-bytes in the input become leading-1s + n_pad = 0 + for c in v: + if c != 0: + break + n_pad += 1 + + return (B58_CHARS[0] * n_pad) + result + + +def sha256d_hash(data: bytes) -> bytes: + return hashlib.sha256(hashlib.sha256(data).digest()).digest() + + +def encode_base58_check(secret: bytes) -> str: + hash_val = sha256d_hash(secret) + return b58encode(secret + hash_val[0:4]) + + +def find_keys(filename: Path) -> set[str]: + """Searches a file for Bitcoin private keys. + + Returns a set of private keys as base58 WIF strings. + """ + + keys = set() + key_count = 0 + with filename.open("rb") as f: + logger.info(f"Opened file: {filename}") + + # Read through target file one block at a time. + while block_bytes := f.read(READ_BLOCK_SIZE): + # Look in this block for each key. + for magic_bytes in MAGIC_BYTES_LIST: + pos = 0 # Index in the block. + while (pos := block_bytes.find(magic_bytes, pos)) > -1: + # Find the magic number. + key_offset = pos + MAGIC_BYTES_LEN + key_data = b"\x80" + block_bytes[key_offset : key_offset + 32] + priv_key_wif = encode_base58_check(key_data) + is_new_key = priv_key_wif not in keys + key_count += 1 + keys.add(priv_key_wif) + global_offset = f.tell() - len(block_bytes) + key_offset + + logger.info( + f"Found {('new key' if is_new_key else 'key again')} " + f"at offset {global_offset:,} = 0x{global_offset:_x} " + f"(using magic bytes {magic_bytes.hex()}): {priv_key_wif} " + f"({key_count:,} keys total, {len(keys):,} unique keys)" + ) + pos += 1 + + # Make sure we didn't miss any keys at the end of the block. + # After scanning the block, seek back so that the next block includes the + # overlap. + if len(block_bytes) == READ_BLOCK_SIZE: + f.seek(f.tell() - (32 + MAGIC_BYTES_LEN)) + + logger.info(f"Closed file: {filename}") + return keys + + +def main_keyhunter( + haystack_file_path: Path, + log_path: Path | None = None, + output_keys_file_path: Path | None = None, +) -> None: + if log_path: + logger.add(log_path) + + logger.info("Starting keyhunter") + + if log_path: + logger.info(f"Logging to console, and file: {log_path}") + else: + logger.info("Logging to console only.") + + if not Path(haystack_file_path).exists(): + msg = f"File not found: {haystack_file_path}" + raise FileNotFoundError(msg) + + keys = find_keys(haystack_file_path) + + keys = sorted(keys) + logger.info(f"Found {len(keys)} keys: {keys}") + + if len(keys) > 0: + logger.info("Printing keys (as base58 WIF private keys) for easy copying:") + for key in keys: + print(key) # noqa: T201 + + if output_keys_file_path: + with output_keys_file_path.open("w") as f: + for key in keys: + f.write(key + "\n") + + logger.info(f"Finished keyhunter. Found {len(keys):,} keys.") + + +def get_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Find Bitcoin private keys in a file.") + parser.add_argument( + "-i", + "--input", + required=True, + dest="input_file_path", + help=( + "The input file (disk image, corrupt wallet.dat, etc.) to search for keys." + ), + ) + parser.add_argument( + "-l", "--log", dest="log_path", help="Log file to write logs to." + ) + parser.add_argument( + "-o", + "--output", + dest="output_file_path", + help="Output file to write the WIF write keys to.", + ) + return parser.parse_args() + + +def main_cli() -> None: + args = get_args() + main_keyhunter( + haystack_file_path=args.input_file_path, + log_path=args.log_path, + output_keys_file_path=args.output_file_path, + ) + + +if __name__ == "__main__": + main_cli() diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..39c80a2 --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1 @@ +"""Unit tests for keyhunter module.""" diff --git a/tests/test_keyhunter.py b/tests/test_keyhunter.py new file mode 100644 index 0000000..f5bea79 --- /dev/null +++ b/tests/test_keyhunter.py @@ -0,0 +1,8 @@ +from keyhunter.keyhunter import b58encode + + +def test_b58encode() -> None: + # Generate cases: https://learnmeabitcoin.com/technical/keys/base58/ + assert b58encode(b"\x00\x00\x00\x01") == "1112" + assert b58encode(b"\x05\xab\xcd") == "2uUx" + assert b58encode(b"\x10\xff\x00\xab\xbc") == "2vDZMDM" diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..0a4a3af --- /dev/null +++ b/uv.lock @@ -0,0 +1,168 @@ +version = 1 +revision = 3 +requires-python = ">=3.12" + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "keyhunter" +version = "0.1.0" +source = { editable = "." } +dependencies = [ + { name = "loguru" }, +] + +[package.dev-dependencies] +dev = [ + { name = "pyright" }, + { name = "pytest" }, + { name = "ruff" }, +] + +[package.metadata] +requires-dist = [{ name = "loguru", specifier = ">=0.7.3" }] + +[package.metadata.requires-dev] +dev = [ + { name = "pyright", specifier = ">=1.1.408" }, + { name = "pytest", specifier = ">=9.0.2" }, + { name = "ruff", specifier = ">=0.14.14" }, +] + +[[package]] +name = "loguru" +version = "0.7.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "win32-setctime", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3a/05/a1dae3dffd1116099471c643b8924f5aa6524411dc6c63fdae648c4f1aca/loguru-0.7.3.tar.gz", hash = "sha256:19480589e77d47b8d85b2c827ad95d49bf31b0dcde16593892eb51dd18706eb6", size = 63559, upload-time = "2024-12-06T11:20:56.608Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/29/0348de65b8cc732daa3e33e67806420b2ae89bdce2b04af740289c5c6c8c/loguru-0.7.3-py3-none-any.whl", hash = "sha256:31a33c10c8e1e10422bfd431aeb5d351c7cf7fa671e3c4df004162264b28220c", size = 61595, upload-time = "2024-12-06T11:20:54.538Z" }, +] + +[[package]] +name = "nodeenv" +version = "1.10.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/24/bf/d1bda4f6168e0b2e9e5958945e01910052158313224ada5ce1fb2e1113b8/nodeenv-1.10.0.tar.gz", hash = "sha256:996c191ad80897d076bdfba80a41994c2b47c68e224c542b48feba42ba00f8bb", size = 55611, upload-time = "2025-12-20T14:08:54.006Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/b2/d0896bdcdc8d28a7fc5717c305f1a861c26e18c05047949fb371034d98bd/nodeenv-1.10.0-py2.py3-none-any.whl", hash = "sha256:5bb13e3eed2923615535339b3c620e76779af4cb4c6a90deccc9e36b274d3827", size = 23438, upload-time = "2025-12-20T14:08:52.782Z" }, +] + +[[package]] +name = "packaging" +version = "26.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "pygments" +version = "2.19.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, +] + +[[package]] +name = "pyright" +version = "1.1.408" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "nodeenv" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/74/b2/5db700e52554b8f025faa9c3c624c59f1f6c8841ba81ab97641b54322f16/pyright-1.1.408.tar.gz", hash = "sha256:f28f2321f96852fa50b5829ea492f6adb0e6954568d1caa3f3af3a5f555eb684", size = 4400578, upload-time = "2026-01-08T08:07:38.795Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/82/a2c93e32800940d9573fb28c346772a14778b84ba7524e691b324620ab89/pyright-1.1.408-py3-none-any.whl", hash = "sha256:090b32865f4fdb1e0e6cd82bf5618480d48eecd2eb2e70f960982a3d9a4c17c1", size = 6399144, upload-time = "2026-01-08T08:07:37.082Z" }, +] + +[[package]] +name = "pytest" +version = "9.0.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" }, +] + +[[package]] +name = "ruff" +version = "0.14.14" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2e/06/f71e3a86b2df0dfa2d2f72195941cd09b44f87711cb7fa5193732cb9a5fc/ruff-0.14.14.tar.gz", hash = "sha256:2d0f819c9a90205f3a867dbbd0be083bee9912e170fd7d9704cc8ae45824896b", size = 4515732, upload-time = "2026-01-22T22:30:17.527Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d2/89/20a12e97bc6b9f9f68343952da08a8099c57237aef953a56b82711d55edd/ruff-0.14.14-py3-none-linux_armv6l.whl", hash = "sha256:7cfe36b56e8489dee8fbc777c61959f60ec0f1f11817e8f2415f429552846aed", size = 10467650, upload-time = "2026-01-22T22:30:08.578Z" }, + { url = "https://files.pythonhosted.org/packages/a3/b1/c5de3fd2d5a831fcae21beda5e3589c0ba67eec8202e992388e4b17a6040/ruff-0.14.14-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:6006a0082336e7920b9573ef8a7f52eec837add1265cc74e04ea8a4368cd704c", size = 10883245, upload-time = "2026-01-22T22:30:04.155Z" }, + { url = "https://files.pythonhosted.org/packages/b8/7c/3c1db59a10e7490f8f6f8559d1db8636cbb13dccebf18686f4e3c9d7c772/ruff-0.14.14-py3-none-macosx_11_0_arm64.whl", hash = "sha256:026c1d25996818f0bf498636686199d9bd0d9d6341c9c2c3b62e2a0198b758de", size = 10231273, upload-time = "2026-01-22T22:30:34.642Z" }, + { url = "https://files.pythonhosted.org/packages/a1/6e/5e0e0d9674be0f8581d1f5e0f0a04761203affce3232c1a1189d0e3b4dad/ruff-0.14.14-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f666445819d31210b71e0a6d1c01e24447a20b85458eea25a25fe8142210ae0e", size = 10585753, upload-time = "2026-01-22T22:30:31.781Z" }, + { url = "https://files.pythonhosted.org/packages/23/09/754ab09f46ff1884d422dc26d59ba18b4e5d355be147721bb2518aa2a014/ruff-0.14.14-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3c0f18b922c6d2ff9a5e6c3ee16259adc513ca775bcf82c67ebab7cbd9da5bc8", size = 10286052, upload-time = "2026-01-22T22:30:24.827Z" }, + { url = "https://files.pythonhosted.org/packages/c8/cc/e71f88dd2a12afb5f50733851729d6b571a7c3a35bfdb16c3035132675a0/ruff-0.14.14-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1629e67489c2dea43e8658c3dba659edbfd87361624b4040d1df04c9740ae906", size = 11043637, upload-time = "2026-01-22T22:30:13.239Z" }, + { url = "https://files.pythonhosted.org/packages/67/b2/397245026352494497dac935d7f00f1468c03a23a0c5db6ad8fc49ca3fb2/ruff-0.14.14-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:27493a2131ea0f899057d49d303e4292b2cae2bb57253c1ed1f256fbcd1da480", size = 12194761, upload-time = "2026-01-22T22:30:22.542Z" }, + { url = "https://files.pythonhosted.org/packages/5b/06/06ef271459f778323112c51b7587ce85230785cd64e91772034ddb88f200/ruff-0.14.14-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:01ff589aab3f5b539e35db38425da31a57521efd1e4ad1ae08fc34dbe30bd7df", size = 12005701, upload-time = "2026-01-22T22:30:20.499Z" }, + { url = "https://files.pythonhosted.org/packages/41/d6/99364514541cf811ccc5ac44362f88df66373e9fec1b9d1c4cc830593fe7/ruff-0.14.14-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1cc12d74eef0f29f51775f5b755913eb523546b88e2d733e1d701fe65144e89b", size = 11282455, upload-time = "2026-01-22T22:29:59.679Z" }, + { url = "https://files.pythonhosted.org/packages/ca/71/37daa46f89475f8582b7762ecd2722492df26421714a33e72ccc9a84d7a5/ruff-0.14.14-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bb8481604b7a9e75eff53772496201690ce2687067e038b3cc31aaf16aa0b974", size = 11215882, upload-time = "2026-01-22T22:29:57.032Z" }, + { url = "https://files.pythonhosted.org/packages/2c/10/a31f86169ec91c0705e618443ee74ede0bdd94da0a57b28e72db68b2dbac/ruff-0.14.14-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:14649acb1cf7b5d2d283ebd2f58d56b75836ed8c6f329664fa91cdea19e76e66", size = 11180549, upload-time = "2026-01-22T22:30:27.175Z" }, + { url = "https://files.pythonhosted.org/packages/fd/1e/c723f20536b5163adf79bdd10c5f093414293cdf567eed9bdb7b83940f3f/ruff-0.14.14-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:e8058d2145566510790eab4e2fad186002e288dec5e0d343a92fe7b0bc1b3e13", size = 10543416, upload-time = "2026-01-22T22:30:01.964Z" }, + { url = "https://files.pythonhosted.org/packages/3e/34/8a84cea7e42c2d94ba5bde1d7a4fae164d6318f13f933d92da6d7c2041ff/ruff-0.14.14-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:e651e977a79e4c758eb807f0481d673a67ffe53cfa92209781dfa3a996cf8412", size = 10285491, upload-time = "2026-01-22T22:30:29.51Z" }, + { url = "https://files.pythonhosted.org/packages/55/ef/b7c5ea0be82518906c978e365e56a77f8de7678c8bb6651ccfbdc178c29f/ruff-0.14.14-py3-none-musllinux_1_2_i686.whl", hash = "sha256:cc8b22da8d9d6fdd844a68ae937e2a0adf9b16514e9a97cc60355e2d4b219fc3", size = 10733525, upload-time = "2026-01-22T22:30:06.499Z" }, + { url = "https://files.pythonhosted.org/packages/6a/5b/aaf1dfbcc53a2811f6cc0a1759de24e4b03e02ba8762daabd9b6bd8c59e3/ruff-0.14.14-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:16bc890fb4cc9781bb05beb5ab4cd51be9e7cb376bf1dd3580512b24eb3fda2b", size = 11315626, upload-time = "2026-01-22T22:30:36.848Z" }, + { url = "https://files.pythonhosted.org/packages/2c/aa/9f89c719c467dfaf8ad799b9bae0df494513fb21d31a6059cb5870e57e74/ruff-0.14.14-py3-none-win32.whl", hash = "sha256:b530c191970b143375b6a68e6f743800b2b786bbcf03a7965b06c4bf04568167", size = 10502442, upload-time = "2026-01-22T22:30:38.93Z" }, + { url = "https://files.pythonhosted.org/packages/87/44/90fa543014c45560cae1fffc63ea059fb3575ee6e1cb654562197e5d16fb/ruff-0.14.14-py3-none-win_amd64.whl", hash = "sha256:3dde1435e6b6fe5b66506c1dff67a421d0b7f6488d466f651c07f4cab3bf20fd", size = 11630486, upload-time = "2026-01-22T22:30:10.852Z" }, + { url = "https://files.pythonhosted.org/packages/9e/6a/40fee331a52339926a92e17ae748827270b288a35ef4a15c9c8f2ec54715/ruff-0.14.14-py3-none-win_arm64.whl", hash = "sha256:56e6981a98b13a32236a72a8da421d7839221fa308b223b9283312312e5ac76c", size = 10920448, upload-time = "2026-01-22T22:30:15.417Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +] + +[[package]] +name = "win32-setctime" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b3/8f/705086c9d734d3b663af0e9bb3d4de6578d08f46b1b101c2442fd9aecaa2/win32_setctime-1.2.0.tar.gz", hash = "sha256:ae1fdf948f5640aae05c511ade119313fb6a30d7eabe25fef9764dca5873c4c0", size = 4867, upload-time = "2024-12-07T15:28:28.314Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e1/07/c6fe3ad3e685340704d314d765b7912993bcb8dc198f0e7a89382d37974b/win32_setctime-1.2.0-py3-none-any.whl", hash = "sha256:95d644c4e708aba81dc3704a116d8cbc974d70b3bdb8be1d150e36be6e9d1390", size = 4083, upload-time = "2024-12-07T15:28:26.465Z" }, +]