diff --git a/.github/workflows/ci_cd.yml b/.github/workflows/ci_cd.yml index 65986dc..74312d0 100644 --- a/.github/workflows/ci_cd.yml +++ b/.github/workflows/ci_cd.yml @@ -14,13 +14,13 @@ jobs: strategy: matrix: os: [ubuntu-latest, windows-latest] - python-version: [3.13] + python-version: ['3.13', '3.14'] steps: - uses: actions/checkout@v4 - name: Set up Python - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} @@ -50,9 +50,9 @@ jobs: - uses: actions/checkout@v4 - name: Set up Python - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: - python-version: 3.13 + python-version: '3.14' - name: Install Poetry uses: snok/install-poetry@v1 diff --git a/.gitignore b/.gitignore index 69beff5..2d2c889 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ +.venv/ platform-tools** dist* build_temp/ diff --git a/poetry.lock b/poetry.lock index ee162b6..08a0a6e 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,220 +1,241 @@ -# This file is automatically @generated by Poetry 2.1.4 and should not be changed by hand. +# This file is automatically @generated by Poetry 2.3.2 and should not be changed by hand. [[package]] name = "altgraph" -version = "0.17.4" +version = "0.17.5" description = "Python graph (network) package" optional = false python-versions = "*" groups = ["build"] files = [ - {file = "altgraph-0.17.4-py2.py3-none-any.whl", hash = "sha256:642743b4750de17e655e6711601b077bc6598dbfa3ba5fa2b2a35ce12b508dff"}, - {file = "altgraph-0.17.4.tar.gz", hash = "sha256:1b5afbb98f6c4dcadb2e2ae6ab9fa994bbb8c1d75f4fa96d340f9437ae454406"}, + {file = "altgraph-0.17.5-py2.py3-none-any.whl", hash = "sha256:f3a22400bce1b0c701683820ac4f3b159cd301acab067c51c653e06961600597"}, + {file = "altgraph-0.17.5.tar.gz", hash = "sha256:c87b395dd12fabde9c99573a9749d67da8d29ef9de0125c7f536699b4a9bc9e7"}, ] [[package]] name = "black" -version = "25.9.0" +version = "26.3.1" description = "The uncompromising code formatter." optional = false -python-versions = ">=3.9" +python-versions = ">=3.10" groups = ["dev"] files = [ - {file = "black-25.9.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ce41ed2614b706fd55fd0b4a6909d06b5bab344ffbfadc6ef34ae50adba3d4f7"}, - {file = "black-25.9.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:2ab0ce111ef026790e9b13bd216fa7bc48edd934ffc4cbf78808b235793cbc92"}, - {file = "black-25.9.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f96b6726d690c96c60ba682955199f8c39abc1ae0c3a494a9c62c0184049a713"}, - {file = "black-25.9.0-cp310-cp310-win_amd64.whl", hash = "sha256:d119957b37cc641596063cd7db2656c5be3752ac17877017b2ffcdb9dfc4d2b1"}, - {file = "black-25.9.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:456386fe87bad41b806d53c062e2974615825c7a52159cde7ccaeb0695fa28fa"}, - {file = "black-25.9.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a16b14a44c1af60a210d8da28e108e13e75a284bf21a9afa6b4571f96ab8bb9d"}, - {file = "black-25.9.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:aaf319612536d502fdd0e88ce52d8f1352b2c0a955cc2798f79eeca9d3af0608"}, - {file = "black-25.9.0-cp311-cp311-win_amd64.whl", hash = "sha256:c0372a93e16b3954208417bfe448e09b0de5cc721d521866cd9e0acac3c04a1f"}, - {file = "black-25.9.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:1b9dc70c21ef8b43248f1d86aedd2aaf75ae110b958a7909ad8463c4aa0880b0"}, - {file = "black-25.9.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8e46eecf65a095fa62e53245ae2795c90bdecabd53b50c448d0a8bcd0d2e74c4"}, - {file = "black-25.9.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9101ee58ddc2442199a25cb648d46ba22cd580b00ca4b44234a324e3ec7a0f7e"}, - {file = "black-25.9.0-cp312-cp312-win_amd64.whl", hash = "sha256:77e7060a00c5ec4b3367c55f39cf9b06e68965a4f2e61cecacd6d0d9b7ec945a"}, - {file = "black-25.9.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0172a012f725b792c358d57fe7b6b6e8e67375dd157f64fa7a3097b3ed3e2175"}, - {file = "black-25.9.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:3bec74ee60f8dfef564b573a96b8930f7b6a538e846123d5ad77ba14a8d7a64f"}, - {file = "black-25.9.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b756fc75871cb1bcac5499552d771822fd9db5a2bb8db2a7247936ca48f39831"}, - {file = "black-25.9.0-cp313-cp313-win_amd64.whl", hash = "sha256:846d58e3ce7879ec1ffe816bb9df6d006cd9590515ed5d17db14e17666b2b357"}, - {file = "black-25.9.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:ef69351df3c84485a8beb6f7b8f9721e2009e20ef80a8d619e2d1788b7816d47"}, - {file = "black-25.9.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:e3c1f4cd5e93842774d9ee4ef6cd8d17790e65f44f7cdbaab5f2cf8ccf22a823"}, - {file = "black-25.9.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:154b06d618233fe468236ba1f0e40823d4eb08b26f5e9261526fde34916b9140"}, - {file = "black-25.9.0-cp39-cp39-win_amd64.whl", hash = "sha256:e593466de7b998374ea2585a471ba90553283fb9beefcfa430d84a2651ed5933"}, - {file = "black-25.9.0-py3-none-any.whl", hash = "sha256:474b34c1342cdc157d307b56c4c65bce916480c4a8f6551fdc6bf9b486a7c4ae"}, - {file = "black-25.9.0.tar.gz", hash = "sha256:0474bca9a0dd1b51791fcc507a4e02078a1c63f6d4e4ae5544b9848c7adfb619"}, + {file = "black-26.3.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:86a8b5035fce64f5dcd1b794cf8ec4d31fe458cf6ce3986a30deb434df82a1d2"}, + {file = "black-26.3.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:5602bdb96d52d2d0672f24f6ffe5218795736dd34807fd0fd55ccd6bf206168b"}, + {file = "black-26.3.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6c54a4a82e291a1fee5137371ab488866b7c86a3305af4026bdd4dc78642e1ac"}, + {file = "black-26.3.1-cp310-cp310-win_amd64.whl", hash = "sha256:6e131579c243c98f35bce64a7e08e87fb2d610544754675d4a0e73a070a5aa3a"}, + {file = "black-26.3.1-cp310-cp310-win_arm64.whl", hash = "sha256:5ed0ca58586c8d9a487352a96b15272b7fa55d139fc8496b519e78023a8dab0a"}, + {file = "black-26.3.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:28ef38aee69e4b12fda8dba75e21f9b4f979b490c8ac0baa7cb505369ac9e1ff"}, + {file = "black-26.3.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:bf9bf162ed91a26f1adba8efda0b573bc6924ec1408a52cc6f82cb73ec2b142c"}, + {file = "black-26.3.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:474c27574d6d7037c1bc875a81d9be0a9a4f9ee95e62800dab3cfaadbf75acd5"}, + {file = "black-26.3.1-cp311-cp311-win_amd64.whl", hash = "sha256:5e9d0d86df21f2e1677cc4bd090cd0e446278bcbbe49bf3659c308c3e402843e"}, + {file = "black-26.3.1-cp311-cp311-win_arm64.whl", hash = "sha256:9a5e9f45e5d5e1c5b5c29b3bd4265dcc90e8b92cf4534520896ed77f791f4da5"}, + {file = "black-26.3.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b5e6f89631eb88a7302d416594a32faeee9fb8fb848290da9d0a5f2903519fc1"}, + {file = "black-26.3.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:41cd2012d35b47d589cb8a16faf8a32ef7a336f56356babd9fcf70939ad1897f"}, + {file = "black-26.3.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f76ff19ec5297dd8e66eb64deda23631e642c9393ab592826fd4bdc97a4bce7"}, + {file = "black-26.3.1-cp312-cp312-win_amd64.whl", hash = "sha256:ddb113db38838eb9f043623ba274cfaf7d51d5b0c22ecb30afe58b1bb8322983"}, + {file = "black-26.3.1-cp312-cp312-win_arm64.whl", hash = "sha256:dfdd51fc3e64ea4f35873d1b3fb25326773d55d2329ff8449139ebaad7357efb"}, + {file = "black-26.3.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:855822d90f884905362f602880ed8b5df1b7e3ee7d0db2502d4388a954cc8c54"}, + {file = "black-26.3.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8a33d657f3276328ce00e4d37fe70361e1ec7614da5d7b6e78de5426cb56332f"}, + {file = "black-26.3.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f1cd08e99d2f9317292a311dfe578fd2a24b15dbce97792f9c4d752275c1fa56"}, + {file = "black-26.3.1-cp313-cp313-win_amd64.whl", hash = "sha256:c7e72339f841b5a237ff14f7d3880ddd0fc7f98a1199e8c4327f9a4f478c1839"}, + {file = "black-26.3.1-cp313-cp313-win_arm64.whl", hash = "sha256:afc622538b430aa4c8c853f7f63bc582b3b8030fd8c80b70fb5fa5b834e575c2"}, + {file = "black-26.3.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2d6bfaf7fd0993b420bed691f20f9492d53ce9a2bcccea4b797d34e947318a78"}, + {file = "black-26.3.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:f89f2ab047c76a9c03f78d0d66ca519e389519902fa27e7a91117ef7611c0568"}, + {file = "black-26.3.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b07fc0dab849d24a80a29cfab8d8a19187d1c4685d8a5e6385a5ce323c1f015f"}, + {file = "black-26.3.1-cp314-cp314-win_amd64.whl", hash = "sha256:0126ae5b7c09957da2bdbd91a9ba1207453feada9e9fe51992848658c6c8e01c"}, + {file = "black-26.3.1-cp314-cp314-win_arm64.whl", hash = "sha256:92c0ec1f2cc149551a2b7b47efc32c866406b6891b0ee4625e95967c8f4acfb1"}, + {file = "black-26.3.1-py3-none-any.whl", hash = "sha256:2bd5aa94fc267d38bb21a70d7410a89f1a1d318841855f698746f8e7f51acd1b"}, + {file = "black-26.3.1.tar.gz", hash = "sha256:2c50f5063a9641c7eed7795014ba37b0f5fa227f3d408b968936e24bc0566b07"}, ] [package.dependencies] click = ">=8.0.0" mypy-extensions = ">=0.4.3" packaging = ">=22.0" -pathspec = ">=0.9.0" +pathspec = ">=1.0.0" platformdirs = ">=2" -pytokens = ">=0.1.10" +pytokens = ">=0.4.0,<0.5.0" [package.extras] colorama = ["colorama (>=0.4.3)"] d = ["aiohttp (>=3.10)"] jupyter = ["ipython (>=7.8.0)", "tokenize-rt (>=3.2.0)"] -uvloop = ["uvloop (>=0.15.2)"] +uvloop = ["uvloop (>=0.15.2) ; sys_platform != \"win32\"", "winloop (>=0.5.0) ; sys_platform == \"win32\""] [[package]] name = "certifi" -version = "2025.10.5" +version = "2026.2.25" description = "Python package for providing Mozilla's CA Bundle." optional = false python-versions = ">=3.7" groups = ["main"] files = [ - {file = "certifi-2025.10.5-py3-none-any.whl", hash = "sha256:0f212c2744a9bb6de0c56639a6f68afe01ecd92d91f14ae897c4fe7bbeeef0de"}, - {file = "certifi-2025.10.5.tar.gz", hash = "sha256:47c09d31ccf2acf0be3f701ea53595ee7e0b8fa08801c6624be771df09ae7b43"}, + {file = "certifi-2026.2.25-py3-none-any.whl", hash = "sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa"}, + {file = "certifi-2026.2.25.tar.gz", hash = "sha256:e887ab5cee78ea814d3472169153c2d12cd43b14bd03329a39a9c6e2e80bfba7"}, ] [[package]] name = "cfgv" -version = "3.4.0" +version = "3.5.0" description = "Validate configuration and produce human readable error messages." optional = false -python-versions = ">=3.8" +python-versions = ">=3.10" groups = ["dev"] files = [ - {file = "cfgv-3.4.0-py2.py3-none-any.whl", hash = "sha256:b7265b1f29fd3316bfcd2b330d63d024f2bfd8bcb8b0272f8e19a504856c48f9"}, - {file = "cfgv-3.4.0.tar.gz", hash = "sha256:e52591d4c5f5dead8e0f673fb16db7949d2cfb3f7da4582893288f0ded8fe560"}, + {file = "cfgv-3.5.0-py2.py3-none-any.whl", hash = "sha256:a8dc6b26ad22ff227d2634a65cb388215ce6cc96bbcc5cfde7641ae87e8dacc0"}, + {file = "cfgv-3.5.0.tar.gz", hash = "sha256:d5b1034354820651caa73ede66a6294d6e95c1b00acc5e9b098e917404669132"}, ] [[package]] name = "charset-normalizer" -version = "3.4.4" +version = "3.4.6" description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." optional = false python-versions = ">=3.7" groups = ["main"] files = [ - {file = "charset_normalizer-3.4.4-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:e824f1492727fa856dd6eda4f7cee25f8518a12f3c4a56a74e8095695089cf6d"}, - {file = "charset_normalizer-3.4.4-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4bd5d4137d500351a30687c2d3971758aac9a19208fc110ccb9d7188fbe709e8"}, - {file = "charset_normalizer-3.4.4-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:027f6de494925c0ab2a55eab46ae5129951638a49a34d87f4c3eda90f696b4ad"}, - {file = "charset_normalizer-3.4.4-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f820802628d2694cb7e56db99213f930856014862f3fd943d290ea8438d07ca8"}, - {file = "charset_normalizer-3.4.4-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:798d75d81754988d2565bff1b97ba5a44411867c0cf32b77a7e8f8d84796b10d"}, - {file = "charset_normalizer-3.4.4-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d1bb833febdff5c8927f922386db610b49db6e0d4f4ee29601d71e7c2694313"}, - {file = "charset_normalizer-3.4.4-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9cd98cdc06614a2f768d2b7286d66805f94c48cde050acdbbb7db2600ab3197e"}, - {file = "charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:077fbb858e903c73f6c9db43374fd213b0b6a778106bc7032446a8e8b5b38b93"}, - {file = "charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:244bfb999c71b35de57821b8ea746b24e863398194a4014e4c76adc2bbdfeff0"}, - {file = "charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:64b55f9dce520635f018f907ff1b0df1fdc31f2795a922fb49dd14fbcdf48c84"}, - {file = "charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:faa3a41b2b66b6e50f84ae4a68c64fcd0c44355741c6374813a800cd6695db9e"}, - {file = "charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:6515f3182dbe4ea06ced2d9e8666d97b46ef4c75e326b79bb624110f122551db"}, - {file = "charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:cc00f04ed596e9dc0da42ed17ac5e596c6ccba999ba6bd92b0e0aef2f170f2d6"}, - {file = "charset_normalizer-3.4.4-cp310-cp310-win32.whl", hash = "sha256:f34be2938726fc13801220747472850852fe6b1ea75869a048d6f896838c896f"}, - {file = "charset_normalizer-3.4.4-cp310-cp310-win_amd64.whl", hash = "sha256:a61900df84c667873b292c3de315a786dd8dac506704dea57bc957bd31e22c7d"}, - {file = "charset_normalizer-3.4.4-cp310-cp310-win_arm64.whl", hash = "sha256:cead0978fc57397645f12578bfd2d5ea9138ea0fac82b2f63f7f7c6877986a69"}, - {file = "charset_normalizer-3.4.4-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:6e1fcf0720908f200cd21aa4e6750a48ff6ce4afe7ff5a79a90d5ed8a08296f8"}, - {file = "charset_normalizer-3.4.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f819d5fe9234f9f82d75bdfa9aef3a3d72c4d24a6e57aeaebba32a704553aa0"}, - {file = "charset_normalizer-3.4.4-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a59cb51917aa591b1c4e6a43c132f0cdc3c76dbad6155df4e28ee626cc77a0a3"}, - {file = "charset_normalizer-3.4.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8ef3c867360f88ac904fd3f5e1f902f13307af9052646963ee08ff4f131adafc"}, - {file = "charset_normalizer-3.4.4-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d9e45d7faa48ee908174d8fe84854479ef838fc6a705c9315372eacbc2f02897"}, - {file = "charset_normalizer-3.4.4-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:840c25fb618a231545cbab0564a799f101b63b9901f2569faecd6b222ac72381"}, - {file = "charset_normalizer-3.4.4-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ca5862d5b3928c4940729dacc329aa9102900382fea192fc5e52eb69d6093815"}, - {file = "charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d9c7f57c3d666a53421049053eaacdd14bbd0a528e2186fcb2e672effd053bb0"}, - {file = "charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:277e970e750505ed74c832b4bf75dac7476262ee2a013f5574dd49075879e161"}, - {file = "charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:31fd66405eaf47bb62e8cd575dc621c56c668f27d46a61d975a249930dd5e2a4"}, - {file = "charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:0d3d8f15c07f86e9ff82319b3d9ef6f4bf907608f53fe9d92b28ea9ae3d1fd89"}, - {file = "charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:9f7fcd74d410a36883701fafa2482a6af2ff5ba96b9a620e9e0721e28ead5569"}, - {file = "charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ebf3e58c7ec8a8bed6d66a75d7fb37b55e5015b03ceae72a8e7c74495551e224"}, - {file = "charset_normalizer-3.4.4-cp311-cp311-win32.whl", hash = "sha256:eecbc200c7fd5ddb9a7f16c7decb07b566c29fa2161a16cf67b8d068bd21690a"}, - {file = "charset_normalizer-3.4.4-cp311-cp311-win_amd64.whl", hash = "sha256:5ae497466c7901d54b639cf42d5b8c1b6a4fead55215500d2f486d34db48d016"}, - {file = "charset_normalizer-3.4.4-cp311-cp311-win_arm64.whl", hash = "sha256:65e2befcd84bc6f37095f5961e68a6f077bf44946771354a28ad434c2cce0ae1"}, - {file = "charset_normalizer-3.4.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0a98e6759f854bd25a58a73fa88833fba3b7c491169f86ce1180c948ab3fd394"}, - {file = "charset_normalizer-3.4.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b5b290ccc2a263e8d185130284f8501e3e36c5e02750fc6b6bdeb2e9e96f1e25"}, - {file = "charset_normalizer-3.4.4-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74bb723680f9f7a6234dcf67aea57e708ec1fbdf5699fb91dfd6f511b0a320ef"}, - {file = "charset_normalizer-3.4.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f1e34719c6ed0b92f418c7c780480b26b5d9c50349e9a9af7d76bf757530350d"}, - {file = "charset_normalizer-3.4.4-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2437418e20515acec67d86e12bf70056a33abdacb5cb1655042f6538d6b085a8"}, - {file = "charset_normalizer-3.4.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:11d694519d7f29d6cd09f6ac70028dba10f92f6cdd059096db198c283794ac86"}, - {file = "charset_normalizer-3.4.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ac1c4a689edcc530fc9d9aa11f5774b9e2f33f9a0c6a57864e90908f5208d30a"}, - {file = "charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:21d142cc6c0ec30d2efee5068ca36c128a30b0f2c53c1c07bd78cb6bc1d3be5f"}, - {file = "charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:5dbe56a36425d26d6cfb40ce79c314a2e4dd6211d51d6d2191c00bed34f354cc"}, - {file = "charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:5bfbb1b9acf3334612667b61bd3002196fe2a1eb4dd74d247e0f2a4d50ec9bbf"}, - {file = "charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:d055ec1e26e441f6187acf818b73564e6e6282709e9bcb5b63f5b23068356a15"}, - {file = "charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:af2d8c67d8e573d6de5bc30cdb27e9b95e49115cd9baad5ddbd1a6207aaa82a9"}, - {file = "charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:780236ac706e66881f3b7f2f32dfe90507a09e67d1d454c762cf642e6e1586e0"}, - {file = "charset_normalizer-3.4.4-cp312-cp312-win32.whl", hash = "sha256:5833d2c39d8896e4e19b689ffc198f08ea58116bee26dea51e362ecc7cd3ed26"}, - {file = "charset_normalizer-3.4.4-cp312-cp312-win_amd64.whl", hash = "sha256:a79cfe37875f822425b89a82333404539ae63dbdddf97f84dcbc3d339aae9525"}, - {file = "charset_normalizer-3.4.4-cp312-cp312-win_arm64.whl", hash = "sha256:376bec83a63b8021bb5c8ea75e21c4ccb86e7e45ca4eb81146091b56599b80c3"}, - {file = "charset_normalizer-3.4.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e1f185f86a6f3403aa2420e815904c67b2f9ebc443f045edd0de921108345794"}, - {file = "charset_normalizer-3.4.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b39f987ae8ccdf0d2642338faf2abb1862340facc796048b604ef14919e55ed"}, - {file = "charset_normalizer-3.4.4-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3162d5d8ce1bb98dd51af660f2121c55d0fa541b46dff7bb9b9f86ea1d87de72"}, - {file = "charset_normalizer-3.4.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:81d5eb2a312700f4ecaa977a8235b634ce853200e828fbadf3a9c50bab278328"}, - {file = "charset_normalizer-3.4.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5bd2293095d766545ec1a8f612559f6b40abc0eb18bb2f5d1171872d34036ede"}, - {file = "charset_normalizer-3.4.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a8a8b89589086a25749f471e6a900d3f662d1d3b6e2e59dcecf787b1cc3a1894"}, - {file = "charset_normalizer-3.4.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc7637e2f80d8530ee4a78e878bce464f70087ce73cf7c1caf142416923b98f1"}, - {file = "charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f8bf04158c6b607d747e93949aa60618b61312fe647a6369f88ce2ff16043490"}, - {file = "charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:554af85e960429cf30784dd47447d5125aaa3b99a6f0683589dbd27e2f45da44"}, - {file = "charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:74018750915ee7ad843a774364e13a3db91682f26142baddf775342c3f5b1133"}, - {file = "charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c0463276121fdee9c49b98908b3a89c39be45d86d1dbaa22957e38f6321d4ce3"}, - {file = "charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:362d61fd13843997c1c446760ef36f240cf81d3ebf74ac62652aebaf7838561e"}, - {file = "charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9a26f18905b8dd5d685d6d07b0cdf98a79f3c7a918906af7cc143ea2e164c8bc"}, - {file = "charset_normalizer-3.4.4-cp313-cp313-win32.whl", hash = "sha256:9b35f4c90079ff2e2edc5b26c0c77925e5d2d255c42c74fdb70fb49b172726ac"}, - {file = "charset_normalizer-3.4.4-cp313-cp313-win_amd64.whl", hash = "sha256:b435cba5f4f750aa6c0a0d92c541fb79f69a387c91e61f1795227e4ed9cece14"}, - {file = "charset_normalizer-3.4.4-cp313-cp313-win_arm64.whl", hash = "sha256:542d2cee80be6f80247095cc36c418f7bddd14f4a6de45af91dfad36d817bba2"}, - {file = "charset_normalizer-3.4.4-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:da3326d9e65ef63a817ecbcc0df6e94463713b754fe293eaa03da99befb9a5bd"}, - {file = "charset_normalizer-3.4.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8af65f14dc14a79b924524b1e7fffe304517b2bff5a58bf64f30b98bbc5079eb"}, - {file = "charset_normalizer-3.4.4-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74664978bb272435107de04e36db5a9735e78232b85b77d45cfb38f758efd33e"}, - {file = "charset_normalizer-3.4.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:752944c7ffbfdd10c074dc58ec2d5a8a4cd9493b314d367c14d24c17684ddd14"}, - {file = "charset_normalizer-3.4.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d1f13550535ad8cff21b8d757a3257963e951d96e20ec82ab44bc64aeb62a191"}, - {file = "charset_normalizer-3.4.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ecaae4149d99b1c9e7b88bb03e3221956f68fd6d50be2ef061b2381b61d20838"}, - {file = "charset_normalizer-3.4.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cb6254dc36b47a990e59e1068afacdcd02958bdcce30bb50cc1700a8b9d624a6"}, - {file = "charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c8ae8a0f02f57a6e61203a31428fa1d677cbe50c93622b4149d5c0f319c1d19e"}, - {file = "charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:47cc91b2f4dd2833fddaedd2893006b0106129d4b94fdb6af1f4ce5a9965577c"}, - {file = "charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:82004af6c302b5d3ab2cfc4cc5f29db16123b1a8417f2e25f9066f91d4411090"}, - {file = "charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b7d8f6c26245217bd2ad053761201e9f9680f8ce52f0fcd8d0755aeae5b2152"}, - {file = "charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:799a7a5e4fb2d5898c60b640fd4981d6a25f1c11790935a44ce38c54e985f828"}, - {file = "charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:99ae2cffebb06e6c22bdc25801d7b30f503cc87dbd283479e7b606f70aff57ec"}, - {file = "charset_normalizer-3.4.4-cp314-cp314-win32.whl", hash = "sha256:f9d332f8c2a2fcbffe1378594431458ddbef721c1769d78e2cbc06280d8155f9"}, - {file = "charset_normalizer-3.4.4-cp314-cp314-win_amd64.whl", hash = "sha256:8a6562c3700cce886c5be75ade4a5db4214fda19fede41d9792d100288d8f94c"}, - {file = "charset_normalizer-3.4.4-cp314-cp314-win_arm64.whl", hash = "sha256:de00632ca48df9daf77a2c65a484531649261ec9f25489917f09e455cb09ddb2"}, - {file = "charset_normalizer-3.4.4-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:ce8a0633f41a967713a59c4139d29110c07e826d131a316b50ce11b1d79b4f84"}, - {file = "charset_normalizer-3.4.4-cp38-cp38-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eaabd426fe94daf8fd157c32e571c85cb12e66692f15516a83a03264b08d06c3"}, - {file = "charset_normalizer-3.4.4-cp38-cp38-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c4ef880e27901b6cc782f1b95f82da9313c0eb95c3af699103088fa0ac3ce9ac"}, - {file = "charset_normalizer-3.4.4-cp38-cp38-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2aaba3b0819274cc41757a1da876f810a3e4d7b6eb25699253a4effef9e8e4af"}, - {file = "charset_normalizer-3.4.4-cp38-cp38-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:778d2e08eda00f4256d7f672ca9fef386071c9202f5e4607920b86d7803387f2"}, - {file = "charset_normalizer-3.4.4-cp38-cp38-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f155a433c2ec037d4e8df17d18922c3a0d9b3232a396690f17175d2946f0218d"}, - {file = "charset_normalizer-3.4.4-cp38-cp38-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a8bf8d0f749c5757af2142fe7903a9df1d2e8aa3841559b2bad34b08d0e2bcf3"}, - {file = "charset_normalizer-3.4.4-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:194f08cbb32dc406d6e1aea671a68be0823673db2832b38405deba2fb0d88f63"}, - {file = "charset_normalizer-3.4.4-cp38-cp38-musllinux_1_2_armv7l.whl", hash = "sha256:6aee717dcfead04c6eb1ce3bd29ac1e22663cdea57f943c87d1eab9a025438d7"}, - {file = "charset_normalizer-3.4.4-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:cd4b7ca9984e5e7985c12bc60a6f173f3c958eae74f3ef6624bb6b26e2abbae4"}, - {file = "charset_normalizer-3.4.4-cp38-cp38-musllinux_1_2_riscv64.whl", hash = "sha256:b7cf1017d601aa35e6bb650b6ad28652c9cd78ee6caff19f3c28d03e1c80acbf"}, - {file = "charset_normalizer-3.4.4-cp38-cp38-musllinux_1_2_s390x.whl", hash = "sha256:e912091979546adf63357d7e2ccff9b44f026c075aeaf25a52d0e95ad2281074"}, - {file = "charset_normalizer-3.4.4-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:5cb4d72eea50c8868f5288b7f7f33ed276118325c1dfd3957089f6b519e1382a"}, - {file = "charset_normalizer-3.4.4-cp38-cp38-win32.whl", hash = "sha256:837c2ce8c5a65a2035be9b3569c684358dfbf109fd3b6969630a87535495ceaa"}, - {file = "charset_normalizer-3.4.4-cp38-cp38-win_amd64.whl", hash = "sha256:44c2a8734b333e0578090c4cd6b16f275e07aa6614ca8715e6c038e865e70576"}, - {file = "charset_normalizer-3.4.4-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:a9768c477b9d7bd54bc0c86dbaebdec6f03306675526c9927c0e8a04e8f94af9"}, - {file = "charset_normalizer-3.4.4-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1bee1e43c28aa63cb16e5c14e582580546b08e535299b8b6158a7c9c768a1f3d"}, - {file = "charset_normalizer-3.4.4-cp39-cp39-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:fd44c878ea55ba351104cb93cc85e74916eb8fa440ca7903e57575e97394f608"}, - {file = "charset_normalizer-3.4.4-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0f04b14ffe5fdc8c4933862d8306109a2c51e0704acfa35d51598eb45a1e89fc"}, - {file = "charset_normalizer-3.4.4-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:cd09d08005f958f370f539f186d10aec3377d55b9eeb0d796025d4886119d76e"}, - {file = "charset_normalizer-3.4.4-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4fe7859a4e3e8457458e2ff592f15ccb02f3da787fcd31e0183879c3ad4692a1"}, - {file = "charset_normalizer-3.4.4-cp39-cp39-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fa09f53c465e532f4d3db095e0c55b615f010ad81803d383195b6b5ca6cbf5f3"}, - {file = "charset_normalizer-3.4.4-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:7fa17817dc5625de8a027cb8b26d9fefa3ea28c8253929b8d6649e705d2835b6"}, - {file = "charset_normalizer-3.4.4-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:5947809c8a2417be3267efc979c47d76a079758166f7d43ef5ae8e9f92751f88"}, - {file = "charset_normalizer-3.4.4-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:4902828217069c3c5c71094537a8e623f5d097858ac6ca8252f7b4d10b7560f1"}, - {file = "charset_normalizer-3.4.4-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:7c308f7e26e4363d79df40ca5b2be1c6ba9f02bdbccfed5abddb7859a6ce72cf"}, - {file = "charset_normalizer-3.4.4-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:2c9d3c380143a1fedbff95a312aa798578371eb29da42106a29019368a475318"}, - {file = "charset_normalizer-3.4.4-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:cb01158d8b88ee68f15949894ccc6712278243d95f344770fa7593fa2d94410c"}, - {file = "charset_normalizer-3.4.4-cp39-cp39-win32.whl", hash = "sha256:2677acec1a2f8ef614c6888b5b4ae4060cc184174a938ed4e8ef690e15d3e505"}, - {file = "charset_normalizer-3.4.4-cp39-cp39-win_amd64.whl", hash = "sha256:f8e160feb2aed042cd657a72acc0b481212ed28b1b9a95c0cee1621b524e1966"}, - {file = "charset_normalizer-3.4.4-cp39-cp39-win_arm64.whl", hash = "sha256:b5d84d37db046c5ca74ee7bb47dd6cbc13f80665fdde3e8040bdd3fb015ecb50"}, - {file = "charset_normalizer-3.4.4-py3-none-any.whl", hash = "sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f"}, - {file = "charset_normalizer-3.4.4.tar.gz", hash = "sha256:94537985111c35f28720e43603b8e7b43a6ecfb2ce1d3058bbe955b73404e21a"}, + {file = "charset_normalizer-3.4.6-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:2e1d8ca8611099001949d1cdfaefc510cf0f212484fe7c565f735b68c78c3c95"}, + {file = "charset_normalizer-3.4.6-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e25369dc110d58ddf29b949377a93e0716d72a24f62bad72b2b39f155949c1fd"}, + {file = "charset_normalizer-3.4.6-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:259695e2ccc253feb2a016303543d691825e920917e31f894ca1a687982b1de4"}, + {file = "charset_normalizer-3.4.6-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:dda86aba335c902b6149a02a55b38e96287157e609200811837678214ba2b1db"}, + {file = "charset_normalizer-3.4.6-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:51fb3c322c81d20567019778cb5a4a6f2dc1c200b886bc0d636238e364848c89"}, + {file = "charset_normalizer-3.4.6-cp310-cp310-manylinux_2_31_armv7l.whl", hash = "sha256:4482481cb0572180b6fd976a4d5c72a30263e98564da68b86ec91f0fe35e8565"}, + {file = "charset_normalizer-3.4.6-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:39f5068d35621da2881271e5c3205125cc456f54e9030d3f723288c873a71bf9"}, + {file = "charset_normalizer-3.4.6-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:8bea55c4eef25b0b19a0337dc4e3f9a15b00d569c77211fa8cde38684f234fb7"}, + {file = "charset_normalizer-3.4.6-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:f0cdaecd4c953bfae0b6bb64910aaaca5a424ad9c72d85cb88417bb9814f7550"}, + {file = "charset_normalizer-3.4.6-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:150b8ce8e830eb7ccb029ec9ca36022f756986aaaa7956aad6d9ec90089338c0"}, + {file = "charset_normalizer-3.4.6-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:e68c14b04827dd76dcbd1aeea9e604e3e4b78322d8faf2f8132c7138efa340a8"}, + {file = "charset_normalizer-3.4.6-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:3778fd7d7cd04ae8f54651f4a7a0bd6e39a0cf20f801720a4c21d80e9b7ad6b0"}, + {file = "charset_normalizer-3.4.6-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:dad6e0f2e481fffdcf776d10ebee25e0ef89f16d691f1e5dee4b586375fdc64b"}, + {file = "charset_normalizer-3.4.6-cp310-cp310-win32.whl", hash = "sha256:74a2e659c7ecbc73562e2a15e05039f1e22c75b7c7618b4b574a3ea9118d1557"}, + {file = "charset_normalizer-3.4.6-cp310-cp310-win_amd64.whl", hash = "sha256:aa9cccf4a44b9b62d8ba8b4dd06c649ba683e4bf04eea606d2e94cfc2d6ff4d6"}, + {file = "charset_normalizer-3.4.6-cp310-cp310-win_arm64.whl", hash = "sha256:e985a16ff513596f217cee86c21371b8cd011c0f6f056d0920aa2d926c544058"}, + {file = "charset_normalizer-3.4.6-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:82060f995ab5003a2d6e0f4ad29065b7672b6593c8c63559beefe5b443242c3e"}, + {file = "charset_normalizer-3.4.6-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:60c74963d8350241a79cb8feea80e54d518f72c26db618862a8f53e5023deaf9"}, + {file = "charset_normalizer-3.4.6-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f6e4333fb15c83f7d1482a76d45a0818897b3d33f00efd215528ff7c51b8e35d"}, + {file = "charset_normalizer-3.4.6-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:bc72863f4d9aba2e8fd9085e63548a324ba706d2ea2c83b260da08a59b9482de"}, + {file = "charset_normalizer-3.4.6-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9cc4fc6c196d6a8b76629a70ddfcd4635a6898756e2d9cac5565cf0654605d73"}, + {file = "charset_normalizer-3.4.6-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:0c173ce3a681f309f31b87125fecec7a5d1347261ea11ebbb856fa6006b23c8c"}, + {file = "charset_normalizer-3.4.6-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c907cdc8109f6c619e6254212e794d6548373cc40e1ec75e6e3823d9135d29cc"}, + {file = "charset_normalizer-3.4.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:404a1e552cf5b675a87f0651f8b79f5f1e6fd100ee88dc612f89aa16abd4486f"}, + {file = "charset_normalizer-3.4.6-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:e3c701e954abf6fc03a49f7c579cc80c2c6cc52525340ca3186c41d3f33482ef"}, + {file = "charset_normalizer-3.4.6-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:7a6967aaf043bceabab5412ed6bd6bd26603dae84d5cb75bf8d9a74a4959d398"}, + {file = "charset_normalizer-3.4.6-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:5feb91325bbceade6afab43eb3b508c63ee53579fe896c77137ded51c6b6958e"}, + {file = "charset_normalizer-3.4.6-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:f820f24b09e3e779fe84c3c456cb4108a7aa639b0d1f02c28046e11bfcd088ed"}, + {file = "charset_normalizer-3.4.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b35b200d6a71b9839a46b9b7fff66b6638bb52fc9658aa58796b0326595d3021"}, + {file = "charset_normalizer-3.4.6-cp311-cp311-win32.whl", hash = "sha256:9ca4c0b502ab399ef89248a2c84c54954f77a070f28e546a85e91da627d1301e"}, + {file = "charset_normalizer-3.4.6-cp311-cp311-win_amd64.whl", hash = "sha256:a9e68c9d88823b274cf1e72f28cb5dc89c990edf430b0bfd3e2fb0785bfeabf4"}, + {file = "charset_normalizer-3.4.6-cp311-cp311-win_arm64.whl", hash = "sha256:97d0235baafca5f2b09cf332cc275f021e694e8362c6bb9c96fc9a0eb74fc316"}, + {file = "charset_normalizer-3.4.6-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:2ef7fedc7a6ecbe99969cd09632516738a97eeb8bd7258bf8a0f23114c057dab"}, + {file = "charset_normalizer-3.4.6-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a4ea868bc28109052790eb2b52a9ab33f3aa7adc02f96673526ff47419490e21"}, + {file = "charset_normalizer-3.4.6-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:836ab36280f21fc1a03c99cd05c6b7af70d2697e374c7af0b61ed271401a72a2"}, + {file = "charset_normalizer-3.4.6-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f1ce721c8a7dfec21fcbdfe04e8f68174183cf4e8188e0645e92aa23985c57ff"}, + {file = "charset_normalizer-3.4.6-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0e28d62a8fc7a1fa411c43bd65e346f3bce9716dc51b897fbe930c5987b402d5"}, + {file = "charset_normalizer-3.4.6-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:530d548084c4a9f7a16ed4a294d459b4f229db50df689bfe92027452452943a0"}, + {file = "charset_normalizer-3.4.6-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:30f445ae60aad5e1f8bdbb3108e39f6fbc09f4ea16c815c66578878325f8f15a"}, + {file = "charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ac2393c73378fea4e52aa56285a3d64be50f1a12395afef9cce47772f60334c2"}, + {file = "charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:90ca27cd8da8118b18a52d5f547859cc1f8354a00cd1e8e5120df3e30d6279e5"}, + {file = "charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:8e5a94886bedca0f9b78fecd6afb6629142fd2605aa70a125d49f4edc6037ee6"}, + {file = "charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:695f5c2823691a25f17bc5d5ffe79fa90972cc34b002ac6c843bb8a1720e950d"}, + {file = "charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:231d4da14bcd9301310faf492051bee27df11f2bc7549bc0bb41fef11b82daa2"}, + {file = "charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a056d1ad2633548ca18ffa2f85c202cfb48b68615129143915b8dc72a806a923"}, + {file = "charset_normalizer-3.4.6-cp312-cp312-win32.whl", hash = "sha256:c2274ca724536f173122f36c98ce188fd24ce3dad886ec2b7af859518ce008a4"}, + {file = "charset_normalizer-3.4.6-cp312-cp312-win_amd64.whl", hash = "sha256:c8ae56368f8cc97c7e40a7ee18e1cedaf8e780cd8bc5ed5ac8b81f238614facb"}, + {file = "charset_normalizer-3.4.6-cp312-cp312-win_arm64.whl", hash = "sha256:899d28f422116b08be5118ef350c292b36fc15ec2daeb9ea987c89281c7bb5c4"}, + {file = "charset_normalizer-3.4.6-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:11afb56037cbc4b1555a34dd69151e8e069bee82e613a73bef6e714ce733585f"}, + {file = "charset_normalizer-3.4.6-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:423fb7e748a08f854a08a222b983f4df1912b1daedce51a72bd24fe8f26a1843"}, + {file = "charset_normalizer-3.4.6-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d73beaac5e90173ac3deb9928a74763a6d230f494e4bfb422c217a0ad8e629bf"}, + {file = "charset_normalizer-3.4.6-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d60377dce4511655582e300dc1e5a5f24ba0cb229005a1d5c8d0cb72bb758ab8"}, + {file = "charset_normalizer-3.4.6-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:530e8cebeea0d76bdcf93357aa5e41336f48c3dc709ac52da2bb167c5b8271d9"}, + {file = "charset_normalizer-3.4.6-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:a26611d9987b230566f24a0a125f17fe0de6a6aff9f25c9f564aaa2721a5fb88"}, + {file = "charset_normalizer-3.4.6-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:34315ff4fc374b285ad7f4a0bf7dcbfe769e1b104230d40f49f700d4ab6bbd84"}, + {file = "charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5f8ddd609f9e1af8c7bd6e2aca279c931aefecd148a14402d4e368f3171769fd"}, + {file = "charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:80d0a5615143c0b3225e5e3ef22c8d5d51f3f72ce0ea6fb84c943546c7b25b6c"}, + {file = "charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:92734d4d8d187a354a556626c221cd1a892a4e0802ccb2af432a1d85ec012194"}, + {file = "charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:613f19aa6e082cf96e17e3ffd89383343d0d589abda756b7764cf78361fd41dc"}, + {file = "charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:2b1a63e8224e401cafe7739f77efd3f9e7f5f2026bda4aead8e59afab537784f"}, + {file = "charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6cceb5473417d28edd20c6c984ab6fee6c6267d38d906823ebfe20b03d607dc2"}, + {file = "charset_normalizer-3.4.6-cp313-cp313-win32.whl", hash = "sha256:d7de2637729c67d67cf87614b566626057e95c303bc0a55ffe391f5205e7003d"}, + {file = "charset_normalizer-3.4.6-cp313-cp313-win_amd64.whl", hash = "sha256:572d7c822caf521f0525ba1bce1a622a0b85cf47ffbdae6c9c19e3b5ac3c4389"}, + {file = "charset_normalizer-3.4.6-cp313-cp313-win_arm64.whl", hash = "sha256:a4474d924a47185a06411e0064b803c68be044be2d60e50e8bddcc2649957c1f"}, + {file = "charset_normalizer-3.4.6-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:9cc6e6d9e571d2f863fa77700701dae73ed5f78881efc8b3f9a4398772ff53e8"}, + {file = "charset_normalizer-3.4.6-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ef5960d965e67165d75b7c7ffc60a83ec5abfc5c11b764ec13ea54fbef8b4421"}, + {file = "charset_normalizer-3.4.6-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b3694e3f87f8ac7ce279d4355645b3c878d24d1424581b46282f24b92f5a4ae2"}, + {file = "charset_normalizer-3.4.6-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5d11595abf8dd942a77883a39d81433739b287b6aa71620f15164f8096221b30"}, + {file = "charset_normalizer-3.4.6-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7bda6eebafd42133efdca535b04ccb338ab29467b3f7bf79569883676fc628db"}, + {file = "charset_normalizer-3.4.6-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:bbc8c8650c6e51041ad1be191742b8b421d05bbd3410f43fa2a00c8db87678e8"}, + {file = "charset_normalizer-3.4.6-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:22c6f0c2fbc31e76c3b8a86fba1a56eda6166e238c29cdd3d14befdb4a4e4815"}, + {file = "charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7edbed096e4a4798710ed6bc75dcaa2a21b68b6c356553ac4823c3658d53743a"}, + {file = "charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:7f9019c9cb613f084481bd6a100b12e1547cf2efe362d873c2e31e4035a6fa43"}, + {file = "charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:58c948d0d086229efc484fe2f30c2d382c86720f55cd9bc33591774348ad44e0"}, + {file = "charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:419a9d91bd238052642a51938af8ac05da5b3343becde08d5cdeab9046df9ee1"}, + {file = "charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:5273b9f0b5835ff0350c0828faea623c68bfa65b792720c453e22b25cc72930f"}, + {file = "charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:0e901eb1049fdb80f5bd11ed5ea1e498ec423102f7a9b9e4645d5b8204ff2815"}, + {file = "charset_normalizer-3.4.6-cp314-cp314-win32.whl", hash = "sha256:b4ff1d35e8c5bd078be89349b6f3a845128e685e751b6ea1169cf2160b344c4d"}, + {file = "charset_normalizer-3.4.6-cp314-cp314-win_amd64.whl", hash = "sha256:74119174722c4349af9708993118581686f343adc1c8c9c007d59be90d077f3f"}, + {file = "charset_normalizer-3.4.6-cp314-cp314-win_arm64.whl", hash = "sha256:e5bcc1a1ae744e0bb59641171ae53743760130600da8db48cbb6e4918e186e4e"}, + {file = "charset_normalizer-3.4.6-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:ad8faf8df23f0378c6d527d8b0b15ea4a2e23c89376877c598c4870d1b2c7866"}, + {file = "charset_normalizer-3.4.6-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f5ea69428fa1b49573eef0cc44a1d43bebd45ad0c611eb7d7eac760c7ae771bc"}, + {file = "charset_normalizer-3.4.6-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:06a7e86163334edfc5d20fe104db92fcd666e5a5df0977cb5680a506fe26cc8e"}, + {file = "charset_normalizer-3.4.6-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e1f6e2f00a6b8edb562826e4632e26d063ac10307e80f7461f7de3ad8ef3f077"}, + {file = "charset_normalizer-3.4.6-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:95b52c68d64c1878818687a473a10547b3292e82b6f6fe483808fb1468e2f52f"}, + {file = "charset_normalizer-3.4.6-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:7504e9b7dc05f99a9bbb4525c67a2c155073b44d720470a148b34166a69c054e"}, + {file = "charset_normalizer-3.4.6-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:172985e4ff804a7ad08eebec0a1640ece87ba5041d565fff23c8f99c1f389484"}, + {file = "charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:4be9f4830ba8741527693848403e2c457c16e499100963ec711b1c6f2049b7c7"}, + {file = "charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:79090741d842f564b1b2827c0b82d846405b744d31e84f18d7a7b41c20e473ff"}, + {file = "charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:87725cfb1a4f1f8c2fc9890ae2f42094120f4b44db9360be5d99a4c6b0e03a9e"}, + {file = "charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:fcce033e4021347d80ed9c66dcf1e7b1546319834b74445f561d2e2221de5659"}, + {file = "charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:ca0276464d148c72defa8bb4390cce01b4a0e425f3b50d1435aa6d7a18107602"}, + {file = "charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:197c1a244a274bb016dd8b79204850144ef77fe81c5b797dc389327adb552407"}, + {file = "charset_normalizer-3.4.6-cp314-cp314t-win32.whl", hash = "sha256:2a24157fa36980478dd1770b585c0f30d19e18f4fb0c47c13aa568f871718579"}, + {file = "charset_normalizer-3.4.6-cp314-cp314t-win_amd64.whl", hash = "sha256:cd5e2801c89992ed8c0a3f0293ae83c159a60d9a5d685005383ef4caca77f2c4"}, + {file = "charset_normalizer-3.4.6-cp314-cp314t-win_arm64.whl", hash = "sha256:47955475ac79cc504ef2704b192364e51d0d473ad452caedd0002605f780101c"}, + {file = "charset_normalizer-3.4.6-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:659a1e1b500fac8f2779dd9e1570464e012f43e580371470b45277a27baa7532"}, + {file = "charset_normalizer-3.4.6-cp38-cp38-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f61aa92e4aad0be58eb6eb4e0c21acf32cf8065f4b2cae5665da756c4ceef982"}, + {file = "charset_normalizer-3.4.6-cp38-cp38-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f50498891691e0864dc3da965f340fada0771f6142a378083dc4608f4ea513e2"}, + {file = "charset_normalizer-3.4.6-cp38-cp38-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:bf625105bb9eef28a56a943fec8c8a98aeb80e7d7db99bd3c388137e6eb2d237"}, + {file = "charset_normalizer-3.4.6-cp38-cp38-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2bd9d128ef93637a5d7a6af25363cf5dec3fa21cf80e68055aad627f280e8afa"}, + {file = "charset_normalizer-3.4.6-cp38-cp38-manylinux_2_31_armv7l.whl", hash = "sha256:d08ec48f0a1c48d75d0356cea971921848fb620fdeba805b28f937e90691209f"}, + {file = "charset_normalizer-3.4.6-cp38-cp38-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1ed80ff870ca6de33f4d953fda4d55654b9a2b340ff39ab32fa3adbcd718f264"}, + {file = "charset_normalizer-3.4.6-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:f98059e4fcd3e3e4e2d632b7cf81c2faae96c43c60b569e9c621468082f1d104"}, + {file = "charset_normalizer-3.4.6-cp38-cp38-musllinux_1_2_armv7l.whl", hash = "sha256:ab30e5e3e706e3063bc6de96b118688cb10396b70bb9864a430f67df98c61ecc"}, + {file = "charset_normalizer-3.4.6-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:d5f5d1e9def3405f60e3ca8232d56f35c98fb7bf581efcc60051ebf53cb8b611"}, + {file = "charset_normalizer-3.4.6-cp38-cp38-musllinux_1_2_riscv64.whl", hash = "sha256:461598cd852bfa5a61b09cae2b1c02e2efcd166ee5516e243d540ac24bfa68a7"}, + {file = "charset_normalizer-3.4.6-cp38-cp38-musllinux_1_2_s390x.whl", hash = "sha256:71be7e0e01753a89cf024abf7ecb6bca2c81738ead80d43004d9b5e3f1244e64"}, + {file = "charset_normalizer-3.4.6-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:df01808ee470038c3f8dc4f48620df7225c49c2d6639e38f96e6d6ac6e6f7b0e"}, + {file = "charset_normalizer-3.4.6-cp38-cp38-win32.whl", hash = "sha256:69dd852c2f0ad631b8b60cfbe25a28c0058a894de5abb566619c205ce0550eae"}, + {file = "charset_normalizer-3.4.6-cp38-cp38-win_amd64.whl", hash = "sha256:517ad0e93394ac532745129ceabdf2696b609ec9f87863d337140317ebce1c14"}, + {file = "charset_normalizer-3.4.6-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:31215157227939b4fb3d740cd23fe27be0439afef67b785a1eb78a3ae69cba9e"}, + {file = "charset_normalizer-3.4.6-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ecbbd45615a6885fe3240eb9db73b9e62518b611850fdf8ab08bd56de7ad2b17"}, + {file = "charset_normalizer-3.4.6-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c45a03a4c69820a399f1dda9e1d8fbf3562eda46e7720458180302021b08f778"}, + {file = "charset_normalizer-3.4.6-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e8aeb10fcbe92767f0fa69ad5a72deca50d0dca07fbde97848997d778a50c9fe"}, + {file = "charset_normalizer-3.4.6-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:54fae94be3d75f3e573c9a1b5402dc593de19377013c9a0e4285e3d402dd3a2a"}, + {file = "charset_normalizer-3.4.6-cp39-cp39-manylinux_2_31_armv7l.whl", hash = "sha256:2f7fdd9b6e6c529d6a2501a2d36b240109e78a8ceaef5687cfcfa2bbe671d297"}, + {file = "charset_normalizer-3.4.6-cp39-cp39-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4d1d02209e06550bdaef34af58e041ad71b88e624f5d825519da3a3308e22687"}, + {file = "charset_normalizer-3.4.6-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:8bc5f0687d796c05b1e28ab0d38a50e6309906ee09375dd3aff6a9c09dd6e8f4"}, + {file = "charset_normalizer-3.4.6-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:ee4ec14bc1680d6b0afab9aea2ef27e26d2024f18b24a2d7155a52b60da7e833"}, + {file = "charset_normalizer-3.4.6-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:d1a2ee9c1499fc8f86f4521f27a973c914b211ffa87322f4ee33bb35392da2c5"}, + {file = "charset_normalizer-3.4.6-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:48696db7f18afb80a068821504296eb0787d9ce239b91ca15059d1d3eaacf13b"}, + {file = "charset_normalizer-3.4.6-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:4f41da960b196ea355357285ad1316a00099f22d0929fe168343b99b254729c9"}, + {file = "charset_normalizer-3.4.6-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:802168e03fba8bbc5ce0d866d589e4b1ca751d06edee69f7f3a19c5a9fe6b597"}, + {file = "charset_normalizer-3.4.6-cp39-cp39-win32.whl", hash = "sha256:8761ac29b6c81574724322a554605608a9960769ea83d2c73e396f3df896ad54"}, + {file = "charset_normalizer-3.4.6-cp39-cp39-win_amd64.whl", hash = "sha256:1cf0a70018692f85172348fe06d3a4b63f94ecb055e13a00c644d368eb82e5b8"}, + {file = "charset_normalizer-3.4.6-cp39-cp39-win_arm64.whl", hash = "sha256:3516bbb8d42169de9e61b8520cbeeeb716f12f4ecfe3fd30a9919aa16c806ca8"}, + {file = "charset_normalizer-3.4.6-py3-none-any.whl", hash = "sha256:947cf925bc916d90adba35a64c82aace04fa39b46b52d4630ece166655905a69"}, + {file = "charset_normalizer-3.4.6.tar.gz", hash = "sha256:1ae6b62897110aa7c79ea2f5dd38d1abca6db663687c0b1ad9aed6f6bae3d9d6"}, ] [[package]] name = "click" -version = "8.3.0" +version = "8.3.1" description = "Composable command line interface toolkit" optional = false python-versions = ">=3.10" groups = ["dev"] files = [ - {file = "click-8.3.0-py3-none-any.whl", hash = "sha256:9b9f285302c6e3064f4330c05f05b81945b2a39544279343e6e7c5f27a9baddc"}, - {file = "click-8.3.0.tar.gz", hash = "sha256:e7b8232224eba16f4ebe410c25ced9f7875cb5f3263ffc93cc3e8da705e229c4"}, + {file = "click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6"}, + {file = "click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a"}, ] [package.dependencies] @@ -235,104 +256,118 @@ markers = {dev = "platform_system == \"Windows\"", test = "sys_platform == \"win [[package]] name = "coverage" -version = "7.11.0" +version = "7.13.5" description = "Code coverage measurement for Python" optional = false python-versions = ">=3.10" groups = ["test"] files = [ - {file = "coverage-7.11.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:eb53f1e8adeeb2e78962bade0c08bfdc461853c7969706ed901821e009b35e31"}, - {file = "coverage-7.11.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:d9a03ec6cb9f40a5c360f138b88266fd8f58408d71e89f536b4f91d85721d075"}, - {file = "coverage-7.11.0-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:0d7f0616c557cbc3d1c2090334eddcbb70e1ae3a40b07222d62b3aa47f608fab"}, - {file = "coverage-7.11.0-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:e44a86a47bbdf83b0a3ea4d7df5410d6b1a0de984fbd805fa5101f3624b9abe0"}, - {file = "coverage-7.11.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:596763d2f9a0ee7eec6e643e29660def2eef297e1de0d334c78c08706f1cb785"}, - {file = "coverage-7.11.0-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ef55537ff511b5e0a43edb4c50a7bf7ba1c3eea20b4f49b1490f1e8e0e42c591"}, - {file = "coverage-7.11.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:9cbabd8f4d0d3dc571d77ae5bdbfa6afe5061e679a9d74b6797c48d143307088"}, - {file = "coverage-7.11.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e24045453384e0ae2a587d562df2a04d852672eb63051d16096d3f08aa4c7c2f"}, - {file = "coverage-7.11.0-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:7161edd3426c8d19bdccde7d49e6f27f748f3c31cc350c5de7c633fea445d866"}, - {file = "coverage-7.11.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:3d4ed4de17e692ba6415b0587bc7f12bc80915031fc9db46a23ce70fc88c9841"}, - {file = "coverage-7.11.0-cp310-cp310-win32.whl", hash = "sha256:765c0bc8fe46f48e341ef737c91c715bd2a53a12792592296a095f0c237e09cf"}, - {file = "coverage-7.11.0-cp310-cp310-win_amd64.whl", hash = "sha256:24d6f3128f1b2d20d84b24f4074475457faedc3d4613a7e66b5e769939c7d969"}, - {file = "coverage-7.11.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3d58ecaa865c5b9fa56e35efc51d1014d4c0d22838815b9fce57a27dd9576847"}, - {file = "coverage-7.11.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:b679e171f1c104a5668550ada700e3c4937110dbdd153b7ef9055c4f1a1ee3cc"}, - {file = "coverage-7.11.0-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:ca61691ba8c5b6797deb221a0d09d7470364733ea9c69425a640f1f01b7c5bf0"}, - {file = "coverage-7.11.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:aef1747ede4bd8ca9cfc04cc3011516500c6891f1b33a94add3253f6f876b7b7"}, - {file = "coverage-7.11.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a1839d08406e4cba2953dcc0ffb312252f14d7c4c96919f70167611f4dee2623"}, - {file = "coverage-7.11.0-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e0eb0a2dcc62478eb5b4cbb80b97bdee852d7e280b90e81f11b407d0b81c4287"}, - {file = "coverage-7.11.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:bc1fbea96343b53f65d5351d8fd3b34fd415a2670d7c300b06d3e14a5af4f552"}, - {file = "coverage-7.11.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:214b622259dd0cf435f10241f1333d32caa64dbc27f8790ab693428a141723de"}, - {file = "coverage-7.11.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:258d9967520cca899695d4eb7ea38be03f06951d6ca2f21fb48b1235f791e601"}, - {file = "coverage-7.11.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:cf9e6ff4ca908ca15c157c409d608da77a56a09877b97c889b98fb2c32b6465e"}, - {file = "coverage-7.11.0-cp311-cp311-win32.whl", hash = "sha256:fcc15fc462707b0680cff6242c48625da7f9a16a28a41bb8fd7a4280920e676c"}, - {file = "coverage-7.11.0-cp311-cp311-win_amd64.whl", hash = "sha256:865965bf955d92790f1facd64fe7ff73551bd2c1e7e6b26443934e9701ba30b9"}, - {file = "coverage-7.11.0-cp311-cp311-win_arm64.whl", hash = "sha256:5693e57a065760dcbeb292d60cc4d0231a6d4b6b6f6a3191561e1d5e8820b745"}, - {file = "coverage-7.11.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:9c49e77811cf9d024b95faf86c3f059b11c0c9be0b0d61bc598f453703bd6fd1"}, - {file = "coverage-7.11.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a61e37a403a778e2cda2a6a39abcc895f1d984071942a41074b5c7ee31642007"}, - {file = "coverage-7.11.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:c79cae102bb3b1801e2ef1511fb50e91ec83a1ce466b2c7c25010d884336de46"}, - {file = "coverage-7.11.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:16ce17ceb5d211f320b62df002fa7016b7442ea0fd260c11cec8ce7730954893"}, - {file = "coverage-7.11.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:80027673e9d0bd6aef86134b0771845e2da85755cf686e7c7c59566cf5a89115"}, - {file = "coverage-7.11.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4d3ffa07a08657306cd2215b0da53761c4d73cb54d9143b9303a6481ec0cd415"}, - {file = "coverage-7.11.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a3b6a5f8b2524fd6c1066bc85bfd97e78709bb5e37b5b94911a6506b65f47186"}, - {file = "coverage-7.11.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:fcc0a4aa589de34bc56e1a80a740ee0f8c47611bdfb28cd1849de60660f3799d"}, - {file = "coverage-7.11.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:dba82204769d78c3fd31b35c3d5f46e06511936c5019c39f98320e05b08f794d"}, - {file = "coverage-7.11.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:81b335f03ba67309a95210caf3eb43bd6fe75a4e22ba653ef97b4696c56c7ec2"}, - {file = "coverage-7.11.0-cp312-cp312-win32.whl", hash = "sha256:037b2d064c2f8cc8716fe4d39cb705779af3fbf1ba318dc96a1af858888c7bb5"}, - {file = "coverage-7.11.0-cp312-cp312-win_amd64.whl", hash = "sha256:d66c0104aec3b75e5fd897e7940188ea1892ca1d0235316bf89286d6a22568c0"}, - {file = "coverage-7.11.0-cp312-cp312-win_arm64.whl", hash = "sha256:d91ebeac603812a09cf6a886ba6e464f3bbb367411904ae3790dfe28311b15ad"}, - {file = "coverage-7.11.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:cc3f49e65ea6e0d5d9bd60368684fe52a704d46f9e7fc413918f18d046ec40e1"}, - {file = "coverage-7.11.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f39ae2f63f37472c17b4990f794035c9890418b1b8cca75c01193f3c8d3e01be"}, - {file = "coverage-7.11.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:7db53b5cdd2917b6eaadd0b1251cf4e7d96f4a8d24e174bdbdf2f65b5ea7994d"}, - {file = "coverage-7.11.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:10ad04ac3a122048688387828b4537bc9cf60c0bf4869c1e9989c46e45690b82"}, - {file = "coverage-7.11.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4036cc9c7983a2b1f2556d574d2eb2154ac6ed55114761685657e38782b23f52"}, - {file = "coverage-7.11.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7ab934dd13b1c5e94b692b1e01bd87e4488cb746e3a50f798cb9464fd128374b"}, - {file = "coverage-7.11.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:59a6e5a265f7cfc05f76e3bb53eca2e0dfe90f05e07e849930fecd6abb8f40b4"}, - {file = "coverage-7.11.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:df01d6c4c81e15a7c88337b795bb7595a8596e92310266b5072c7e301168efbd"}, - {file = "coverage-7.11.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:8c934bd088eed6174210942761e38ee81d28c46de0132ebb1801dbe36a390dcc"}, - {file = "coverage-7.11.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5a03eaf7ec24078ad64a07f02e30060aaf22b91dedf31a6b24d0d98d2bba7f48"}, - {file = "coverage-7.11.0-cp313-cp313-win32.whl", hash = "sha256:695340f698a5f56f795b2836abe6fb576e7c53d48cd155ad2f80fd24bc63a040"}, - {file = "coverage-7.11.0-cp313-cp313-win_amd64.whl", hash = "sha256:2727d47fce3ee2bac648528e41455d1b0c46395a087a229deac75e9f88ba5a05"}, - {file = "coverage-7.11.0-cp313-cp313-win_arm64.whl", hash = "sha256:0efa742f431529699712b92ecdf22de8ff198df41e43aeaaadf69973eb93f17a"}, - {file = "coverage-7.11.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:587c38849b853b157706407e9ebdca8fd12f45869edb56defbef2daa5fb0812b"}, - {file = "coverage-7.11.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:b971bdefdd75096163dd4261c74be813c4508477e39ff7b92191dea19f24cd37"}, - {file = "coverage-7.11.0-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:269bfe913b7d5be12ab13a95f3a76da23cf147be7fa043933320ba5625f0a8de"}, - {file = "coverage-7.11.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:dadbcce51a10c07b7c72b0ce4a25e4b6dcb0c0372846afb8e5b6307a121eb99f"}, - {file = "coverage-7.11.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9ed43fa22c6436f7957df036331f8fe4efa7af132054e1844918866cd228af6c"}, - {file = "coverage-7.11.0-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9516add7256b6713ec08359b7b05aeff8850c98d357784c7205b2e60aa2513fa"}, - {file = "coverage-7.11.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:eb92e47c92fcbcdc692f428da67db33337fa213756f7adb6a011f7b5a7a20740"}, - {file = "coverage-7.11.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:d06f4fc7acf3cabd6d74941d53329e06bab00a8fe10e4df2714f0b134bfc64ef"}, - {file = "coverage-7.11.0-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:6fbcee1a8f056af07ecd344482f711f563a9eb1c2cad192e87df00338ec3cdb0"}, - {file = "coverage-7.11.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:dbbf012be5f32533a490709ad597ad8a8ff80c582a95adc8d62af664e532f9ca"}, - {file = "coverage-7.11.0-cp313-cp313t-win32.whl", hash = "sha256:cee6291bb4fed184f1c2b663606a115c743df98a537c969c3c64b49989da96c2"}, - {file = "coverage-7.11.0-cp313-cp313t-win_amd64.whl", hash = "sha256:a386c1061bf98e7ea4758e4313c0ab5ecf57af341ef0f43a0bf26c2477b5c268"}, - {file = "coverage-7.11.0-cp313-cp313t-win_arm64.whl", hash = "sha256:f9ea02ef40bb83823b2b04964459d281688fe173e20643870bb5d2edf68bc836"}, - {file = "coverage-7.11.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:c770885b28fb399aaf2a65bbd1c12bf6f307ffd112d6a76c5231a94276f0c497"}, - {file = "coverage-7.11.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a3d0e2087dba64c86a6b254f43e12d264b636a39e88c5cc0a01a7c71bcfdab7e"}, - {file = "coverage-7.11.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:73feb83bb41c32811973b8565f3705caf01d928d972b72042b44e97c71fd70d1"}, - {file = "coverage-7.11.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c6f31f281012235ad08f9a560976cc2fc9c95c17604ff3ab20120fe480169bca"}, - {file = "coverage-7.11.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e9570ad567f880ef675673992222746a124b9595506826b210fbe0ce3f0499cd"}, - {file = "coverage-7.11.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8badf70446042553a773547a61fecaa734b55dc738cacf20c56ab04b77425e43"}, - {file = "coverage-7.11.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a09c1211959903a479e389685b7feb8a17f59ec5a4ef9afde7650bd5eabc2777"}, - {file = "coverage-7.11.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:5ef83b107f50db3f9ae40f69e34b3bd9337456c5a7fe3461c7abf8b75dd666a2"}, - {file = "coverage-7.11.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:f91f927a3215b8907e214af77200250bb6aae36eca3f760f89780d13e495388d"}, - {file = "coverage-7.11.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:cdbcd376716d6b7fbfeedd687a6c4be019c5a5671b35f804ba76a4c0a778cba4"}, - {file = "coverage-7.11.0-cp314-cp314-win32.whl", hash = "sha256:bab7ec4bb501743edc63609320aaec8cd9188b396354f482f4de4d40a9d10721"}, - {file = "coverage-7.11.0-cp314-cp314-win_amd64.whl", hash = "sha256:3d4ba9a449e9364a936a27322b20d32d8b166553bfe63059bd21527e681e2fad"}, - {file = "coverage-7.11.0-cp314-cp314-win_arm64.whl", hash = "sha256:ce37f215223af94ef0f75ac68ea096f9f8e8c8ec7d6e8c346ee45c0d363f0479"}, - {file = "coverage-7.11.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:f413ce6e07e0d0dc9c433228727b619871532674b45165abafe201f200cc215f"}, - {file = "coverage-7.11.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:05791e528a18f7072bf5998ba772fe29db4da1234c45c2087866b5ba4dea710e"}, - {file = "coverage-7.11.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:cacb29f420cfeb9283b803263c3b9a068924474ff19ca126ba9103e1278dfa44"}, - {file = "coverage-7.11.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:314c24e700d7027ae3ab0d95fbf8d53544fca1f20345fd30cd219b737c6e58d3"}, - {file = "coverage-7.11.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:630d0bd7a293ad2fc8b4b94e5758c8b2536fdf36c05f1681270203e463cbfa9b"}, - {file = "coverage-7.11.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e89641f5175d65e2dbb44db15fe4ea48fade5d5bbb9868fdc2b4fce22f4a469d"}, - {file = "coverage-7.11.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c9f08ea03114a637dab06cedb2e914da9dc67fa52c6015c018ff43fdde25b9c2"}, - {file = "coverage-7.11.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:ce9f3bde4e9b031eaf1eb61df95c1401427029ea1bfddb8621c1161dcb0fa02e"}, - {file = "coverage-7.11.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:e4dc07e95495923d6fd4d6c27bf70769425b71c89053083843fd78f378558996"}, - {file = "coverage-7.11.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:424538266794db2861db4922b05d729ade0940ee69dcf0591ce8f69784db0e11"}, - {file = "coverage-7.11.0-cp314-cp314t-win32.whl", hash = "sha256:4c1eeb3fb8eb9e0190bebafd0462936f75717687117339f708f395fe455acc73"}, - {file = "coverage-7.11.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b56efee146c98dbf2cf5cffc61b9829d1e94442df4d7398b26892a53992d3547"}, - {file = "coverage-7.11.0-cp314-cp314t-win_arm64.whl", hash = "sha256:b5c2705afa83f49bd91962a4094b6b082f94aef7626365ab3f8f4bd159c5acf3"}, - {file = "coverage-7.11.0-py3-none-any.whl", hash = "sha256:4b7589765348d78fb4e5fb6ea35d07564e387da2fc5efff62e0222971f155f68"}, - {file = "coverage-7.11.0.tar.gz", hash = "sha256:167bd504ac1ca2af7ff3b81d245dfea0292c5032ebef9d66cc08a7d28c1b8050"}, + {file = "coverage-7.13.5-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e0723d2c96324561b9aa76fb982406e11d93cdb388a7a7da2b16e04719cf7ca5"}, + {file = "coverage-7.13.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:52f444e86475992506b32d4e5ca55c24fc88d73bcbda0e9745095b28ef4dc0cf"}, + {file = "coverage-7.13.5-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:704de6328e3d612a8f6c07000a878ff38181ec3263d5a11da1db294fa6a9bdf8"}, + {file = "coverage-7.13.5-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:a1a6d79a14e1ec1832cabc833898636ad5f3754a678ef8bb4908515208bf84f4"}, + {file = "coverage-7.13.5-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:79060214983769c7ba3f0cee10b54c97609dca4d478fa1aa32b914480fd5738d"}, + {file = "coverage-7.13.5-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:356e76b46783a98c2a2fe81ec79df4883a1e62895ea952968fb253c114e7f930"}, + {file = "coverage-7.13.5-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0cef0cdec915d11254a7f549c1170afecce708d30610c6abdded1f74e581666d"}, + {file = "coverage-7.13.5-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:dc022073d063b25a402454e5712ef9e007113e3a676b96c5f29b2bda29352f40"}, + {file = "coverage-7.13.5-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:9b74db26dfea4f4e50d48a4602207cd1e78be33182bc9cbf22da94f332f99878"}, + {file = "coverage-7.13.5-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:ad146744ca4fd09b50c482650e3c1b1f4dfa1d4792e0a04a369c7f23336f0400"}, + {file = "coverage-7.13.5-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:c555b48be1853fe3997c11c4bd521cdd9a9612352de01fa4508f16ec341e6fe0"}, + {file = "coverage-7.13.5-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:7034b5c56a58ae5e85f23949d52c14aca2cfc6848a31764995b7de88f13a1ea0"}, + {file = "coverage-7.13.5-cp310-cp310-win32.whl", hash = "sha256:eb7fdf1ef130660e7415e0253a01a7d5a88c9c4d158bcf75cbbd922fd65a5b58"}, + {file = "coverage-7.13.5-cp310-cp310-win_amd64.whl", hash = "sha256:3e1bb5f6c78feeb1be3475789b14a0f0a5b47d505bfc7267126ccbd50289999e"}, + {file = "coverage-7.13.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:66a80c616f80181f4d643b0f9e709d97bcea413ecd9631e1dedc7401c8e6695d"}, + {file = "coverage-7.13.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:145ede53ccbafb297c1c9287f788d1bc3efd6c900da23bf6931b09eafc931587"}, + {file = "coverage-7.13.5-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:0672854dc733c342fa3e957e0605256d2bf5934feeac328da9e0b5449634a642"}, + {file = "coverage-7.13.5-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:ec10e2a42b41c923c2209b846126c6582db5e43a33157e9870ba9fb70dc7854b"}, + {file = "coverage-7.13.5-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:be3d4bbad9d4b037791794ddeedd7d64a56f5933a2c1373e18e9e568b9141686"}, + {file = "coverage-7.13.5-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4d2afbc5cc54d286bfb54541aa50b64cdb07a718227168c87b9e2fb8f25e1743"}, + {file = "coverage-7.13.5-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3ad050321264c49c2fa67bb599100456fc51d004b82534f379d16445da40fb75"}, + {file = "coverage-7.13.5-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7300c8a6d13335b29bb76d7651c66af6bd8658517c43499f110ddc6717bfc209"}, + {file = "coverage-7.13.5-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:eb07647a5738b89baab047f14edd18ded523de60f3b30e75c2acc826f79c839a"}, + {file = "coverage-7.13.5-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:9adb6688e3b53adffefd4a52d72cbd8b02602bfb8f74dcd862337182fd4d1a4e"}, + {file = "coverage-7.13.5-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7c8d4bc913dd70b93488d6c496c77f3aff5ea99a07e36a18f865bca55adef8bd"}, + {file = "coverage-7.13.5-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:0e3c426ffc4cd952f54ee9ffbdd10345709ecc78a3ecfd796a57236bfad0b9b8"}, + {file = "coverage-7.13.5-cp311-cp311-win32.whl", hash = "sha256:259b69bb83ad9894c4b25be2528139eecba9a82646ebdda2d9db1ba28424a6bf"}, + {file = "coverage-7.13.5-cp311-cp311-win_amd64.whl", hash = "sha256:258354455f4e86e3e9d0d17571d522e13b4e1e19bf0f8596bcf9476d61e7d8a9"}, + {file = "coverage-7.13.5-cp311-cp311-win_arm64.whl", hash = "sha256:bff95879c33ec8da99fc9b6fe345ddb5be6414b41d6d1ad1c8f188d26f36e028"}, + {file = "coverage-7.13.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:460cf0114c5016fa841214ff5564aa4864f11948da9440bc97e21ad1f4ba1e01"}, + {file = "coverage-7.13.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0e223ce4b4ed47f065bfb123687686512e37629be25cc63728557ae7db261422"}, + {file = "coverage-7.13.5-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:6e3370441f4513c6252bf042b9c36d22491142385049243253c7e48398a15a9f"}, + {file = "coverage-7.13.5-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:03ccc709a17a1de074fb1d11f217342fb0d2b1582ed544f554fc9fc3f07e95f5"}, + {file = "coverage-7.13.5-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3f4818d065964db3c1c66dc0fbdac5ac692ecbc875555e13374fdbe7eedb4376"}, + {file = "coverage-7.13.5-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:012d5319e66e9d5a218834642d6c35d265515a62f01157a45bcc036ecf947256"}, + {file = "coverage-7.13.5-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8dd02af98971bdb956363e4827d34425cb3df19ee550ef92855b0acb9c7ce51c"}, + {file = "coverage-7.13.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f08fd75c50a760c7eb068ae823777268daaf16a80b918fa58eea888f8e3919f5"}, + {file = "coverage-7.13.5-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:843ea8643cf967d1ac7e8ecd4bb00c99135adf4816c0c0593fdcc47b597fcf09"}, + {file = "coverage-7.13.5-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:9d44d7aa963820b1b971dbecd90bfe5fe8f81cff79787eb6cca15750bd2f79b9"}, + {file = "coverage-7.13.5-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:7132bed4bd7b836200c591410ae7d97bf7ae8be6fc87d160b2bd881df929e7bf"}, + {file = "coverage-7.13.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a698e363641b98843c517817db75373c83254781426e94ada3197cabbc2c919c"}, + {file = "coverage-7.13.5-cp312-cp312-win32.whl", hash = "sha256:bdba0a6b8812e8c7df002d908a9a2ea3c36e92611b5708633c50869e6d922fdf"}, + {file = "coverage-7.13.5-cp312-cp312-win_amd64.whl", hash = "sha256:d2c87e0c473a10bffe991502eac389220533024c8082ec1ce849f4218dded810"}, + {file = "coverage-7.13.5-cp312-cp312-win_arm64.whl", hash = "sha256:bf69236a9a81bdca3bff53796237aab096cdbf8d78a66ad61e992d9dac7eb2de"}, + {file = "coverage-7.13.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5ec4af212df513e399cf11610cc27063f1586419e814755ab362e50a85ea69c1"}, + {file = "coverage-7.13.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:941617e518602e2d64942c88ec8499f7fbd49d3f6c4327d3a71d43a1973032f3"}, + {file = "coverage-7.13.5-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:da305e9937617ee95c2e39d8ff9f040e0487cbf1ac174f777ed5eddd7a7c1f26"}, + {file = "coverage-7.13.5-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:78e696e1cc714e57e8b25760b33a8b1026b7048d270140d25dafe1b0a1ee05a3"}, + {file = "coverage-7.13.5-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:02ca0eed225b2ff301c474aeeeae27d26e2537942aa0f87491d3e147e784a82b"}, + {file = "coverage-7.13.5-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:04690832cbea4e4663d9149e05dba142546ca05cb1848816760e7f58285c970a"}, + {file = "coverage-7.13.5-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0590e44dd2745c696a778f7bab6aa95256de2cbc8b8cff4f7db8ff09813d6969"}, + {file = "coverage-7.13.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d7cfad2d6d81dd298ab6b89fe72c3b7b05ec7544bdda3b707ddaecff8d25c161"}, + {file = "coverage-7.13.5-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:e092b9499de38ae0fbfbc603a74660eb6ff3e869e507b50d85a13b6db9863e15"}, + {file = "coverage-7.13.5-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:48c39bc4a04d983a54a705a6389512883d4a3b9862991b3617d547940e9f52b1"}, + {file = "coverage-7.13.5-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:2d3807015f138ffea1ed9afeeb8624fd781703f2858b62a8dd8da5a0994c57b6"}, + {file = "coverage-7.13.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ee2aa19e03161671ec964004fb74b2257805d9710bf14a5c704558b9d8dbaf17"}, + {file = "coverage-7.13.5-cp313-cp313-win32.whl", hash = "sha256:ce1998c0483007608c8382f4ff50164bfc5bd07a2246dd272aa4043b75e61e85"}, + {file = "coverage-7.13.5-cp313-cp313-win_amd64.whl", hash = "sha256:631efb83f01569670a5e866ceb80fe483e7c159fac6f167e6571522636104a0b"}, + {file = "coverage-7.13.5-cp313-cp313-win_arm64.whl", hash = "sha256:f4cd16206ad171cbc2470dbea9103cf9a7607d5fe8c242fdf1edf36174020664"}, + {file = "coverage-7.13.5-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0428cbef5783ad91fe240f673cc1f76b25e74bbfe1a13115e4aa30d3f538162d"}, + {file = "coverage-7.13.5-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e0b216a19534b2427cc201a26c25da4a48633f29a487c61258643e89d28200c0"}, + {file = "coverage-7.13.5-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:972a9cd27894afe4bc2b1480107054e062df08e671df7c2f18c205e805ccd806"}, + {file = "coverage-7.13.5-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:4b59148601efcd2bac8c4dbf1f0ad6391693ccf7a74b8205781751637076aee3"}, + {file = "coverage-7.13.5-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:505d7083c8b0c87a8fa8c07370c285847c1f77739b22e299ad75a6af6c32c5c9"}, + {file = "coverage-7.13.5-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:60365289c3741e4db327e7baff2a4aaacf22f788e80fa4683393891b70a89fbd"}, + {file = "coverage-7.13.5-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1b88c69c8ef5d4b6fe7dea66d6636056a0f6a7527c440e890cf9259011f5e606"}, + {file = "coverage-7.13.5-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:5b13955d31d1633cf9376908089b7cebe7d15ddad7aeaabcbe969a595a97e95e"}, + {file = "coverage-7.13.5-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:f70c9ab2595c56f81a89620e22899eea8b212a4041bd728ac6f4a28bf5d3ddd0"}, + {file = "coverage-7.13.5-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:084b84a8c63e8d6fc7e3931b316a9bcafca1458d753c539db82d31ed20091a87"}, + {file = "coverage-7.13.5-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:ad14385487393e386e2ea988b09d62dd42c397662ac2dabc3832d71253eee479"}, + {file = "coverage-7.13.5-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:7f2c47b36fe7709a6e83bfadf4eefb90bd25fbe4014d715224c4316f808e59a2"}, + {file = "coverage-7.13.5-cp313-cp313t-win32.whl", hash = "sha256:67e9bc5449801fad0e5dff329499fb090ba4c5800b86805c80617b4e29809b2a"}, + {file = "coverage-7.13.5-cp313-cp313t-win_amd64.whl", hash = "sha256:da86cdcf10d2519e10cabb8ac2de03da1bcb6e4853790b7fbd48523332e3a819"}, + {file = "coverage-7.13.5-cp313-cp313t-win_arm64.whl", hash = "sha256:0ecf12ecb326fe2c339d93fc131816f3a7367d223db37817208905c89bded911"}, + {file = "coverage-7.13.5-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fbabfaceaeb587e16f7008f7795cd80d20ec548dc7f94fbb0d4ec2e038ce563f"}, + {file = "coverage-7.13.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9bb2a28101a443669a423b665939381084412b81c3f8c0fcfbac57f4e30b5b8e"}, + {file = "coverage-7.13.5-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:bd3a2fbc1c6cccb3c5106140d87cc6a8715110373ef42b63cf5aea29df8c217a"}, + {file = "coverage-7.13.5-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6c36ddb64ed9d7e496028d1d00dfec3e428e0aabf4006583bb1839958d280510"}, + {file = "coverage-7.13.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:380e8e9084d8eb38db3a9176a1a4f3c0082c3806fa0dc882d1d87abc3c789247"}, + {file = "coverage-7.13.5-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e808af52a0513762df4d945ea164a24b37f2f518cbe97e03deaa0ee66139b4d6"}, + {file = "coverage-7.13.5-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e301d30dd7e95ae068671d746ba8c34e945a82682e62918e41b2679acd2051a0"}, + {file = "coverage-7.13.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:800bc829053c80d240a687ceeb927a94fd108bbdc68dfbe505d0d75ab578a882"}, + {file = "coverage-7.13.5-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:0b67af5492adb31940ee418a5a655c28e48165da5afab8c7fa6fd72a142f8740"}, + {file = "coverage-7.13.5-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:c9136ff29c3a91e25b1d1552b5308e53a1e0653a23e53b6366d7c2dcbbaf8a16"}, + {file = "coverage-7.13.5-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:cff784eef7f0b8f6cb28804fbddcfa99f89efe4cc35fb5627e3ac58f91ed3ac0"}, + {file = "coverage-7.13.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:68a4953be99b17ac3c23b6efbc8a38330d99680c9458927491d18700ef23ded0"}, + {file = "coverage-7.13.5-cp314-cp314-win32.whl", hash = "sha256:35a31f2b1578185fbe6aa2e74cea1b1d0bbf4c552774247d9160d29b80ed56cc"}, + {file = "coverage-7.13.5-cp314-cp314-win_amd64.whl", hash = "sha256:2aa055ae1857258f9e0045be26a6d62bdb47a72448b62d7b55f4820f361a2633"}, + {file = "coverage-7.13.5-cp314-cp314-win_arm64.whl", hash = "sha256:1b11eef33edeae9d142f9b4358edb76273b3bfd30bc3df9a4f95d0e49caf94e8"}, + {file = "coverage-7.13.5-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:10a0c37f0b646eaff7cce1874c31d1f1ccb297688d4c747291f4f4c70741cc8b"}, + {file = "coverage-7.13.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b5db73ba3c41c7008037fa731ad5459fc3944cb7452fc0aa9f822ad3533c583c"}, + {file = "coverage-7.13.5-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:750db93a81e3e5a9831b534be7b1229df848b2e125a604fe6651e48aa070e5f9"}, + {file = "coverage-7.13.5-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9ddb4f4a5479f2539644be484da179b653273bca1a323947d48ab107b3ed1f29"}, + {file = "coverage-7.13.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d8a7a2049c14f413163e2bdabd37e41179b1d1ccb10ffc6ccc4b7a718429c607"}, + {file = "coverage-7.13.5-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e1c85e0b6c05c592ea6d8768a66a254bfb3874b53774b12d4c89c481eb78cb90"}, + {file = "coverage-7.13.5-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:777c4d1eff1b67876139d24288aaf1817f6c03d6bae9c5cc8d27b83bcfe38fe3"}, + {file = "coverage-7.13.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:6697e29b93707167687543480a40f0db8f356e86d9f67ddf2e37e2dfd91a9dab"}, + {file = "coverage-7.13.5-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:8fdf453a942c3e4d99bd80088141c4c6960bb232c409d9c3558e2dbaa3998562"}, + {file = "coverage-7.13.5-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:32ca0c0114c9834a43f045a87dcebd69d108d8ffb666957ea65aa132f50332e2"}, + {file = "coverage-7.13.5-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:8769751c10f339021e2638cd354e13adeac54004d1941119b2c96fe5276d45ea"}, + {file = "coverage-7.13.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cec2d83125531bd153175354055cdb7a09987af08a9430bd173c937c6d0fba2a"}, + {file = "coverage-7.13.5-cp314-cp314t-win32.whl", hash = "sha256:0cd9ed7a8b181775459296e402ca4fb27db1279740a24e93b3b41942ebe4b215"}, + {file = "coverage-7.13.5-cp314-cp314t-win_amd64.whl", hash = "sha256:301e3b7dfefecaca37c9f1aa6f0049b7d4ab8dd933742b607765d757aca77d43"}, + {file = "coverage-7.13.5-cp314-cp314t-win_arm64.whl", hash = "sha256:9dacc2ad679b292709e0f5fc1ac74a6d4d5562e424058962c7bb0c658ad25e45"}, + {file = "coverage-7.13.5-py3-none-any.whl", hash = "sha256:34b02417cf070e173989b3db962f7ed56d2f644307b2cf9d5a0f258e13084a61"}, + {file = "coverage-7.13.5.tar.gz", hash = "sha256:c81f6515c4c40141f83f502b07bbfa5c240ba25bbe73da7b33f1e5b6120ff179"}, ] [package.extras] @@ -352,14 +387,14 @@ files = [ [[package]] name = "filelock" -version = "3.20.0" +version = "3.25.2" description = "A platform independent file lock." optional = false python-versions = ">=3.10" groups = ["dev"] files = [ - {file = "filelock-3.20.0-py3-none-any.whl", hash = "sha256:339b4732ffda5cd79b13f4e2711a31b0365ce445d95d243bb996273d072546a2"}, - {file = "filelock-3.20.0.tar.gz", hash = "sha256:711e943b4ec6be42e1d4e6690b48dc175c822967466bb31c0c293f34334c13f4"}, + {file = "filelock-3.25.2-py3-none-any.whl", hash = "sha256:ca8afb0da15f229774c9ad1b455ed96e85a81373065fb10446672f64444ddf70"}, + {file = "filelock-3.25.2.tar.gz", hash = "sha256:b64ece2b38f4ca29dd3e810287aa8c48182bbecd1ae6e9ae126c9b35f1382694"}, ] [[package]] @@ -381,14 +416,14 @@ pyflakes = ">=3.4.0,<3.5.0" [[package]] name = "identify" -version = "2.6.15" +version = "2.6.18" description = "File identification library for Python" optional = false -python-versions = ">=3.9" +python-versions = ">=3.10" groups = ["dev"] files = [ - {file = "identify-2.6.15-py2.py3-none-any.whl", hash = "sha256:1181ef7608e00704db228516541eb83a88a9f94433a8c80bb9b5bd54b1d81757"}, - {file = "identify-2.6.15.tar.gz", hash = "sha256:e4f4864b96c6557ef2a1e1c951771838f4edc9df3a72ec7118b338801b11c7bf"}, + {file = "identify-2.6.18-py2.py3-none-any.whl", hash = "sha256:8db9d3c8ea9079db92cafb0ebf97abdc09d52e97f4dcf773a2e694048b7cd737"}, + {file = "identify-2.6.18.tar.gz", hash = "sha256:873ac56a5e3fd63e7438a7ecbc4d91aca692eb3fefa4534db2b7913f3fc352fd"}, ] [package.extras] @@ -411,27 +446,128 @@ all = ["flake8 (>=7.1.1)", "mypy (>=1.11.2)", "pytest (>=8.3.2)", "ruff (>=0.6.2 [[package]] name = "iniconfig" -version = "2.1.0" +version = "2.3.0" description = "brain-dead simple config-ini parsing" optional = false -python-versions = ">=3.8" +python-versions = ">=3.10" groups = ["test"] files = [ - {file = "iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760"}, - {file = "iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7"}, + {file = "iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12"}, + {file = "iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730"}, +] + +[[package]] +name = "librt" +version = "0.8.1" +description = "Mypyc runtime library" +optional = false +python-versions = ">=3.9" +groups = ["dev"] +markers = "platform_python_implementation != \"PyPy\"" +files = [ + {file = "librt-0.8.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:81fd938344fecb9373ba1b155968c8a329491d2ce38e7ddb76f30ffb938f12dc"}, + {file = "librt-0.8.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:5db05697c82b3a2ec53f6e72b2ed373132b0c2e05135f0696784e97d7f5d48e7"}, + {file = "librt-0.8.1-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d56bc4011975f7460bea7b33e1ff425d2f1adf419935ff6707273c77f8a4ada6"}, + {file = "librt-0.8.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5cdc0f588ff4b663ea96c26d2a230c525c6fc62b28314edaaaca8ed5af931ad0"}, + {file = "librt-0.8.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:97c2b54ff6717a7a563b72627990bec60d8029df17df423f0ed37d56a17a176b"}, + {file = "librt-0.8.1-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8f1125e6bbf2f1657d9a2f3ccc4a2c9b0c8b176965bb565dd4d86be67eddb4b6"}, + {file = "librt-0.8.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:8f4bb453f408137d7581be309b2fbc6868a80e7ef60c88e689078ee3a296ae71"}, + {file = "librt-0.8.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:c336d61d2fe74a3195edc1646d53ff1cddd3a9600b09fa6ab75e5514ba4862a7"}, + {file = "librt-0.8.1-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:eb5656019db7c4deacf0c1a55a898c5bb8f989be904597fcb5232a2f4828fa05"}, + {file = "librt-0.8.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:c25d9e338d5bed46c1632f851babf3d13c78f49a225462017cf5e11e845c5891"}, + {file = "librt-0.8.1-cp310-cp310-win32.whl", hash = "sha256:aaab0e307e344cb28d800957ef3ec16605146ef0e59e059a60a176d19543d1b7"}, + {file = "librt-0.8.1-cp310-cp310-win_amd64.whl", hash = "sha256:56e04c14b696300d47b3bc5f1d10a00e86ae978886d0cee14e5714fafb5df5d2"}, + {file = "librt-0.8.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:681dc2451d6d846794a828c16c22dc452d924e9f700a485b7ecb887a30aad1fd"}, + {file = "librt-0.8.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a3b4350b13cc0e6f5bec8fa7caf29a8fb8cdc051a3bae45cfbfd7ce64f009965"}, + {file = "librt-0.8.1-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:ac1e7817fd0ed3d14fd7c5df91daed84c48e4c2a11ee99c0547f9f62fdae13da"}, + {file = "librt-0.8.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:747328be0c5b7075cde86a0e09d7a9196029800ba75a1689332348e998fb85c0"}, + {file = "librt-0.8.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f0af2bd2bc204fa27f3d6711d0f360e6b8c684a035206257a81673ab924aa11e"}, + {file = "librt-0.8.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d480de377f5b687b6b1bc0c0407426da556e2a757633cc7e4d2e1a057aa688f3"}, + {file = "librt-0.8.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d0ee06b5b5291f609ddb37b9750985b27bc567791bc87c76a569b3feed8481ac"}, + {file = "librt-0.8.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:9e2c6f77b9ad48ce5603b83b7da9ee3e36b3ab425353f695cba13200c5d96596"}, + {file = "librt-0.8.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:439352ba9373f11cb8e1933da194dcc6206daf779ff8df0ed69c5e39113e6a99"}, + {file = "librt-0.8.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:82210adabbc331dbb65d7868b105185464ef13f56f7f76688565ad79f648b0fe"}, + {file = "librt-0.8.1-cp311-cp311-win32.whl", hash = "sha256:52c224e14614b750c0a6d97368e16804a98c684657c7518752c356834fff83bb"}, + {file = "librt-0.8.1-cp311-cp311-win_amd64.whl", hash = "sha256:c00e5c884f528c9932d278d5c9cbbea38a6b81eb62c02e06ae53751a83a4d52b"}, + {file = "librt-0.8.1-cp311-cp311-win_arm64.whl", hash = "sha256:f7cdf7f26c2286ffb02e46d7bac56c94655540b26347673bea15fa52a6af17e9"}, + {file = "librt-0.8.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a28f2612ab566b17f3698b0da021ff9960610301607c9a5e8eaca62f5e1c350a"}, + {file = "librt-0.8.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:60a78b694c9aee2a0f1aaeaa7d101cf713e92e8423a941d2897f4fa37908dab9"}, + {file = "librt-0.8.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:758509ea3f1eba2a57558e7e98f4659d0ea7670bff49673b0dde18a3c7e6c0eb"}, + {file = "librt-0.8.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:039b9f2c506bd0ab0f8725aa5ba339c6f0cd19d3b514b50d134789809c24285d"}, + {file = "librt-0.8.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5bb54f1205a3a6ab41a6fd71dfcdcbd278670d3a90ca502a30d9da583105b6f7"}, + {file = "librt-0.8.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:05bd41cdee35b0c59c259f870f6da532a2c5ca57db95b5f23689fcb5c9e42440"}, + {file = "librt-0.8.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:adfab487facf03f0d0857b8710cf82d0704a309d8ffc33b03d9302b4c64e91a9"}, + {file = "librt-0.8.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:153188fe98a72f206042be10a2c6026139852805215ed9539186312d50a8e972"}, + {file = "librt-0.8.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:dd3c41254ee98604b08bd5b3af5bf0a89740d4ee0711de95b65166bf44091921"}, + {file = "librt-0.8.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e0d138c7ae532908cbb342162b2611dbd4d90c941cd25ab82084aaf71d2c0bd0"}, + {file = "librt-0.8.1-cp312-cp312-win32.whl", hash = "sha256:43353b943613c5d9c49a25aaffdba46f888ec354e71e3529a00cca3f04d66a7a"}, + {file = "librt-0.8.1-cp312-cp312-win_amd64.whl", hash = "sha256:ff8baf1f8d3f4b6b7257fcb75a501f2a5499d0dda57645baa09d4d0d34b19444"}, + {file = "librt-0.8.1-cp312-cp312-win_arm64.whl", hash = "sha256:0f2ae3725904f7377e11cc37722d5d401e8b3d5851fb9273d7f4fe04f6b3d37d"}, + {file = "librt-0.8.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7e6bad1cd94f6764e1e21950542f818a09316645337fd5ab9a7acc45d99a8f35"}, + {file = "librt-0.8.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cf450f498c30af55551ba4f66b9123b7185362ec8b625a773b3d39aa1a717583"}, + {file = "librt-0.8.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:eca45e982fa074090057132e30585a7e8674e9e885d402eae85633e9f449ce6c"}, + {file = "librt-0.8.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0c3811485fccfda840861905b8c70bba5ec094e02825598bb9d4ca3936857a04"}, + {file = "librt-0.8.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5e4af413908f77294605e28cfd98063f54b2c790561383971d2f52d113d9c363"}, + {file = "librt-0.8.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5212a5bd7fae98dae95710032902edcd2ec4dc994e883294f75c857b83f9aba0"}, + {file = "librt-0.8.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e692aa2d1d604e6ca12d35e51fdc36f4cda6345e28e36374579f7ef3611b3012"}, + {file = "librt-0.8.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:4be2a5c926b9770c9e08e717f05737a269b9d0ebc5d2f0060f0fe3fe9ce47acb"}, + {file = "librt-0.8.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:fd1a720332ea335ceb544cf0a03f81df92abd4bb887679fd1e460976b0e6214b"}, + {file = "librt-0.8.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:93c2af9e01e0ef80d95ae3c720be101227edae5f2fe7e3dc63d8857fadfc5a1d"}, + {file = "librt-0.8.1-cp313-cp313-win32.whl", hash = "sha256:086a32dbb71336627e78cc1d6ee305a68d038ef7d4c39aaff41ae8c9aa46e91a"}, + {file = "librt-0.8.1-cp313-cp313-win_amd64.whl", hash = "sha256:e11769a1dbda4da7b00a76cfffa67aa47cfa66921d2724539eee4b9ede780b79"}, + {file = "librt-0.8.1-cp313-cp313-win_arm64.whl", hash = "sha256:924817ab3141aca17893386ee13261f1d100d1ef410d70afe4389f2359fea4f0"}, + {file = "librt-0.8.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:6cfa7fe54fd4d1f47130017351a959fe5804bda7a0bc7e07a2cdbc3fdd28d34f"}, + {file = "librt-0.8.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:228c2409c079f8c11fb2e5d7b277077f694cb93443eb760e00b3b83cb8b3176c"}, + {file = "librt-0.8.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:7aae78ab5e3206181780e56912d1b9bb9f90a7249ce12f0e8bf531d0462dd0fc"}, + {file = "librt-0.8.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:172d57ec04346b047ca6af181e1ea4858086c80bdf455f61994c4aa6fc3f866c"}, + {file = "librt-0.8.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6b1977c4ea97ce5eb7755a78fae68d87e4102e4aaf54985e8b56806849cc06a3"}, + {file = "librt-0.8.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:10c42e1f6fd06733ef65ae7bebce2872bcafd8d6e6b0a08fe0a05a23b044fb14"}, + {file = "librt-0.8.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:4c8dfa264b9193c4ee19113c985c95f876fae5e51f731494fc4e0cf594990ba7"}, + {file = "librt-0.8.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:01170b6729a438f0dedc4a26ed342e3dc4f02d1000b4b19f980e1877f0c297e6"}, + {file = "librt-0.8.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:7b02679a0d783bdae30d443025b94465d8c3dc512f32f5b5031f93f57ac32071"}, + {file = "librt-0.8.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:190b109bb69592a3401fe1ffdea41a2e73370ace2ffdc4a0e8e2b39cdea81b78"}, + {file = "librt-0.8.1-cp314-cp314-win32.whl", hash = "sha256:e70a57ecf89a0f64c24e37f38d3fe217a58169d2fe6ed6d70554964042474023"}, + {file = "librt-0.8.1-cp314-cp314-win_amd64.whl", hash = "sha256:7e2f3edca35664499fbb36e4770650c4bd4a08abc1f4458eab9df4ec56389730"}, + {file = "librt-0.8.1-cp314-cp314-win_arm64.whl", hash = "sha256:0d2f82168e55ddefd27c01c654ce52379c0750ddc31ee86b4b266bcf4d65f2a3"}, + {file = "librt-0.8.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:2c74a2da57a094bd48d03fa5d196da83d2815678385d2978657499063709abe1"}, + {file = "librt-0.8.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a355d99c4c0d8e5b770313b8b247411ed40949ca44e33e46a4789b9293a907ee"}, + {file = "librt-0.8.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:2eb345e8b33fb748227409c9f1233d4df354d6e54091f0e8fc53acdb2ffedeb7"}, + {file = "librt-0.8.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9be2f15e53ce4e83cc08adc29b26fb5978db62ef2a366fbdf716c8a6c8901040"}, + {file = "librt-0.8.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:785ae29c1f5c6e7c2cde2c7c0e148147f4503da3abc5d44d482068da5322fd9e"}, + {file = "librt-0.8.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1d3a7da44baf692f0c6aeb5b2a09c5e6fc7a703bca9ffa337ddd2e2da53f7732"}, + {file = "librt-0.8.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5fc48998000cbc39ec0d5311312dda93ecf92b39aaf184c5e817d5d440b29624"}, + {file = "librt-0.8.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:e96baa6820280077a78244b2e06e416480ed859bbd8e5d641cf5742919d8beb4"}, + {file = "librt-0.8.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:31362dbfe297b23590530007062c32c6f6176f6099646bb2c95ab1b00a57c382"}, + {file = "librt-0.8.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cc3656283d11540ab0ea01978378e73e10002145117055e03722417aeab30994"}, + {file = "librt-0.8.1-cp314-cp314t-win32.whl", hash = "sha256:738f08021b3142c2918c03692608baed43bc51144c29e35807682f8070ee2a3a"}, + {file = "librt-0.8.1-cp314-cp314t-win_amd64.whl", hash = "sha256:89815a22daf9c51884fb5dbe4f1ef65ee6a146e0b6a8df05f753e2e4a9359bf4"}, + {file = "librt-0.8.1-cp314-cp314t-win_arm64.whl", hash = "sha256:bf512a71a23504ed08103a13c941f763db13fb11177beb3d9244c98c29fb4a61"}, + {file = "librt-0.8.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:3dff3d3ca8db20e783b1bc7de49c0a2ab0b8387f31236d6a026597d07fcd68ac"}, + {file = "librt-0.8.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:08eec3a1fc435f0d09c87b6bf1ec798986a3544f446b864e4099633a56fcd9ed"}, + {file = "librt-0.8.1-cp39-cp39-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:e3f0a41487fd5fad7e760b9e8a90e251e27c2816fbc2cff36a22a0e6bcbbd9dd"}, + {file = "librt-0.8.1-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bacdb58d9939d95cc557b4dbaa86527c9db2ac1ed76a18bc8d26f6dc8647d851"}, + {file = "librt-0.8.1-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b6d7ab1f01aa753188605b09a51faa44a3327400b00b8cce424c71910fc0a128"}, + {file = "librt-0.8.1-cp39-cp39-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4998009e7cb9e896569f4be7004f09d0ed70d386fa99d42b6d363f6d200501ac"}, + {file = "librt-0.8.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:2cc68eeeef5e906839c7bb0815748b5b0a974ec27125beefc0f942715785b551"}, + {file = "librt-0.8.1-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:0bf69d79a23f4f40b8673a947a234baeeb133b5078b483b7297c5916539cf5d5"}, + {file = "librt-0.8.1-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:22b46eabd76c1986ee7d231b0765ad387d7673bbd996aa0d0d054b38ac65d8f6"}, + {file = "librt-0.8.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:237796479f4d0637d6b9cbcb926ff424a97735e68ade6facf402df4ec93375ed"}, + {file = "librt-0.8.1-cp39-cp39-win32.whl", hash = "sha256:4beb04b8c66c6ae62f8c1e0b2f097c1ebad9295c929a8d5286c05eae7c2fc7dc"}, + {file = "librt-0.8.1-cp39-cp39-win_amd64.whl", hash = "sha256:64548cde61b692dc0dc379f4b5f59a2f582c2ebe7890d09c1ae3b9e66fa015b7"}, + {file = "librt-0.8.1.tar.gz", hash = "sha256:be46a14693955b3bd96014ccbdb8339ee8c9346fbe11c1b78901b55125f14c73"}, ] [[package]] name = "macholib" -version = "1.16.3" +version = "1.16.4" description = "Mach-O header analysis and editing" optional = false python-versions = "*" groups = ["build"] markers = "sys_platform == \"darwin\"" files = [ - {file = "macholib-1.16.3-py2.py3-none-any.whl", hash = "sha256:0e315d7583d38b8c77e815b1ecbdbf504a8258d8b3e17b61165c6feb60d18f2c"}, - {file = "macholib-1.16.3.tar.gz", hash = "sha256:07ae9e15e8e4cd9a788013d81f5908b3609aa76f9b1421bae9c4d7606ec86a30"}, + {file = "macholib-1.16.4-py2.py3-none-any.whl", hash = "sha256:da1a3fa8266e30f0ce7e97c6a54eefaae8edd1e5f86f3eb8b95457cae90265ea"}, + {file = "macholib-1.16.4.tar.gz", hash = "sha256:f408c93ab2e995cd2c46e34fe328b130404be143469e41bc366c807448979362"}, ] [package.dependencies] @@ -451,53 +587,54 @@ files = [ [[package]] name = "mypy" -version = "1.18.2" +version = "1.19.1" description = "Optional static typing for Python" optional = false python-versions = ">=3.9" groups = ["dev"] files = [ - {file = "mypy-1.18.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:c1eab0cf6294dafe397c261a75f96dc2c31bffe3b944faa24db5def4e2b0f77c"}, - {file = "mypy-1.18.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:7a780ca61fc239e4865968ebc5240bb3bf610ef59ac398de9a7421b54e4a207e"}, - {file = "mypy-1.18.2-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:448acd386266989ef11662ce3c8011fd2a7b632e0ec7d61a98edd8e27472225b"}, - {file = "mypy-1.18.2-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f9e171c465ad3901dc652643ee4bffa8e9fef4d7d0eece23b428908c77a76a66"}, - {file = "mypy-1.18.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:592ec214750bc00741af1f80cbf96b5013d81486b7bb24cb052382c19e40b428"}, - {file = "mypy-1.18.2-cp310-cp310-win_amd64.whl", hash = "sha256:7fb95f97199ea11769ebe3638c29b550b5221e997c63b14ef93d2e971606ebed"}, - {file = "mypy-1.18.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:807d9315ab9d464125aa9fcf6d84fde6e1dc67da0b6f80e7405506b8ac72bc7f"}, - {file = "mypy-1.18.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:776bb00de1778caf4db739c6e83919c1d85a448f71979b6a0edd774ea8399341"}, - {file = "mypy-1.18.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1379451880512ffce14505493bd9fe469e0697543717298242574882cf8cdb8d"}, - {file = "mypy-1.18.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1331eb7fd110d60c24999893320967594ff84c38ac6d19e0a76c5fd809a84c86"}, - {file = "mypy-1.18.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3ca30b50a51e7ba93b00422e486cbb124f1c56a535e20eff7b2d6ab72b3b2e37"}, - {file = "mypy-1.18.2-cp311-cp311-win_amd64.whl", hash = "sha256:664dc726e67fa54e14536f6e1224bcfce1d9e5ac02426d2326e2bb4e081d1ce8"}, - {file = "mypy-1.18.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:33eca32dd124b29400c31d7cf784e795b050ace0e1f91b8dc035672725617e34"}, - {file = "mypy-1.18.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a3c47adf30d65e89b2dcd2fa32f3aeb5e94ca970d2c15fcb25e297871c8e4764"}, - {file = "mypy-1.18.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5d6c838e831a062f5f29d11c9057c6009f60cb294fea33a98422688181fe2893"}, - {file = "mypy-1.18.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01199871b6110a2ce984bde85acd481232d17413868c9807e95c1b0739a58914"}, - {file = "mypy-1.18.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a2afc0fa0b0e91b4599ddfe0f91e2c26c2b5a5ab263737e998d6817874c5f7c8"}, - {file = "mypy-1.18.2-cp312-cp312-win_amd64.whl", hash = "sha256:d8068d0afe682c7c4897c0f7ce84ea77f6de953262b12d07038f4d296d547074"}, - {file = "mypy-1.18.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:07b8b0f580ca6d289e69209ec9d3911b4a26e5abfde32228a288eb79df129fcc"}, - {file = "mypy-1.18.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:ed4482847168439651d3feee5833ccedbf6657e964572706a2adb1f7fa4dfe2e"}, - {file = "mypy-1.18.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c3ad2afadd1e9fea5cf99a45a822346971ede8685cc581ed9cd4d42eaf940986"}, - {file = "mypy-1.18.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a431a6f1ef14cf8c144c6b14793a23ec4eae3db28277c358136e79d7d062f62d"}, - {file = "mypy-1.18.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7ab28cc197f1dd77a67e1c6f35cd1f8e8b73ed2217e4fc005f9e6a504e46e7ba"}, - {file = "mypy-1.18.2-cp313-cp313-win_amd64.whl", hash = "sha256:0e2785a84b34a72ba55fb5daf079a1003a34c05b22238da94fcae2bbe46f3544"}, - {file = "mypy-1.18.2-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:62f0e1e988ad41c2a110edde6c398383a889d95b36b3e60bcf155f5164c4fdce"}, - {file = "mypy-1.18.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:8795a039bab805ff0c1dfdb8cd3344642c2b99b8e439d057aba30850b8d3423d"}, - {file = "mypy-1.18.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6ca1e64b24a700ab5ce10133f7ccd956a04715463d30498e64ea8715236f9c9c"}, - {file = "mypy-1.18.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d924eef3795cc89fecf6bedc6ed32b33ac13e8321344f6ddbf8ee89f706c05cb"}, - {file = "mypy-1.18.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:20c02215a080e3a2be3aa50506c67242df1c151eaba0dcbc1e4e557922a26075"}, - {file = "mypy-1.18.2-cp314-cp314-win_amd64.whl", hash = "sha256:749b5f83198f1ca64345603118a6f01a4e99ad4bf9d103ddc5a3200cc4614adf"}, - {file = "mypy-1.18.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:25a9c8fb67b00599f839cf472713f54249a62efd53a54b565eb61956a7e3296b"}, - {file = "mypy-1.18.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:c2b9c7e284ee20e7598d6f42e13ca40b4928e6957ed6813d1ab6348aa3f47133"}, - {file = "mypy-1.18.2-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d6985ed057513e344e43a26cc1cd815c7a94602fb6a3130a34798625bc2f07b6"}, - {file = "mypy-1.18.2-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:22f27105f1525ec024b5c630c0b9f36d5c1cc4d447d61fe51ff4bd60633f47ac"}, - {file = "mypy-1.18.2-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:030c52d0ea8144e721e49b1f68391e39553d7451f0c3f8a7565b59e19fcb608b"}, - {file = "mypy-1.18.2-cp39-cp39-win_amd64.whl", hash = "sha256:aa5e07ac1a60a253445797e42b8b2963c9675563a94f11291ab40718b016a7a0"}, - {file = "mypy-1.18.2-py3-none-any.whl", hash = "sha256:22a1748707dd62b58d2ae53562ffc4d7f8bcc727e8ac7cbc69c053ddc874d47e"}, - {file = "mypy-1.18.2.tar.gz", hash = "sha256:06a398102a5f203d7477b2923dda3634c36727fa5c237d8f859ef90c42a9924b"}, + {file = "mypy-1.19.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:5f05aa3d375b385734388e844bc01733bd33c644ab48e9684faa54e5389775ec"}, + {file = "mypy-1.19.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:022ea7279374af1a5d78dfcab853fe6a536eebfda4b59deab53cd21f6cd9f00b"}, + {file = "mypy-1.19.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee4c11e460685c3e0c64a4c5de82ae143622410950d6be863303a1c4ba0e36d6"}, + {file = "mypy-1.19.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:de759aafbae8763283b2ee5869c7255391fbc4de3ff171f8f030b5ec48381b74"}, + {file = "mypy-1.19.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:ab43590f9cd5108f41aacf9fca31841142c786827a74ab7cc8a2eacb634e09a1"}, + {file = "mypy-1.19.1-cp310-cp310-win_amd64.whl", hash = "sha256:2899753e2f61e571b3971747e302d5f420c3fd09650e1951e99f823bc3089dac"}, + {file = "mypy-1.19.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:d8dfc6ab58ca7dda47d9237349157500468e404b17213d44fc1cb77bce532288"}, + {file = "mypy-1.19.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e3f276d8493c3c97930e354b2595a44a21348b320d859fb4a2b9f66da9ed27ab"}, + {file = "mypy-1.19.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2abb24cf3f17864770d18d673c85235ba52456b36a06b6afc1e07c1fdcd3d0e6"}, + {file = "mypy-1.19.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a009ffa5a621762d0c926a078c2d639104becab69e79538a494bcccb62cc0331"}, + {file = "mypy-1.19.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f7cee03c9a2e2ee26ec07479f38ea9c884e301d42c6d43a19d20fb014e3ba925"}, + {file = "mypy-1.19.1-cp311-cp311-win_amd64.whl", hash = "sha256:4b84a7a18f41e167f7995200a1d07a4a6810e89d29859df936f1c3923d263042"}, + {file = "mypy-1.19.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a8174a03289288c1f6c46d55cef02379b478bfbc8e358e02047487cad44c6ca1"}, + {file = "mypy-1.19.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ffcebe56eb09ff0c0885e750036a095e23793ba6c2e894e7e63f6d89ad51f22e"}, + {file = "mypy-1.19.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b64d987153888790bcdb03a6473d321820597ab8dd9243b27a92153c4fa50fd2"}, + {file = "mypy-1.19.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c35d298c2c4bba75feb2195655dfea8124d855dfd7343bf8b8c055421eaf0cf8"}, + {file = "mypy-1.19.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:34c81968774648ab5ac09c29a375fdede03ba253f8f8287847bd480782f73a6a"}, + {file = "mypy-1.19.1-cp312-cp312-win_amd64.whl", hash = "sha256:b10e7c2cd7870ba4ad9b2d8a6102eb5ffc1f16ca35e3de6bfa390c1113029d13"}, + {file = "mypy-1.19.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e3157c7594ff2ef1634ee058aafc56a82db665c9438fd41b390f3bde1ab12250"}, + {file = "mypy-1.19.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bdb12f69bcc02700c2b47e070238f42cb87f18c0bc1fc4cdb4fb2bc5fd7a3b8b"}, + {file = "mypy-1.19.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f859fb09d9583a985be9a493d5cfc5515b56b08f7447759a0c5deaf68d80506e"}, + {file = "mypy-1.19.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c9a6538e0415310aad77cb94004ca6482330fece18036b5f360b62c45814c4ef"}, + {file = "mypy-1.19.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:da4869fc5e7f62a88f3fe0b5c919d1d9f7ea3cef92d3689de2823fd27e40aa75"}, + {file = "mypy-1.19.1-cp313-cp313-win_amd64.whl", hash = "sha256:016f2246209095e8eda7538944daa1d60e1e8134d98983b9fc1e92c1fc0cb8dd"}, + {file = "mypy-1.19.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:06e6170bd5836770e8104c8fdd58e5e725cfeb309f0a6c681a811f557e97eac1"}, + {file = "mypy-1.19.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:804bd67b8054a85447c8954215a906d6eff9cabeabe493fb6334b24f4bfff718"}, + {file = "mypy-1.19.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:21761006a7f497cb0d4de3d8ef4ca70532256688b0523eee02baf9eec895e27b"}, + {file = "mypy-1.19.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:28902ee51f12e0f19e1e16fbe2f8f06b6637f482c459dd393efddd0ec7f82045"}, + {file = "mypy-1.19.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:481daf36a4c443332e2ae9c137dfee878fcea781a2e3f895d54bd3002a900957"}, + {file = "mypy-1.19.1-cp314-cp314-win_amd64.whl", hash = "sha256:8bb5c6f6d043655e055be9b542aa5f3bdd30e4f3589163e85f93f3640060509f"}, + {file = "mypy-1.19.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:7bcfc336a03a1aaa26dfce9fff3e287a3ba99872a157561cbfcebe67c13308e3"}, + {file = "mypy-1.19.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:b7951a701c07ea584c4fe327834b92a30825514c868b1f69c30445093fdd9d5a"}, + {file = "mypy-1.19.1-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b13cfdd6c87fc3efb69ea4ec18ef79c74c3f98b4e5498ca9b85ab3b2c2329a67"}, + {file = "mypy-1.19.1-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4f28f99c824ecebcdaa2e55d82953e38ff60ee5ec938476796636b86afa3956e"}, + {file = "mypy-1.19.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:c608937067d2fc5a4dd1a5ce92fd9e1398691b8c5d012d66e1ddd430e9244376"}, + {file = "mypy-1.19.1-cp39-cp39-win_amd64.whl", hash = "sha256:409088884802d511ee52ca067707b90c883426bd95514e8cfda8281dc2effe24"}, + {file = "mypy-1.19.1-py3-none-any.whl", hash = "sha256:f1235f5ea01b7db5468d53ece6aaddf1ad0b88d9e7462b86ef96fe04995d7247"}, + {file = "mypy-1.19.1.tar.gz", hash = "sha256:19d88bb05303fe63f71dd2c6270daca27cb9401c4ca8255fe50d1d920e0eb9ba"}, ] [package.dependencies] +librt = {version = ">=0.6.2", markers = "platform_python_implementation != \"PyPy\""} mypy_extensions = ">=1.0.0" pathspec = ">=0.9.0" typing_extensions = ">=4.6.0" @@ -523,70 +660,71 @@ files = [ [[package]] name = "nodeenv" -version = "1.9.1" +version = "1.10.0" description = "Node.js virtual environment builder" optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" groups = ["dev"] files = [ - {file = "nodeenv-1.9.1-py2.py3-none-any.whl", hash = "sha256:ba11c9782d29c27c70ffbdda2d7415098754709be8a7056d79a737cd901155c9"}, - {file = "nodeenv-1.9.1.tar.gz", hash = "sha256:6ec12890a2dab7946721edbfbcd91f3319c6ccc9aec47be7c7e6b7011ee6645f"}, + {file = "nodeenv-1.10.0-py2.py3-none-any.whl", hash = "sha256:5bb13e3eed2923615535339b3c620e76779af4cb4c6a90deccc9e36b274d3827"}, + {file = "nodeenv-1.10.0.tar.gz", hash = "sha256:996c191ad80897d076bdfba80a41994c2b47c68e224c542b48feba42ba00f8bb"}, ] [[package]] name = "packaging" -version = "25.0" +version = "26.0" description = "Core utilities for Python packages" optional = false python-versions = ">=3.8" groups = ["build", "dev", "test"] files = [ - {file = "packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484"}, - {file = "packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f"}, + {file = "packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529"}, + {file = "packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4"}, ] [[package]] name = "pathspec" -version = "0.12.1" +version = "1.0.4" description = "Utility library for gitignore style pattern matching of file paths." optional = false -python-versions = ">=3.8" +python-versions = ">=3.9" groups = ["dev"] files = [ - {file = "pathspec-0.12.1-py3-none-any.whl", hash = "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08"}, - {file = "pathspec-0.12.1.tar.gz", hash = "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712"}, + {file = "pathspec-1.0.4-py3-none-any.whl", hash = "sha256:fb6ae2fd4e7c921a165808a552060e722767cfa526f99ca5156ed2ce45a5c723"}, + {file = "pathspec-1.0.4.tar.gz", hash = "sha256:0210e2ae8a21a9137c0d470578cb0e595af87edaa6ebf12ff176f14a02e0e645"}, ] +[package.extras] +hyperscan = ["hyperscan (>=0.7)"] +optional = ["typing-extensions (>=4)"] +re2 = ["google-re2 (>=1.1)"] +tests = ["pytest (>=9)", "typing-extensions (>=4.15)"] + [[package]] name = "pefile" -version = "2023.2.7" +version = "2024.8.26" description = "Python PE parsing module" optional = false python-versions = ">=3.6.0" groups = ["build"] markers = "sys_platform == \"win32\"" files = [ - {file = "pefile-2023.2.7-py3-none-any.whl", hash = "sha256:da185cd2af68c08a6cd4481f7325ed600a88f6a813bad9dea07ab3ef73d8d8d6"}, - {file = "pefile-2023.2.7.tar.gz", hash = "sha256:82e6114004b3d6911c77c3953e3838654b04511b8b66e8583db70c65998017dc"}, + {file = "pefile-2024.8.26-py3-none-any.whl", hash = "sha256:76f8b485dcd3b1bb8166f1128d395fa3d87af26360c2358fb75b80019b957c6f"}, + {file = "pefile-2024.8.26.tar.gz", hash = "sha256:3ff6c5d8b43e8c37bb6e6dd5085658d658a7a0bdcd20b6a07b1fcfc1c4e9d632"}, ] [[package]] name = "platformdirs" -version = "4.5.0" +version = "4.9.4" description = "A small Python package for determining appropriate platform-specific dirs, e.g. a `user data dir`." optional = false python-versions = ">=3.10" groups = ["main", "dev"] files = [ - {file = "platformdirs-4.5.0-py3-none-any.whl", hash = "sha256:e578a81bb873cbb89a41fcc904c7ef523cc18284b7e3b3ccf06aca1403b7ebd3"}, - {file = "platformdirs-4.5.0.tar.gz", hash = "sha256:70ddccdd7c99fc5942e9fc25636a8b34d04c24b335100223152c2803e4063312"}, + {file = "platformdirs-4.9.4-py3-none-any.whl", hash = "sha256:68a9a4619a666ea6439f2ff250c12a853cd1cbd5158d258bd824a7df6be2f868"}, + {file = "platformdirs-4.9.4.tar.gz", hash = "sha256:1ec356301b7dc906d83f371c8f487070e99d3ccf9e501686456394622a01a934"}, ] -[package.extras] -docs = ["furo (>=2025.9.25)", "proselint (>=0.14)", "sphinx (>=8.2.3)", "sphinx-autodoc-typehints (>=3.2)"] -test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=8.4.2)", "pytest-cov (>=7)", "pytest-mock (>=3.15.1)"] -type = ["mypy (>=1.18.2)"] - [[package]] name = "pluggy" version = "1.6.0" @@ -605,14 +743,14 @@ testing = ["coverage", "pytest", "pytest-benchmark"] [[package]] name = "pre-commit" -version = "4.3.0" +version = "4.5.1" description = "A framework for managing and maintaining multi-language pre-commit hooks." optional = false -python-versions = ">=3.9" +python-versions = ">=3.10" groups = ["dev"] files = [ - {file = "pre_commit-4.3.0-py2.py3-none-any.whl", hash = "sha256:2b0747ad7e6e967169136edffee14c16e148a778a54e4f967921aa1ebf2308d8"}, - {file = "pre_commit-4.3.0.tar.gz", hash = "sha256:499fe450cc9d42e9d58e606262795ecb64dd05438943c62b66f6a8673da30b16"}, + {file = "pre_commit-4.5.1-py2.py3-none-any.whl", hash = "sha256:3b3afd891e97337708c1674210f8eba659b52a38ea5f822ff142d10786221f77"}, + {file = "pre_commit-4.5.1.tar.gz", hash = "sha256:eb545fcff725875197837263e977ea257a402056661f09dae08e4b149b030a61"}, ] [package.dependencies] @@ -663,32 +801,32 @@ windows-terminal = ["colorama (>=0.4.6)"] [[package]] name = "pyinstaller" -version = "6.16.0" +version = "6.19.0" description = "PyInstaller bundles a Python application and all its dependencies into a single package." optional = false python-versions = "<3.15,>=3.8" groups = ["build"] files = [ - {file = "pyinstaller-6.16.0-py3-none-macosx_10_13_universal2.whl", hash = "sha256:7fd1c785219a87ca747c21fa92f561b0d2926a7edc06d0a0fe37f3736e00bd7a"}, - {file = "pyinstaller-6.16.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:b756ddb9007b8141c5476b553351f9d97559b8af5d07f9460869bfae02be26b0"}, - {file = "pyinstaller-6.16.0-py3-none-manylinux2014_i686.whl", hash = "sha256:0a48f55b85ff60f83169e10050f2759019cf1d06773ad1c4da3a411cd8751058"}, - {file = "pyinstaller-6.16.0-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:73ba72e04fcece92e32518bbb1e1fb5ac2892677943dfdff38e01a06e8742851"}, - {file = "pyinstaller-6.16.0-py3-none-manylinux2014_s390x.whl", hash = "sha256:b1752488248f7899281b17ca3238eefb5410521291371a686a4f5830f29f52b3"}, - {file = "pyinstaller-6.16.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:ba618a61627ee674d6d68e5de084ba17c707b59a4f2a856084b3999bdffbd3f0"}, - {file = "pyinstaller-6.16.0-py3-none-musllinux_1_1_aarch64.whl", hash = "sha256:c8b7ef536711617e12fef4673806198872033fa06fa92326ad7fd1d84a9fa454"}, - {file = "pyinstaller-6.16.0-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:d1ebf84d02c51fed19b82a8abb4df536923abd55bb684d694e1356e4ae2a0ce5"}, - {file = "pyinstaller-6.16.0-py3-none-win32.whl", hash = "sha256:6d5f8617f3650ff9ef893e2ab4ddbf3c0d23d0c602ef74b5df8fbef4607840c8"}, - {file = "pyinstaller-6.16.0-py3-none-win_amd64.whl", hash = "sha256:bc10eb1a787f99fea613509f55b902fbd2d8b73ff5f51ff245ea29a481d97d41"}, - {file = "pyinstaller-6.16.0-py3-none-win_arm64.whl", hash = "sha256:d0af8a401de792c233c32c44b16d065ca9ab8262ee0c906835c12bdebc992a64"}, - {file = "pyinstaller-6.16.0.tar.gz", hash = "sha256:53559fe1e041a234f2b4dcc3288ea8bdd57f7cad8a6644e422c27bb407f3edef"}, + {file = "pyinstaller-6.19.0-py3-none-macosx_10_13_universal2.whl", hash = "sha256:4190e76b74f0c4b5c5f11ac360928cd2e36ec8e3194d437bf6b8648c7bc0c134"}, + {file = "pyinstaller-6.19.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:8bd68abd812d8a6ba33b9f1810e91fee0f325969733721b78151f0065319ca11"}, + {file = "pyinstaller-6.19.0-py3-none-manylinux2014_i686.whl", hash = "sha256:1ec54ef967996ca61dacba676227e2b23219878ccce5ee9d6f3aada7b8ed8abf"}, + {file = "pyinstaller-6.19.0-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:4ab2bb52e58448e14ddf9450601bdedd66800465043501c1d8f1cab87b60b122"}, + {file = "pyinstaller-6.19.0-py3-none-manylinux2014_s390x.whl", hash = "sha256:da6d5c6391ccefe73554b9fa29b86001c8e378e0f20c2a4004f836ba537eff63"}, + {file = "pyinstaller-6.19.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:a0fc5f6b3c55aa54353f0c74ffa59b1115433c1850c6f655d62b461a2ed6cbbe"}, + {file = "pyinstaller-6.19.0-py3-none-musllinux_1_1_aarch64.whl", hash = "sha256:e649ba6bd1b0b89b210ad92adb5fbdc8a42dd2c5ca4f72ef3a0bfec83a424b83"}, + {file = "pyinstaller-6.19.0-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:481a909c8e60c8692fc60fcb1344d984b44b943f8bc9682f2fcdae305ad297e6"}, + {file = "pyinstaller-6.19.0-py3-none-win32.whl", hash = "sha256:3c5c251054fe4cfaa04c34a363dcfbf811545438cb7198304cd444756bc2edd2"}, + {file = "pyinstaller-6.19.0-py3-none-win_amd64.whl", hash = "sha256:b5bb6536c6560330d364d91522250f254b107cf69129d9cbcd0e6727c570be33"}, + {file = "pyinstaller-6.19.0-py3-none-win_arm64.whl", hash = "sha256:c2d5a539b0bfe6159d5522c8c70e1c0e487f22c2badae0f97d45246223b798ea"}, + {file = "pyinstaller-6.19.0.tar.gz", hash = "sha256:ec73aeb8bd9b7f2f1240d328a4542e90b3c6e6fbc106014778431c616592a865"}, ] [package.dependencies] altgraph = "*" macholib = {version = ">=1.8", markers = "sys_platform == \"darwin\""} packaging = ">=22.0" -pefile = {version = ">=2022.5.30,<2024.8.26 || >2024.8.26", markers = "sys_platform == \"win32\""} -pyinstaller-hooks-contrib = ">=2025.8" +pefile = {version = ">=2022.5.30", markers = "sys_platform == \"win32\""} +pyinstaller-hooks-contrib = ">=2026.0" pywin32-ctypes = {version = ">=0.2.1", markers = "sys_platform == \"win32\""} setuptools = ">=42.0.0" @@ -698,14 +836,14 @@ hook-testing = ["execnet (>=1.5.0)", "psutil", "pytest (>=2.7.3)"] [[package]] name = "pyinstaller-hooks-contrib" -version = "2025.9" +version = "2026.3" description = "Community maintained hooks for PyInstaller" optional = false python-versions = ">=3.8" groups = ["build"] files = [ - {file = "pyinstaller_hooks_contrib-2025.9-py3-none-any.whl", hash = "sha256:ccbfaa49399ef6b18486a165810155e5a8d4c59b41f20dc5da81af7482aaf038"}, - {file = "pyinstaller_hooks_contrib-2025.9.tar.gz", hash = "sha256:56e972bdaad4e9af767ed47d132362d162112260cbe488c9da7fee01f228a5a6"}, + {file = "pyinstaller_hooks_contrib-2026.3-py3-none-any.whl", hash = "sha256:5ecd1068ad262afecadf07556279d2be52ca93a88b049fae17f1a2eb2969254a"}, + {file = "pyinstaller_hooks_contrib-2026.3.tar.gz", hash = "sha256:800d3a198a49a6cd0de2d7fb795005fdca7a0222ed9cb47c0691abd1c27b9310"}, ] [package.dependencies] @@ -714,20 +852,20 @@ setuptools = ">=42.0.0" [[package]] name = "pytest" -version = "8.4.2" +version = "9.0.2" description = "pytest: simple powerful testing with Python" optional = false -python-versions = ">=3.9" +python-versions = ">=3.10" groups = ["test"] files = [ - {file = "pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79"}, - {file = "pytest-8.4.2.tar.gz", hash = "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01"}, + {file = "pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b"}, + {file = "pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11"}, ] [package.dependencies] colorama = {version = ">=0.4", markers = "sys_platform == \"win32\""} -iniconfig = ">=1" -packaging = ">=20" +iniconfig = ">=1.0.1" +packaging = ">=22" pluggy = ">=1.5,<2" pygments = ">=2.7.2" @@ -773,15 +911,75 @@ pytest = ">=6.2.5" dev = ["pre-commit", "pytest-asyncio", "tox"] [[package]] -name = "pytokens" -version = "0.2.0" -description = "A Fast, spec compliant Python 3.13+ tokenizer that runs on older Pythons." +name = "python-discovery" +version = "1.2.0" +description = "Python interpreter discovery" optional = false python-versions = ">=3.8" groups = ["dev"] files = [ - {file = "pytokens-0.2.0-py3-none-any.whl", hash = "sha256:74d4b318c67f4295c13782ddd9abcb7e297ec5630ad060eb90abf7ebbefe59f8"}, - {file = "pytokens-0.2.0.tar.gz", hash = "sha256:532d6421364e5869ea57a9523bf385f02586d4662acbcc0342afd69511b4dd43"}, + {file = "python_discovery-1.2.0-py3-none-any.whl", hash = "sha256:1e108f1bbe2ed0ef089823d28805d5ad32be8e734b86a5f212bf89b71c266e4a"}, + {file = "python_discovery-1.2.0.tar.gz", hash = "sha256:7d33e350704818b09e3da2bd419d37e21e7c30db6e0977bb438916e06b41b5b1"}, +] + +[package.dependencies] +filelock = ">=3.15.4" +platformdirs = ">=4.3.6,<5" + +[package.extras] +docs = ["furo (>=2025.12.19)", "sphinx (>=9.1)", "sphinx-autodoc-typehints (>=3.6.3)", "sphinxcontrib-mermaid (>=2)"] +testing = ["covdefaults (>=2.3)", "coverage (>=7.5.4)", "pytest (>=8.3.5)", "pytest-mock (>=3.14)", "setuptools (>=75.1)"] + +[[package]] +name = "pytokens" +version = "0.4.1" +description = "A Fast, spec compliant Python 3.14+ tokenizer that runs on older Pythons." +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "pytokens-0.4.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:2a44ed93ea23415c54f3face3b65ef2b844d96aeb3455b8a69b3df6beab6acc5"}, + {file = "pytokens-0.4.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:add8bf86b71a5d9fb5b89f023a80b791e04fba57960aa790cc6125f7f1d39dfe"}, + {file = "pytokens-0.4.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:670d286910b531c7b7e3c0b453fd8156f250adb140146d234a82219459b9640c"}, + {file = "pytokens-0.4.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:4e691d7f5186bd2842c14813f79f8884bb03f5995f0575272009982c5ac6c0f7"}, + {file = "pytokens-0.4.1-cp310-cp310-win_amd64.whl", hash = "sha256:27b83ad28825978742beef057bfe406ad6ed524b2d28c252c5de7b4a6dd48fa2"}, + {file = "pytokens-0.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d70e77c55ae8380c91c0c18dea05951482e263982911fc7410b1ffd1dadd3440"}, + {file = "pytokens-0.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4a58d057208cb9075c144950d789511220b07636dd2e4708d5645d24de666bdc"}, + {file = "pytokens-0.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b49750419d300e2b5a3813cf229d4e5a4c728dae470bcc89867a9ad6f25a722d"}, + {file = "pytokens-0.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:d9907d61f15bf7261d7e775bd5d7ee4d2930e04424bab1972591918497623a16"}, + {file = "pytokens-0.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:ee44d0f85b803321710f9239f335aafe16553b39106384cef8e6de40cb4ef2f6"}, + {file = "pytokens-0.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:140709331e846b728475786df8aeb27d24f48cbcf7bcd449f8de75cae7a45083"}, + {file = "pytokens-0.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6d6c4268598f762bc8e91f5dbf2ab2f61f7b95bdc07953b602db879b3c8c18e1"}, + {file = "pytokens-0.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:24afde1f53d95348b5a0eb19488661147285ca4dd7ed752bbc3e1c6242a304d1"}, + {file = "pytokens-0.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5ad948d085ed6c16413eb5fec6b3e02fa00dc29a2534f088d3302c47eb59adf9"}, + {file = "pytokens-0.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:3f901fe783e06e48e8cbdc82d631fca8f118333798193e026a50ce1b3757ea68"}, + {file = "pytokens-0.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8bdb9d0ce90cbf99c525e75a2fa415144fd570a1ba987380190e8b786bc6ef9b"}, + {file = "pytokens-0.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5502408cab1cb18e128570f8d598981c68a50d0cbd7c61312a90507cd3a1276f"}, + {file = "pytokens-0.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:29d1d8fb1030af4d231789959f21821ab6325e463f0503a61d204343c9b355d1"}, + {file = "pytokens-0.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:970b08dd6b86058b6dc07efe9e98414f5102974716232d10f32ff39701e841c4"}, + {file = "pytokens-0.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:9bd7d7f544d362576be74f9d5901a22f317efc20046efe2034dced238cbbfe78"}, + {file = "pytokens-0.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4a14d5f5fc78ce85e426aa159489e2d5961acf0e47575e08f35584009178e321"}, + {file = "pytokens-0.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97f50fd18543be72da51dd505e2ed20d2228c74e0464e4262e4899797803d7fa"}, + {file = "pytokens-0.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dc74c035f9bfca0255c1af77ddd2d6ae8419012805453e4b0e7513e17904545d"}, + {file = "pytokens-0.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f66a6bbe741bd431f6d741e617e0f39ec7257ca1f89089593479347cc4d13324"}, + {file = "pytokens-0.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:b35d7e5ad269804f6697727702da3c517bb8a5228afa450ab0fa787732055fc9"}, + {file = "pytokens-0.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:8fcb9ba3709ff77e77f1c7022ff11d13553f3c30299a9fe246a166903e9091eb"}, + {file = "pytokens-0.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:79fc6b8699564e1f9b521582c35435f1bd32dd06822322ec44afdeba666d8cb3"}, + {file = "pytokens-0.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d31b97b3de0f61571a124a00ffe9a81fb9939146c122c11060725bd5aea79975"}, + {file = "pytokens-0.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:967cf6e3fd4adf7de8fc73cd3043754ae79c36475c1c11d514fc72cf5490094a"}, + {file = "pytokens-0.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:584c80c24b078eec1e227079d56dc22ff755e0ba8654d8383b2c549107528918"}, + {file = "pytokens-0.4.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:da5baeaf7116dced9c6bb76dc31ba04a2dc3695f3d9f74741d7910122b456edc"}, + {file = "pytokens-0.4.1-cp38-cp38-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:11edda0942da80ff58c4408407616a310adecae1ddd22eef8c692fe266fa5009"}, + {file = "pytokens-0.4.1-cp38-cp38-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0fc71786e629cef478cbf29d7ea1923299181d0699dbe7c3c0f4a583811d9fc1"}, + {file = "pytokens-0.4.1-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:dcafc12c30dbaf1e2af0490978352e0c4041a7cde31f4f81435c2a5e8b9cabb6"}, + {file = "pytokens-0.4.1-cp38-cp38-win_amd64.whl", hash = "sha256:42f144f3aafa5d92bad964d471a581651e28b24434d184871bd02e3a0d956037"}, + {file = "pytokens-0.4.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:34bcc734bd2f2d5fe3b34e7b3c0116bfb2397f2d9666139988e7a3eb5f7400e3"}, + {file = "pytokens-0.4.1-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:941d4343bf27b605e9213b26bfa1c4bf197c9c599a9627eb7305b0defcfe40c1"}, + {file = "pytokens-0.4.1-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3ad72b851e781478366288743198101e5eb34a414f1d5627cdd585ca3b25f1db"}, + {file = "pytokens-0.4.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:682fa37ff4d8e95f7df6fe6fe6a431e8ed8e788023c6bcc0f0880a12eab80ad1"}, + {file = "pytokens-0.4.1-cp39-cp39-win_amd64.whl", hash = "sha256:30f51edd9bb7f85c748979384165601d028b84f7bd13fe14d3e065304093916a"}, + {file = "pytokens-0.4.1-py3-none-any.whl", hash = "sha256:26cef14744a8385f35d0e095dc8b3a7583f6c953c2e3d269c7f82484bf5ad2de"}, + {file = "pytokens-0.4.1.tar.gz", hash = "sha256:292052fe80923aae2260c073f822ceba21f3872ced9a68bb7953b348e561179a"}, ] [package.extras] @@ -907,24 +1105,24 @@ use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"] [[package]] name = "setuptools" -version = "80.9.0" -description = "Easily download, build, install, upgrade, and uninstall Python packages" +version = "82.0.1" +description = "Most extensible Python build backend with support for C/C++ extension modules" optional = false python-versions = ">=3.9" groups = ["build"] files = [ - {file = "setuptools-80.9.0-py3-none-any.whl", hash = "sha256:062d34222ad13e0cc312a4c02d73f059e86a4acbfbdea8f8f76b28c99f306922"}, - {file = "setuptools-80.9.0.tar.gz", hash = "sha256:f36b47402ecde768dbfafc46e8e4207b4360c654f1f3bb84475f0a28628fb19c"}, + {file = "setuptools-82.0.1-py3-none-any.whl", hash = "sha256:a59e362652f08dcd477c78bb6e7bd9d80a7995bc73ce773050228a348ce2e5bb"}, + {file = "setuptools-82.0.1.tar.gz", hash = "sha256:7d872682c5d01cfde07da7bccc7b65469d3dca203318515ada1de5eda35efbf9"}, ] [package.extras] -check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1) ; sys_platform != \"cygwin\"", "ruff (>=0.8.0) ; sys_platform != \"cygwin\""] -core = ["importlib_metadata (>=6) ; python_version < \"3.10\"", "jaraco.functools (>=4)", "jaraco.text (>=3.7)", "more_itertools", "more_itertools (>=8.8)", "packaging (>=24.2)", "platformdirs (>=4.2.2)", "tomli (>=2.0.1) ; python_version < \"3.11\"", "wheel (>=0.43.0)"] +check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1) ; sys_platform != \"cygwin\"", "ruff (>=0.13.0) ; sys_platform != \"cygwin\""] +core = ["importlib_metadata (>=6) ; python_version < \"3.10\"", "jaraco.functools (>=4)", "jaraco.text (>=3.7)", "more_itertools", "more_itertools (>=8.8)", "packaging (>=24.2)", "tomli (>=2.0.1) ; python_version < \"3.11\"", "wheel (>=0.43.0)"] cover = ["pytest-cov"] doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "pygments-github-lexers (==0.0.5)", "pyproject-hooks (!=1.1)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-favicon", "sphinx-inline-tabs", "sphinx-lint", "sphinx-notfound-page (>=1,<2)", "sphinx-reredirects", "sphinxcontrib-towncrier", "towncrier (<24.7)"] enabler = ["pytest-enabler (>=2.2)"] test = ["build[virtualenv] (>=1.0.3)", "filelock (>=3.4.0)", "ini2toml[lite] (>=0.14)", "jaraco.develop (>=7.21) ; python_version >= \"3.9\" and sys_platform != \"cygwin\"", "jaraco.envs (>=2.2)", "jaraco.path (>=3.7.2)", "jaraco.test (>=5.5)", "packaging (>=24.2)", "pip (>=19.1)", "pyproject-hooks (!=1.1)", "pytest (>=6,!=8.1.*)", "pytest-home (>=0.5)", "pytest-perf ; sys_platform != \"cygwin\"", "pytest-subprocess", "pytest-timeout", "pytest-xdist (>=3)", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel (>=0.44.0)"] -type = ["importlib_metadata (>=7.0.2) ; python_version < \"3.10\"", "jaraco.develop (>=7.21) ; sys_platform != \"cygwin\"", "mypy (==1.14.*)", "pytest-mypy"] +type = ["importlib_metadata (>=7.0.2) ; python_version < \"3.10\"", "jaraco.develop (>=7.21) ; sys_platform != \"cygwin\"", "mypy (==1.18.*)", "pytest-mypy"] [[package]] name = "typing-extensions" @@ -940,44 +1138,41 @@ files = [ [[package]] name = "urllib3" -version = "2.5.0" +version = "2.6.3" description = "HTTP library with thread-safe connection pooling, file post, and more." optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "urllib3-2.5.0-py3-none-any.whl", hash = "sha256:e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc"}, - {file = "urllib3-2.5.0.tar.gz", hash = "sha256:3fc47733c7e419d4bc3f6b3dc2b4f890bb743906a30d56ba4a5bfa4bbff92760"}, + {file = "urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4"}, + {file = "urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed"}, ] [package.extras] -brotli = ["brotli (>=1.0.9) ; platform_python_implementation == \"CPython\"", "brotlicffi (>=0.8.0) ; platform_python_implementation != \"CPython\""] +brotli = ["brotli (>=1.2.0) ; platform_python_implementation == \"CPython\"", "brotlicffi (>=1.2.0.0) ; platform_python_implementation != \"CPython\""] h2 = ["h2 (>=4,<5)"] socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"] -zstd = ["zstandard (>=0.18.0)"] +zstd = ["backports-zstd (>=1.0.0) ; python_version < \"3.14\""] [[package]] name = "virtualenv" -version = "20.35.3" +version = "21.2.0" description = "Virtual Python Environment builder" optional = false python-versions = ">=3.8" groups = ["dev"] files = [ - {file = "virtualenv-20.35.3-py3-none-any.whl", hash = "sha256:63d106565078d8c8d0b206d48080f938a8b25361e19432d2c9db40d2899c810a"}, - {file = "virtualenv-20.35.3.tar.gz", hash = "sha256:4f1a845d131133bdff10590489610c98c168ff99dc75d6c96853801f7f67af44"}, + {file = "virtualenv-21.2.0-py3-none-any.whl", hash = "sha256:1bd755b504931164a5a496d217c014d098426cddc79363ad66ac78125f9d908f"}, + {file = "virtualenv-21.2.0.tar.gz", hash = "sha256:1720dc3a62ef5b443092e3f499228599045d7fea4c79199770499df8becf9098"}, ] [package.dependencies] distlib = ">=0.3.7,<1" -filelock = ">=3.12.2,<4" +filelock = {version = ">=3.24.2,<4", markers = "python_version >= \"3.10\""} platformdirs = ">=3.9.1,<5" - -[package.extras] -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)"] -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\""] +python-discovery = ">=1" [metadata] lock-version = "2.1" python-versions = ">=3.13, <3.15" -content-hash = "47a6969e152d3dadf68515dbb1e508592a42b2cbe34d71e87908a28baf034d47" +content-hash = "c1309aed05c4d4524eb625dd9b846807373c0ff0ab42b216c640847845da2bdd" diff --git a/poetry.toml b/poetry.toml new file mode 100644 index 0000000..ab1033b --- /dev/null +++ b/poetry.toml @@ -0,0 +1,2 @@ +[virtualenvs] +in-project = true diff --git a/pyproject.toml b/pyproject.toml index 8526e8d..76c5bd3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ description = "An Android file transfer util for Windows and Linux. MacOS suppor authors = [ { name = "Jason Ross", email = "51939451+JMR-dev@users.noreply.github.com" }, ] -license = { text = "MIT" } +license = "MIT" readme = "README.md" requires-python = ">=3.13, <3.15" dependencies = ["requests>=2.32.4,<3.0.0", "platformdirs>4.0.0,<5.0.0"] @@ -20,19 +20,19 @@ urllib3 = "^2.0" requires = ["poetry-core>=2.0.0,<3.0.0"] build-backend = "poetry.core.masonry.api" -[tool.poetry.scripts] +[project.scripts] # Main application entry point android-file-handler = "src.main:main" [tool.poetry.group.dev.dependencies] -black = "^25.9.0" +black = "^26.3.1" flake8 = "^7.3.0" mypy = "^1.18.2" pre-commit = "^4.3.0" [tool.poetry.group.test.dependencies] -pytest = "^8.4.2" +pytest = "^9.0.2" pytest-mock = "^3.15.1" pytest-cov = "^7.0.0" diff --git a/src/core/__init__.py b/src/core/__init__.py index c8292cf..344d19c 100644 --- a/src/core/__init__.py +++ b/src/core/__init__.py @@ -1,4 +1,4 @@ """ Core Package Core functionality and business logic components. -""" \ No newline at end of file +""" diff --git a/src/core/adb_command.py b/src/core/adb_command.py index bedd246..8672478 100644 --- a/src/core/adb_command.py +++ b/src/core/adb_command.py @@ -13,11 +13,13 @@ from .platform_tools import get_adb_binary_path class ADBCommandRunner: """Handles ADB command execution and device communication.""" - + def __init__(self): self.current_process: Optional[subprocess.Popen] = None - - def run_adb_command(self, args: list, capture_output: bool = True) -> Union[Tuple[str, str, int], subprocess.Popen, Tuple[None, str, int]]: + + def run_adb_command( + self, args: list, capture_output: bool = True + ) -> Union[Tuple[str, str, int], subprocess.Popen, Tuple[None, str, int]]: """Run an ADB command and return output.""" cmd = [get_adb_binary_path()] + args try: @@ -34,7 +36,7 @@ class ADBCommandRunner: return None, str(e), -1 else: return None - + def check_device(self) -> Optional[str]: """Check if an Android device is connected.""" result = self.run_adb_command(["devices"], capture_output=True) @@ -46,7 +48,7 @@ class ADBCommandRunner: if line.endswith("\tdevice"): return line.split()[0] return None - + def parse_progress(self, text_line: str) -> Optional[int]: """Parse progress percentage from ADB output. @@ -121,7 +123,7 @@ class ADBCommandRunner: return 100 return None - + def cancel_current_operation(self) -> bool: """Cancel the current ADB operation. @@ -149,9 +151,9 @@ class ADBCommandRunner: # Force kill if it doesn't terminate gracefully self.current_process.kill() self.current_process.wait() - + self.current_process = None return True except Exception: return False - return False \ No newline at end of file + return False diff --git a/src/core/adb_manager.py b/src/core/adb_manager.py index ed75e1e..e630c9c 100644 --- a/src/core/adb_manager.py +++ b/src/core/adb_manager.py @@ -12,10 +12,10 @@ from typing import Optional, Tuple, Callable # Import our modular components from .platform_tools import ( - ensure_platform_tools_in_user_dir, - get_adb_binary_path, + ensure_platform_tools_in_user_dir, + get_adb_binary_path, is_adb_available, - download_and_extract_adb + download_and_extract_adb, ) from .platform_utils import get_platform_tools_directory, get_platform_type from .file_transfer import ADBFileTransfer @@ -40,7 +40,7 @@ logger = logging.getLogger(__name__) class ADBManager: """Main interface for ADB operations, device management, and file transfers.""" - + def __init__(self): """Initialize the ADB manager.""" self.command_runner = ADBCommandRunner() @@ -53,19 +53,19 @@ class ADBManager: self.deduplicator = FileDeduplicator() except Exception: self.deduplicator = None - + # Progress and status callbacks for compatibility self.progress_callback = None self.status_callback = None - + # Transfer progress tracking self.transfer_progress = { - 'current_file': 0, - 'total_files': 0, - 'transferred_bytes': 0, - 'total_bytes': 0 + "current_file": 0, + "total_files": 0, + "transferred_bytes": 0, + "total_bytes": 0, } - + # Lazily initialize ADB binary path to reduce side effects during init self._adb_path: Optional[str] = None @@ -81,68 +81,70 @@ class ADBManager: @adb_path.setter def adb_path(self, value: Optional[str]) -> None: self._adb_path = value - + def is_available(self) -> bool: """Check if ADB is available.""" return is_adb_available() - + def ensure_adb_installed(self) -> bool: """Ensure ADB is installed and available.""" try: if self.is_available(): return True - + # Try to download and install ADB self.adb_path = ensure_platform_tools_in_user_dir() if self.adb_path and os.path.exists(self.adb_path): return True - + except Exception as e: print(f"Error ensuring ADB installation: {e}") - + return False - + def get_devices(self) -> list[str]: """Get list of connected Android devices.""" try: args = ["devices"] stdout, stderr, returncode = self.command_runner.run_adb_command(args) - + if returncode != 0: return [] - + devices = [] - for line in stdout.split('\n')[1:]: # Skip first line "List of devices attached" + for line in stdout.split("\n")[ + 1: + ]: # Skip first line "List of devices attached" line = line.strip() - if line and '\t' in line: - device_id = line.split('\t')[0] + if line and "\t" in line: + device_id = line.split("\t")[0] if device_id: devices.append(device_id) - + return devices except Exception: return [] - + def is_device_connected(self, device_id: Optional[str] = None) -> bool: """Check if a specific device is connected.""" devices = self.get_devices() if not devices: return False - + if device_id: return device_id in devices else: # Check if any device is connected return len(devices) > 0 - + def select_device(self, device_id: str) -> None: """Select a specific device for operations.""" self.selected_device = device_id - + def get_selected_device(self) -> Optional[str]: """Get the currently selected device.""" return self.selected_device - + def list_files(self, path: str, device_id: Optional[str] = None) -> list[dict]: """List files in the specified path on the device.""" # Sanitize inputs to prevent command injection @@ -174,58 +176,64 @@ class ADBManager: return [] args = device_args + ["shell", "ls", "-la", sanitized_path] - + try: stdout, stderr, returncode = self.command_runner.run_adb_command(args) if returncode != 0: return [] - + files = [] - for line in stdout.split('\n'): + for line in stdout.split("\n"): line = line.strip() - if not line or line.startswith('total '): + if not line or line.startswith("total "): continue - + # Parse ls -la output parts = line.split() if len(parts) < 9: continue - + permissions = parts[0] size_str = parts[4] - + # Join filename parts (handles spaces in filenames) - name = ' '.join(parts[8:]) - + name = " ".join(parts[8:]) + # Skip current and parent directory entries - if name in ['.', '..']: + if name in [".", ".."]: continue - - file_type = 'folder' if permissions.startswith('d') else 'file' - + + file_type = "folder" if permissions.startswith("d") else "file" + try: - size = int(size_str) if file_type == 'file' else 0 + size = int(size_str) if file_type == "file" else 0 except ValueError: size = 0 - + # Combine date and time parts - modified = ' '.join(parts[5:8]) if len(parts) >= 8 else '' - - files.append({ - 'name': name, - 'type': file_type, - 'size': size, - 'permissions': permissions, - 'modified': modified - }) - + modified = " ".join(parts[5:8]) if len(parts) >= 8 else "" + + files.append( + { + "name": name, + "type": file_type, + "size": size, + "permissions": permissions, + "modified": modified, + } + ) + return files except Exception: return [] - - def pull_file(self, remote_path: str, local_path: str, - progress_callback: Optional[Callable[[int, int], None]] = None, - device_id: Optional[str] = None) -> Tuple[bool, str]: + + def pull_file( + self, + remote_path: str, + local_path: str, + progress_callback: Optional[Callable[[int, int], None]] = None, + device_id: Optional[str] = None, + ) -> Tuple[bool, str]: """Pull a file from device to local system.""" try: # Create local directory if it doesn't exist @@ -236,7 +244,7 @@ class ADBManager: except Exception: # Normalize error message for tests return False, "Failed to pull file" - + # Use our file transfer module success = self.file_transfer.pull_file(remote_path, local_path) if success: @@ -245,10 +253,14 @@ class ADBManager: return False, "Failed to pull file" except Exception as e: return False, "Failed to pull file" - - def pull_folder(self, remote_path: str, local_path: str, - progress_callback: Optional[Callable[[int, int], None]] = None, - device_id: Optional[str] = None) -> Tuple[bool, str]: + + def pull_folder( + self, + remote_path: str, + local_path: str, + progress_callback: Optional[Callable[[int, int], None]] = None, + device_id: Optional[str] = None, + ) -> Tuple[bool, str]: """Pull a folder from device to local system.""" try: # Create local directory @@ -256,7 +268,7 @@ class ADBManager: os.makedirs(local_path, exist_ok=True) except Exception: return False, "Failed to pull folder" - + # Use our file transfer module success = self.file_transfer.pull_folder(remote_path, local_path) if success: @@ -265,15 +277,19 @@ class ADBManager: return False, "Failed to pull folder" except Exception as e: return False, "Failed to pull folder" - - def push_file(self, local_path: str, remote_path: str, - progress_callback: Optional[Callable[[int, int], None]] = None, - device_id: Optional[str] = None) -> Tuple[bool, str]: + + def push_file( + self, + local_path: str, + remote_path: str, + progress_callback: Optional[Callable[[int, int], None]] = None, + device_id: Optional[str] = None, + ) -> Tuple[bool, str]: """Push a file from local system to device.""" try: if not os.path.exists(local_path): return False, f"Local file not found: {local_path}" - + # Use our file transfer module success = self.file_transfer.push_file(local_path, remote_path) if success: @@ -282,15 +298,19 @@ class ADBManager: return False, "Failed to push file" except Exception as e: return False, f"Error pushing file: {str(e)}" - - def push_folder(self, local_path: str, remote_path: str, - progress_callback: Optional[Callable[[int, int], None]] = None, - device_id: Optional[str] = None) -> Tuple[bool, str]: + + def push_folder( + self, + local_path: str, + remote_path: str, + progress_callback: Optional[Callable[[int, int], None]] = None, + device_id: Optional[str] = None, + ) -> Tuple[bool, str]: """Push a folder from local system to device.""" try: if not os.path.exists(local_path): return False, f"Local folder not found: {local_path}" - + # Use our file transfer module success = self.file_transfer.push_folder(local_path, remote_path) if success: @@ -299,8 +319,10 @@ class ADBManager: return False, "Failed to push folder" except Exception as e: return False, f"Error pushing folder: {str(e)}" - - def delete_file(self, remote_path: str, device_id: Optional[str] = None) -> Tuple[bool, str]: + + def delete_file( + self, remote_path: str, device_id: Optional[str] = None + ) -> Tuple[bool, str]: """Delete a file on the device.""" # Sanitize inputs to prevent command injection try: @@ -328,7 +350,9 @@ class ADBManager: except Exception as e: return False, f"Error deleting file: {str(e)}" - def create_folder(self, remote_path: str, device_id: Optional[str] = None) -> Tuple[bool, str]: + def create_folder( + self, remote_path: str, device_id: Optional[str] = None + ) -> Tuple[bool, str]: """Create a folder on the device.""" # Sanitize inputs to prevent command injection try: @@ -356,7 +380,9 @@ class ADBManager: except Exception as e: return False, f"Error creating folder: {str(e)}" - def delete_folder(self, remote_path: str, device_id: Optional[str] = None) -> Tuple[bool, str]: + def delete_folder( + self, remote_path: str, device_id: Optional[str] = None + ) -> Tuple[bool, str]: """Delete a folder on the device.""" # Sanitize inputs to prevent command injection try: @@ -384,7 +410,9 @@ class ADBManager: except Exception as e: return False, f"Error deleting folder: {str(e)}" - def move_item(self, old_path: str, new_path: str, device_id: Optional[str] = None) -> Tuple[bool, str]: + def move_item( + self, old_path: str, new_path: str, device_id: Optional[str] = None + ) -> Tuple[bool, str]: """Move/rename a file or folder on the device.""" # Sanitize inputs to prevent command injection try: @@ -413,7 +441,9 @@ class ADBManager: except Exception as e: return False, f"Error moving item: {str(e)}" - def get_file_info(self, remote_path: str, device_id: Optional[str] = None) -> Optional[dict]: + def get_file_info( + self, remote_path: str, device_id: Optional[str] = None + ) -> Optional[dict]: """Get information about a file or folder on the device.""" # Sanitize inputs to prevent command injection try: @@ -443,50 +473,54 @@ class ADBManager: return None args = device_args + ["shell", "ls", "-la", sanitized_path] - + try: stdout, stderr, returncode = self.command_runner.run_adb_command(args) if returncode != 0: return None - - lines = stdout.strip().split('\n') + + lines = stdout.strip().split("\n") if not lines: return None - + # Parse the first non-empty line (should be the file info) for line in lines: line = line.strip() - if line and not line.startswith('total '): + if line and not line.startswith("total "): parts = line.split() if len(parts) >= 9: permissions = parts[0] size_str = parts[4] - name = ' '.join(parts[8:]) - - file_type = 'folder' if permissions.startswith('d') else 'file' - + name = " ".join(parts[8:]) + + file_type = "folder" if permissions.startswith("d") else "file" + try: - size = int(size_str) if file_type == 'file' else 0 + size = int(size_str) if file_type == "file" else 0 except ValueError: size = 0 - - modified = ' '.join(parts[5:8]) if len(parts) >= 8 else '' - + + modified = " ".join(parts[5:8]) if len(parts) >= 8 else "" + return { - 'name': name, - 'type': file_type, - 'size': size, - 'permissions': permissions, - 'modified': modified + "name": name, + "type": file_type, + "size": size, + "permissions": permissions, + "modified": modified, } - + return None except Exception: return None - - def pull_folder_with_dedup(self, remote_path: str, local_path: str, - progress_callback: Optional[Callable[[int, int], None]] = None, - device_id: Optional[str] = None) -> Tuple[bool, Optional[dict]]: + + def pull_folder_with_dedup( + self, + remote_path: str, + local_path: str, + progress_callback: Optional[Callable[[int, int], None]] = None, + device_id: Optional[str] = None, + ) -> Tuple[bool, Optional[dict]]: """Pull a folder from device with deduplication support. Args: @@ -500,13 +534,19 @@ class ADBManager: """ # For now, just call the regular pull_folder # TODO: Implement actual deduplication logic - success, message = self.pull_folder(remote_path, local_path, progress_callback, device_id) - stats = {'message': message} if success else None + success, message = self.pull_folder( + remote_path, local_path, progress_callback, device_id + ) + stats = {"message": message} if success else None return success, stats - def push_folder_with_dedup(self, local_path: str, remote_path: str, - progress_callback: Optional[Callable[[int, int], None]] = None, - device_id: Optional[str] = None) -> Tuple[bool, Optional[dict]]: + def push_folder_with_dedup( + self, + local_path: str, + remote_path: str, + progress_callback: Optional[Callable[[int, int], None]] = None, + device_id: Optional[str] = None, + ) -> Tuple[bool, Optional[dict]]: """Push a folder to device with deduplication support. Args: @@ -520,11 +560,17 @@ class ADBManager: """ # For now, just call the regular push_folder # TODO: Implement actual deduplication logic - success, message = self.push_folder(local_path, remote_path, progress_callback, device_id) - stats = {'message': message} if success else None + success, message = self.push_folder( + local_path, remote_path, progress_callback, device_id + ) + stats = {"message": message} if success else None return success, stats - def deduplicate_files(self, folder_path: str, progress_callback: Optional[Callable[[str], None]] = None) -> Tuple[int, list]: + def deduplicate_files( + self, + folder_path: str, + progress_callback: Optional[Callable[[str], None]] = None, + ) -> Tuple[int, list]: """Find and optionally remove duplicate files in a folder.""" deduplicator = FileDeduplicator() @@ -564,15 +610,15 @@ class ADBManager: self.status_callback(message) def _update_transfer_progress(self, current_file: int, total_files: int) -> None: - self.transfer_progress['current_file'] = current_file - self.transfer_progress['total_files'] = total_files + self.transfer_progress["current_file"] = current_file + self.transfer_progress["total_files"] = total_files if self.status_callback: self.status_callback(f"TRANSFER_PROGRESS:{current_file}:{total_files}") def _reset_transfer_progress(self) -> None: - self.transfer_progress['current_file'] = 0 - self.transfer_progress['total_files'] = 0 - self.transfer_progress['files_to_transfer'] = 0 + self.transfer_progress["current_file"] = 0 + self.transfer_progress["total_files"] = 0 + self.transfer_progress["files_to_transfer"] = 0 def check_local_disk_space(self) -> bool: tools_dir = get_platform_tools_directory() @@ -589,7 +635,9 @@ class ADBManager: p = subprocess.run(cmd, capture_output=True, text=True, timeout=15) return p.stdout, p.stderr, p.returncode else: - p = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True) + p = subprocess.Popen( + cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True + ) self.current_process = p return p except Exception as e: @@ -598,7 +646,7 @@ class ADBManager: return None def check_device(self) -> Optional[str]: - out, err, rc = self.run_adb_command(['devices'], capture_output=True) + out, err, rc = self.run_adb_command(["devices"], capture_output=True) if rc != 0 or not out: return None for line in str(out).splitlines(): diff --git a/src/core/file_transfer.py b/src/core/file_transfer.py index befd298..59faf83 100644 --- a/src/core/file_transfer.py +++ b/src/core/file_transfer.py @@ -22,7 +22,7 @@ except ImportError: class ADBFileTransfer(ProgressTracker): """Handles ADB file transfer operations with progress tracking.""" - + def __init__(self): ProgressTracker.__init__(self) # Composition: allow tests to patch ADBCommandRunner constructor @@ -43,7 +43,7 @@ class ADBFileTransfer(ProgressTracker): return bool(result) if result is not None else False except Exception: return False - + def _validate_windows_root_path(self, path: str, operation: str): """Validate Windows root drive paths and raise on invalid usage. @@ -54,13 +54,17 @@ class ADBFileTransfer(ProgressTracker): """ # Detect bare drive root (e.g., "C:" or "D:") path_str = str(path).strip() - if len(path_str) >= 2 and path_str[1] == ":" and (len(path_str) == 2 or path_str.endswith(("/", "\\"))): + if ( + len(path_str) >= 2 + and path_str[1] == ":" + and (len(path_str) == 2 or path_str.endswith(("/", "\\"))) + ): op_lower = operation.lower() if "push" in op_lower: raise ValueError("Cannot push to Windows root") if "pull" in op_lower: raise ValueError("Cannot pull from Windows root") - + def pull_file(self, remote_file_path: str, local_file_path: str) -> bool: """Pull a single file from Android device to local machine.""" # Sanitize paths to prevent injection and traversal @@ -86,9 +90,11 @@ class ADBFileTransfer(ProgressTracker): self.update_status(f"Failed to create local directory: {e}") pass - result = self.runner.run_adb_command(['pull', sanitized_remote, sanitized_local]) + result = self.runner.run_adb_command( + ["pull", sanitized_remote, sanitized_local] + ) return self._is_command_success(result) - + def push_file(self, local_file_path: str, remote_file_path: str) -> bool: """Push a single file from local machine to Android device.""" # Sanitize paths to prevent injection and traversal @@ -102,9 +108,11 @@ class ADBFileTransfer(ProgressTracker): if not os.path.exists(sanitized_local) or not os.path.isfile(sanitized_local): return False - result = self.runner.run_adb_command(['push', sanitized_local, sanitized_remote]) + result = self.runner.run_adb_command( + ["push", sanitized_local, sanitized_remote] + ) return self._is_command_success(result) - + def pull_folder(self, remote_path: str, local_path: str) -> bool: """Pull files from Android device to local machine.""" # Sanitize paths to prevent injection and traversal @@ -123,9 +131,11 @@ class ADBFileTransfer(ProgressTracker): except Exception: # Ignore directory creation failures for test environment pass - result = self.runner.run_adb_command(['pull', sanitized_remote, sanitized_local]) + result = self.runner.run_adb_command( + ["pull", sanitized_remote, sanitized_local] + ) return self._is_command_success(result) - + def push_folder(self, local_path: str, remote_path: str) -> bool: """Push files from local machine to Android device.""" # Sanitize paths to prevent injection and traversal @@ -138,9 +148,11 @@ class ADBFileTransfer(ProgressTracker): if not os.path.exists(sanitized_local) or not os.path.isdir(sanitized_local): return False - result = self.runner.run_adb_command(['push', sanitized_local, sanitized_remote]) + result = self.runner.run_adb_command( + ["push", sanitized_local, sanitized_remote] + ) return self._is_command_success(result) - + def _execute_transfer_command(self, cmd: list, operation_name: str) -> bool: """Execute a single file transfer command with progress tracking.""" try: @@ -165,11 +177,11 @@ class ADBFileTransfer(ProgressTracker): for line in proc.stdout: line_count += 1 current_time = time.time() - + # Check for file completion if "1 file" in line and ("pulled" in line or "pushed" in line): self.update_transfer_progress(1, 1) - + pct = self.parse_progress(line) if pct is not None: @@ -179,9 +191,14 @@ class ADBFileTransfer(ProgressTracker): last_update_time = current_time else: # For single files, use simpler progress estimation - estimated = TransferProgressEstimator.estimate_progress_from_time( - start_time, last_progress, elapsed_threshold=1.0, - max_increment=20, max_progress=90 + estimated = ( + TransferProgressEstimator.estimate_progress_from_time( + start_time, + last_progress, + elapsed_threshold=1.0, + max_increment=20, + max_progress=90, + ) ) if estimated is not None: self.update_progress(estimated) @@ -213,9 +230,10 @@ class ADBFileTransfer(ProgressTracker): self.update_status(f"{operation_name} error: {e}") self.current_process = None return False - - def _execute_folder_transfer_command(self, cmd: list, operation_name: str, - completion_verb: str) -> bool: + + def _execute_folder_transfer_command( + self, cmd: list, operation_name: str, completion_verb: str + ) -> bool: """Execute a folder transfer command with progress tracking.""" try: self.update_progress(0) @@ -240,22 +258,28 @@ class ADBFileTransfer(ProgressTracker): for line in proc.stdout: line_count += 1 current_time = time.time() - + # Check for file completion patterns in ADB output - if f": 1 file {completion_verb}" in line or f"files {completion_verb}" in line: + if ( + f": 1 file {completion_verb}" in line + or f"files {completion_verb}" in line + ): # Extract number of files from the line if f"1 file {completion_verb}" in line: files_transferred += 1 else: # Parse "X files pulled/pushed" pattern - match = re.search(rf'(\d+) files {completion_verb}', line) + match = re.search(rf"(\d+) files {completion_verb}", line) if match: files_transferred = int(match.group(1)) - + # Update file transfer progress - if self.transfer_progress['files_to_transfer'] > 0: - self.update_transfer_progress(files_transferred, self.transfer_progress['files_to_transfer']) - + if self.transfer_progress["files_to_transfer"] > 0: + self.update_transfer_progress( + files_transferred, + self.transfer_progress["files_to_transfer"], + ) + pct = self.parse_progress(line) if pct is not None: @@ -269,9 +293,13 @@ class ADBFileTransfer(ProgressTracker): should_update = False new_progress = last_progress - if operation_name == "Transfer": # Pull operation - more complex logic - estimated = TransferProgressEstimator.estimate_complex_progress( - line_count, elapsed_time, last_progress + if ( + operation_name == "Transfer" + ): # Pull operation - more complex logic + estimated = ( + TransferProgressEstimator.estimate_complex_progress( + line_count, elapsed_time, last_progress + ) ) if estimated is not None and time_since_last_update >= 2.0: new_progress = estimated @@ -281,9 +309,14 @@ class ADBFileTransfer(ProgressTracker): new_progress = min(last_progress + increment, 90) should_update = True else: # Push operation - simpler logic - estimated = TransferProgressEstimator.estimate_progress_from_time( - start_time, last_progress, elapsed_threshold=1.0, - max_increment=20, max_progress=90 + estimated = ( + TransferProgressEstimator.estimate_progress_from_time( + start_time, + last_progress, + elapsed_threshold=1.0, + max_increment=20, + max_progress=90, + ) ) if estimated is not None: new_progress = estimated @@ -318,7 +351,7 @@ class ADBFileTransfer(ProgressTracker): self.update_status(f"{operation_name} error: {e}") self.current_process = None return False - + def cancel_transfer(self) -> bool: """Cancel the current transfer operation.""" # Local implementation to avoid depending on ADBCommandRunner inheritance @@ -341,4 +374,4 @@ class ADBFileTransfer(ProgressTracker): return True except Exception: return False - return False \ No newline at end of file + return False diff --git a/src/core/platform_tools.py b/src/core/platform_tools.py index 3b66998..d16cfc8 100644 --- a/src/core/platform_tools.py +++ b/src/core/platform_tools.py @@ -11,8 +11,12 @@ import shutil import tempfile from typing import Optional -from .platform_utils import get_adb_binary_name, get_platform_tools_directory, is_windows, is_linux - +from .platform_utils import ( + get_adb_binary_name, + get_platform_tools_directory, + is_windows, + is_linux, +) # Constants for download URLs ADB_WIN_ZIP_URL = ( @@ -43,11 +47,15 @@ def ensure_platform_tools_in_user_dir(version_tag: Optional[str] = "latest") -> # Determine base data dir if user_data_dir: - data_root = os.path.join(user_data_dir("android-file-handler"), "platform-tools") + data_root = os.path.join( + user_data_dir("android-file-handler"), "platform-tools" + ) else: # Fallback: use home-based location home = os.path.expanduser("~") - data_root = os.path.join(home, ".local", "share", "android-file-handler", "platform-tools") + data_root = os.path.join( + home, ".local", "share", "android-file-handler", "platform-tools" + ) os.makedirs(data_root, exist_ok=True) @@ -102,8 +110,12 @@ def ensure_platform_tools_in_user_dir(version_tag: Optional[str] = "latest") -> raise RuntimeError(f"Redirect to untrusted domain: {resp.url}") # Check Content-Type to ensure we're getting a zip file - content_type = resp.headers.get('Content-Type', '') - if content_type and 'zip' not in content_type.lower() and 'octet-stream' not in content_type.lower(): + content_type = resp.headers.get("Content-Type", "") + if ( + content_type + and "zip" not in content_type.lower() + and "octet-stream" not in content_type.lower() + ): raise RuntimeError(f"Unexpected content type: {content_type}") zip_path = os.path.join(tmp_dir, "platform-tools.zip") @@ -137,10 +149,17 @@ def ensure_platform_tools_in_user_dir(version_tag: Optional[str] = "latest") -> tmp_dir_normalized = os.path.normpath(tmp_dir) # Ensure tmp_dir has a trailing separator for proper prefix matching - tmp_dir_with_sep = tmp_dir_normalized if tmp_dir_normalized.endswith(os.sep) else tmp_dir_normalized + os.sep + tmp_dir_with_sep = ( + tmp_dir_normalized + if tmp_dir_normalized.endswith(os.sep) + else tmp_dir_normalized + os.sep + ) # Check if normalized path is within tmp_dir - if not (normalized.startswith(tmp_dir_with_sep) or normalized == tmp_dir_normalized): + if not ( + normalized.startswith(tmp_dir_with_sep) + or normalized == tmp_dir_normalized + ): raise RuntimeError(f"Zip contains path traversal: {info.filename}") zf.extractall(tmp_dir) @@ -151,7 +170,9 @@ def ensure_platform_tools_in_user_dir(version_tag: Optional[str] = "latest") -> # try to find a platform-tools directory inside temp for entry in os.listdir(tmp_dir): candidate = os.path.join(tmp_dir, entry) - if os.path.isdir(candidate) and entry.lower().startswith("platform-tools"): + if os.path.isdir(candidate) and entry.lower().startswith( + "platform-tools" + ): extracted_dir = candidate break @@ -212,7 +233,8 @@ def get_adb_binary_path() -> str: # Legacy pathway: some tests patch ensure_platform_tools_in_user_dir via adb_manager module try: from . import adb_manager as _adb_manager # type: ignore - if hasattr(_adb_manager, 'ensure_platform_tools_in_user_dir'): + + if hasattr(_adb_manager, "ensure_platform_tools_in_user_dir"): adb_path = _adb_manager.ensure_platform_tools_in_user_dir() if adb_path and os.path.isfile(adb_path): return adb_path @@ -248,4 +270,4 @@ def download_and_extract_adb() -> bool: return True return False except Exception: - return False \ No newline at end of file + return False diff --git a/src/core/platform_utils.py b/src/core/platform_utils.py index 8ccbcf8..fd19842 100644 --- a/src/core/platform_utils.py +++ b/src/core/platform_utils.py @@ -10,7 +10,7 @@ from typing import Optional def get_executable_directory() -> str: """Get the directory containing the executable or script.""" - if getattr(sys, 'frozen', False): + if getattr(sys, "frozen", False): # Running as executable (PyInstaller, cx_Freeze, etc.) return os.path.dirname(sys.executable) else: @@ -21,14 +21,14 @@ def get_executable_directory() -> str: def get_platform_tools_directory() -> str: """Get platform-tools directory.""" base_dir = get_executable_directory() - + # Check if we're in development mode (running from src/ directory) - if not getattr(sys, 'frozen', False): + if not getattr(sys, "frozen", False): # Running as script - check if we're in src/ directory or subdirectory - if base_dir.endswith('src'): + if base_dir.endswith("src"): # Already in src directory - place platform-tools here return os.path.join(base_dir, "platform-tools") - elif base_dir.endswith('gui') or os.path.basename(base_dir) in ['gui']: + elif base_dir.endswith("gui") or os.path.basename(base_dir) in ["gui"]: # In src/gui subdirectory - go up one level to src src_dir = os.path.dirname(base_dir) return os.path.join(src_dir, "platform-tools") @@ -42,7 +42,7 @@ def get_platform_tools_directory() -> str: else: # Fallback to current directory return os.path.join(base_dir, "src", "platform-tools") - + # Running as executable - use directory next to binary return os.path.join(base_dir, "platform-tools") @@ -72,4 +72,4 @@ def is_linux() -> bool: def is_macos() -> bool: """Check if running on macOS.""" - return sys.platform.startswith("darwin") \ No newline at end of file + return sys.platform.startswith("darwin") diff --git a/src/core/progress_tracker.py b/src/core/progress_tracker.py index fccdc7f..4da8f4e 100644 --- a/src/core/progress_tracker.py +++ b/src/core/progress_tracker.py @@ -22,9 +22,9 @@ class ProgressTracker: self.current_speed: float = 0.0 self.estimated_time_remaining: int = 0 self.transfer_progress: Dict[str, int] = { - 'current_file': 0, - 'total_files': 0, - 'files_to_transfer': 0 + "current_file": 0, + "total_files": 0, + "files_to_transfer": 0, } def set_progress_callback(self, callback: Callable[[int], None]) -> None: @@ -84,8 +84,8 @@ class ProgressTracker: def update_transfer_progress(self, current_file: int, total_files: int) -> None: """Update transfer progress for file counting.""" - self.transfer_progress['current_file'] = current_file - self.transfer_progress['total_files'] = total_files + self.transfer_progress["current_file"] = current_file + self.transfer_progress["total_files"] = total_files # Send progress update through status callback with special format progress_message = f"TRANSFER_PROGRESS:{current_file}:{total_files}" if self.status_callback: @@ -94,14 +94,14 @@ class ProgressTracker: def reset_transfer_progress(self) -> None: """Reset transfer progress counters.""" self.transfer_progress = { - 'current_file': 0, - 'total_files': 0, - 'files_to_transfer': 0 + "current_file": 0, + "total_files": 0, + "files_to_transfer": 0, } def set_files_to_transfer(self, count: int) -> None: """Set the total number of files to transfer.""" - self.transfer_progress['files_to_transfer'] = count + self.transfer_progress["files_to_transfer"] = count # Utilities expected by tests/UI def get_progress_percentage(self) -> float: @@ -153,38 +153,48 @@ class ProgressTracker: class TransferProgressEstimator: """Estimates transfer progress based on various factors.""" - + @staticmethod - def estimate_progress_from_time(start_time: float, last_progress: int, - elapsed_threshold: float = 1.0, - max_increment: int = 20, - max_progress: int = 90) -> Optional[int]: + def estimate_progress_from_time( + start_time: float, + last_progress: int, + elapsed_threshold: float = 1.0, + max_increment: int = 20, + max_progress: int = 90, + ) -> Optional[int]: """Estimate progress based on elapsed time.""" current_time = time.time() elapsed_time = current_time - start_time - + if elapsed_time >= elapsed_threshold and last_progress < max_progress: new_progress = min(last_progress + max_increment, max_progress) return int(new_progress) - + return None - + @staticmethod - def estimate_progress_from_activity(line_count: int, last_progress: int, - activity_threshold: int = 50, - increment: int = 5, - max_progress: int = 90) -> Optional[int]: + def estimate_progress_from_activity( + line_count: int, + last_progress: int, + activity_threshold: int = 50, + increment: int = 5, + max_progress: int = 90, + ) -> Optional[int]: """Estimate progress based on output line activity.""" if line_count % activity_threshold == 0 and last_progress < max_progress: - calculated_increment = max(1, min(increment, max_progress // (line_count // activity_threshold + 1))) + calculated_increment = max( + 1, + min(increment, max_progress // (line_count // activity_threshold + 1)), + ) new_progress = min(last_progress + calculated_increment, max_progress) return int(new_progress) - + return None - + @staticmethod - def estimate_complex_progress(line_count: int, elapsed_time: float, - last_progress: int) -> Optional[int]: + def estimate_complex_progress( + line_count: int, elapsed_time: float, last_progress: int + ) -> Optional[int]: """Estimate progress using complex algorithm for large transfers.""" if elapsed_time >= 2.0 and last_progress < 95: if line_count > 100: @@ -193,7 +203,7 @@ class TransferProgressEstimator: new_progress = min(activity_factor + time_factor, 95) else: new_progress = min(last_progress + 10, 95) - + return int(new_progress) - - return None \ No newline at end of file + + return None diff --git a/src/gui/components/__init__.py b/src/gui/components/__init__.py index 79f09ea..e7f3cac 100644 --- a/src/gui/components/__init__.py +++ b/src/gui/components/__init__.py @@ -1,4 +1,4 @@ """ GUI Components Package Reusable UI components and widgets. -""" \ No newline at end of file +""" diff --git a/src/gui/components/file_browser.py b/src/gui/components/file_browser.py index b8175bf..16122c0 100644 --- a/src/gui/components/file_browser.py +++ b/src/gui/components/file_browser.py @@ -16,10 +16,9 @@ class AndroidFileBrowser: self.adb_manager = adb_manager self.path_callback = path_callback - def show_browser(self, direction="pull"): - """Show a browsable Android folder tree. - + """Show a browsable Android folder tree. + Args: direction: "pull" to show files and folders, "push" to show folders only """ @@ -40,7 +39,7 @@ class AndroidFileBrowser: else: browser_window.title("Browse Android Files and Folders") label_text = "Browse Android device files and folders:" - + browser_window.geometry("500x400") browser_window.transient(self.parent) browser_window.grab_set() @@ -212,10 +211,7 @@ class AndroidFileBrowser: # Additional validation and cleanup file_name = file_name.strip() - if ( - file_name - and not file_name.startswith(".") - ): + if file_name and not file_name.startswith("."): files.append(file_name) # Add folders to tree first (sorted) @@ -230,7 +226,7 @@ class AndroidFileBrowser: ) # Add a dummy child to make it expandable tree.insert(item, "end", text="Loading...") - + # Add files to tree (sorted) - only if not in push mode if files and direction != "push": for file in sorted(files): @@ -241,10 +237,14 @@ class AndroidFileBrowser: text=f"📄 {file}", values=[file_path, "file"], ) - + # If no folders or files found, show indicator if not folders and (not files or direction == "push"): - empty_text = "(No Folders)" if direction == "push" else "(Empty Directory)" + empty_text = ( + "(No Folders)" + if direction == "push" + else "(Empty Directory)" + ) tree.insert( parent_item, "end", text=empty_text, values=["", ""] ) @@ -455,7 +455,7 @@ class AndroidFileBrowser: def select_current_item(): """Intelligently select the currently highlighted item (file or folder).""" selected_item = tree.selection()[0] if tree.selection() else None - + if selected_item: # Get the item type and path from values item_values = tree.item(selected_item, "values") @@ -465,7 +465,10 @@ class AndroidFileBrowser: if len(item_values) >= 2 and item_values[1] == "file": if direction == "push": # In push mode, don't allow file selection - messagebox.showwarning("Invalid Selection", "Please select a folder as the destination.") + messagebox.showwarning( + "Invalid Selection", + "Please select a folder as the destination.", + ) return else: # It's a file and we're in pull mode - select the file path directly @@ -479,7 +482,7 @@ class AndroidFileBrowser: self.path_callback(item_path) browser_window.destroy() return - + # Fallback: use current path (for backwards compatibility or when no specific item is selected) current_path = current_path_var.get() if current_path and current_path.strip(): @@ -488,11 +491,13 @@ class AndroidFileBrowser: browser_window.destroy() else: selection_type = "folder" if direction == "push" else "file or folder" - messagebox.showwarning("No Selection", f"Please select a {selection_type}.") + messagebox.showwarning( + "No Selection", f"Please select a {selection_type}." + ) - tk.Button( - button_frame, text="Select", command=select_current_item - ).pack(side="left", padx=5) + tk.Button(button_frame, text="Select", command=select_current_item).pack( + side="left", padx=5 + ) tk.Button(button_frame, text="Cancel", command=browser_window.destroy).pack( side="left", padx=5 ) diff --git a/src/gui/components/ui_components.py b/src/gui/components/ui_components.py index 6f2546b..98bfca1 100644 --- a/src/gui/components/ui_components.py +++ b/src/gui/components/ui_components.py @@ -8,17 +8,23 @@ from tkinter import messagebox from typing import Callable, Optional try: - from ..dialogs.license_agreement import LicenseAgreementFrame, check_license_agreement + from ..dialogs.license_agreement import ( + LicenseAgreementFrame, + check_license_agreement, + ) except ImportError: - from gui.dialogs.license_agreement import LicenseAgreementFrame, check_license_agreement + from gui.dialogs.license_agreement import ( + LicenseAgreementFrame, + check_license_agreement, + ) class PathSelectorFrame: """Frame component for path selection with browse button.""" - + def __init__(self, parent: tk.Widget, label_text: str, browse_command: Callable): """Initialize the path selector frame. - + Args: parent: Parent widget label_text: Text for the label @@ -27,67 +33,61 @@ class PathSelectorFrame: self.frame = tk.Frame(parent) self.label = tk.Label(self.frame, text=label_text) self.label.pack(anchor="w") - + # Path frame with label and button path_frame = tk.Frame(self.frame) path_frame.pack(fill="x", pady=(0, 10)) - + self.path_var = tk.StringVar(value="Please select file or folder ->") - self.path_display = tk.Label( - path_frame, - textvariable=self.path_var, - anchor="w" - ) + self.path_display = tk.Label(path_frame, textvariable=self.path_var, anchor="w") self.path_display.pack(side="left", fill="x", expand=True) - + self.browse_btn = tk.Button( - path_frame, - text="Browse...", - command=browse_command + path_frame, text="Browse...", command=browse_command ) self.browse_btn.pack(side="right", padx=(5, 0)) - + def pack(self, **kwargs): """Pack the frame.""" self.frame.pack(**kwargs) - + def pack_forget(self): """Remove the frame from packing.""" self.frame.pack_forget() - + def set_path(self, path: str) -> None: """Set the displayed path. - + Args: path: Path to display """ self.path_var.set(path) - + def get_path(self) -> str: """Get the current path. - + Returns: Current path string """ return self.path_var.get().strip() - + def is_path_selected(self) -> bool: """Check if a valid path is selected. - + Returns: True if path is selected and not the default placeholder """ path = self.get_path() return path and path != "Please select file or folder ->" - + def clear_path(self) -> None: """Clear the path selection.""" self.path_var.set("Please select file or folder ->") - + def enable_browse(self) -> None: """Enable the browse button.""" self.browse_btn.config(state="normal") - + def disable_browse(self) -> None: """Disable the browse button.""" self.browse_btn.config(state="disabled") @@ -95,19 +95,19 @@ class PathSelectorFrame: class DirectionSelector: """Component for selecting transfer direction (pull/push).""" - + def __init__(self, parent: tk.Widget, on_change_command: Optional[Callable] = None): """Initialize the direction selector. - + Args: parent: Parent widget on_change_command: Command to execute when direction changes """ self.direction_var = tk.StringVar(value="pull") - + self.frame = tk.Frame(parent) self.frame.pack(anchor="w", padx=10, pady=(10, 0)) - + tk.Radiobutton( self.frame, text="Pull (Android → Computer)", @@ -115,7 +115,7 @@ class DirectionSelector: value="pull", command=on_change_command, ).pack(side="left") - + tk.Radiobutton( self.frame, text="Push (Computer → Android)", @@ -123,10 +123,10 @@ class DirectionSelector: value="push", command=on_change_command, ).pack(side="left", padx=(20, 0)) - + def get_direction(self) -> str: """Get the current direction. - + Returns: Current direction ('pull' or 'push') """ @@ -135,10 +135,10 @@ class DirectionSelector: class StatusLabel: """Responsive status label with word wrapping.""" - + def __init__(self, parent: tk.Widget, initial_text: str = "Status: Idle"): """Initialize the status label. - + Args: parent: Parent widget initial_text: Initial status text @@ -148,37 +148,40 @@ class StatusLabel: text=initial_text, wraplength=0, # Will be set dynamically justify="center", - anchor="center" + anchor="center", ) self.label.pack(padx=10, fill="x", pady=(20, 5)) - + # Bind parent window resize to update wrapping parent.bind("", self._on_window_configure) - + def set_text(self, text: str) -> None: """Set the status text. - + Args: text: Text to display """ self.label.config(text=text) - + def get_text(self) -> str: """Get the current status text. - + Returns: Current status text """ return self.label.cget("text") - + def _on_window_configure(self, event) -> None: """Handle window resize events to update label wrapping. - + Args: event: Configure event """ # Only handle configure events for the main window, not child widgets - if hasattr(event.widget, 'winfo_toplevel') and event.widget == event.widget.winfo_toplevel(): + if ( + hasattr(event.widget, "winfo_toplevel") + and event.widget == event.widget.winfo_toplevel() + ): # Calculate available width for the status label # Account for padding (10px on each side) and some margin available_width = event.widget.winfo_width() - 40 @@ -188,25 +191,21 @@ class StatusLabel: class TransferButton: """Multi-mode transfer button that changes text and behavior based on state.""" - + def __init__(self, parent: tk.Widget): """Initialize the transfer button. - + Args: parent: Parent widget """ - self.button = tk.Button( - parent, - text="Start Transfer", - state="disabled" - ) + self.button = tk.Button(parent, text="Start Transfer", state="disabled") self.button.pack(pady=10) - + self.current_mode = "transfer" # transfer, recheck, cancel - + def set_transfer_mode(self, command: Callable, enabled: bool = True) -> None: """Set button to transfer mode. - + Args: command: Command to execute on button click enabled: Whether button should be enabled @@ -215,53 +214,44 @@ class TransferButton: self.button.config( text="Start Transfer", command=command, - state="normal" if enabled else "disabled" + state="normal" if enabled else "disabled", ) - + def set_recheck_mode(self, command: Callable) -> None: """Set button to recheck device mode. - + Args: command: Command to execute on button click """ self.current_mode = "recheck" self.button.config( - text="Recheck for connected Android device", - command=command, - state="normal" + text="Recheck for connected Android device", command=command, state="normal" ) - + def set_cancel_mode(self, command: Callable) -> None: """Set button to cancel transfer mode. - + Args: command: Command to execute on button click """ self.current_mode = "cancel" - self.button.config( - text="Cancel Transfer", - command=command, - state="normal" - ) - + self.button.config(text="Cancel Transfer", command=command, state="normal") + def set_checking_mode(self) -> None: """Set button to temporary checking state.""" - self.button.config( - text="Checking...", - state="disabled" - ) - + self.button.config(text="Checking...", state="disabled") + def enable(self) -> None: """Enable the button.""" self.button.config(state="normal") - + def disable(self) -> None: """Disable the button.""" self.button.config(state="disabled") - + def get_mode(self) -> str: """Get the current button mode. - + Returns: Current mode string ('transfer', 'recheck', 'cancel') """ @@ -270,10 +260,10 @@ class TransferButton: class LicenseManager: """Manages license agreement display and main interface switching.""" - + def __init__(self, parent_window: tk.Tk): """Initialize the license manager. - + Args: parent_window: The main window instance """ @@ -281,39 +271,39 @@ class LicenseManager: self.license_agreed = check_license_agreement() self.license_frame = None self.on_agreed_callback: Optional[Callable] = None - + def needs_license_agreement(self) -> bool: """Check if license agreement is needed. - + Returns: True if license agreement needs to be shown, False otherwise """ return not self.license_agreed - + def show_license_agreement(self, on_agreed_callback: Callable) -> None: """Show the license agreement interface. - + Args: on_agreed_callback: Callback to execute when license is agreed """ self.on_agreed_callback = on_agreed_callback - + # Adjust window size for license agreement self.parent.geometry("700x600") self.parent.minsize(700, 600) - + # Create license agreement frame self.license_frame = LicenseAgreementFrame(self.parent, self._on_license_agreed) - + def _on_license_agreed(self) -> None: """Handle when user agrees to license.""" self.license_agreed = True - + # Remove license frame if self.license_frame: self.license_frame.destroy() self.license_frame = None - + # Execute callback if self.on_agreed_callback: - self.on_agreed_callback() \ No newline at end of file + self.on_agreed_callback() diff --git a/src/gui/dialogs/__init__.py b/src/gui/dialogs/__init__.py index 4809958..f0197f9 100644 --- a/src/gui/dialogs/__init__.py +++ b/src/gui/dialogs/__init__.py @@ -1,4 +1,4 @@ """ GUI Dialogs Package Dialog windows and user interaction components. -""" \ No newline at end of file +""" diff --git a/src/gui/dialogs/dialog_manager.py b/src/gui/dialogs/dialog_manager.py index 75a2637..ad0e2d0 100644 --- a/src/gui/dialogs/dialog_manager.py +++ b/src/gui/dialogs/dialog_manager.py @@ -11,10 +11,10 @@ from typing import Optional, Tuple class DialogManager: """Manages various dialog boxes and user interactions.""" - + def __init__(self, parent_window: tk.Tk): """Initialize the dialog manager. - + Args: parent_window: The main window instance """ @@ -22,7 +22,6 @@ class DialogManager: self.troubleshooting_steps = ( "Android device appears to have been disconnected and/or USB debugging is disabled.\n" "Please ensure your Android device is securely connected at both ends.\n\n" - "To enable USB debugging:\n" "1. Connect your device to the computer via USB\n" "2. Open Settings → About phone\n" @@ -33,33 +32,28 @@ class DialogManager: " (Tip: Use the search icon at the top if you can't find it)\n" "6. Connect via USB and tap 'Trust' when prompted\n" " (Checking 'Remember' is recommended for future transfers)\n\n" - "Ensure File Transfer mode is enabled:\n" "1. After connecting, swipe down to view notifications\n" "2. Look for a USB notification (often shows 'Charging over USB')\n" "3. Tap the notification and select 'File Transfer' or 'MTP' mode\n\n" - "Note: Menu names may vary by Android version:\n" "• Some devices show 'Developer options' under 'System'\n" "• Others may have it directly in the main Settings menu\n" "• Samsung devices might show 'Software information' instead of 'About phone'\n\n" - "If you're still having trouble:\n" "• Try a different USB cable or port (some cables only support charging)\n" "• Restart both your phone and computer\n" "• Make sure your phone screen is unlocked when connecting\n" "• Set your phone screen timeout to 30 minutes (especially for long transfers)\n\n" "• Use a different computer to test if the issue is computer-specific\n" - "Windows users: If you see a driver installation popup, please allow it to complete.\n" "Linux users: You may need to run 'sudo usermod -a -G plugdev $USER' and reboot.\n" - "After completing these steps, click 'Recheck for connected Android device' to try again." ) - + def show_file_folder_selection_notice(self) -> bool: """Show instructions for file and folder selection in a custom dialog. - + Returns: True if user clicked OK, False if user cancelled or closed dialog """ @@ -71,20 +65,20 @@ class DialogManager: dialog.resizable(True, True) dialog.transient(self.parent) dialog.grab_set() - + # Center the dialog on the parent window dialog.update_idletasks() x = self.parent.winfo_x() + (self.parent.winfo_width() // 2) - (600 // 2) y = self.parent.winfo_y() + (self.parent.winfo_height() // 2) - (250 // 2) dialog.geometry(f"600x250+{x}+{y}") - + # Track if OK was clicked dialog_confirmed = False - + # Create main frame main_frame = tk.Frame(dialog) main_frame.pack(fill="both", expand=True, padx=20, pady=20) - + # Selection notice text notice_text = ( "How file and folder selection works in this application\n\n" @@ -93,7 +87,7 @@ class DialogManager: "(the current directory you are viewing, not a highlighted folder) will be selected for transfer.\n\n" "You can only transfer one file or one folder at a time." ) - + # Create responsive text label text_label = tk.Label( main_frame, @@ -101,54 +95,56 @@ class DialogManager: justify="left", anchor="nw", wraplength=0, # Will be set dynamically - font=("Arial", 10) + font=("Arial", 10), ) text_label.pack(fill="both", expand=True, pady=(0, 20)) - + # OK button with confirmation callback def on_ok_clicked(): nonlocal dialog_confirmed dialog_confirmed = True dialog.destroy() - + ok_button = tk.Button( - main_frame, - text="OK", - command=on_ok_clicked, - width=10, - font=("Arial", 10) + main_frame, text="OK", command=on_ok_clicked, width=10, font=("Arial", 10) ) ok_button.pack(pady=10) - + # Configure text wrapping on dialog resize def on_dialog_configure(event): if event.widget == dialog: # Calculate available width for text (account for padding and margins) - available_width = dialog.winfo_width() - 60 # 20px padding * 2 + some margin + available_width = ( + dialog.winfo_width() - 60 + ) # 20px padding * 2 + some margin if available_width > 200: # Minimum reasonable width text_label.config(wraplength=available_width) - + dialog.bind("", on_dialog_configure) - + # Set initial wrap length - dialog.after(10, lambda: on_dialog_configure(type('Event', (), {'widget': dialog})())) - + dialog.after( + 10, lambda: on_dialog_configure(type("Event", (), {"widget": dialog})()) + ) + # Handle window close (X button) - treat as cancel def on_dialog_close(): nonlocal dialog_confirmed dialog_confirmed = False dialog.destroy() - + dialog.protocol("WM_DELETE_WINDOW", on_dialog_close) - + # Wait for dialog to close dialog.wait_window() - + return dialog_confirmed - - def show_enable_debugging_instructions(self, callback: Optional[callable] = None) -> None: + + def show_enable_debugging_instructions( + self, callback: Optional[callable] = None + ) -> None: """Show instructions to connect device, enable file transfer, and enable USB debugging. - + Args: callback: Optional callback to execute after dialog is closed """ @@ -160,17 +156,17 @@ class DialogManager: dialog.resizable(True, True) dialog.transient(self.parent) dialog.grab_set() - + # Center the dialog on the parent window dialog.update_idletasks() x = self.parent.winfo_x() + (self.parent.winfo_width() // 2) - (700 // 2) y = self.parent.winfo_y() + (self.parent.winfo_height() // 2) - (450 // 2) dialog.geometry(f"700x450+{x}+{y}") - + # Create main frame main_frame = tk.Frame(dialog) main_frame.pack(fill="both", expand=True, padx=20, pady=20) - + # Create responsive text label text_label = tk.Label( main_frame, @@ -178,43 +174,43 @@ class DialogManager: justify="left", anchor="nw", wraplength=0, # Will be set dynamically - font=("Arial", 10) + font=("Arial", 10), ) text_label.pack(fill="both", expand=True, pady=(0, 20)) - + # OK button ok_button = tk.Button( - main_frame, - text="OK", - command=dialog.destroy, - width=10, - font=("Arial", 10) + main_frame, text="OK", command=dialog.destroy, width=10, font=("Arial", 10) ) ok_button.pack(pady=10) - + # Configure text wrapping on dialog resize def on_dialog_configure(event): if event.widget == dialog: # Calculate available width for text (account for padding and margins) - available_width = dialog.winfo_width() - 60 # 20px padding * 2 + some margin + available_width = ( + dialog.winfo_width() - 60 + ) # 20px padding * 2 + some margin if available_width > 200: # Minimum reasonable width text_label.config(wraplength=available_width) - + dialog.bind("", on_dialog_configure) - + # Set initial wrap length - dialog.after(10, lambda: on_dialog_configure(type('Event', (), {'widget': dialog})())) - + dialog.after( + 10, lambda: on_dialog_configure(type("Event", (), {"widget": dialog})()) + ) + # Handle dialog close def on_close(): dialog.destroy() if callback: callback() - + # After user clicks OK, ensure callback is executed dialog.protocol("WM_DELETE_WINDOW", on_close) ok_button.config(command=on_close) - + def show_disable_debugging_reminder(self) -> None: """Show reminder to disable USB debugging after transfer.""" msg = ( @@ -223,10 +219,12 @@ class DialogManager: "Settings → Developer Options → disable 'USB debugging'." ) messagebox.showinfo("Disable USB Debugging", msg) - - def show_transfer_stats(self, stats: dict, operation: str, deduplicator=None) -> None: + + def show_transfer_stats( + self, stats: dict, operation: str, deduplicator=None + ) -> None: """Show transfer statistics dialog. - + Args: stats: Dictionary containing transfer statistics operation: Type of operation ("Pull" or "Push") @@ -234,98 +232,104 @@ class DialogManager: """ # Format bytes saved bytes_saved_str = "" - if stats['bytes_saved'] > 0: - if deduplicator and hasattr(deduplicator, 'format_bytes'): - bytes_saved_str = f" ({deduplicator.format_bytes(stats['bytes_saved'])} saved)" + if stats["bytes_saved"] > 0: + if deduplicator and hasattr(deduplicator, "format_bytes"): + bytes_saved_str = ( + f" ({deduplicator.format_bytes(stats['bytes_saved'])} saved)" + ) else: bytes_saved_str = f" ({stats['bytes_saved']} bytes saved)" - + # Build message title = f"{operation} Transfer Complete" - - if stats['total_files'] == 0: + + if stats["total_files"] == 0: message = "No files were found to transfer." else: message_parts = [ f"Transfer completed successfully!\n", f"Files found: {stats['total_files']}", f"Files transferred: {stats['transferred']}", - f"Duplicate files skipped: {stats['skipped']}{bytes_saved_str}" + f"Duplicate files skipped: {stats['skipped']}{bytes_saved_str}", ] - - if stats['skipped'] > 0: - message_parts.append(f"\nDuplicate detection helped avoid unnecessary transfers!") - + + if stats["skipped"] > 0: + message_parts.append( + f"\nDuplicate detection helped avoid unnecessary transfers!" + ) + message = "\n".join(message_parts) - + # Show dialog messagebox.showinfo(title, message) - + def show_error(self, title: str, message: str) -> None: """Show an error dialog. - + Args: title: Dialog title message: Error message to display """ messagebox.showerror(title, message) - + def show_info(self, title: str, message: str) -> None: """Show an info dialog. - + Args: title: Dialog title message: Info message to display """ messagebox.showinfo(title, message) - - def browse_local_file_or_folder(self, direction: str, initial_dir: Optional[str] = None) -> Optional[str]: + + def browse_local_file_or_folder( + self, direction: str, initial_dir: Optional[str] = None + ) -> Optional[str]: """Browse for local file or folder based on transfer direction. - + Args: direction: Transfer direction ('pull' or 'push') initial_dir: Initial directory to open browser in - + Returns: Selected path or None if cancelled """ if initial_dir is None: initial_dir = os.path.expanduser("~") - + # Show helpful notification about folder selection behavior if not self.show_file_folder_selection_notice(): return None # User cancelled the notice dialog - + if direction == "push": # For push, show file selection first, then folder selection if cancelled - + # First try file selection selected_path = filedialog.askopenfilename( title="Select file to push to Android device", initialdir=initial_dir, - filetypes=[("All files", "*.*")] + filetypes=[("All files", "*.*")], ) - + # If no file was selected, offer folder selection as an alternative if not selected_path: selected_path = filedialog.askdirectory( title="Select folder to push to Android device", - initialdir=initial_dir + initialdir=initial_dir, ) - + return selected_path if selected_path else None - + else: # pull direction # For pull, only allow folder selection (destination) folder = filedialog.askdirectory( title="Select destination folder for pulled files", - initialdir=initial_dir + initialdir=initial_dir, ) return folder if folder else None - + def show_file_folder_choice(self, on_file_callback, on_folder_callback): """Show a dialog to choose between file or folder selection. - + Args: on_file_callback: Callback for file selection on_folder_callback: Callback for folder selection @@ -337,94 +341,107 @@ class DialogManager: choice_window.resizable(False, False) choice_window.transient(self.parent) choice_window.grab_set() - + # Center the window - choice_window.geometry("+{}+{}".format( - self.parent.winfo_x() + 100, - self.parent.winfo_y() + 100 - )) - + choice_window.geometry( + "+{}+{}".format(self.parent.winfo_x() + 100, self.parent.winfo_y() + 100) + ) + main_frame = tk.Frame(choice_window) main_frame.pack(fill="both", expand=True, padx=20, pady=20) - - tk.Label(main_frame, text="What would you like to select?", - font=("Arial", 11)).pack(pady=(0, 15)) - + + tk.Label( + main_frame, text="What would you like to select?", font=("Arial", 11) + ).pack(pady=(0, 15)) + button_frame = tk.Frame(main_frame) button_frame.pack(fill="x") - + def select_file(): choice_window.destroy() on_file_callback() - + def select_folder(): choice_window.destroy() on_folder_callback() - + def cancel_selection(): choice_window.destroy() - - tk.Button(button_frame, text="📄 File", command=select_file, - width=12, font=("Arial", 10)).pack(side="left", padx=(0, 10)) - tk.Button(button_frame, text="📁 Folder", command=select_folder, - width=12, font=("Arial", 10)).pack(side="left", padx=(0, 10)) - tk.Button(button_frame, text="Cancel", command=cancel_selection, - width=12).pack(side="right") - + + tk.Button( + button_frame, + text="📄 File", + command=select_file, + width=12, + font=("Arial", 10), + ).pack(side="left", padx=(0, 10)) + tk.Button( + button_frame, + text="📁 Folder", + command=select_folder, + width=12, + font=("Arial", 10), + ).pack(side="left", padx=(0, 10)) + tk.Button(button_frame, text="Cancel", command=cancel_selection, width=12).pack( + side="right" + ) + def show_transfer_stats(self, stats: dict, operation: str): """Show transfer statistics in a dialog. - + Args: stats: Dictionary containing transfer statistics operation: Description of the operation performed """ if not stats: return - + stats_window = tk.Toplevel(self.parent) stats_window.title("Transfer Statistics") stats_window.geometry("400x300") stats_window.resizable(True, True) stats_window.transient(self.parent) stats_window.grab_set() - + # Center the window - stats_window.geometry("+{}+{}".format( - self.parent.winfo_x() + 60, - self.parent.winfo_y() + 60 - )) - + stats_window.geometry( + "+{}+{}".format(self.parent.winfo_x() + 60, self.parent.winfo_y() + 60) + ) + main_frame = tk.Frame(stats_window) main_frame.pack(fill="both", expand=True, padx=15, pady=15) - + # Title - title_label = tk.Label(main_frame, text=f"📊 Transfer Complete - {operation.title()}", - font=("Arial", 14, "bold")) + title_label = tk.Label( + main_frame, + text=f"📊 Transfer Complete - {operation.title()}", + font=("Arial", 14, "bold"), + ) title_label.pack(pady=(0, 15)) - + # Stats display - stats_text = tk.Text(main_frame, wrap=tk.WORD, font=("Courier", 10), - height=12, width=50) + stats_text = tk.Text( + main_frame, wrap=tk.WORD, font=("Courier", 10), height=12, width=50 + ) stats_text.pack(fill="both", expand=True, pady=(0, 15)) - + # Format and insert stats stats_content = [] for key, value in stats.items(): - formatted_key = key.replace('_', ' ').title() + formatted_key = key.replace("_", " ").title() stats_content.append(f"{formatted_key}: {value}") - + stats_text.insert(tk.END, "\n".join(stats_content)) stats_text.config(state=tk.DISABLED) - + # Close button - tk.Button(main_frame, text="Close", - command=stats_window.destroy).pack() - + tk.Button(main_frame, text="Close", command=stats_window.destroy).pack() + def show_disable_debugging_reminder(self): """Show reminder about disabling USB debugging after transfer.""" messagebox.showinfo( - "Security Reminder", + "Security Reminder", "Transfer complete!\n\nFor security, consider disabling USB debugging when not needed:\n" "Settings → Developer Options → USB debugging (toggle off)\n\n" - "Keep it enabled if you plan to transfer files again soon." - ) \ No newline at end of file + "Keep it enabled if you plan to transfer files again soon.", + ) diff --git a/src/gui/dialogs/license_agreement.py b/src/gui/dialogs/license_agreement.py index 78f28ad..efb4731 100644 --- a/src/gui/dialogs/license_agreement.py +++ b/src/gui/dialogs/license_agreement.py @@ -116,7 +116,9 @@ def resource_path(relative_path: str) -> str: base = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) return os.path.normpath(os.path.join(base, relative_path)) except Exception: - return os.path.normpath(os.path.join(os.path.dirname(os.path.abspath(__file__)), relative_path)) + return os.path.normpath( + os.path.join(os.path.dirname(os.path.abspath(__file__)), relative_path) + ) def run_windows_first_run_if_needed() -> None: @@ -136,7 +138,11 @@ def run_windows_first_run_if_needed() -> None: if not os.path.exists(script_path): return try: - subprocess.Popen(["powershell.exe", "-ExecutionPolicy", "Bypass", "-File", script_path], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + subprocess.Popen( + ["powershell.exe", "-ExecutionPolicy", "Bypass", "-File", script_path], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) except Exception: pass except Exception: @@ -156,106 +162,104 @@ class LicenseAgreementFrame(tk.Frame): super().__init__(parent) self.on_agree_callback = on_agree_callback self.setup_ui() - + def setup_ui(self): """Setup the license agreement UI.""" # Configure the frame to fill the window self.pack(fill=tk.BOTH, expand=True, padx=20, pady=20) - + # Header header_label = tk.Label( - self, - text="License Agreement", - font=("Arial", 16, "bold") + self, text="License Agreement", font=("Arial", 16, "bold") ) header_label.pack(pady=(0, 10)) - + # Instruction instruction_label = tk.Label( - self, + self, text="Please read and accept the license agreement to continue using Android File Handler:", font=("Arial", 10), - wraplength=500 + wraplength=500, ) instruction_label.pack(pady=(0, 15)) - + # License text area text_frame = tk.Frame(self) text_frame.pack(fill=tk.BOTH, expand=True, pady=(0, 20)) - + self.license_text = scrolledtext.ScrolledText( - text_frame, - wrap=tk.WORD, - width=80, + text_frame, + wrap=tk.WORD, + width=80, height=20, font=("Courier", 9), state=tk.DISABLED, bg="#f8f8f8", relief=tk.SUNKEN, - bd=2 + bd=2, ) self.license_text.pack(fill=tk.BOTH, expand=True) - + # Insert license text self.license_text.config(state=tk.NORMAL) self.license_text.insert(tk.END, get_mit_license_text()) self.license_text.config(state=tk.DISABLED) - + # Button frame button_frame = tk.Frame(self) button_frame.pack(fill=tk.X, pady=(10, 0)) - + # Buttons self.agree_btn = tk.Button( - button_frame, - text="I Agree", + button_frame, + text="I Agree", command=self.on_agree, bg="#4CAF50", - fg="white", + fg="white", font=("Arial", 11, "bold"), width=15, - height=2 + height=2, ) self.agree_btn.pack(side=tk.LEFT) - + self.disagree_btn = tk.Button( - button_frame, - text="Disagree & Exit", + button_frame, + text="Disagree & Exit", command=self.on_disagree, bg="#ff6b6b", fg="white", font=("Arial", 11, "bold"), width=15, - height=2 + height=2, ) self.disagree_btn.pack(side=tk.RIGHT) - + # Center text center_label = tk.Label( button_frame, text="You must agree to the license terms to use this software", font=("Arial", 9), - fg="#666666" + fg="#666666", ) center_label.pack(expand=True) - + def on_agree(self): """Handle user clicking Agree.""" if save_license_agreement(): self.on_agree_callback() else: messagebox.showerror( - "Error", - "Could not save license agreement. Please check file permissions and try again." + "Error", + "Could not save license agreement. Please check file permissions and try again.", ) - + def on_disagree(self): """Handle user clicking Disagree.""" # Ask for confirmation result = messagebox.askyesno( "Exit Application", "Are you sure you want to exit? You must agree to the license terms to use this software.", - icon="warning" + icon="warning", ) if result: sys.exit(0) diff --git a/src/gui/handlers/__init__.py b/src/gui/handlers/__init__.py index a6733e8..5fe0274 100644 --- a/src/gui/handlers/__init__.py +++ b/src/gui/handlers/__init__.py @@ -1,4 +1,4 @@ """ GUI Handlers Package Event handlers and animation controllers. -""" \ No newline at end of file +""" diff --git a/src/gui/handlers/animation_handler.py b/src/gui/handlers/animation_handler.py index 859ef4a..b118965 100644 --- a/src/gui/handlers/animation_handler.py +++ b/src/gui/handlers/animation_handler.py @@ -9,10 +9,10 @@ from typing import Optional, Dict, Any class AnimationHandler: """Handles all GUI animations including transfer and scanning animations.""" - + def __init__(self, parent_window: tk.Tk): """Initialize the animation handler. - + Args: parent_window: The main window instance """ @@ -20,46 +20,42 @@ class AnimationHandler: self.animation_job: Optional[str] = None self.animation_dots = 0 self.scanning_active = False - + # File transfer progress tracking - self.transfer_file_progress = { - 'current': 0, - 'total': 0, - 'active': False - } - + self.transfer_file_progress = {"current": 0, "total": 0, "active": False} + def start_scanning_animation(self) -> None: """Start the 'Scanning for duplicates...' animation.""" self.animation_dots = 0 self.scanning_active = True self.animation_job = self.parent.after(0, self._animate_scanning_text) - + def start_transfer_animation(self) -> None: """Start the 'Transferring...' animation.""" self.animation_dots = 0 self.scanning_active = False self.animation_job = self.parent.after(0, self._animate_transfer_text) - + def stop_animation(self) -> None: """Stop any running animation.""" if self.animation_job is not None: self.parent.after_cancel(self.animation_job) self.animation_job = None - + self.scanning_active = False self._reset_file_progress() - + def update_transfer_progress(self, current: int, total: int) -> None: """Update the file transfer progress. - + Args: current: Current number of files transferred total: Total number of files to transfer """ - self.transfer_file_progress['current'] = current - self.transfer_file_progress['total'] = total - self.transfer_file_progress['active'] = True - + self.transfer_file_progress["current"] = current + self.transfer_file_progress["total"] = total + self.transfer_file_progress["active"] = True + def _animate_scanning_text(self) -> None: """Animate the scanning text with dots.""" if self.animation_job is not None and self.scanning_active: @@ -69,56 +65,54 @@ class AnimationHandler: self.animation_dots = (self.animation_dots + 1) % 5 # Cycle 0-4 dots # Schedule next update in 500ms self.animation_job = self.parent.after(500, self._animate_scanning_text) - + def _animate_transfer_text(self) -> None: """Animate the transfer text with dots.""" if self.animation_job is not None and not self.scanning_active: dots = "." * (self.animation_dots + 1) - + # Show file progress if available - if (self.transfer_file_progress['active'] and - self.transfer_file_progress['total'] > 0): - current = self.transfer_file_progress['current'] - total = self.transfer_file_progress['total'] + if ( + self.transfer_file_progress["active"] + and self.transfer_file_progress["total"] > 0 + ): + current = self.transfer_file_progress["current"] + total = self.transfer_file_progress["total"] status_text = f"Transferring {current} of {total} files{dots}" else: status_text = f"Transferring{dots}" - + self._update_status_label(status_text) self.animation_dots = (self.animation_dots + 1) % 5 # Cycle 0-4 dots # Schedule next update in 500ms self.animation_job = self.parent.after(500, self._animate_transfer_text) - + def _update_status_label(self, text: str) -> None: """Update the status label with the given text. - + Args: text: Text to display in the status label """ - if hasattr(self.parent, 'status_label'): + if hasattr(self.parent, "status_label"): self.parent.status_label.config(text=text) self.parent.update_idletasks() - + def _reset_file_progress(self) -> None: """Reset file progress tracking.""" - self.transfer_file_progress = { - 'current': 0, - 'total': 0, - 'active': False - } - + self.transfer_file_progress = {"current": 0, "total": 0, "active": False} + def is_animation_running(self) -> bool: """Check if any animation is currently running. - + Returns: True if animation is running, False otherwise """ return self.animation_job is not None - + def is_scanning(self) -> bool: """Check if scanning animation is active. - + Returns: True if scanning animation is active, False otherwise """ - return self.scanning_active \ No newline at end of file + return self.scanning_active diff --git a/src/gui/main_window.py b/src/gui/main_window.py index 3a4964c..49554d3 100644 --- a/src/gui/main_window.py +++ b/src/gui/main_window.py @@ -21,8 +21,11 @@ try: from .handlers.animation_handler import AnimationHandler from .dialogs.dialog_manager import DialogManager from .components.ui_components import ( - PathSelectorFrame, DirectionSelector, StatusLabel, - TransferButton, LicenseManager + PathSelectorFrame, + DirectionSelector, + StatusLabel, + TransferButton, + LicenseManager, ) from ..managers.device_manager import DeviceManager from ..managers.transfer_manager import TransferManager @@ -32,8 +35,11 @@ except ImportError: from gui.handlers.animation_handler import AnimationHandler from gui.dialogs.dialog_manager import DialogManager from gui.components.ui_components import ( - PathSelectorFrame, DirectionSelector, StatusLabel, - TransferButton, LicenseManager + PathSelectorFrame, + DirectionSelector, + StatusLabel, + TransferButton, + LicenseManager, ) from managers.device_manager import DeviceManager from managers.transfer_manager import TransferManager @@ -45,26 +51,23 @@ class AndroidFileHandlerGUI(tk.Tk): def __init__(self): """Initialize the main GUI application.""" super().__init__() - + # Transfer tracking for thread safety self.current_transfer_id = 0 self.device_connected = False - + # Initialize modular components self.license_manager = LicenseManager(self) self.device_manager = DeviceManager(self) # Creates its own ADBManager self.dialog_manager = DialogManager(self) self.animation_handler = AnimationHandler(self) self.transfer_manager = TransferManager( - self, - self.device_manager, - self.animation_handler, - self.dialog_manager + self, self.device_manager, self.animation_handler, self.dialog_manager ) - + # Get ADB manager reference from device manager self.adb_manager = self.device_manager.adb_manager - + # Initialize UI and start application self._setup_initial_ui() @@ -75,7 +78,7 @@ class AndroidFileHandlerGUI(tk.Tk): self.geometry("520x320") self.minsize(520, 320) self.resizable(True, True) - + if self.license_manager.needs_license_agreement(): # Show license agreement first self.license_manager.show_license_agreement(self._show_main_interface) @@ -88,7 +91,7 @@ class AndroidFileHandlerGUI(tk.Tk): # Reset window size for main interface self.geometry("520x320") self.minsize(520, 320) - + # Setup main UI self._setup_main_ui() self._initialize_components() @@ -98,22 +101,18 @@ class AndroidFileHandlerGUI(tk.Tk): """Setup the main user interface.""" # Direction selection self.direction_selector = DirectionSelector(self, self._on_direction_change) - + # Create a container frame for the path sections that can be reordered self.path_container = tk.Frame(self) self.path_container.pack(fill="x", padx=10, pady=(10, 0)) # Create path selector components self.android_path_selector = PathSelectorFrame( - self.path_container, - "Android device:", - self.browse_remote_folder + self.path_container, "Android device:", self.browse_remote_folder ) - + self.computer_path_selector = PathSelectorFrame( - self.path_container, - "Computer:", - self.browse_local_folder + self.path_container, "Computer:", self.browse_local_folder ) # Initially arrange for pull (Android on top) @@ -124,7 +123,7 @@ class AndroidFileHandlerGUI(tk.Tk): # Transfer button with multi-mode functionality self.transfer_button = TransferButton(self) - + # Initially disable components until device is connected self._disable_browse_buttons() @@ -134,20 +133,24 @@ class AndroidFileHandlerGUI(tk.Tk): def _initialize_components(self): """Initialize GUI components and handlers.""" # Set up ADB callbacks directly - self.adb_manager.set_progress_callback(lambda x: None) # Ignore progress for now + self.adb_manager.set_progress_callback( + lambda x: None + ) # Ignore progress for now self.adb_manager.set_status_callback(self._update_status) - + # Connect transfer manager callbacks self.transfer_manager.set_status_callback(self._update_status) - self.transfer_manager.set_ui_callback('disable_controls', self.disable_controls) - self.transfer_manager.set_ui_callback('enable_controls', self.enable_controls) - self.transfer_manager.set_ui_callback('show_error', self.report_error) - self.transfer_manager.set_ui_callback('show_stats', self._show_transfer_stats) - self.transfer_manager.set_ui_callback('show_reminder', self._show_debugging_reminder) + self.transfer_manager.set_ui_callback("disable_controls", self.disable_controls) + self.transfer_manager.set_ui_callback("enable_controls", self.enable_controls) + self.transfer_manager.set_ui_callback("show_error", self.report_error) + self.transfer_manager.set_ui_callback("show_stats", self._show_transfer_stats) + self.transfer_manager.set_ui_callback( + "show_reminder", self._show_debugging_reminder + ) def _update_status(self, message: str): """Update the status label from any thread. - + Args: message: Status message to display """ @@ -166,7 +169,7 @@ class AndroidFileHandlerGUI(tk.Tk): """Validate selected paths and update button state accordingly.""" android_path_valid = self.android_path_selector.is_path_selected() computer_path_valid = self.computer_path_selector.is_path_selected() - + if android_path_valid and computer_path_valid and self.device_connected: self.transfer_button.set_transfer_mode(self.start_transfer, enabled=True) else: @@ -193,25 +196,29 @@ class AndroidFileHandlerGUI(tk.Tk): # Initialize ADB and check device connection self.device_manager.initialize_adb() self.device_connected = self.device_manager.check_device_connection() - + if self.device_connected: - self._update_status("Status: Android device detected. Ready for file transfer.") + self._update_status( + "Status: Android device detected. Ready for file transfer." + ) self._enable_browse_buttons() self._validate_paths_and_update_button() else: - self._update_status("Status: No Android device detected. Please connect your device and enable USB debugging.") + self._update_status( + "Status: No Android device detected. Please connect your device and enable USB debugging." + ) self.transfer_button.set_recheck_mode(self.recheck_device) def _on_direction_change(self): """Handle transfer direction change.""" self._arrange_path_sections() - + def _arrange_path_sections(self): """Arrange path sections based on transfer direction.""" # Remove both sections first self.android_path_selector.pack_forget() self.computer_path_selector.pack_forget() - + if self.direction_selector.get_direction() == "pull": # Pull: Android (source) on top, Computer (destination) on bottom self.android_path_selector.pack(fill="x", pady=(0, 0)) @@ -223,56 +230,63 @@ class AndroidFileHandlerGUI(tk.Tk): def browse_remote_folder(self): """Open the Android file browser for remote path selection.""" + def on_path_selected(path): self.android_path_selector.set_path(path) self._validate_paths_and_update_button() + browser = AndroidFileBrowser(self, self.adb_manager, on_path_selected) browser.show_browser(direction="pull") - + def browse_local_folder(self): """Browse for local file or folder selection.""" + def on_file_selected(): filename = filedialog.askopenfilename( - title="Select a file to transfer", - initialdir=os.path.expanduser("~") + title="Select a file to transfer", initialdir=os.path.expanduser("~") ) if filename: self.computer_path_selector.set_path(filename) self._validate_paths_and_update_button() - + def on_folder_selected(): foldername = filedialog.askdirectory( - title="Select a folder to transfer", - initialdir=os.path.expanduser("~") + title="Select a folder to transfer", initialdir=os.path.expanduser("~") ) if foldername: self.computer_path_selector.set_path(foldername) self._validate_paths_and_update_button() - + # Show file/folder selection dialog - self.dialog_manager.show_file_folder_choice(on_file_selected, on_folder_selected) + self.dialog_manager.show_file_folder_choice( + on_file_selected, on_folder_selected + ) def recheck_device(self): """Recheck for connected Android device.""" self.transfer_button.set_checking_mode() self.animation_handler.start_scanning_animation("Status: Scanning") - + def perform_recheck(): self.device_connected = self.device_manager.check_device_connection() self.after(0, self._handle_device_recheck_result) - + threading.Thread(target=perform_recheck, daemon=True).start() def _handle_device_recheck_result(self): """Handle the result of device recheck.""" self.animation_handler.stop_animation() - + if self.device_connected: - self._update_status("Status: Android device detected. Ready for file transfer.") + self._update_status( + "Status: Android device detected. Ready for file transfer." + ) self._enable_browse_buttons() self._validate_paths_and_update_button() else: - self._update_status("Status: No Android device detected. Please connect your device and enable USB debugging.") + self._update_status( + "Status: No Android device detected. Please connect your device and enable USB debugging." + ) self.transfer_button.set_recheck_mode(self.recheck_device) def start_transfer(self): @@ -282,34 +296,34 @@ class AndroidFileHandlerGUI(tk.Tk): direction = self.direction_selector.get_direction() remote_path = self.android_path_selector.get_path() local_path = self.computer_path_selector.get_path() - + # Validate paths if not self.android_path_selector.is_path_selected(): messagebox.showerror("Error", "Please select an Android device path.") return - + if not self.computer_path_selector.is_path_selected(): messagebox.showerror("Error", "Please select a computer path.") return - + # Check device connection if not self.device_manager.check_device_connection(): - messagebox.showerror("Error", "Android device not connected. Please check your connection and try again.") + messagebox.showerror( + "Error", + "Android device not connected. Please check your connection and try again.", + ) self._handle_device_disconnection() return - + # Switch to cancel mode and start transfer self.transfer_button.set_cancel_mode(self.cancel_transfer) self.animation_handler.start_transfer_animation("Status: Transferring") - + # Start transfer using transfer manager self.transfer_manager.start_transfer( - direction, - remote_path, - local_path, - self._on_transfer_complete + direction, remote_path, local_path, self._on_transfer_complete ) - + except Exception as e: self.report_error(f"Error starting transfer: {str(e)}") @@ -321,23 +335,28 @@ class AndroidFileHandlerGUI(tk.Tk): self._update_status("Status: Transfer cancelled by user.") self.enable_controls() self._validate_paths_and_update_button() - + except Exception as e: self.report_error(f"Error cancelling transfer: {str(e)}") def _on_transfer_complete(self, success: bool, stats: dict, operation: str): """Handle transfer completion.""" self.animation_handler.stop_animation() - + if success: self._update_status(f"Status: Transfer complete! Successfully {operation}.") if stats: self._show_transfer_stats(stats, operation) self._show_debugging_reminder() else: - self._update_status("Status: Transfer failed. Please check your connection and try again.") - messagebox.showerror("Transfer Failed", "The file transfer was not successful. Please check your device connection and try again.") - + self._update_status( + "Status: Transfer failed. Please check your connection and try again." + ) + messagebox.showerror( + "Transfer Failed", + "The file transfer was not successful. Please check your device connection and try again.", + ) + self.enable_controls() self._validate_paths_and_update_button() @@ -347,7 +366,9 @@ class AndroidFileHandlerGUI(tk.Tk): self._clear_paths_and_disable_button() self._disable_browse_buttons() self.transfer_button.set_recheck_mode(self.recheck_device) - self._update_status("Status: Device disconnected. Please reconnect and enable USB debugging.") + self._update_status( + "Status: Device disconnected. Please reconnect and enable USB debugging." + ) def disable_controls(self): """Disable UI controls during transfer.""" @@ -366,7 +387,7 @@ class AndroidFileHandlerGUI(tk.Tk): def _disable_controls_ui(self): """Disable controls on UI thread.""" self._disable_browse_buttons() - + def _enable_controls_ui(self): """Enable controls on UI thread.""" self._enable_browse_buttons() @@ -396,13 +417,13 @@ class AndroidFileHandlerGUI(tk.Tk): """Handle window close event.""" try: # Cancel any ongoing transfers - if hasattr(self, 'transfer_manager'): + if hasattr(self, "transfer_manager"): self.transfer_manager.cancel_transfer() - + # Stop any animations - if hasattr(self, 'animation_handler'): + if hasattr(self, "animation_handler"): self.animation_handler.stop_animation() - + self.destroy() except Exception as e: print(f"Error during close: {e}") @@ -416,4 +437,4 @@ def main(): if __name__ == "__main__": - main() \ No newline at end of file + main() diff --git a/src/gui/progress_handler.py b/src/gui/progress_handler.py index d6697d0..5ae0e1a 100644 --- a/src/gui/progress_handler.py +++ b/src/gui/progress_handler.py @@ -11,18 +11,25 @@ class ProgressHandler: """Handles progress bar updates and status messages.""" def __init__( - self, parent_widget: tk.Widget, progress_bar: ttk.Progressbar, status_label: tk.Label + self, + parent_widget: tk.Widget, + progress_bar: ttk.Progressbar, + status_label: tk.Label, ) -> None: """Initialize the progress handler.""" self.parent = parent_widget - self.progress_bar = progress_bar # Don't call as function - it's already created + self.progress_bar = ( + progress_bar # Don't call as function - it's already created + ) self.status_label = status_label self._last_percentage: float = 0.0 self._transfer_active: bool = False # Track if a transfer is actually active - def update_progress(self, bytes_transferred_or_percentage, bytes_total=None) -> None: + def update_progress( + self, bytes_transferred_or_percentage, bytes_total=None + ) -> None: """Update the progress bar (thread-safe). - + Args: bytes_transferred_or_percentage: Either bytes transferred (if bytes_total provided) or percentage (0-100) bytes_total: Total number of bytes to transfer (optional) @@ -37,10 +44,10 @@ class ProgressHandler: else: # Called with just percentage percentage = float(bytes_transferred_or_percentage) - + # Ensure percentage is within valid range percentage = max(0.0, min(100.0, percentage)) - + # Schedule UI update on main thread using a proper closure def update_ui() -> None: self._update_progress_ui(percentage) @@ -49,15 +56,15 @@ class ProgressHandler: def _update_progress_ui(self, percentage: float) -> None: """Internal method to update progress bar on main thread. - + Args: percentage: Progress percentage (0.0 to 100.0) - used for logging only in indeterminate mode """ - + # Only log significant progress jumps (10% or more) if abs(percentage - self._last_percentage) >= 10.0: pass # Could add non-debug logging here if needed - + self._last_percentage = percentage try: @@ -67,13 +74,14 @@ class ProgressHandler: elif percentage >= 100: self.progress_bar.stop() # Stop animation when complete self._transfer_active = False # Transfer is done - + self.parent.update_idletasks() except Exception as exception_error: pass # Silent error handling def reset_progress(self) -> None: """Reset progress bar to 0 (thread-safe).""" + def update_ui() -> None: try: self.progress_bar.stop() # Stop any animation @@ -91,10 +99,11 @@ class ProgressHandler: def set_status(self, message: str) -> None: """Set the status label text (thread-safe). - + Args: message: The status message to display """ + # Schedule UI update on main thread def update_ui() -> None: self._set_status_ui(message) @@ -103,7 +112,7 @@ class ProgressHandler: def _set_status_ui(self, message: str) -> None: """Internal method to set status label on main thread. - + Args: message: The status message to display """ @@ -111,4 +120,4 @@ class ProgressHandler: self.status_label.config(text=message) self.parent.update_idletasks() except Exception as exception_error: - pass # Silent error handling \ No newline at end of file + pass # Silent error handling diff --git a/src/managers/__init__.py b/src/managers/__init__.py index 7d1505b..cc4bf26 100644 --- a/src/managers/__init__.py +++ b/src/managers/__init__.py @@ -1,4 +1,4 @@ """ Managers Package Business logic managers for device and transfer operations. -""" \ No newline at end of file +""" diff --git a/src/managers/device_manager.py b/src/managers/device_manager.py index 045cf62..67e6426 100644 --- a/src/managers/device_manager.py +++ b/src/managers/device_manager.py @@ -15,10 +15,14 @@ except ImportError: class DeviceManager: """Manages Android device connections and ADB operations.""" - - def __init__(self, parent_window: tk.Tk, status_callback: Optional[Callable[[str], None]] = None): + + def __init__( + self, + parent_window: tk.Tk, + status_callback: Optional[Callable[[str], None]] = None, + ): """Initialize the device manager. - + Args: parent_window: The main window instance status_callback: Callback function for status updates @@ -27,14 +31,14 @@ class DeviceManager: self.status_callback = status_callback self.adb_manager = ADBManager() self.device_connected = False - + # Set up ADB callbacks self.adb_manager.set_status_callback(self._on_adb_status_update) self.adb_manager.set_progress_callback(self._on_adb_progress_update) - + def initialize_adb(self) -> bool: """Initialize ADB and download tools if needed. - + Returns: True if ADB is available and ready, False otherwise """ @@ -46,14 +50,14 @@ class DeviceManager: "and speeds up file transfers over USB between computers and Android devices. " "Please do not delete or move the platform-tools folder that will be " "downloaded. These are tools written by Google " - "and they are required for this application to function properly." + "and they are required for this application to function properly.", ) - + self._update_status("ADB not found locally. Downloading...") self.parent.update() self.adb_manager.download_and_extract_adb() success = is_adb_available() - + if success: self._update_status("ADB downloaded and ready.") return True @@ -64,18 +68,18 @@ class DeviceManager: ) messagebox.showerror("Error", "Failed to download ADB tools. Exiting.") return False - + return True - + def check_device_connection(self) -> Optional[str]: """Check for device connection and update status. - + Returns: Device ID if connected, None otherwise """ self._update_status("Checking for connected device...") self.parent.update() - + device = self.adb_manager.check_device() if device: self.device_connected = True @@ -88,34 +92,36 @@ class DeviceManager: "securely inserted, USB debugging is enabled, and that File Transfer mode is turned on." ) return None - + def is_remote_file(self, remote_path: str) -> bool: """Check if the remote path points to a file (not a directory). - + Args: remote_path: Path on the Android device - + Returns: True if it's a file, False if it's a directory or check fails """ try: - result = self.adb_manager.run_adb_command(["shell", "ls", "-la", remote_path]) + result = self.adb_manager.run_adb_command( + ["shell", "ls", "-la", remote_path] + ) if isinstance(result, tuple) and len(result) == 3: stdout, stderr, returncode = result if returncode == 0 and stdout: # If the output starts with '-', it's a regular file - return stdout.strip().startswith('-') + return stdout.strip().startswith("-") return False except Exception: return False - + def get_file_transfer_methods(self, direction: str, is_file: bool): """Get the appropriate transfer methods based on direction and type. - + Args: direction: Transfer direction ('pull' or 'push') is_file: Whether transferring a file (True) or folder (False) - + Returns: Tuple of (transfer_method, transfer_type) """ @@ -129,44 +135,44 @@ class DeviceManager: return self.adb_manager.push_file, "file" else: return self.adb_manager.push_folder_with_dedup, "folder" - + def cancel_current_operation(self) -> None: """Cancel the current ADB operation.""" try: self.adb_manager.cancel_current_operation() except Exception as e: print(f"Error cancelling operation: {e}") - + def cancel_transfer(self) -> bool: """Cancel the current transfer operation. - + Returns: True if transfer was cancelled successfully, False otherwise """ return self.adb_manager.cancel_transfer() - + def _update_status(self, message: str) -> None: """Update status through callback if available. - + Args: message: Status message to display """ if self.status_callback: self.status_callback(message) - + def _on_adb_status_update(self, message: str) -> None: """Handle status updates from ADB manager. - + Args: message: Status message from ADB operations """ self._update_status(message) - + def _on_adb_progress_update(self, percentage: int) -> None: """Handle progress updates from ADB manager. - + Args: percentage: Progress percentage (0-100) """ # Currently we ignore progress updates and rely on status updates - pass \ No newline at end of file + pass diff --git a/src/managers/transfer_manager.py b/src/managers/transfer_manager.py index 50d61b8..71bbc8c 100644 --- a/src/managers/transfer_manager.py +++ b/src/managers/transfer_manager.py @@ -19,11 +19,16 @@ except ImportError: class TransferManager: """Manages file transfer operations and coordination.""" - - def __init__(self, parent_window, device_manager: DeviceManager, - animation_handler: AnimationHandler, dialog_manager: DialogManager): + + def __init__( + self, + parent_window, + device_manager: DeviceManager, + animation_handler: AnimationHandler, + dialog_manager: DialogManager, + ): """Initialize the transfer manager. - + Args: parent_window: The main window instance device_manager: Device manager instance @@ -34,41 +39,46 @@ class TransferManager: self.device_manager = device_manager self.animation_handler = animation_handler self.dialog_manager = dialog_manager - + # Transfer tracking self.current_transfer_id = 0 - + # UI callbacks self.ui_callbacks = {} - + def set_ui_callback(self, name: str, callback: Callable) -> None: """Set a UI callback function. - + Args: name: Name of the callback callback: Function to call """ self.ui_callbacks[name] = callback - + def set_status_callback(self, callback: Callable[[str], None]) -> None: """Set the status update callback. - + Args: callback: Function to call for status updates """ - self.ui_callbacks['status'] = callback + self.ui_callbacks["status"] = callback self.status_callback = callback - + def set_controls_callback(self, callback: Callable[[], None]) -> None: """Set the controls update callback. - + Args: callback: Function to call for enabling/disabling controls """ self.controls_callback = callback - - def start_transfer(self, direction: str, source_path: str, dest_path: str, - completion_callback: Optional[Callable] = None) -> bool: + + def start_transfer( + self, + direction: str, + source_path: str, + dest_path: str, + completion_callback: Optional[Callable] = None, + ) -> bool: """Start a file transfer operation. Args: @@ -91,18 +101,18 @@ class TransferManager: is_file = os.path.isfile(source_path) # Disable controls during transfer - if 'disable_controls' in self.ui_callbacks: - self.ui_callbacks['disable_controls']() + if "disable_controls" in self.ui_callbacks: + self.ui_callbacks["disable_controls"]() # Start transfer in background thread transfer_thread = threading.Thread( target=self._transfer_thread, args=(direction, source_path, dest_path, transfer_id, is_file), - daemon=True + daemon=True, ) transfer_thread.start() return True - + def _is_remote_file(self, remote_path: str) -> bool: """Check if a remote path is a file. @@ -121,63 +131,76 @@ class TransferManager: def cancel_transfer(self) -> bool: """Cancel the current transfer operation. - + Returns: True if transfer was cancelled successfully, False otherwise """ # Cancel the actual ADB process cancelled = self.device_manager.cancel_transfer() - + # Increment transfer ID to invalidate current transfer self.current_transfer_id += 1 - + # Stop animation and restore UI self.animation_handler.stop_animation() - + # Update status - status = "Transfer cancelled by user." if cancelled else "Transfer cancellation failed." + status = ( + "Transfer cancelled by user." + if cancelled + else "Transfer cancellation failed." + ) self._update_status(status) - + return cancelled - - def _validate_transfer_paths(self, direction: str, source_path: str, dest_path: str) -> bool: + + def _validate_transfer_paths( + self, direction: str, source_path: str, dest_path: str + ) -> bool: """Validate transfer paths based on direction. - + Args: direction: Transfer direction ('pull' or 'push') source_path: Source path dest_path: Destination path - + Returns: True if paths are valid, False otherwise """ if not source_path or not dest_path: - self.dialog_manager.show_error("Input Error", "Both source and destination paths are required.") + self.dialog_manager.show_error( + "Input Error", "Both source and destination paths are required." + ) return False - + if direction == "push": # For push operations, validate local source path exists if not os.path.exists(source_path): self.dialog_manager.show_error( - "Input Error", - f"Source path does not exist: {source_path}" + "Input Error", f"Source path does not exist: {source_path}" ) return False else: # pull operations # For pull operations, validate local destination is a directory if not os.path.isdir(dest_path): self.dialog_manager.show_error( - "Input Error", - "Destination must be a valid directory for pulled files." + "Input Error", + "Destination must be a valid directory for pulled files.", ) return False - + return True - - def _transfer_thread(self, direction: str, source_path: str, dest_path: str, - transfer_id: int, is_file: bool) -> None: + + def _transfer_thread( + self, + direction: str, + source_path: str, + dest_path: str, + transfer_id: int, + is_file: bool, + ) -> None: """Background thread function for transfer operations. - + Args: direction: Transfer direction ('pull' or 'push') source_path: Source path @@ -189,7 +212,7 @@ class TransferManager: # Check if this transfer is still current if self.current_transfer_id != transfer_id: return - + # Recheck device connectivity before proceeding device = self.device_manager.adb_manager.check_device() if not device: @@ -197,15 +220,22 @@ class TransferManager: self.animation_handler.stop_animation() self.parent.after(0, self._handle_device_disconnection) return - + # Get the appropriate transfer method - transfer_method, transfer_type = self.device_manager.get_file_transfer_methods(direction, is_file) - + transfer_method, transfer_type = ( + self.device_manager.get_file_transfer_methods(direction, is_file) + ) + # Perform the transfer success, stats = self._execute_transfer( - direction, source_path, dest_path, transfer_method, transfer_type, is_file + direction, + source_path, + dest_path, + transfer_method, + transfer_type, + is_file, ) - + if success and self.current_transfer_id == transfer_id: self.animation_handler.stop_animation() transfer_desc = "File" if is_file else "Folder" @@ -213,31 +243,50 @@ class TransferManager: f"{transfer_desc} transfer completed successfully. " "To start another transfer, please select another file or folder." ) - + # Show debugging reminder and transfer statistics - self.parent.after(0, self.dialog_manager.show_disable_debugging_reminder) - + self.parent.after( + 0, self.dialog_manager.show_disable_debugging_reminder + ) + if stats is not None: - self.parent.after(0, lambda: self.dialog_manager.show_transfer_stats( - stats, direction.capitalize(), self.device_manager.adb_manager.deduplicator - )) - + self.parent.after( + 0, + lambda: self.dialog_manager.show_transfer_stats( + stats, + direction.capitalize(), + self.device_manager.adb_manager.deduplicator, + ), + ) + except Exception as e: if self.current_transfer_id == transfer_id: self.animation_handler.stop_animation() transfer_desc = "file" if is_file else "folder" - error_msg = f"{direction.capitalize()} {transfer_desc} operation failed: {e}" - self.parent.after(0, lambda: self.dialog_manager.show_error("Transfer Error", error_msg)) + error_msg = ( + f"{direction.capitalize()} {transfer_desc} operation failed: {e}" + ) + self.parent.after( + 0, + lambda: self.dialog_manager.show_error("Transfer Error", error_msg), + ) finally: if self.current_transfer_id == transfer_id: self.animation_handler.stop_animation() if self.controls_callback: self.parent.after(0, self.controls_callback) - - def _execute_transfer(self, direction: str, source_path: str, dest_path: str, - transfer_method: Callable, transfer_type: str, is_file: bool) -> Tuple[bool, Optional[Dict[str, Any]]]: + + def _execute_transfer( + self, + direction: str, + source_path: str, + dest_path: str, + transfer_method: Callable, + transfer_type: str, + is_file: bool, + ) -> Tuple[bool, Optional[Dict[str, Any]]]: """Execute the actual transfer operation. - + Args: direction: Transfer direction source_path: Source path @@ -245,7 +294,7 @@ class TransferManager: transfer_method: Transfer method to call transfer_type: Type of transfer ('file' or 'folder') is_file: True if transferring a file - + Returns: Tuple of (success, stats_dict or None) """ @@ -264,7 +313,7 @@ class TransferManager: # File transfers return just success success = transfer_method(source_path, dest_path) return success, None - + def _handle_device_disconnection(self) -> None: """Handle device disconnection during transfers.""" self.device_manager.device_connected = False @@ -273,19 +322,19 @@ class TransferManager: "securely inserted, USB debugging is enabled, and that File Transfer mode is turned on." ) self.dialog_manager.show_enable_debugging_instructions() - + def _update_status(self, message: str) -> None: """Update status through callback if available. - + Args: message: Status message to display """ if self.status_callback: self.status_callback(message) - + def handle_adb_status_update(self, message: str) -> None: """Handle status updates from ADB operations. - + Args: message: Status message from ADB """ @@ -297,10 +346,13 @@ class TransferManager: total = int(parts[2]) self.animation_handler.update_transfer_progress(current, total) return - + # Check for animation state changes if self.animation_handler.is_animation_running(): - if "Scanning for duplicates" in message and not self.animation_handler.is_scanning(): + if ( + "Scanning for duplicates" in message + and not self.animation_handler.is_scanning() + ): # Already scanning, ignore duplicate messages return elif "Starting transfer" in message or "Transferring" in message: @@ -308,9 +360,11 @@ class TransferManager: self.animation_handler.stop_animation() self.animation_handler.start_transfer_animation() return - elif ("Duplicate scan complete" in message or - "No duplicates found" in message or - "All files already exist" in message): + elif ( + "Duplicate scan complete" in message + or "No duplicates found" in message + or "All files already exist" in message + ): # Allow these messages to show briefly before transfer starts self.animation_handler.stop_animation() self._update_status(message) @@ -322,6 +376,6 @@ class TransferManager: # Start scanning animation self.animation_handler.start_scanning_animation() return - + # Normal status update - self._update_status(message) \ No newline at end of file + self._update_status(message) diff --git a/src/utils/__init__.py b/src/utils/__init__.py index ec72994..e8e6013 100644 --- a/src/utils/__init__.py +++ b/src/utils/__init__.py @@ -1,4 +1,4 @@ """ Utils Package Utility functions and helper modules. -""" \ No newline at end of file +""" diff --git a/src/utils/file_deduplication.py b/src/utils/file_deduplication.py index 5aace7d..d862154 100644 --- a/src/utils/file_deduplication.py +++ b/src/utils/file_deduplication.py @@ -10,44 +10,49 @@ from typing import Optional, Dict, List, Tuple, Callable class FileDeduplicator: """Handles file hash computation and duplicate detection.""" - - def __init__(self, status_callback: Optional[Callable[[str], None]] = None, - progress_callback: Optional[Callable[[int], None]] = None): + + def __init__( + self, + status_callback: Optional[Callable[[str], None]] = None, + progress_callback: Optional[Callable[[int], None]] = None, + ): """Initialize the deduplicator with optional callbacks. - + Args: status_callback: Function to call with status updates progress_callback: Function to call with progress updates (0-100) """ self.status_callback = status_callback self.progress_callback = progress_callback - + def _update_status(self, message: str) -> None: """Update status if callback is available.""" if self.status_callback: self.status_callback(message) - + def _update_progress(self, percentage: int) -> None: """Update progress if callback is available.""" if self.progress_callback: self.progress_callback(percentage) - - def compute_local_file_hash(self, file_path: str, algorithm: str = 'sha256') -> Optional[str]: + + def compute_local_file_hash( + self, file_path: str, algorithm: str = "sha256" + ) -> Optional[str]: """Compute hash of a local file. - + Args: file_path: Path to the local file algorithm: Hash algorithm to use ('md5', 'sha1', 'sha256') - + Returns: Hex digest of the file hash, or None if error """ try: if not os.path.isfile(file_path): return None - + hash_obj = hashlib.new(algorithm) - with open(file_path, 'rb') as file_handle: + with open(file_path, "rb") as file_handle: # Read in chunks to handle large files efficiently for chunk in iter(lambda: file_handle.read(8192), b""): hash_obj.update(chunk) @@ -56,139 +61,162 @@ class FileDeduplicator: self._update_status(f"Error computing hash for {file_path}: {exception}") return None - def compute_remote_file_hash(self, remote_path: str, algorithm: str = 'sha256', - adb_command_runner: Optional[Callable] = None) -> Optional[str]: + def compute_remote_file_hash( + self, + remote_path: str, + algorithm: str = "sha256", + adb_command_runner: Optional[Callable] = None, + ) -> Optional[str]: """Compute hash of a file on the Android device using ADB shell. - + Args: remote_path: Path to the file on the Android device algorithm: Hash algorithm to use ('md5', 'sha1', 'sha256') adb_command_runner: Function that runs ADB commands and returns (stdout, stderr, returncode) - + Returns: Hex digest of the file hash, or None if error """ if not adb_command_runner: self._update_status("No ADB command runner provided") return None - + try: # Map algorithm names to Android shell commands - hash_commands = { - 'md5': 'md5sum', - 'sha1': 'sha1sum', - 'sha256': 'sha256sum' - } - + hash_commands = {"md5": "md5sum", "sha1": "sha1sum", "sha256": "sha256sum"} + if algorithm not in hash_commands: self._update_status(f"Unsupported hash algorithm: {algorithm}") return None - - cmd = ['shell', hash_commands[algorithm], remote_path] + + cmd = ["shell", hash_commands[algorithm], remote_path] stdout, stderr, returncode = adb_command_runner(cmd, capture_output=True) - + if returncode != 0 or not stdout: self._update_status(f"Failed to compute remote hash: {stderr}") return None - + # Parse output: "hash_value filename" hash_value = stdout.split()[0] if stdout else None return hash_value.lower() if hash_value else None - + except Exception as exception: - self._update_status(f"Error computing remote hash for {remote_path}: {exception}") + self._update_status( + f"Error computing remote hash for {remote_path}: {exception}" + ) return None - def check_files_identical(self, local_path: str, remote_path: str, - adb_command_runner: Optional[Callable] = None, - algorithm: str = 'sha256') -> bool: + def check_files_identical( + self, + local_path: str, + remote_path: str, + adb_command_runner: Optional[Callable] = None, + algorithm: str = "sha256", + ) -> bool: """Check if local and remote files are identical by comparing hashes. - + Args: local_path: Path to the local file remote_path: Path to the remote file adb_command_runner: Function that runs ADB commands algorithm: Hash algorithm to use - + Returns: True if files are identical, False otherwise """ local_hash = self.compute_local_file_hash(local_path, algorithm) - remote_hash = self.compute_remote_file_hash(remote_path, algorithm, adb_command_runner) - + remote_hash = self.compute_remote_file_hash( + remote_path, algorithm, adb_command_runner + ) + if local_hash is None or remote_hash is None: return False - + return local_hash == remote_hash - def build_local_file_hash_map(self, file_paths: List[str], - algorithm: str = 'sha256') -> Dict[str, str]: + def build_local_file_hash_map( + self, file_paths: List[str], algorithm: str = "sha256" + ) -> Dict[str, str]: """Build a mapping of local file paths to their hashes. - + Args: file_paths: List of local file paths to hash algorithm: Hash algorithm to use - + Returns: Dictionary mapping file paths to their hash values """ hash_map = {} total_files = len(file_paths) - + for index, file_path in enumerate(file_paths): file_hash = self.compute_local_file_hash(file_path, algorithm) - + if file_hash: hash_map[file_path] = file_hash - + # Update progress if total_files > 0: progress_percentage = int((index + 1) * 100 / total_files) self._update_progress(progress_percentage) - self._update_status(f"Computing local hashes... {index + 1}/{total_files}") - + self._update_status( + f"Computing local hashes... {index + 1}/{total_files}" + ) + return hash_map - def build_remote_file_hash_map(self, file_paths: List[str], - adb_command_runner: Optional[Callable] = None, - algorithm: str = 'sha256') -> Dict[str, str]: + def build_remote_file_hash_map( + self, + file_paths: List[str], + adb_command_runner: Optional[Callable] = None, + algorithm: str = "sha256", + ) -> Dict[str, str]: """Build a mapping of remote file paths to their hashes. - + Args: file_paths: List of remote file paths to hash adb_command_runner: Function that runs ADB commands algorithm: Hash algorithm to use - + Returns: Dictionary mapping file paths to their hash values """ if not adb_command_runner: self._update_status("No ADB command runner provided") return {} - + hash_map = {} total_files = len(file_paths) - + for index, file_path in enumerate(file_paths): - file_hash = self.compute_remote_file_hash(file_path, algorithm, adb_command_runner) - + file_hash = self.compute_remote_file_hash( + file_path, algorithm, adb_command_runner + ) + if file_hash: hash_map[file_path] = file_hash - + # Update progress if total_files > 0: progress_percentage = int((index + 1) * 100 / total_files) self._update_progress(progress_percentage) - self._update_status(f"Computing remote hashes... {index + 1}/{total_files}") - + self._update_status( + f"Computing remote hashes... {index + 1}/{total_files}" + ) + return hash_map - def find_duplicate_files(self, source_files: List[str], target_files: List[str], - is_remote_source: bool = False, is_remote_target: bool = False, - adb_command_runner: Optional[Callable] = None, - algorithm: str = 'sha256') -> Tuple[List[str], List[str]]: + def find_duplicate_files( + self, + source_files: List[str], + target_files: List[str], + is_remote_source: bool = False, + is_remote_target: bool = False, + adb_command_runner: Optional[Callable] = None, + algorithm: str = "sha256", + ) -> Tuple[List[str], List[str]]: """Find files that are duplicates between source and target lists. - + Args: source_files: List of source file paths target_files: List of target file paths @@ -196,56 +224,68 @@ class FileDeduplicator: is_remote_target: True if target files are on Android device adb_command_runner: Function that runs ADB commands algorithm: Hash algorithm to use - + Returns: Tuple of (files_to_transfer, duplicate_files) """ self._update_status("Building hash maps for duplicate detection...") - + # Build hash maps for both source and target if is_remote_source: - source_hashes = self.build_remote_file_hash_map(source_files, adb_command_runner, algorithm) + source_hashes = self.build_remote_file_hash_map( + source_files, adb_command_runner, algorithm + ) else: source_hashes = self.build_local_file_hash_map(source_files, algorithm) - + if is_remote_target: - target_hashes = self.build_remote_file_hash_map(target_files, adb_command_runner, algorithm) + target_hashes = self.build_remote_file_hash_map( + target_files, adb_command_runner, algorithm + ) else: target_hashes = self.build_local_file_hash_map(target_files, algorithm) - + # Find duplicates by comparing hashes target_hash_values = set(target_hashes.values()) files_to_transfer = [] duplicate_files = [] - + for source_file in source_files: source_hash = source_hashes.get(source_file) if source_hash and source_hash in target_hash_values: duplicate_files.append(source_file) else: files_to_transfer.append(source_file) - - self._update_status(f"Found {len(duplicate_files)} duplicates, {len(files_to_transfer)} files to transfer") - + + self._update_status( + f"Found {len(duplicate_files)} duplicates, {len(files_to_transfer)} files to transfer" + ) + return files_to_transfer, duplicate_files - def get_file_size(self, file_path: str, is_remote: bool = False, - adb_command_runner: Optional[Callable] = None) -> Optional[int]: + def get_file_size( + self, + file_path: str, + is_remote: bool = False, + adb_command_runner: Optional[Callable] = None, + ) -> Optional[int]: """Get the size of a file in bytes. - + Args: file_path: Path to the file is_remote: True if file is on Android device adb_command_runner: Function that runs ADB commands - + Returns: File size in bytes, or None if error """ try: if is_remote and adb_command_runner: - cmd = ['shell', 'stat', '-c', '%s', file_path] - stdout, stderr, returncode = adb_command_runner(cmd, capture_output=True) - + cmd = ["shell", "stat", "-c", "%s", file_path] + stdout, stderr, returncode = adb_command_runner( + cmd, capture_output=True + ) + if returncode == 0 and stdout.strip().isdigit(): return int(stdout.strip()) else: @@ -258,35 +298,39 @@ class FileDeduplicator: except Exception: return None - def calculate_transfer_savings(self, duplicate_files: List[str], is_remote: bool = False, - adb_command_runner: Optional[Callable] = None) -> Tuple[int, int]: + def calculate_transfer_savings( + self, + duplicate_files: List[str], + is_remote: bool = False, + adb_command_runner: Optional[Callable] = None, + ) -> Tuple[int, int]: """Calculate the number of bytes and files that would be saved by skipping duplicates. - + Args: duplicate_files: List of duplicate file paths is_remote: True if files are on Android device adb_command_runner: Function that runs ADB commands - + Returns: Tuple of (bytes_saved, files_saved) """ bytes_saved = 0 files_saved = 0 - + for file_path in duplicate_files: file_size = self.get_file_size(file_path, is_remote, adb_command_runner) if file_size is not None: bytes_saved += file_size files_saved += 1 - + return bytes_saved, files_saved def format_bytes(self, bytes_value: int) -> str: """Format bytes into human-readable string. - + Args: bytes_value: Number of bytes - + Returns: Formatted string (e.g., "1.5 MB") """ diff --git a/src/utils/security_utils.py b/src/utils/security_utils.py index 20eeb40..b029958 100644 --- a/src/utils/security_utils.py +++ b/src/utils/security_utils.py @@ -8,8 +8,8 @@ import re from typing import Optional # Pre-compiled regex patterns for performance -_DANGEROUS_CHAR_PATTERN = re.compile(r'[;|&$`\n\r><(){}[\]!]') -_DANGEROUS_PATH_PATTERN = re.compile(r'[;|&`\n\r]|\$[({]|&&|\|\||>>') +_DANGEROUS_CHAR_PATTERN = re.compile(r"[;|&$`\n\r><(){}[\]!]") +_DANGEROUS_PATH_PATTERN = re.compile(r"[;|&`\n\r]|\$[({]|&&|\|\||>>") def sanitize_path_component(component: str) -> str: @@ -28,17 +28,19 @@ def sanitize_path_component(component: str) -> str: raise ValueError("Path component cannot be empty") # Check for null bytes - if '\x00' in component: + if "\x00" in component: raise ValueError("Path component contains null byte") # Check for dangerous characters using pre-compiled regex # Matches any shell metacharacters that could be used for command injection match = _DANGEROUS_CHAR_PATTERN.search(component) if match: - raise ValueError(f"Path component contains dangerous character: {match.group()}") + raise ValueError( + f"Path component contains dangerous character: {match.group()}" + ) # Check for command substitution patterns - if '$(' in component or '${' in component: + if "$(" in component or "${" in component: raise ValueError("Path component contains command substitution pattern") return component @@ -63,7 +65,7 @@ def sanitize_android_path(path: str) -> str: path = path.strip() # Check for null bytes - if '\x00' in path: + if "\x00" in path: raise ValueError("Path contains null byte") # Check for command injection patterns using pre-compiled regex @@ -83,7 +85,9 @@ def sanitize_android_path(path: str) -> str: return path -def sanitize_local_path(path: str, base_dir: Optional[str] = None, allow_nonexistent: bool = True) -> str: +def sanitize_local_path( + path: str, base_dir: Optional[str] = None, allow_nonexistent: bool = True +) -> str: """Sanitize a local filesystem path and check for path traversal. Args: @@ -104,7 +108,7 @@ def sanitize_local_path(path: str, base_dir: Optional[str] = None, allow_nonexis path = path.strip() # Check for null bytes - if '\x00' in path: + if "\x00" in path: raise ValueError("Path contains null byte") # Normalize the path to resolve .. and symlinks @@ -131,8 +135,13 @@ def sanitize_local_path(path: str, base_dir: Optional[str] = None, allow_nonexis else: base_dir_abs = os.path.normpath(os.path.realpath(base_dir)) # Check if the normalized path starts with the base directory - if not normalized_path.startswith(base_dir_abs + os.sep) and normalized_path != base_dir_abs: - raise ValueError(f"Path traversal detected: path is outside base directory") + if ( + not normalized_path.startswith(base_dir_abs + os.sep) + and normalized_path != base_dir_abs + ): + raise ValueError( + f"Path traversal detected: path is outside base directory" + ) except (ValueError, OSError) as e: raise ValueError(f"Invalid base directory: {e}") @@ -155,11 +164,11 @@ def validate_device_id(device_id: str) -> str: raise ValueError("Device ID cannot be empty") # Device IDs should only contain alphanumeric characters, dots, colons, and hyphens - if not re.match(r'^[a-zA-Z0-9.:_-]+$', device_id): + if not re.match(r"^[a-zA-Z0-9.:_-]+$", device_id): raise ValueError("Device ID contains invalid characters") # Check for command injection patterns - dangerous_chars = [';', '|', '&', '$', '`', '\n', '\r', ' ', '>', '<'] + dangerous_chars = [";", "|", "&", "$", "`", "\n", "\r", " ", ">", "<"] for char in dangerous_chars: if char in device_id: raise ValueError(f"Device ID contains dangerous character: {char}") diff --git a/tests/__init__.py b/tests/__init__.py index 7b3aa58..fae6326 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -1 +1 @@ -"""Test package initialization.""" \ No newline at end of file +"""Test package initialization.""" diff --git a/tests/conftest.py b/tests/conftest.py index 28740db..687eac0 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -5,13 +5,14 @@ import sys import os # Add src directory to path for imports -sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'src')) +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "src")) @pytest.fixture def mock_tkinter_root(): """Create a mock Tkinter root window for GUI tests.""" import unittest.mock + return unittest.mock.MagicMock() @@ -19,20 +20,20 @@ def mock_tkinter_root(): def mock_tkinter_imports(): """Mock tkinter imports to avoid GUI dependencies in tests.""" import unittest.mock - + # Mock tkinter modules mock_tk = unittest.mock.MagicMock() mock_messagebox = unittest.mock.MagicMock() mock_filedialog = unittest.mock.MagicMock() - + modules_to_mock = { - 'tkinter': mock_tk, - 'tkinter.messagebox': mock_messagebox, - 'tkinter.filedialog': mock_filedialog, - 'tkinter.ttk': unittest.mock.MagicMock(), + "tkinter": mock_tk, + "tkinter.messagebox": mock_messagebox, + "tkinter.filedialog": mock_filedialog, + "tkinter.ttk": unittest.mock.MagicMock(), } - - with unittest.mock.patch.dict('sys.modules', modules_to_mock): + + with unittest.mock.patch.dict("sys.modules", modules_to_mock): yield @@ -41,7 +42,7 @@ def temp_directory(): """Create a temporary directory for testing.""" import tempfile import shutil - + temp_dir = tempfile.mkdtemp() yield temp_dir shutil.rmtree(temp_dir) @@ -52,11 +53,11 @@ def temp_file(): """Create a temporary file for testing.""" import tempfile import os - + fd, temp_path = tempfile.mkstemp() os.close(fd) yield temp_path try: os.unlink(temp_path) except FileNotFoundError: - pass \ No newline at end of file + pass diff --git a/tests/core/test_adb_command.py b/tests/core/test_adb_command.py index 2f7178c..a54773e 100644 --- a/tests/core/test_adb_command.py +++ b/tests/core/test_adb_command.py @@ -10,179 +10,178 @@ from src.core.adb_command import ADBCommandRunner class TestADBCommandRunner: """Test ADB command runner functionality.""" - + def test_init(self): """Test ADBCommandRunner initialization.""" runner = ADBCommandRunner() assert runner.current_process is None - - @patch('src.core.adb_command.get_adb_binary_path') - @patch('subprocess.run') + + @patch("src.core.adb_command.get_adb_binary_path") + @patch("subprocess.run") def test_run_adb_command_success(self, mock_subprocess, mock_get_path): """Test successful ADB command execution.""" - mock_get_path.return_value = '/path/to/adb' + mock_get_path.return_value = "/path/to/adb" mock_result = MagicMock() mock_result.returncode = 0 mock_result.stdout = "device_list" mock_result.stderr = "" mock_subprocess.return_value = mock_result - + runner = ADBCommandRunner() - stdout, stderr, returncode = runner.run_adb_command(['devices']) - + stdout, stderr, returncode = runner.run_adb_command(["devices"]) + assert returncode == 0 assert stdout == "device_list" assert stderr == "" mock_subprocess.assert_called_once_with( - ['/path/to/adb', 'devices'], - capture_output=True, - text=True, - timeout=15 + ["/path/to/adb", "devices"], capture_output=True, text=True, timeout=15 ) - - @patch('src.core.adb_command.get_adb_binary_path') - @patch('subprocess.run') + + @patch("src.core.adb_command.get_adb_binary_path") + @patch("subprocess.run") def test_run_adb_command_failure(self, mock_subprocess, mock_get_path): """Test ADB command execution failure.""" - mock_get_path.return_value = '/path/to/adb' + mock_get_path.return_value = "/path/to/adb" mock_result = MagicMock() mock_result.returncode = 1 mock_result.stdout = "" mock_result.stderr = "error message" mock_subprocess.return_value = mock_result - + runner = ADBCommandRunner() - stdout, stderr, returncode = runner.run_adb_command(['invalid']) - + stdout, stderr, returncode = runner.run_adb_command(["invalid"]) + assert returncode == 1 assert stdout == "" assert stderr == "error message" - - @patch('src.core.adb_command.get_adb_binary_path') - @patch('subprocess.run') + + @patch("src.core.adb_command.get_adb_binary_path") + @patch("subprocess.run") def test_run_adb_command_exception(self, mock_subprocess, mock_get_path): """Test ADB command execution with exception.""" - mock_get_path.return_value = '/path/to/adb' + mock_get_path.return_value = "/path/to/adb" mock_subprocess.side_effect = FileNotFoundError("ADB not found") - + runner = ADBCommandRunner() - stdout, stderr, returncode = runner.run_adb_command(['devices']) - + stdout, stderr, returncode = runner.run_adb_command(["devices"]) + assert returncode == -1 assert stdout is None assert "ADB not found" in stderr - - @patch('src.core.adb_command.get_adb_binary_path') - @patch('subprocess.Popen') + + @patch("src.core.adb_command.get_adb_binary_path") + @patch("subprocess.Popen") def test_run_adb_command_no_capture(self, mock_popen, mock_get_path): """Test ADB command execution without output capture.""" - mock_get_path.return_value = '/path/to/adb' + mock_get_path.return_value = "/path/to/adb" mock_process = MagicMock() mock_popen.return_value = mock_process - + runner = ADBCommandRunner() - result = runner.run_adb_command(['devices'], capture_output=False) - + result = runner.run_adb_command(["devices"], capture_output=False) + assert result == mock_process mock_popen.assert_called_once_with( - ['/path/to/adb', 'devices'], + ["/path/to/adb", "devices"], stdout=subprocess.PIPE, stderr=subprocess.STDOUT, - text=True + text=True, ) - + def test_check_device_no_process(self): """Test device check with no current process.""" runner = ADBCommandRunner() result = runner.check_device() assert result is None - - @patch('src.core.adb_command.get_adb_binary_path') - @patch('subprocess.run') + + @patch("src.core.adb_command.get_adb_binary_path") + @patch("subprocess.run") def test_check_device_with_devices(self, mock_subprocess, mock_get_path): """Test device check with connected devices.""" - mock_get_path.return_value = '/path/to/adb' + mock_get_path.return_value = "/path/to/adb" mock_result = MagicMock() mock_result.returncode = 0 - mock_result.stdout = "List of devices attached\ndevice1\tdevice\ndevice2\tdevice" + mock_result.stdout = ( + "List of devices attached\ndevice1\tdevice\ndevice2\tdevice" + ) mock_subprocess.return_value = mock_result - + runner = ADBCommandRunner() result = runner.check_device() - + assert result == "device1" - - @patch('src.core.adb_command.get_adb_binary_path') - @patch('subprocess.run') + + @patch("src.core.adb_command.get_adb_binary_path") + @patch("subprocess.run") def test_check_device_no_devices(self, mock_subprocess, mock_get_path): """Test device check with no connected devices.""" - mock_get_path.return_value = '/path/to/adb' + mock_get_path.return_value = "/path/to/adb" mock_result = MagicMock() mock_result.returncode = 0 mock_result.stdout = "List of devices attached\n" mock_subprocess.return_value = mock_result - + runner = ADBCommandRunner() result = runner.check_device() - + assert result is None - + def test_parse_progress_valid_percentage(self): """Test parsing valid progress percentage.""" runner = ADBCommandRunner() result = runner.parse_progress("Transferring: 45%") assert result == 45 - + def test_parse_progress_valid_fraction(self): """Test parsing valid progress fraction.""" runner = ADBCommandRunner() result = runner.parse_progress("1024/2048 KB transferred") assert result == 50 - + def test_parse_progress_valid_bytes(self): """Test parsing valid progress bytes.""" runner = ADBCommandRunner() result = runner.parse_progress("2048 KB/s (1048576 bytes in 2.5s)") assert result == 100 - + def test_parse_progress_invalid(self): """Test parsing invalid progress string.""" runner = ADBCommandRunner() result = runner.parse_progress("No progress info here") assert result is None - + def test_parse_progress_empty(self): """Test parsing empty progress string.""" runner = ADBCommandRunner() result = runner.parse_progress("") assert result is None - + def test_cancel_current_operation_no_process(self): """Test canceling operation with no current process.""" runner = ADBCommandRunner() result = runner.cancel_current_operation() assert result is False - + def test_cancel_current_operation_with_process(self): """Test canceling operation with active process.""" runner = ADBCommandRunner() mock_process = MagicMock() mock_process.poll.return_value = None # Process is running runner.current_process = mock_process - + result = runner.cancel_current_operation() - + assert result is True mock_process.terminate.assert_called_once() - + def test_cancel_current_operation_finished_process(self): """Test canceling operation with finished process.""" runner = ADBCommandRunner() mock_process = MagicMock() mock_process.poll.return_value = 0 # Process finished runner.current_process = mock_process - + result = runner.cancel_current_operation() - + assert result is False - mock_process.terminate.assert_not_called() \ No newline at end of file + mock_process.terminate.assert_not_called() diff --git a/tests/core/test_adb_manager.py b/tests/core/test_adb_manager.py index db6327b..ec36812 100644 --- a/tests/core/test_adb_manager.py +++ b/tests/core/test_adb_manager.py @@ -10,160 +10,170 @@ from src.core.adb_manager import ADBManager class TestADBManager: """Test main ADB manager functionality.""" - - @patch('src.core.adb_manager.get_adb_binary_path') + + @patch("src.core.adb_manager.get_adb_binary_path") def test_init_success(self, mock_get_path): """Test ADBManager initialization with successful ADB path.""" - mock_get_path.return_value = '/path/to/adb' - + mock_get_path.return_value = "/path/to/adb" + manager = ADBManager() - - assert manager.adb_path == '/path/to/adb' + + assert manager.adb_path == "/path/to/adb" assert manager.selected_device is None assert manager.progress_callback is None assert manager.status_callback is None - assert manager.transfer_progress['current_file'] == 0 - - @patch('src.core.adb_manager.get_adb_binary_path') + assert manager.transfer_progress["current_file"] == 0 + + @patch("src.core.adb_manager.get_adb_binary_path") def test_init_adb_path_failure(self, mock_get_path): """Test ADBManager initialization when ADB path fails.""" mock_get_path.side_effect = Exception("ADB not found") - + manager = ADBManager() - + assert manager.adb_path is None - - @patch('src.core.adb_manager.is_adb_available') + + @patch("src.core.adb_manager.is_adb_available") def test_is_available_true(self, mock_is_available): """Test ADB availability check returns True.""" mock_is_available.return_value = True - + manager = ADBManager() assert manager.is_available() is True - - @patch('src.core.adb_manager.is_adb_available') + + @patch("src.core.adb_manager.is_adb_available") def test_is_available_false(self, mock_is_available): """Test ADB availability check returns False.""" mock_is_available.return_value = False - + manager = ADBManager() assert manager.is_available() is False - - @patch('src.core.adb_manager.is_adb_available') - @patch('src.core.adb_manager.ensure_platform_tools_in_user_dir') - @patch('os.path.exists') - def test_ensure_adb_installed_already_available(self, mock_exists, mock_ensure, mock_is_available): + + @patch("src.core.adb_manager.is_adb_available") + @patch("src.core.adb_manager.ensure_platform_tools_in_user_dir") + @patch("os.path.exists") + def test_ensure_adb_installed_already_available( + self, mock_exists, mock_ensure, mock_is_available + ): """Test ensure_adb_installed when ADB is already available.""" mock_is_available.return_value = True - + manager = ADBManager() result = manager.ensure_adb_installed() - + assert result is True mock_ensure.assert_not_called() - - @patch('src.core.adb_manager.is_adb_available') - @patch('src.core.adb_manager.ensure_platform_tools_in_user_dir') - @patch('os.path.exists') - def test_ensure_adb_installed_download_success(self, mock_exists, mock_ensure, mock_is_available): + + @patch("src.core.adb_manager.is_adb_available") + @patch("src.core.adb_manager.ensure_platform_tools_in_user_dir") + @patch("os.path.exists") + def test_ensure_adb_installed_download_success( + self, mock_exists, mock_ensure, mock_is_available + ): """Test ensure_adb_installed with successful download.""" mock_is_available.return_value = False - mock_ensure.return_value = '/user/data/adb' + mock_ensure.return_value = "/user/data/adb" mock_exists.return_value = True - + manager = ADBManager() result = manager.ensure_adb_installed() - + assert result is True - assert manager.adb_path == '/user/data/adb' - - @patch('src.core.adb_manager.is_adb_available') - @patch('src.core.adb_manager.ensure_platform_tools_in_user_dir') - def test_ensure_adb_installed_download_failure(self, mock_ensure, mock_is_available): + assert manager.adb_path == "/user/data/adb" + + @patch("src.core.adb_manager.is_adb_available") + @patch("src.core.adb_manager.ensure_platform_tools_in_user_dir") + def test_ensure_adb_installed_download_failure( + self, mock_ensure, mock_is_available + ): """Test ensure_adb_installed with download failure.""" mock_is_available.return_value = False mock_ensure.side_effect = Exception("Download failed") - + manager = ADBManager() result = manager.ensure_adb_installed() - + assert result is False - + def test_get_devices_success(self): """Test successful device enumeration.""" manager = ADBManager() manager.command_runner.run_adb_command = MagicMock( - return_value=("List of devices attached\ndevice1\tdevice\ndevice2\tdevice", "", 0) + return_value=( + "List of devices attached\ndevice1\tdevice\ndevice2\tdevice", + "", + 0, + ) ) - + devices = manager.get_devices() - + assert devices == ["device1", "device2"] - + def test_get_devices_failure(self): """Test device enumeration failure.""" manager = ADBManager() manager.command_runner.run_adb_command = MagicMock( return_value=("", "Error", 1) ) - + devices = manager.get_devices() - + assert devices == [] - + def test_get_devices_no_devices(self): """Test device enumeration with no connected devices.""" manager = ADBManager() manager.command_runner.run_adb_command = MagicMock( return_value=("List of devices attached\n", "", 0) ) - + devices = manager.get_devices() - + assert devices == [] - - @patch.object(ADBManager, 'get_devices') + + @patch.object(ADBManager, "get_devices") def test_is_device_connected_specific_device(self, mock_get_devices): """Test checking if specific device is connected.""" mock_get_devices.return_value = ["device1", "device2"] - + manager = ADBManager() - + assert manager.is_device_connected("device1") is True assert manager.is_device_connected("device3") is False - - @patch.object(ADBManager, 'get_devices') + + @patch.object(ADBManager, "get_devices") def test_is_device_connected_any_device(self, mock_get_devices): """Test checking if any device is connected.""" mock_get_devices.return_value = ["device1"] - + manager = ADBManager() - + assert manager.is_device_connected() is True - + mock_get_devices.return_value = [] assert manager.is_device_connected() is False - + def test_select_device(self): """Test device selection.""" manager = ADBManager() manager.select_device("test_device") - + assert manager.selected_device == "test_device" - + def test_get_selected_device(self): """Test getting selected device.""" manager = ADBManager() manager.selected_device = "test_device" - + assert manager.get_selected_device() == "test_device" - + def test_get_selected_device_none(self): """Test getting selected device when none selected.""" manager = ADBManager() - + assert manager.get_selected_device() is None - + def test_list_files_success(self): """Test successful file listing.""" manager = ADBManager() @@ -172,233 +182,223 @@ class TestADBManager: "total 12\n" "drwxrwxr-x 2 user user 4096 Jan 1 12:00 Documents\n" "-rw-rw-r-- 1 user user 100 Jan 1 12:00 test.txt", - "", 0 + "", + 0, ) ) - + files = manager.list_files("/sdcard") - + assert len(files) == 2 - assert files[0]['name'] == 'Documents' - assert files[0]['type'] == 'folder' - assert files[1]['name'] == 'test.txt' - assert files[1]['type'] == 'file' - assert files[1]['size'] == 100 - + assert files[0]["name"] == "Documents" + assert files[0]["type"] == "folder" + assert files[1]["name"] == "test.txt" + assert files[1]["type"] == "file" + assert files[1]["size"] == 100 + def test_list_files_failure(self): """Test file listing failure.""" manager = ADBManager() manager.command_runner.run_adb_command = MagicMock( return_value=("", "Permission denied", 1) ) - + files = manager.list_files("/root") - + assert files == [] - - @patch('os.makedirs') + + @patch("os.makedirs") def test_pull_file_success(self, mock_makedirs): """Test successful file pull.""" manager = ADBManager() manager.file_transfer.pull_file = MagicMock(return_value=True) - + success, message = manager.pull_file("/sdcard/test.txt", "/local/test.txt") - + assert success is True assert "successfully" in message mock_makedirs.assert_called_once_with("/local", exist_ok=True) - + def test_pull_file_failure(self): """Test file pull failure.""" manager = ADBManager() manager.file_transfer.pull_file = MagicMock(return_value=False) - + success, message = manager.pull_file("/sdcard/test.txt", "/local/test.txt") - + assert success is False assert "Failed" in message - - @patch('os.makedirs') + + @patch("os.makedirs") def test_pull_folder_success(self, mock_makedirs): """Test successful folder pull.""" manager = ADBManager() manager.file_transfer.pull_folder = MagicMock(return_value=True) - + success, message = manager.pull_folder("/sdcard/Documents", "/local/Documents") - + assert success is True assert "successfully" in message mock_makedirs.assert_called_once_with("/local/Documents", exist_ok=True) - + def test_pull_folder_failure(self): """Test folder pull failure.""" manager = ADBManager() manager.file_transfer.pull_folder = MagicMock(return_value=False) - + success, message = manager.pull_folder("/sdcard/Documents", "/local/Documents") - + assert success is False assert "Failed" in message - - @patch('os.path.exists') + + @patch("os.path.exists") def test_push_file_success(self, mock_exists): """Test successful file push.""" mock_exists.return_value = True manager = ADBManager() manager.file_transfer.push_file = MagicMock(return_value=True) - + success, message = manager.push_file("/local/test.txt", "/sdcard/test.txt") - + assert success is True assert "successfully" in message - - @patch('os.path.exists') + + @patch("os.path.exists") def test_push_file_not_found(self, mock_exists): """Test file push when local file doesn't exist.""" mock_exists.return_value = False manager = ADBManager() - + success, message = manager.push_file("/local/test.txt", "/sdcard/test.txt") - + assert success is False assert "not found" in message - - @patch('os.path.exists') + + @patch("os.path.exists") def test_push_file_failure(self, mock_exists): """Test file push failure.""" mock_exists.return_value = True manager = ADBManager() manager.file_transfer.push_file = MagicMock(return_value=False) - + success, message = manager.push_file("/local/test.txt", "/sdcard/test.txt") - + assert success is False assert "Failed" in message - - @patch('os.path.exists') + + @patch("os.path.exists") def test_push_folder_success(self, mock_exists): """Test successful folder push.""" mock_exists.return_value = True manager = ADBManager() manager.file_transfer.push_folder = MagicMock(return_value=True) - + success, message = manager.push_folder("/local/Documents", "/sdcard/Documents") - + assert success is True assert "successfully" in message - + def test_delete_file_success(self): """Test successful file deletion.""" manager = ADBManager() - manager.command_runner.run_adb_command = MagicMock( - return_value=("", "", 0) - ) - + manager.command_runner.run_adb_command = MagicMock(return_value=("", "", 0)) + success, message = manager.delete_file("/sdcard/test.txt") - + assert success is True assert "deleted" in message - + def test_delete_file_failure(self): """Test file deletion failure.""" manager = ADBManager() manager.command_runner.run_adb_command = MagicMock( return_value=("", "Permission denied", 1) ) - + success, message = manager.delete_file("/sdcard/test.txt") - + assert success is False assert "Failed" in message - + def test_create_folder_success(self): """Test successful folder creation.""" manager = ADBManager() - manager.command_runner.run_adb_command = MagicMock( - return_value=("", "", 0) - ) - + manager.command_runner.run_adb_command = MagicMock(return_value=("", "", 0)) + success, message = manager.create_folder("/sdcard/NewFolder") - + assert success is True assert "created" in message - + def test_delete_folder_success(self): """Test successful folder deletion.""" manager = ADBManager() - manager.command_runner.run_adb_command = MagicMock( - return_value=("", "", 0) - ) - + manager.command_runner.run_adb_command = MagicMock(return_value=("", "", 0)) + success, message = manager.delete_folder("/sdcard/OldFolder") - + assert success is True assert "deleted" in message - + def test_move_item_success(self): """Test successful item move/rename.""" manager = ADBManager() - manager.command_runner.run_adb_command = MagicMock( - return_value=("", "", 0) - ) - + manager.command_runner.run_adb_command = MagicMock(return_value=("", "", 0)) + success, message = manager.move_item("/sdcard/old.txt", "/sdcard/new.txt") - + assert success is True assert "moved" in message - + def test_get_file_info_success(self): """Test successful file info retrieval.""" manager = ADBManager() manager.command_runner.run_adb_command = MagicMock( - return_value=( - "-rw-rw-r-- 1 user user 1024 Jan 1 12:00 test.txt", - "", 0 - ) + return_value=("-rw-rw-r-- 1 user user 1024 Jan 1 12:00 test.txt", "", 0) ) - + info = manager.get_file_info("/sdcard/test.txt") - + assert info is not None - assert info['name'] == 'test.txt' - assert info['type'] == 'file' - assert info['size'] == 1024 - + assert info["name"] == "test.txt" + assert info["type"] == "file" + assert info["size"] == 1024 + def test_get_file_info_failure(self): """Test file info retrieval failure.""" manager = ADBManager() manager.command_runner.run_adb_command = MagicMock( return_value=("", "File not found", 1) ) - + info = manager.get_file_info("/sdcard/nonexistent.txt") - + assert info is None - - @patch('src.core.adb_manager.FileDeduplicator') + + @patch("src.core.adb_manager.FileDeduplicator") def test_deduplicate_files_success(self, mock_deduplicator_class): """Test successful file deduplication.""" mock_deduplicator = MagicMock() - mock_deduplicator.find_duplicates.return_value = [['file1.txt', 'file2.txt']] + mock_deduplicator.find_duplicates.return_value = [["file1.txt", "file2.txt"]] mock_deduplicator.remove_duplicates.return_value = 1 mock_deduplicator_class.return_value = mock_deduplicator - + manager = ADBManager() removed_count, duplicates = manager.deduplicate_files("/test/folder") - + assert removed_count == 1 assert len(duplicates) == 1 - - @patch('src.core.adb_manager.FileDeduplicator') + + @patch("src.core.adb_manager.FileDeduplicator") def test_deduplicate_files_no_duplicates(self, mock_deduplicator_class): """Test file deduplication with no duplicates found.""" mock_deduplicator = MagicMock() mock_deduplicator.find_duplicates.return_value = [] mock_deduplicator_class.return_value = mock_deduplicator - + manager = ADBManager() removed_count, duplicates = manager.deduplicate_files("/test/folder") - + assert removed_count == 0 assert duplicates == [] @@ -436,7 +436,9 @@ class TestADBManagerSecurityIntegration: for path in malicious_paths: success, message = manager.delete_file(path) assert not success, f"Failed to reject malicious path: {path}" - assert "Invalid path" in message, f"Expected security error message for: {path}" + assert ( + "Invalid path" in message + ), f"Expected security error message for: {path}" def test_create_folder_rejects_malicious_input(self): """Test that create_folder() rejects malicious path inputs.""" @@ -465,7 +467,9 @@ class TestADBManagerSecurityIntegration: assert "Invalid path" in message # Malicious destination - success, message = manager.move_item("/sdcard/source", "/sdcard/dest && malicious") + success, message = manager.move_item( + "/sdcard/source", "/sdcard/dest && malicious" + ) assert not success assert "Invalid path" in message @@ -513,7 +517,7 @@ class TestADBManagerSecurityIntegration: result = manager.get_file_info("/sdcard/file\x00.txt") assert result is None - @patch('src.core.adb_manager.ADBCommandRunner') + @patch("src.core.adb_manager.ADBCommandRunner") def test_sanitized_paths_passed_to_adb_commands(self, mock_runner_class): """Test that sanitized paths are passed to ADB commands, not original inputs.""" mock_runner = MagicMock() @@ -616,4 +620,4 @@ class TestADBManagerSecurityIntegration: result = manager.list_files("/sdcard/DCIM") # No status messages for validation errors - assert not any("Invalid" in msg for msg in status_messages) \ No newline at end of file + assert not any("Invalid" in msg for msg in status_messages) diff --git a/tests/core/test_file_transfer.py b/tests/core/test_file_transfer.py index f507680..26316dd 100644 --- a/tests/core/test_file_transfer.py +++ b/tests/core/test_file_transfer.py @@ -10,33 +10,33 @@ from src.core.file_transfer import ADBFileTransfer class TestADBFileTransfer: """Test ADB file transfer functionality.""" - + def test_init(self): """Test ADBFileTransfer initialization.""" transfer = ADBFileTransfer() assert transfer.progress_callback is None assert transfer.current_process is None - + def test_validate_windows_root_path_valid(self): """Test Windows root path validation with valid path.""" transfer = ADBFileTransfer() # Should not raise exception transfer._validate_windows_root_path("C:/Users/test", "push") - + def test_validate_windows_root_path_invalid_push(self): """Test Windows root path validation with invalid path for push.""" transfer = ADBFileTransfer() with pytest.raises(ValueError, match="Cannot push to Windows root"): transfer._validate_windows_root_path("C:", "push") - + def test_validate_windows_root_path_invalid_pull(self): """Test Windows root path validation with invalid path for pull.""" transfer = ADBFileTransfer() with pytest.raises(ValueError, match="Cannot pull from Windows root"): transfer._validate_windows_root_path("C:", "pull") - - @patch('src.core.file_transfer.ADBCommandRunner') - @patch('os.path.exists') + + @patch("src.core.file_transfer.ADBCommandRunner") + @patch("os.path.exists") def test_pull_file_success(self, mock_exists, mock_command_runner): """Test successful file pull operation.""" mock_exists.return_value = False # Remote file doesn't exist locally @@ -49,40 +49,42 @@ class TestADBFileTransfer: assert result is True # sanitize_local_path converts to absolute path, so we need to match that - expected_local_path = os.path.abspath(os.path.normpath('/local/test.txt')) - mock_runner.run_adb_command.assert_called_with(['pull', '/sdcard/test.txt', expected_local_path]) - - @patch('src.core.file_transfer.ADBCommandRunner') - @patch('os.path.exists') + expected_local_path = os.path.abspath(os.path.normpath("/local/test.txt")) + mock_runner.run_adb_command.assert_called_with( + ["pull", "/sdcard/test.txt", expected_local_path] + ) + + @patch("src.core.file_transfer.ADBCommandRunner") + @patch("os.path.exists") def test_pull_file_already_exists(self, mock_exists, mock_command_runner): """Test file pull when local file already exists.""" mock_exists.return_value = True mock_runner = MagicMock() mock_command_runner.return_value = mock_runner - + transfer = ADBFileTransfer() result = transfer.pull_file("/sdcard/test.txt", "/local/test.txt") - + assert result is False mock_runner.run_adb_command.assert_not_called() - - @patch('src.core.file_transfer.ADBCommandRunner') - @patch('os.path.exists') + + @patch("src.core.file_transfer.ADBCommandRunner") + @patch("os.path.exists") def test_pull_file_command_failure(self, mock_exists, mock_command_runner): """Test file pull with ADB command failure.""" mock_exists.return_value = False mock_runner = MagicMock() mock_runner.run_adb_command.return_value = ("", "Error", 1) mock_command_runner.return_value = mock_runner - + transfer = ADBFileTransfer() result = transfer.pull_file("/sdcard/test.txt", "/local/test.txt") - + assert result is False - - @patch('src.core.file_transfer.ADBCommandRunner') - @patch('os.path.exists') - @patch('os.path.isfile') + + @patch("src.core.file_transfer.ADBCommandRunner") + @patch("os.path.exists") + @patch("os.path.isfile") def test_push_file_success(self, mock_isfile, mock_exists, mock_command_runner): """Test successful file push operation.""" mock_exists.return_value = True # Local file exists @@ -96,42 +98,44 @@ class TestADBFileTransfer: assert result is True # sanitize_local_path converts to absolute path - expected_local_path = os.path.abspath(os.path.normpath('/local/test.txt')) - mock_runner.run_adb_command.assert_called_with(['push', expected_local_path, '/sdcard/test.txt']) - - @patch('src.core.file_transfer.ADBCommandRunner') - @patch('os.path.exists') + expected_local_path = os.path.abspath(os.path.normpath("/local/test.txt")) + mock_runner.run_adb_command.assert_called_with( + ["push", expected_local_path, "/sdcard/test.txt"] + ) + + @patch("src.core.file_transfer.ADBCommandRunner") + @patch("os.path.exists") def test_push_file_not_exists(self, mock_exists, mock_command_runner): """Test file push when local file doesn't exist.""" mock_exists.return_value = False mock_runner = MagicMock() mock_command_runner.return_value = mock_runner - + transfer = ADBFileTransfer() result = transfer.push_file("/local/test.txt", "/sdcard/test.txt") - + assert result is False mock_runner.run_adb_command.assert_not_called() - - @patch('src.core.file_transfer.ADBCommandRunner') - @patch('os.path.exists') - @patch('os.path.isfile') + + @patch("src.core.file_transfer.ADBCommandRunner") + @patch("os.path.exists") + @patch("os.path.isfile") def test_push_file_not_a_file(self, mock_isfile, mock_exists, mock_command_runner): """Test file push when local path is not a file.""" mock_exists.return_value = True mock_isfile.return_value = False # It's a directory, not a file mock_runner = MagicMock() mock_command_runner.return_value = mock_runner - + transfer = ADBFileTransfer() result = transfer.push_file("/local/test", "/sdcard/test.txt") - + assert result is False mock_runner.run_adb_command.assert_not_called() - - @patch('src.core.file_transfer.ADBCommandRunner') - @patch('os.path.exists') - @patch('os.path.isdir') + + @patch("src.core.file_transfer.ADBCommandRunner") + @patch("os.path.exists") + @patch("os.path.isdir") def test_pull_folder_success(self, mock_isdir, mock_exists, mock_command_runner): """Test successful folder pull operation.""" mock_exists.return_value = False # Local folder doesn't exist @@ -145,26 +149,28 @@ class TestADBFileTransfer: assert result is True # sanitize_local_path converts to absolute path - expected_local_path = os.path.abspath(os.path.normpath('/local/Documents')) - mock_runner.run_adb_command.assert_called_with(['pull', '/sdcard/Documents', expected_local_path]) - - @patch('src.core.file_transfer.ADBCommandRunner') - @patch('os.path.exists') + expected_local_path = os.path.abspath(os.path.normpath("/local/Documents")) + mock_runner.run_adb_command.assert_called_with( + ["pull", "/sdcard/Documents", expected_local_path] + ) + + @patch("src.core.file_transfer.ADBCommandRunner") + @patch("os.path.exists") def test_pull_folder_already_exists(self, mock_exists, mock_command_runner): """Test folder pull when local folder already exists.""" mock_exists.return_value = True mock_runner = MagicMock() mock_command_runner.return_value = mock_runner - + transfer = ADBFileTransfer() result = transfer.pull_folder("/sdcard/Documents", "/local/Documents") - + assert result is False mock_runner.run_adb_command.assert_not_called() - - @patch('src.core.file_transfer.ADBCommandRunner') - @patch('os.path.exists') - @patch('os.path.isdir') + + @patch("src.core.file_transfer.ADBCommandRunner") + @patch("os.path.exists") + @patch("os.path.isdir") def test_push_folder_success(self, mock_isdir, mock_exists, mock_command_runner): """Test successful folder push operation.""" mock_exists.return_value = True # Local folder exists @@ -178,49 +184,51 @@ class TestADBFileTransfer: assert result is True # sanitize_local_path converts to absolute path - expected_local_path = os.path.abspath(os.path.normpath('/local/Documents')) - mock_runner.run_adb_command.assert_called_with(['push', expected_local_path, '/sdcard/Documents']) - - @patch('src.core.file_transfer.ADBCommandRunner') - @patch('os.path.exists') + expected_local_path = os.path.abspath(os.path.normpath("/local/Documents")) + mock_runner.run_adb_command.assert_called_with( + ["push", expected_local_path, "/sdcard/Documents"] + ) + + @patch("src.core.file_transfer.ADBCommandRunner") + @patch("os.path.exists") def test_push_folder_not_exists(self, mock_exists, mock_command_runner): """Test folder push when local folder doesn't exist.""" mock_exists.return_value = False mock_runner = MagicMock() mock_command_runner.return_value = mock_runner - + transfer = ADBFileTransfer() result = transfer.push_folder("/local/Documents", "/sdcard/Documents") - + assert result is False mock_runner.run_adb_command.assert_not_called() - + def test_cancel_transfer_no_process(self): """Test canceling transfer with no current process.""" transfer = ADBFileTransfer() result = transfer.cancel_transfer() assert result is False - + def test_cancel_transfer_with_process(self): """Test canceling transfer with active process.""" transfer = ADBFileTransfer() mock_process = MagicMock() mock_process.poll.return_value = None # Process is running transfer.current_process = mock_process - + result = transfer.cancel_transfer() - + assert result is True mock_process.terminate.assert_called_once() - + def test_cancel_transfer_finished_process(self): """Test canceling transfer with finished process.""" transfer = ADBFileTransfer() mock_process = MagicMock() mock_process.poll.return_value = 0 # Process finished transfer.current_process = mock_process - + result = transfer.cancel_transfer() - + assert result is False - mock_process.terminate.assert_not_called() \ No newline at end of file + mock_process.terminate.assert_not_called() diff --git a/tests/core/test_platform_tools.py b/tests/core/test_platform_tools.py index edfa043..2ac48a3 100644 --- a/tests/core/test_platform_tools.py +++ b/tests/core/test_platform_tools.py @@ -8,10 +8,10 @@ from unittest.mock import Mock, patch, mock_open, MagicMock import pytest from src.core.platform_tools import ( - is_adb_available, + is_adb_available, get_adb_binary_path, ensure_platform_tools_in_user_dir, - download_and_extract_adb + download_and_extract_adb, ) @@ -20,112 +20,159 @@ class TestPlatformTools(unittest.TestCase): def test_is_adb_available_true(self): """Test adb availability when binary exists.""" - with patch('src.core.platform_tools.get_adb_binary_path', return_value='/path/to/adb'): - with patch('os.path.isfile', return_value=True): + with patch( + "src.core.platform_tools.get_adb_binary_path", return_value="/path/to/adb" + ): + with patch("os.path.isfile", return_value=True): result = is_adb_available() assert result is True def test_is_adb_available_false(self): """Test adb availability when binary doesn't exist.""" - with patch('src.core.platform_tools.get_adb_binary_path', return_value=None): + with patch("src.core.platform_tools.get_adb_binary_path", return_value=None): result = is_adb_available() assert result is False def test_get_adb_binary_path_user_dir_success(self): """Test getting ADB path from user directory.""" - with patch('src.core.platform_tools.ensure_platform_tools_in_user_dir', return_value='/user/adb'): - with patch('os.path.isfile', return_value=True): + with patch( + "src.core.platform_tools.ensure_platform_tools_in_user_dir", + return_value="/user/adb", + ): + with patch("os.path.isfile", return_value=True): result = get_adb_binary_path() - assert result == '/user/adb' + assert result == "/user/adb" def test_get_adb_binary_path_local_fallback(self): """Test fallback to local platform-tools.""" - with patch('src.core.platform_tools.ensure_platform_tools_in_user_dir', side_effect=Exception()): - with patch('src.core.platform_utils.get_platform_tools_directory', return_value='/local/platform-tools'): - with patch('src.core.platform_utils.get_adb_binary_name', return_value='adb'): - with patch('os.path.isfile', return_value=True): - with patch('os.path.join', return_value='/local/platform-tools/adb'): + with patch( + "src.core.platform_tools.ensure_platform_tools_in_user_dir", + side_effect=Exception(), + ): + with patch( + "src.core.platform_utils.get_platform_tools_directory", + return_value="/local/platform-tools", + ): + with patch( + "src.core.platform_utils.get_adb_binary_name", return_value="adb" + ): + with patch("os.path.isfile", return_value=True): + with patch( + "os.path.join", return_value="/local/platform-tools/adb" + ): result = get_adb_binary_path() - assert result == '/local/platform-tools/adb' + assert result == "/local/platform-tools/adb" def test_get_adb_binary_path_windows(self): """Test getting ADB path on Windows.""" - with patch('src.core.platform_tools.ensure_platform_tools_in_user_dir', return_value='/user/adb.exe'): - with patch('os.path.isfile', return_value=True): + with patch( + "src.core.platform_tools.ensure_platform_tools_in_user_dir", + return_value="/user/adb.exe", + ): + with patch("os.path.isfile", return_value=True): result = get_adb_binary_path() - assert result == '/user/adb.exe' + assert result == "/user/adb.exe" def test_ensure_platform_tools_simple(self): """Test basic platform tools installation.""" + # Just test the function doesn't crash with basic mocking def mock_isdir(path): # Return True for the extracted platform-tools directory and target directory checks - if path == '/tmp/test/platform-tools': + if path == "/tmp/test/platform-tools": return True - if path.endswith('platform-tools') and 'tmp' in path: + if path.endswith("platform-tools") and "tmp" in path: return True # Return False for other directory checks (like existing target_dir) return False - - with patch('os.makedirs'): - with patch('os.path.islink', return_value=False): - with patch('os.path.isdir', side_effect=mock_isdir): - with patch('tempfile.mkdtemp', return_value='/tmp/test'): - with patch('os.listdir', return_value=['platform-tools']): # Mock directory listing - with patch('requests.get') as mock_get: - with patch('builtins.open', mock_open()): - with patch('zipfile.is_zipfile', return_value=True): - with patch('zipfile.ZipFile') as mock_zip: + + with patch("os.makedirs"): + with patch("os.path.islink", return_value=False): + with patch("os.path.isdir", side_effect=mock_isdir): + with patch("tempfile.mkdtemp", return_value="/tmp/test"): + with patch( + "os.listdir", return_value=["platform-tools"] + ): # Mock directory listing + with patch("requests.get") as mock_get: + with patch("builtins.open", mock_open()): + with patch("zipfile.is_zipfile", return_value=True): + with patch("zipfile.ZipFile") as mock_zip: # Mock zip file entries mock_info = Mock() - mock_info.filename = 'platform-tools/adb' + mock_info.filename = "platform-tools/adb" mock_info.file_size = 1000 mock_zip_instance = MagicMock() - mock_zip_instance.infolist.return_value = [mock_info] - mock_zip.return_value.__enter__.return_value = mock_zip_instance + mock_zip_instance.infolist.return_value = [ + mock_info + ] + mock_zip.return_value.__enter__.return_value = ( + mock_zip_instance + ) - with patch('shutil.move'): - with patch('os.chmod'): - with patch('os.symlink'): - with patch('shutil.rmtree'): + with patch("shutil.move"): + with patch("os.chmod"): + with patch("os.symlink"): + with patch("shutil.rmtree"): mock_response = Mock() - mock_response.iter_content.return_value = [b'content'] - mock_response.raise_for_status.return_value = None + mock_response.iter_content.return_value = [ + b"content" + ] + mock_response.raise_for_status.return_value = ( + None + ) mock_response.url = "https://dl.google.com/android/repository/platform-tools-latest-linux.zip" - mock_response.headers = {'Content-Type': 'application/zip'} - mock_get.return_value = mock_response + mock_response.headers = { + "Content-Type": "application/zip" + } + mock_get.return_value = ( + mock_response + ) - result = ensure_platform_tools_in_user_dir() + result = ( + ensure_platform_tools_in_user_dir() + ) assert result is not None def test_download_and_extract_adb_linux(self): """Test ADB download and extraction on Linux.""" - with patch('src.core.platform_tools.ensure_platform_tools_in_user_dir', return_value='/test/adb'): - with patch('os.path.isfile', return_value=True): - with patch('os.chmod') as mock_chmod: - with patch('os.name', 'posix'): + with patch( + "src.core.platform_tools.ensure_platform_tools_in_user_dir", + return_value="/test/adb", + ): + with patch("os.path.isfile", return_value=True): + with patch("os.chmod") as mock_chmod: + with patch("os.name", "posix"): result = download_and_extract_adb() assert result is True - mock_chmod.assert_called_once_with('/test/adb', 0o755) + mock_chmod.assert_called_once_with("/test/adb", 0o755) def test_download_and_extract_adb_windows(self): """Test ADB download and extraction on Windows.""" - with patch('src.core.platform_tools.ensure_platform_tools_in_user_dir', return_value='/test/adb.exe'): - with patch('os.path.isfile', return_value=True): - with patch('os.name', 'nt'): + with patch( + "src.core.platform_tools.ensure_platform_tools_in_user_dir", + return_value="/test/adb.exe", + ): + with patch("os.path.isfile", return_value=True): + with patch("os.name", "nt"): result = download_and_extract_adb() assert result is True def test_download_and_extract_adb_failure(self): """Test ADB download failure when file doesn't exist.""" - with patch('src.core.platform_tools.ensure_platform_tools_in_user_dir', return_value='/test/adb'): - with patch('os.path.isfile', return_value=False): + with patch( + "src.core.platform_tools.ensure_platform_tools_in_user_dir", + return_value="/test/adb", + ): + with patch("os.path.isfile", return_value=False): result = download_and_extract_adb() assert result is False def test_download_and_extract_adb_exception(self): """Test ADB download failure with exception.""" - with patch('src.core.platform_tools.ensure_platform_tools_in_user_dir', side_effect=Exception()): + with patch( + "src.core.platform_tools.ensure_platform_tools_in_user_dir", + side_effect=Exception(), + ): result = download_and_extract_adb() assert result is False @@ -154,5 +201,5 @@ class TestPlatformToolsSecurityValidation: assert True # Documentation test -if __name__ == '__main__': - unittest.main() \ No newline at end of file +if __name__ == "__main__": + unittest.main() diff --git a/tests/core/test_platform_utils.py b/tests/core/test_platform_utils.py index e7b58e6..1a1de50 100644 --- a/tests/core/test_platform_utils.py +++ b/tests/core/test_platform_utils.py @@ -9,88 +9,103 @@ from unittest.mock import patch from src.core.platform_utils import ( get_executable_directory, get_platform_tools_directory, - get_platform_type + get_platform_type, ) class TestPlatformUtils: """Test platform utility functions.""" - + def test_get_executable_directory_frozen(self): """Test get_executable_directory when running as executable.""" - with patch.object(sys, 'frozen', True, create=True): - with patch.object(sys, 'executable', '/path/to/app'): + with patch.object(sys, "frozen", True, create=True): + with patch.object(sys, "executable", "/path/to/app"): result = get_executable_directory() - assert result == '/path/to' - + assert result == "/path/to" + def test_get_executable_directory_script(self): """Test get_executable_directory when running as script.""" - with patch.object(sys, 'frozen', False, create=True): - with patch('os.path.abspath') as mock_abspath: - with patch('os.path.dirname') as mock_dirname: - mock_abspath.return_value = '/path/to/script.py' - mock_dirname.return_value = '/path/to' + with patch.object(sys, "frozen", False, create=True): + with patch("os.path.abspath") as mock_abspath: + with patch("os.path.dirname") as mock_dirname: + mock_abspath.return_value = "/path/to/script.py" + mock_dirname.return_value = "/path/to" result = get_executable_directory() - assert result == '/path/to' + assert result == "/path/to" # The function calls abspath on the actual platform_utils.py file mock_abspath.assert_called_once() - + def test_get_platform_type_linux(self): """Test platform type detection for Linux.""" - with patch('sys.platform', 'linux'): + with patch("sys.platform", "linux"): result = get_platform_type() - assert result == 'linux' - + assert result == "linux" + def test_get_platform_type_windows(self): """Test platform type detection for Windows.""" - with patch('sys.platform', 'win32'): + with patch("sys.platform", "win32"): result = get_platform_type() - assert result == 'win32' - + assert result == "win32" + def test_get_platform_type_darwin(self): """Test platform type detection for macOS.""" - with patch('sys.platform', 'darwin'): + with patch("sys.platform", "darwin"): result = get_platform_type() - assert result == 'darwin' - + assert result == "darwin" + def test_get_platform_tools_directory_frozen(self): """Test platform-tools directory when running as executable.""" - with patch.object(sys, 'frozen', True, create=True): - with patch('src.core.platform_utils.get_executable_directory', return_value='/app/dir'): + with patch.object(sys, "frozen", True, create=True): + with patch( + "src.core.platform_utils.get_executable_directory", + return_value="/app/dir", + ): result = get_platform_tools_directory() - expected = os.path.join('/app/dir', 'platform-tools') + expected = os.path.join("/app/dir", "platform-tools") assert result == expected - + def test_get_platform_tools_directory_src(self): """Test platform-tools directory when running from src.""" - with patch.object(sys, 'frozen', False, create=True): - with patch('src.core.platform_utils.get_executable_directory', return_value='/project/src'): + with patch.object(sys, "frozen", False, create=True): + with patch( + "src.core.platform_utils.get_executable_directory", + return_value="/project/src", + ): result = get_platform_tools_directory() - expected = os.path.join('/project/src', 'platform-tools') + expected = os.path.join("/project/src", "platform-tools") assert result == expected - + def test_get_platform_tools_directory_gui_subdirectory(self): """Test platform-tools directory when running from src/gui.""" - with patch.object(sys, 'frozen', False, create=True): - with patch('src.core.platform_utils.get_executable_directory', return_value='/project/src/gui'): + with patch.object(sys, "frozen", False, create=True): + with patch( + "src.core.platform_utils.get_executable_directory", + return_value="/project/src/gui", + ): result = get_platform_tools_directory() - expected = os.path.join('/project/src', 'platform-tools') + expected = os.path.join("/project/src", "platform-tools") assert result == expected - + def test_get_platform_tools_directory_project_root(self): """Test platform-tools directory when running from project root.""" - with patch.object(sys, 'frozen', False, create=True): - with patch('src.core.platform_utils.get_executable_directory', return_value='/project'): - with patch('os.path.exists', return_value=True): + with patch.object(sys, "frozen", False, create=True): + with patch( + "src.core.platform_utils.get_executable_directory", + return_value="/project", + ): + with patch("os.path.exists", return_value=True): result = get_platform_tools_directory() - expected = os.path.join('/project', 'src', 'platform-tools') + expected = os.path.join("/project", "src", "platform-tools") assert result == expected - + def test_get_platform_tools_directory_fallback(self): """Test platform-tools directory fallback behavior.""" - with patch.object(sys, 'frozen', False, create=True): - with patch('src.core.platform_utils.get_executable_directory', return_value='/somewhere'): - with patch('os.path.exists', return_value=False): + with patch.object(sys, "frozen", False, create=True): + with patch( + "src.core.platform_utils.get_executable_directory", + return_value="/somewhere", + ): + with patch("os.path.exists", return_value=False): result = get_platform_tools_directory() - expected = os.path.join('/somewhere', 'src', 'platform-tools') - assert result == expected \ No newline at end of file + expected = os.path.join("/somewhere", "src", "platform-tools") + assert result == expected diff --git a/tests/core/test_progress_tracker.py b/tests/core/test_progress_tracker.py index 9d6c755..1125841 100644 --- a/tests/core/test_progress_tracker.py +++ b/tests/core/test_progress_tracker.py @@ -10,7 +10,7 @@ from src.core.progress_tracker import ProgressTracker class TestProgressTracker: """Test progress tracking functionality.""" - + def test_init(self): """Test ProgressTracker initialization.""" tracker = ProgressTracker() @@ -19,111 +19,111 @@ class TestProgressTracker: assert tracker.transferred_bytes == 0 assert tracker.current_speed == 0.0 assert tracker.estimated_time_remaining == 0 - + def test_start_tracking(self): """Test starting progress tracking.""" tracker = ProgressTracker() - with patch('time.time', return_value=1000.0): + with patch("time.time", return_value=1000.0): tracker.start_tracking(1024) - + assert tracker.start_time == 1000.0 assert tracker.total_bytes == 1024 assert tracker.transferred_bytes == 0 - + def test_update_progress_first_update(self): """Test first progress update.""" tracker = ProgressTracker() - with patch('time.time', return_value=1000.0): + with patch("time.time", return_value=1000.0): tracker.start_tracking(1024) - - with patch('time.time', return_value=1001.0): + + with patch("time.time", return_value=1001.0): tracker.update_progress(512) - + assert tracker.transferred_bytes == 512 assert tracker.current_speed == 512.0 # 512 bytes in 1 second - + def test_update_progress_multiple_updates(self): """Test multiple progress updates.""" tracker = ProgressTracker() - with patch('time.time', return_value=1000.0): + with patch("time.time", return_value=1000.0): tracker.start_tracking(1024) - - with patch('time.time', return_value=1001.0): + + with patch("time.time", return_value=1001.0): tracker.update_progress(256) - - with patch('time.time', return_value=1002.0): + + with patch("time.time", return_value=1002.0): tracker.update_progress(512) - + assert tracker.transferred_bytes == 512 assert tracker.current_speed == 256.0 # Average speed - + def test_update_progress_zero_time_elapsed(self): """Test progress update with zero time elapsed.""" tracker = ProgressTracker() - with patch('time.time', return_value=1000.0): + with patch("time.time", return_value=1000.0): tracker.start_tracking(1024) tracker.update_progress(512) - + assert tracker.transferred_bytes == 512 assert tracker.current_speed == 0.0 # No time elapsed - + def test_get_progress_percentage_no_total(self): """Test getting progress percentage with no total bytes.""" tracker = ProgressTracker() assert tracker.get_progress_percentage() == 0.0 - + def test_get_progress_percentage_with_progress(self): """Test getting progress percentage with progress.""" tracker = ProgressTracker() tracker.total_bytes = 1000 tracker.transferred_bytes = 250 - + assert tracker.get_progress_percentage() == 25.0 - + def test_get_progress_percentage_complete(self): """Test getting progress percentage when complete.""" tracker = ProgressTracker() tracker.total_bytes = 1000 tracker.transferred_bytes = 1000 - + assert tracker.get_progress_percentage() == 100.0 - + def test_get_progress_percentage_over_100(self): """Test getting progress percentage over 100%.""" tracker = ProgressTracker() tracker.total_bytes = 1000 tracker.transferred_bytes = 1200 - + assert tracker.get_progress_percentage() == 100.0 # Capped at 100% - + def test_estimate_time_remaining_no_speed(self): """Test time estimation with no speed.""" tracker = ProgressTracker() tracker.total_bytes = 1000 tracker.transferred_bytes = 250 tracker.current_speed = 0.0 - + assert tracker.estimate_time_remaining() == 0 - + def test_estimate_time_remaining_with_speed(self): """Test time estimation with speed.""" tracker = ProgressTracker() tracker.total_bytes = 1000 tracker.transferred_bytes = 250 tracker.current_speed = 125.0 # 125 bytes/second - + remaining_time = tracker.estimate_time_remaining() assert remaining_time == 6 # (1000-250)/125 = 6 seconds - + def test_estimate_time_remaining_complete(self): """Test time estimation when transfer is complete.""" tracker = ProgressTracker() tracker.total_bytes = 1000 tracker.transferred_bytes = 1000 tracker.current_speed = 100.0 - + assert tracker.estimate_time_remaining() == 0 - + def test_reset_tracking(self): """Test resetting progress tracking.""" tracker = ProgressTracker() @@ -131,53 +131,53 @@ class TestProgressTracker: tracker.total_bytes = 1000 tracker.transferred_bytes = 500 tracker.current_speed = 100.0 - + tracker.reset() - + assert tracker.start_time is None assert tracker.total_bytes == 0 assert tracker.transferred_bytes == 0 assert tracker.current_speed == 0.0 assert tracker.estimated_time_remaining == 0 - + def test_format_speed_bytes(self): """Test formatting speed in bytes per second.""" tracker = ProgressTracker() tracker.current_speed = 512.0 - + formatted = tracker.format_speed() assert formatted == "512.0 B/s" - + def test_format_speed_kilobytes(self): """Test formatting speed in kilobytes per second.""" tracker = ProgressTracker() tracker.current_speed = 1536.0 # 1.5 KB/s - + formatted = tracker.format_speed() assert formatted == "1.5 KB/s" - + def test_format_speed_megabytes(self): """Test formatting speed in megabytes per second.""" tracker = ProgressTracker() tracker.current_speed = 2097152.0 # 2 MB/s - + formatted = tracker.format_speed() assert formatted == "2.0 MB/s" - + def test_format_time_seconds(self): """Test formatting time in seconds.""" tracker = ProgressTracker() formatted = tracker.format_time(30) assert formatted == "00:30" - + def test_format_time_minutes(self): """Test formatting time in minutes and seconds.""" tracker = ProgressTracker() formatted = tracker.format_time(150) # 2:30 assert formatted == "02:30" - + def test_format_time_hours(self): """Test formatting time in hours, minutes and seconds.""" tracker = ProgressTracker() formatted = tracker.format_time(3661) # 1:01:01 - assert formatted == "01:01:01" \ No newline at end of file + assert formatted == "01:01:01" diff --git a/tests/managers/test_device_manager.py b/tests/managers/test_device_manager.py index 7593f53..51aff0f 100644 --- a/tests/managers/test_device_manager.py +++ b/tests/managers/test_device_manager.py @@ -9,259 +9,287 @@ from src.managers.device_manager import DeviceManager class TestDeviceManager: """Test DeviceManager class functionality.""" - + @pytest.fixture def mock_parent_window(self): """Create a mock parent window for testing.""" return MagicMock(spec=tk.Tk) - + @pytest.fixture def mock_status_callback(self): """Create a mock status callback for testing.""" return MagicMock() - + @pytest.fixture def device_manager(self, mock_parent_window, mock_status_callback): """Create a DeviceManager instance for testing.""" - with patch('src.managers.device_manager.ADBManager') as mock_adb: + with patch("src.managers.device_manager.ADBManager") as mock_adb: manager = DeviceManager(mock_parent_window, mock_status_callback) manager.adb_manager = mock_adb.return_value return manager - + def test_init(self, mock_parent_window, mock_status_callback): """Test DeviceManager initialization.""" - with patch('src.managers.device_manager.ADBManager') as mock_adb_class: + with patch("src.managers.device_manager.ADBManager") as mock_adb_class: mock_adb_instance = MagicMock() mock_adb_class.return_value = mock_adb_instance - + device_manager = DeviceManager(mock_parent_window, mock_status_callback) - + assert device_manager.parent == mock_parent_window assert device_manager.status_callback == mock_status_callback assert device_manager.device_connected is False - + # Verify ADB callbacks are set mock_adb_instance.set_status_callback.assert_called_once() mock_adb_instance.set_progress_callback.assert_called_once() - + def test_init_without_status_callback(self, mock_parent_window): """Test DeviceManager initialization without status callback.""" - with patch('src.managers.device_manager.ADBManager'): + with patch("src.managers.device_manager.ADBManager"): device_manager = DeviceManager(mock_parent_window) assert device_manager.status_callback is None - - @patch('src.managers.device_manager.is_adb_available', return_value=True) + + @patch("src.managers.device_manager.is_adb_available", return_value=True) def test_initialize_adb_already_available(self, mock_is_available, device_manager): """Test ADB initialization when ADB is already available.""" result = device_manager.initialize_adb() assert result is True - - @patch('src.managers.device_manager.is_adb_available') - @patch('src.managers.device_manager.messagebox.showinfo') - def test_initialize_adb_needs_download_success(self, mock_showinfo, mock_is_available, device_manager): + + @patch("src.managers.device_manager.is_adb_available") + @patch("src.managers.device_manager.messagebox.showinfo") + def test_initialize_adb_needs_download_success( + self, mock_showinfo, mock_is_available, device_manager + ): """Test ADB initialization when download is needed and succeeds.""" # Mock is_adb_available to return False first (triggering download), then True (after download) mock_is_available.side_effect = [False, True] device_manager.adb_manager.download_and_extract_adb.return_value = True - + result = device_manager.initialize_adb() - + # Should show welcome message mock_showinfo.assert_called_once() assert "Welcome to Android File Transfer!" in mock_showinfo.call_args[0][0] - + # Should attempt download device_manager.adb_manager.download_and_extract_adb.assert_called_once() - + assert result is True - - @patch('src.managers.device_manager.is_adb_available', return_value=False) - @patch('src.managers.device_manager.messagebox.showinfo') - @patch('src.managers.device_manager.messagebox.showerror') - def test_initialize_adb_needs_download_failure(self, mock_showerror, mock_showinfo, mock_is_available, device_manager): + + @patch("src.managers.device_manager.is_adb_available", return_value=False) + @patch("src.managers.device_manager.messagebox.showinfo") + @patch("src.managers.device_manager.messagebox.showerror") + def test_initialize_adb_needs_download_failure( + self, mock_showerror, mock_showinfo, mock_is_available, device_manager + ): """Test ADB initialization when download fails.""" device_manager.adb_manager.download_and_extract_adb.return_value = False - + result = device_manager.initialize_adb() - + # Should show welcome message mock_showinfo.assert_called_once() - + # Should attempt download device_manager.adb_manager.download_and_extract_adb.assert_called_once() - + # Should show error message mock_showerror.assert_called_once() - + assert result is False - + def test_check_device_connection_connected(self, device_manager): """Test device connection check when device is connected.""" device_manager.adb_manager.check_device.return_value = "ABC123" - + result = device_manager.check_device_connection() - + assert result == "ABC123" # Returns the device ID, not a boolean assert device_manager.device_connected is True device_manager.adb_manager.check_device.assert_called_once() - + def test_check_device_connection_not_connected(self, device_manager): """Test device connection check when device is not connected.""" device_manager.adb_manager.check_device.return_value = None - + result = device_manager.check_device_connection() - + assert result is None # Returns None, not False assert device_manager.device_connected is False device_manager.adb_manager.check_device.assert_called_once() - + def test_check_device_connection_exception(self, device_manager): """Test device connection check when exception occurs.""" - device_manager.adb_manager.check_device.side_effect = Exception("Connection error") - + device_manager.adb_manager.check_device.side_effect = Exception( + "Connection error" + ) + # The method doesn't catch exceptions, so it should raise with pytest.raises(Exception, match="Connection error"): device_manager.check_device_connection() - + def test_device_connected_property_after_connection(self, device_manager): """Test device_connected property after successful connection.""" device_manager.adb_manager.check_device.return_value = "TEST123" - + device_id = device_manager.check_device_connection() - + assert device_id == "TEST123" assert device_manager.device_connected is True - + def test_device_connected_property_after_failed_connection(self, device_manager): """Test device_connected property after failed connection.""" device_manager.adb_manager.check_device.return_value = None - + device_id = device_manager.check_device_connection() - + assert device_id is None assert device_manager.device_connected is False - + def test_on_adb_status_update_with_callback(self, device_manager): """Test ADB status update with callback.""" device_manager._on_adb_status_update("Test status") - + device_manager.status_callback.assert_called_once_with("Test status") - + def test_on_adb_status_update_without_callback(self, device_manager): """Test ADB status update without callback.""" device_manager.status_callback = None - + # Should not raise an exception device_manager._on_adb_status_update("Test status") - + def test_on_adb_progress_update(self, device_manager): """Test ADB progress update.""" # This method currently just passes through, so we test it doesn't crash device_manager._on_adb_progress_update(50) # No assertions needed as the method doesn't do anything currently - + def test_is_remote_file_detects_file(self, device_manager): """Test is_remote_file correctly identifies a file.""" # Mock adb command to return file listing (starts with '-' for files) - device_manager.adb_manager.run_adb_command.return_value = ("-rw-r--r-- 1 root root 1234 test.txt", "", 0) - + device_manager.adb_manager.run_adb_command.return_value = ( + "-rw-r--r-- 1 root root 1234 test.txt", + "", + 0, + ) + result = device_manager.is_remote_file("/sdcard/test.txt") assert result is True - + def test_is_remote_file_detects_directory(self, device_manager): """Test is_remote_file correctly identifies a directory.""" # Mock adb command to return directory listing (starts with 'd' for directories) - device_manager.adb_manager.run_adb_command.return_value = ("drwxr-xr-x 1 root root 4096 testdir", "", 0) - + device_manager.adb_manager.run_adb_command.return_value = ( + "drwxr-xr-x 1 root root 4096 testdir", + "", + 0, + ) + result = device_manager.is_remote_file("/sdcard/testdir") assert result is False - + def test_is_remote_file_command_fails(self, device_manager): """Test is_remote_file when ADB command fails.""" - device_manager.adb_manager.run_adb_command.return_value = ("", "No such file", 1) - + device_manager.adb_manager.run_adb_command.return_value = ( + "", + "No such file", + 1, + ) + result = device_manager.is_remote_file("/sdcard/nonexistent") assert result is False - + def test_get_file_transfer_methods_push_file(self, device_manager): """Test getting file transfer methods for pushing a file.""" - method_func, transfer_type = device_manager.get_file_transfer_methods("push", True) + method_func, transfer_type = device_manager.get_file_transfer_methods( + "push", True + ) assert transfer_type == "file" assert method_func == device_manager.adb_manager.push_file - + def test_get_file_transfer_methods_pull_folder(self, device_manager): """Test getting file transfer methods for pulling a folder.""" - method_func, transfer_type = device_manager.get_file_transfer_methods("pull", False) - assert transfer_type == "folder" + method_func, transfer_type = device_manager.get_file_transfer_methods( + "pull", False + ) + assert transfer_type == "folder" assert method_func == device_manager.adb_manager.pull_folder_with_dedup - + def test_get_file_transfer_methods_pull_file(self, device_manager): """Test getting file transfer methods for pulling a file.""" - method_func, transfer_type = device_manager.get_file_transfer_methods("pull", True) + method_func, transfer_type = device_manager.get_file_transfer_methods( + "pull", True + ) assert transfer_type == "file" assert method_func == device_manager.adb_manager.pull_file - + def test_get_file_transfer_methods_push_folder(self, device_manager): """Test getting file transfer methods for pushing a folder.""" - method_func, transfer_type = device_manager.get_file_transfer_methods("push", False) + method_func, transfer_type = device_manager.get_file_transfer_methods( + "push", False + ) assert transfer_type == "folder" assert method_func == device_manager.adb_manager.push_folder_with_dedup - + def test_cancel_current_operation(self, device_manager): """Test canceling current ADB operation.""" # This method returns None, so just test that it calls the right method device_manager.cancel_current_operation() - + device_manager.adb_manager.cancel_current_operation.assert_called_once() - + def test_device_connected_property_access(self, device_manager): """Test device_connected property access.""" # Test initial state - assert hasattr(device_manager, 'device_connected') - - # Test setting the property + assert hasattr(device_manager, "device_connected") + + # Test setting the property device_manager.device_connected = True assert device_manager.device_connected is True - + device_manager.device_connected = False assert device_manager.device_connected is False - + def test_adb_manager_property_access(self, device_manager): """Test accessing ADB manager property.""" - assert hasattr(device_manager, 'adb_manager') + assert hasattr(device_manager, "adb_manager") assert device_manager.adb_manager is not None class TestDeviceManagerIntegration: """Integration tests for DeviceManager.""" - + def test_full_initialization_flow(self): """Test the complete initialization flow.""" mock_parent = MagicMock(spec=tk.Tk) mock_callback = MagicMock() - - with patch('src.managers.device_manager.ADBManager') as mock_adb_class: - with patch('src.managers.device_manager.is_adb_available', return_value=True): + + with patch("src.managers.device_manager.ADBManager") as mock_adb_class: + with patch( + "src.managers.device_manager.is_adb_available", return_value=True + ): device_manager = DeviceManager(mock_parent, mock_callback) result = device_manager.initialize_adb() - + assert result is True assert device_manager.device_connected is False - + def test_device_connection_workflow(self): """Test the device connection workflow.""" mock_parent = MagicMock(spec=tk.Tk) - - with patch('src.managers.device_manager.ADBManager') as mock_adb_class: + + with patch("src.managers.device_manager.ADBManager") as mock_adb_class: mock_adb_instance = mock_adb_class.return_value device_manager = DeviceManager(mock_parent) - + # Test connection success - should return device ID, not boolean mock_adb_instance.check_device.return_value = "TEST123" result = device_manager.check_device_connection() assert result == "TEST123" # Returns device ID assert device_manager.device_connected is True - + # Test connection failure - should return None, not False mock_adb_instance.check_device.return_value = None result = device_manager.check_device_connection() @@ -269,5 +297,5 @@ class TestDeviceManagerIntegration: assert device_manager.device_connected is False -if __name__ == '__main__': - pytest.main([__file__]) \ No newline at end of file +if __name__ == "__main__": + pytest.main([__file__]) diff --git a/tests/utils/test_file_deduplication.py b/tests/utils/test_file_deduplication.py index 3363f2b..c71d291 100644 --- a/tests/utils/test_file_deduplication.py +++ b/tests/utils/test_file_deduplication.py @@ -11,213 +11,245 @@ from src.utils.file_deduplication import FileDeduplicator class TestFileDeduplicator: """Test FileDeduplicator class functionality.""" - + @pytest.fixture def deduplicator(self): """Create a FileDeduplicator instance for testing.""" return FileDeduplicator() - + @pytest.fixture def deduplicator_with_callbacks(self): """Create a FileDeduplicator instance with callbacks for testing.""" status_callback = MagicMock() progress_callback = MagicMock() - return FileDeduplicator(status_callback, progress_callback), status_callback, progress_callback - + return ( + FileDeduplicator(status_callback, progress_callback), + status_callback, + progress_callback, + ) + def test_init_without_callbacks(self, deduplicator): """Test FileDeduplicator initialization without callbacks.""" assert deduplicator.status_callback is None assert deduplicator.progress_callback is None - + def test_init_with_callbacks(self): """Test FileDeduplicator initialization with callbacks.""" status_callback = MagicMock() progress_callback = MagicMock() deduplicator = FileDeduplicator(status_callback, progress_callback) - + assert deduplicator.status_callback == status_callback assert deduplicator.progress_callback == progress_callback - + def test_update_status_with_callback(self, deduplicator_with_callbacks): """Test status update with callback.""" deduplicator, status_callback, _ = deduplicator_with_callbacks deduplicator._update_status("Test message") status_callback.assert_called_once_with("Test message") - + def test_update_status_without_callback(self, deduplicator): """Test status update without callback.""" # Should not raise an exception deduplicator._update_status("Test message") - + def test_update_progress_with_callback(self, deduplicator_with_callbacks): """Test progress update with callback.""" deduplicator, _, progress_callback = deduplicator_with_callbacks deduplicator._update_progress(75) progress_callback.assert_called_once_with(75) - + def test_update_progress_without_callback(self, deduplicator): """Test progress update without callback.""" # Should not raise an exception deduplicator._update_progress(75) - - @patch('builtins.open', new_callable=mock_open, read_data=b'test file content') - @patch('os.path.isfile', return_value=True) - def test_compute_local_file_hash_small_file(self, mock_isfile, mock_file, deduplicator): + + @patch("builtins.open", new_callable=mock_open, read_data=b"test file content") + @patch("os.path.isfile", return_value=True) + def test_compute_local_file_hash_small_file( + self, mock_isfile, mock_file, deduplicator + ): """Test file hash computation for small file.""" - expected_hash = hashlib.sha256(b'test file content').hexdigest() - result = deduplicator.compute_local_file_hash('/fake/path/file.txt') - + expected_hash = hashlib.sha256(b"test file content").hexdigest() + result = deduplicator.compute_local_file_hash("/fake/path/file.txt") + assert result == expected_hash - mock_file.assert_called_once_with('/fake/path/file.txt', 'rb') - mock_isfile.assert_called_once_with('/fake/path/file.txt') - - @patch('builtins.open', new_callable=mock_open) - @patch('os.path.isfile', return_value=True) - def test_compute_local_file_hash_large_file(self, mock_isfile, mock_file, deduplicator_with_callbacks): + mock_file.assert_called_once_with("/fake/path/file.txt", "rb") + mock_isfile.assert_called_once_with("/fake/path/file.txt") + + @patch("builtins.open", new_callable=mock_open) + @patch("os.path.isfile", return_value=True) + def test_compute_local_file_hash_large_file( + self, mock_isfile, mock_file, deduplicator_with_callbacks + ): """Test file hash computation for large file with progress updates.""" deduplicator, status_callback, progress_callback = deduplicator_with_callbacks - + # Mock reading chunks mock_file.return_value.__enter__.return_value.read.side_effect = [ - b'chunk1' * 1000, # First chunk - b'chunk2' * 1000, # Second chunk - b'', # EOF + b"chunk1" * 1000, # First chunk + b"chunk2" * 1000, # Second chunk + b"", # EOF ] - - result = deduplicator.compute_local_file_hash('/fake/path/largefile.txt') - + + result = deduplicator.compute_local_file_hash("/fake/path/largefile.txt") + # Should return a valid hash assert isinstance(result, str) assert len(result) == 64 # SHA256 hex digest length - - @patch('os.path.isfile', return_value=False) + + @patch("os.path.isfile", return_value=False) def test_compute_local_file_hash_nonexistent_file(self, mock_isfile, deduplicator): """Test file hash computation for nonexistent file.""" - result = deduplicator.compute_local_file_hash('/fake/nonexistent/file.txt') + result = deduplicator.compute_local_file_hash("/fake/nonexistent/file.txt") assert result is None - mock_isfile.assert_called_once_with('/fake/nonexistent/file.txt') - - @patch('builtins.open', side_effect=PermissionError("Permission denied")) - @patch('os.path.isfile', return_value=True) - def test_compute_local_file_hash_permission_error(self, mock_isfile, mock_file, deduplicator): + mock_isfile.assert_called_once_with("/fake/nonexistent/file.txt") + + @patch("builtins.open", side_effect=PermissionError("Permission denied")) + @patch("os.path.isfile", return_value=True) + def test_compute_local_file_hash_permission_error( + self, mock_isfile, mock_file, deduplicator + ): """Test file hash computation with permission error.""" - result = deduplicator.compute_local_file_hash('/fake/protected/file.txt') + result = deduplicator.compute_local_file_hash("/fake/protected/file.txt") assert result is None - + def test_check_files_identical_same_hash(self, deduplicator): """Test file comparison with identical hashes.""" - with patch.object(deduplicator, 'compute_local_file_hash') as mock_hash: - mock_hash.return_value = 'abc123' - with patch.object(deduplicator, 'compute_remote_file_hash') as mock_remote_hash: - mock_remote_hash.return_value = 'abc123' - result = deduplicator.check_files_identical('/file1.txt', '/file2.txt') + with patch.object(deduplicator, "compute_local_file_hash") as mock_hash: + mock_hash.return_value = "abc123" + with patch.object( + deduplicator, "compute_remote_file_hash" + ) as mock_remote_hash: + mock_remote_hash.return_value = "abc123" + result = deduplicator.check_files_identical("/file1.txt", "/file2.txt") assert result is True - + def test_check_files_identical_different_hash(self, deduplicator): """Test file comparison with different hashes.""" - with patch.object(deduplicator, 'compute_local_file_hash') as mock_hash: - mock_hash.return_value = 'abc123' - with patch.object(deduplicator, 'compute_remote_file_hash') as mock_remote_hash: - mock_remote_hash.return_value = 'def456' - result = deduplicator.check_files_identical('/file1.txt', '/file2.txt') + with patch.object(deduplicator, "compute_local_file_hash") as mock_hash: + mock_hash.return_value = "abc123" + with patch.object( + deduplicator, "compute_remote_file_hash" + ) as mock_remote_hash: + mock_remote_hash.return_value = "def456" + result = deduplicator.check_files_identical("/file1.txt", "/file2.txt") assert result is False - + def test_check_files_identical_one_hash_none(self, deduplicator): """Test file comparison with one hash being None.""" - with patch.object(deduplicator, 'compute_local_file_hash') as mock_hash: - mock_hash.return_value = 'abc123' - with patch.object(deduplicator, 'compute_remote_file_hash') as mock_remote_hash: + with patch.object(deduplicator, "compute_local_file_hash") as mock_hash: + mock_hash.return_value = "abc123" + with patch.object( + deduplicator, "compute_remote_file_hash" + ) as mock_remote_hash: mock_remote_hash.return_value = None - result = deduplicator.check_files_identical('/file1.txt', '/file2.txt') + result = deduplicator.check_files_identical("/file1.txt", "/file2.txt") assert result is False - + def test_check_files_identical_both_hashes_none(self, deduplicator): """Test file comparison with both hashes being None.""" - with patch.object(deduplicator, 'compute_local_file_hash') as mock_hash: + with patch.object(deduplicator, "compute_local_file_hash") as mock_hash: mock_hash.return_value = None - with patch.object(deduplicator, 'compute_remote_file_hash') as mock_remote_hash: + with patch.object( + deduplicator, "compute_remote_file_hash" + ) as mock_remote_hash: mock_remote_hash.return_value = None - result = deduplicator.check_files_identical('/file1.txt', '/file2.txt') + result = deduplicator.check_files_identical("/file1.txt", "/file2.txt") assert result is False - - @patch('os.path.exists') - @patch('os.listdir') - @patch('os.path.isfile') - def test_build_local_file_hash_map(self, mock_isfile, mock_listdir, mock_exists, deduplicator): + + @patch("os.path.exists") + @patch("os.listdir") + @patch("os.path.isfile") + def test_build_local_file_hash_map( + self, mock_isfile, mock_listdir, mock_exists, deduplicator + ): """Test getting local file hashes from directory.""" mock_exists.return_value = True - mock_listdir.return_value = ['file1.txt', 'file2.jpg', 'subdir'] - mock_isfile.side_effect = lambda x: not x.endswith('subdir') - - file_paths = ['/fake/directory/file1.txt', '/fake/directory/file2.jpg'] - - with patch.object(deduplicator, 'compute_local_file_hash') as mock_hash: - mock_hash.side_effect = ['hash1', 'hash2'] - + mock_listdir.return_value = ["file1.txt", "file2.jpg", "subdir"] + mock_isfile.side_effect = lambda x: not x.endswith("subdir") + + file_paths = ["/fake/directory/file1.txt", "/fake/directory/file2.jpg"] + + with patch.object(deduplicator, "compute_local_file_hash") as mock_hash: + mock_hash.side_effect = ["hash1", "hash2"] + result = deduplicator.build_local_file_hash_map(file_paths) - + expected = { - '/fake/directory/file1.txt': 'hash1', - '/fake/directory/file2.jpg': 'hash2' + "/fake/directory/file1.txt": "hash1", + "/fake/directory/file2.jpg": "hash2", } assert result == expected - - @patch('os.path.exists', return_value=False) - def test_build_local_file_hash_map_nonexistent_files(self, mock_exists, deduplicator): + + @patch("os.path.exists", return_value=False) + def test_build_local_file_hash_map_nonexistent_files( + self, mock_exists, deduplicator + ): """Test getting local file hashes from nonexistent files.""" - result = deduplicator.build_local_file_hash_map(['/fake/nonexistent']) + result = deduplicator.build_local_file_hash_map(["/fake/nonexistent"]) assert result == {} - + def test_find_duplicate_files_with_duplicates(self, deduplicator): """Test finding duplicates when duplicates exist.""" - local_files = ['/local/file1.txt', '/local/file2.jpg', '/local/file3.txt'] - remote_files = ['/remote/remote1.txt', '/remote/remote2.jpg', '/remote/remote3.txt'] - - with patch.object(deduplicator, 'build_local_file_hash_map') as mock_local: - with patch.object(deduplicator, 'build_remote_file_hash_map') as mock_remote: + local_files = ["/local/file1.txt", "/local/file2.jpg", "/local/file3.txt"] + remote_files = [ + "/remote/remote1.txt", + "/remote/remote2.jpg", + "/remote/remote3.txt", + ] + + with patch.object(deduplicator, "build_local_file_hash_map") as mock_local: + with patch.object( + deduplicator, "build_remote_file_hash_map" + ) as mock_remote: mock_local.return_value = { - '/local/file1.txt': 'hash1', - '/local/file2.jpg': 'hash2', - '/local/file3.txt': 'hash3' + "/local/file1.txt": "hash1", + "/local/file2.jpg": "hash2", + "/local/file3.txt": "hash3", } mock_remote.return_value = { - '/remote/remote1.txt': 'hash1', # Duplicate of file1.txt - '/remote/remote2.jpg': 'hash4', # Unique - '/remote/remote3.txt': 'hash3' # Duplicate of file3.txt + "/remote/remote1.txt": "hash1", # Duplicate of file1.txt + "/remote/remote2.jpg": "hash4", # Unique + "/remote/remote3.txt": "hash3", # Duplicate of file3.txt } - + files_to_transfer, duplicates = deduplicator.find_duplicate_files( - local_files, remote_files, is_remote_target=True) - + local_files, remote_files, is_remote_target=True + ) + # Should find 2 duplicates: file1.txt and file3.txt assert len(duplicates) == 2 - assert '/local/file1.txt' in duplicates - assert '/local/file3.txt' in duplicates + assert "/local/file1.txt" in duplicates + assert "/local/file3.txt" in duplicates # file2.jpg should be transferred since it's not a duplicate assert len(files_to_transfer) == 1 - assert '/local/file2.jpg' in files_to_transfer - + assert "/local/file2.jpg" in files_to_transfer + def test_find_duplicate_files_no_duplicates(self, deduplicator): """Test finding duplicates when no duplicates exist.""" - local_files = ['/local/file1.txt', '/local/file2.jpg'] - remote_files = ['/remote/remote1.txt', '/remote/remote2.jpg'] - - with patch.object(deduplicator, 'build_local_file_hash_map') as mock_local: - with patch.object(deduplicator, 'build_remote_file_hash_map') as mock_remote: + local_files = ["/local/file1.txt", "/local/file2.jpg"] + remote_files = ["/remote/remote1.txt", "/remote/remote2.jpg"] + + with patch.object(deduplicator, "build_local_file_hash_map") as mock_local: + with patch.object( + deduplicator, "build_remote_file_hash_map" + ) as mock_remote: mock_local.return_value = { - '/local/file1.txt': 'hash1', - '/local/file2.jpg': 'hash2' + "/local/file1.txt": "hash1", + "/local/file2.jpg": "hash2", } mock_remote.return_value = { - '/remote/remote1.txt': 'hash3', - '/remote/remote2.jpg': 'hash4' + "/remote/remote1.txt": "hash3", + "/remote/remote2.jpg": "hash4", } - + files_to_transfer, duplicates = deduplicator.find_duplicate_files( - local_files, remote_files, is_remote_target=True) + local_files, remote_files, is_remote_target=True + ) assert duplicates == [] assert files_to_transfer == local_files - + def test_find_duplicate_files_empty_collections(self, deduplicator): """Test finding duplicates with empty collections.""" files_to_transfer, duplicates = deduplicator.find_duplicate_files([], []) @@ -227,66 +259,66 @@ class TestFileDeduplicator: class TestFileDeduplicatorIntegration: """Integration tests for FileDeduplicator with real files.""" - + def test_real_file_hash_computation(self): """Test hash computation with real temporary files.""" deduplicator = FileDeduplicator() - + # Create temporary files with known content - with tempfile.NamedTemporaryFile(mode='w', delete=False) as tf1: + with tempfile.NamedTemporaryFile(mode="w", delete=False) as tf1: tf1.write("test content 1") temp_file1 = tf1.name - - with tempfile.NamedTemporaryFile(mode='w', delete=False) as tf2: + + with tempfile.NamedTemporaryFile(mode="w", delete=False) as tf2: tf2.write("test content 1") # Same content temp_file2 = tf2.name - - with tempfile.NamedTemporaryFile(mode='w', delete=False) as tf3: + + with tempfile.NamedTemporaryFile(mode="w", delete=False) as tf3: tf3.write("test content 2") # Different content temp_file3 = tf3.name - + try: hash1 = deduplicator.compute_local_file_hash(temp_file1) hash2 = deduplicator.compute_local_file_hash(temp_file2) hash3 = deduplicator.compute_local_file_hash(temp_file3) - + # Same content should have same hash assert hash1 == hash2 # Different content should have different hash assert hash1 != hash3 - + # Test file comparison - since check_files_identical expects local/remote, # we'll just compare hashes directly for local files assert hash1 == hash2 # Same files should have same hash assert hash1 != hash3 # Different files should have different hash - + finally: # Clean up os.unlink(temp_file1) os.unlink(temp_file2) os.unlink(temp_file3) - + def test_real_directory_hash_computation(self): """Test directory hash computation with real temporary directory.""" deduplicator = FileDeduplicator() - + # Create temporary directory with files with tempfile.TemporaryDirectory() as temp_dir: - file1_path = os.path.join(temp_dir, 'file1.txt') - file2_path = os.path.join(temp_dir, 'file2.txt') - - with open(file1_path, 'w') as f1: + file1_path = os.path.join(temp_dir, "file1.txt") + file2_path = os.path.join(temp_dir, "file2.txt") + + with open(file1_path, "w") as f1: f1.write("content 1") - with open(file2_path, 'w') as f2: + with open(file2_path, "w") as f2: f2.write("content 2") - + hashes = deduplicator.build_local_file_hash_map([file1_path, file2_path]) - + assert file1_path in hashes assert file2_path in hashes assert len(hashes) == 2 assert hashes[file1_path] != hashes[file2_path] -if __name__ == '__main__': - pytest.main([__file__]) \ No newline at end of file +if __name__ == "__main__": + pytest.main([__file__]) diff --git a/tests/utils/test_security_utils.py b/tests/utils/test_security_utils.py index 7b3f367..1ebcf6f 100644 --- a/tests/utils/test_security_utils.py +++ b/tests/utils/test_security_utils.py @@ -28,7 +28,7 @@ class TestSanitizePathComponent: def test_dangerous_chars(self): """Test that dangerous characters are rejected.""" - dangerous_chars = [';', '|', '&', '$', '`', '\n', '\r', '>', '<', '(', ')'] + dangerous_chars = [";", "|", "&", "$", "`", "\n", "\r", ">", "<", "(", ")"] for char in dangerous_chars: with pytest.raises(ValueError, match="dangerous character"): sanitize_path_component(f"file{char}name.txt") @@ -60,8 +60,14 @@ class TestSanitizeAndroidPath: def test_path_with_spaces(self): """Test that paths with spaces are allowed.""" - assert sanitize_android_path("/sdcard/My Photos/vacation.jpg") == "/sdcard/My Photos/vacation.jpg" - assert sanitize_android_path("/sdcard/DCIM/Camera Roll/IMG_001.jpg") == "/sdcard/DCIM/Camera Roll/IMG_001.jpg" + assert ( + sanitize_android_path("/sdcard/My Photos/vacation.jpg") + == "/sdcard/My Photos/vacation.jpg" + ) + assert ( + sanitize_android_path("/sdcard/DCIM/Camera Roll/IMG_001.jpg") + == "/sdcard/DCIM/Camera Roll/IMG_001.jpg" + ) def test_empty_path(self): """Test that empty paths are rejected.""" @@ -150,6 +156,7 @@ class TestSanitizeLocalPath: def test_existing_path_resolves_symlinks(self): """Test that existing paths still resolve symlinks.""" import tempfile + with tempfile.TemporaryDirectory() as tmpdir: # Create a real directory real_dir = os.path.join(tmpdir, "real") @@ -168,6 +175,7 @@ class TestSanitizeLocalPath: def test_nonexistent_path_strict_mode(self): """Test that strict mode (allow_nonexistent=False) works for existing paths.""" import tempfile + with tempfile.TemporaryDirectory() as tmpdir: # Test with existing directory result = sanitize_local_path(tmpdir, allow_nonexistent=False) @@ -190,7 +198,7 @@ class TestValidateDeviceId: def test_invalid_characters(self): """Test that invalid characters are rejected.""" - dangerous_chars = [';', '|', '&', '$', '`', '\n', '\r', ' ', '>', '<'] + dangerous_chars = [";", "|", "&", "$", "`", "\n", "\r", " ", ">", "<"] for char in dangerous_chars: with pytest.raises(ValueError): validate_device_id(f"device{char}123") @@ -222,6 +230,7 @@ class TestSecurityIntegration: def test_symlink_attack_prevention(self): """Test that symlink-based path traversal is blocked.""" import tempfile + with tempfile.TemporaryDirectory() as tmpdir: # Create a base directory base_dir = os.path.join(tmpdir, "safe") @@ -293,6 +302,7 @@ class TestUnicodeAndEdgeCases: def test_local_path_windows_style(self): """Test that Windows-style paths are normalized correctly.""" import platform + if platform.system() == "Windows": # Windows paths should be normalized result = sanitize_local_path("C:\\Users\\Test\\Documents") @@ -307,6 +317,7 @@ class TestUnicodeAndEdgeCases: def test_local_path_with_mixed_separators(self): """Test that paths with mixed separators are normalized.""" import platform + if platform.system() == "Windows": # Windows should handle mixed separators mixed_path = "C:/Users\\Test/Documents"