diff --git a/.github/workflows/autodeps.yml b/.github/workflows/autodeps.yml index 757f6c8..2974d9b 100644 --- a/.github/workflows/autodeps.yml +++ b/.github/workflows/autodeps.yml @@ -20,6 +20,8 @@ jobs: steps: - name: Checkout + with: + persist-credentials: true # credentials are needed to push commits uses: actions/checkout@v4 - name: Setup python uses: actions/setup-python@v5 @@ -28,21 +30,17 @@ jobs: - name: Bump dependencies run: | - python -m pip install -U pip pre-commit - python -m pip install -r test-requirements.txt - uv pip compile --universal --python-version=3.10 --upgrade test-requirements.in -o test-requirements.txt - pre-commit autoupdate --jobs 0 + python -m pip install -U uv + uv lock --upgrade + uv tool install pre-commit + uv run pre-commit autoupdate --jobs 0 - name: Install new requirements - run: python -m pip install -r test-requirements.txt + run: uv sync # apply newer versions' formatting - - name: Black - run: black src/azul - - - name: uv - run: | - uv pip compile --universal --python-version=3.10 test-requirements.in -o test-requirements.txt + - name: Pre-commit updates + run: uv run pre-commit run -a || true - name: Commit changes and create automerge PR env: diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fc9b89d..bdc7d48 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,5 +1,7 @@ name: CI +permissions: {} + on: push: branches-ignore: @@ -11,52 +13,52 @@ concurrency: cancel-in-progress: true jobs: -## Windows: -## name: 'Windows (${{ matrix.python }}, ${{ matrix.arch }}${{ matrix.extra_name }})' -## timeout-minutes: 20 -## runs-on: 'windows-latest' -## strategy: -## fail-fast: false -## matrix: -## python: ['3.10', '3.11', '3.12'] -## arch: ['x86', 'x64'] -## continue-on-error: >- -## ${{ -## ( -## endsWith(matrix.python, '-dev') -## || endsWith(matrix.python, '-nightly') -## ) -## && true -## || false -## }} -## steps: -## - name: Checkout -## uses: actions/checkout@v4 -## - name: Setup python -## uses: actions/setup-python@v5 -## with: -## # This allows the matrix to specify just the major.minor version while still -## # expanding it to get the latest patch version including alpha releases. -## # This avoids the need to update for each new alpha, beta, release candidate, -## # and then finally an actual release version. actions/setup-python doesn't -## # support this for PyPy presently so we get no help there. -## # -## # 'CPython' -> '3.9.0-alpha - 3.9.X' -## # 'PyPy' -> 'pypy-3.9' -## python-version: ${{ fromJSON(format('["{0}", "{1}"]', format('{0}.0-alpha - {0}.X', matrix.python), matrix.python))[startsWith(matrix.python, 'pypy')] }} -## architecture: '${{ matrix.arch }}' -## cache: pip -## cache-dependency-path: test-requirements.txt -## - name: Run tests -## run: ./ci.sh -## shell: bash + Windows: + name: 'Windows (${{ matrix.python }}, ${{ matrix.arch }}${{ matrix.extra_name }})' + timeout-minutes: 20 + runs-on: 'windows-latest' + strategy: + fail-fast: false + matrix: + python: ['3.10', '3.11', '3.12', '3.13'] + arch: ['x86', 'x64'] + continue-on-error: >- + ${{ + ( + endsWith(matrix.python, '-dev') + || endsWith(matrix.python, '-nightly') + ) + && true + || false + }} + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + persist-credentials: false + - name: Setup python + uses: actions/setup-python@v5 + with: + # This allows the matrix to specify just the major.minor version while still + # expanding it to get the latest patch version including alpha releases. + # This avoids the need to update for each new alpha, beta, release candidate, + # and then finally an actual release version. actions/setup-python doesn't + # support this for PyPy presently so we get no help there. + # + # 'CPython' -> '3.9.0-alpha - 3.9.X' + # 'PyPy' -> 'pypy-3.9' + python-version: ${{ fromJSON(format('["{0}", "{1}"]', format('{0}.0-alpha - {0}.X', matrix.python), matrix.python))[startsWith(matrix.python, 'pypy')] }} + architecture: '${{ matrix.arch }}' + cache: pip + cache-dependency-path: test-requirements.txt + - name: Run tests + run: ./ci.sh + shell: bash Ubuntu: name: 'Ubuntu (${{ matrix.python }}${{ matrix.extra_name }})' timeout-minutes: 10 runs-on: 'ubuntu-latest' - # Only run for PRs or pushes to main - if: github.event_name == 'pull_request' || (github.event_name == 'push' && github.ref == 'refs/heads/main') strategy: fail-fast: false matrix: @@ -78,6 +80,8 @@ jobs: }} steps: - name: Checkout + with: + persist-credentials: false uses: actions/checkout@v4 - name: Setup python uses: actions/setup-python@v5 @@ -96,49 +100,51 @@ jobs: env: CHECK_FORMATTING: '${{ matrix.check_formatting }}' -## macOS: -## name: 'macOS (${{ matrix.python }})' -## timeout-minutes: 15 -## runs-on: 'macos-latest' -## strategy: -## fail-fast: false -## matrix: -## python: ['3.10', '3.11', '3.12'] -## continue-on-error: >- -## ${{ -## ( -## endsWith(matrix.python, '-dev') -## || endsWith(matrix.python, '-nightly') -## ) -## && true -## || false -## }} -## steps: -## - name: Checkout -## uses: actions/checkout@v4 -## - name: Setup python -## uses: actions/setup-python@v5 -## with: -## python-version: ${{ fromJSON(format('["{0}", "{1}"]', format('{0}.0-alpha - {0}.X', matrix.python), matrix.python))[startsWith(matrix.python, 'pypy')] }} -## cache: pip -## cache-dependency-path: test-requirements.txt -## - name: Run tests -## run: ./ci.sh + macOS: + name: 'macOS (${{ matrix.python }})' + timeout-minutes: 15 + runs-on: 'macos-latest' + strategy: + fail-fast: false + matrix: + python: ['3.10', '3.11', '3.12', '3.13'] + continue-on-error: >- + ${{ + ( + endsWith(matrix.python, '-dev') + || endsWith(matrix.python, '-nightly') + ) + && true + || false + }} + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + persist-credentials: false + - name: Setup python + uses: actions/setup-python@v5 + with: + python-version: ${{ fromJSON(format('["{0}", "{1}"]', format('{0}.0-alpha - {0}.X', matrix.python), matrix.python))[startsWith(matrix.python, 'pypy')] }} + cache: pip + cache-dependency-path: test-requirements.txt + - name: Run tests + run: ./ci.sh + + # https://github.com/marketplace/actions/alls-green#why + check: # This job does nothing and is only used for the branch protection + + if: always() + + needs: + - Windows + - Ubuntu + - macOS -## # https://github.com/marketplace/actions/alls-green#why -## check: # This job does nothing and is only used for the branch protection -## -## if: always() -## -## needs: -## - Windows -## - Ubuntu -## - macOS -## -## runs-on: ubuntu-latest -## -## steps: -## - name: Decide whether the needed jobs succeeded or failed -## uses: re-actors/alls-green@release/v1 -## with: -## jobs: ${{ toJSON(needs) }} + runs-on: ubuntu-latest + + steps: + - name: Decide whether the needed jobs succeeded or failed + uses: re-actors/alls-green@release/v1 + with: + jobs: ${{ toJSON(needs) }} diff --git a/.gitignore b/.gitignore index 8d514aa..a55ab68 100644 --- a/.gitignore +++ b/.gitignore @@ -86,6 +86,7 @@ instance/ docs/_build/ # PyBuilder +.pybuilder/ target/ # Jupyter Notebook @@ -96,6 +97,8 @@ profile_default/ ipython_config.py # pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: .python-version # pipenv @@ -105,6 +108,21 @@ ipython_config.py # install all needed dependencies. #Pipfile.lock +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +#poetry.lock + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +#pdm.lock +# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it +# in version control. +# https://pdm.fming.dev/#use-with-ide +.pdm.toml + # PEP 582; used by e.g. github.com/David-OConnor/pyflow __pypackages__/ @@ -139,11 +157,15 @@ venv.bak/ .dmypy.json dmypy.json -# Pyre type checker +# Pyre static type analyzer .pyre/ -# Sphinx documentation -doc/_build/ +# Cython debug symbols +cython_debug/ # PyCharm +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. .idea/ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 23d272f..1132490 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -2,7 +2,7 @@ ci: autofix_prs: true autoupdate_schedule: quarterly submodules: false - skip: [badgie, project-requirements] + skip: [badgie] repos: - repo: https://github.com/pre-commit/pre-commit-hooks @@ -16,18 +16,17 @@ repos: - id: check-merge-conflict - id: mixed-line-ending - id: check-case-conflict + - id: check-added-large-files - id: sort-simple-yaml files: .pre-commit-config.yaml - - repo: https://github.com/psf/black-pre-commit-mirror - rev: 26.1.0 - hooks: - - id: black - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.14.14 + rev: v0.15.0 hooks: - - id: ruff + - id: ruff-format + - id: ruff-check types: [file] types_or: [python, pyi, toml] + args: ["--show-fixes"] - repo: https://github.com/CoolCat467/badgie rev: v0.9.6 hooks: @@ -36,11 +35,14 @@ repos: rev: v2.4.1 hooks: - id: codespell - - repo: local + additional_dependencies: + - tomli + - repo: https://github.com/adhtruong/mirrors-typos + rev: v1.43.2 + hooks: + - id: typos + - repo: https://github.com/woodruffw/zizmor-pre-commit + rev: v1.22.0 hooks: - - id: project-requirements - name: regenerate requirements.in - language: system - entry: python tools/project_requirements.py - pass_filenames: false - files: ^(test-requirements.in)|(pyproject.toml)$ + - id: zizmor + args: ["--fix", "--no-progress"] diff --git a/LICENSE-THIRD-PARTY b/LICENSE-THIRD-PARTY index c7343f4..e1a17ae 100644 --- a/LICENSE-THIRD-PARTY +++ b/LICENSE-THIRD-PARTY @@ -1,395 +1,3 @@ ---------------------------------------------------------------------------------------------------- - GNU LESSER GENERAL PUBLIC LICENSE -Applies to: - - Copyright (c) 2023, ItsDrike - All rights reserved. - - src/azul/base_io.py: Entire file - - src/azul/buffer.py: Entire file - - src/azul/utils.py: Entire file - - src/azul/encryption.py: Almost entire file (see details below) - - src/azul/encrypted_event.py: Almost entire file (see details below) - - tests/helpers.py: Entire file - - tests/protocol_helpers.py: Entire file - - tests/test_base_io.py: Entire file - - tests/test_buffer.py: Entire file - - tests/test_encryption.py: Entire file - - tests/test_utils.py: Entire file ---------------------------------------------------------------------------------------------------- - GNU LESSER GENERAL PUBLIC LICENSE - Version 3, 29 June 2007 - - Copyright (C) 2007 Free Software Foundation, Inc. - Everyone is permitted to copy and distribute verbatim copies - of this license document, but changing it is not allowed. - - - This version of the GNU Lesser General Public License incorporates -the terms and conditions of version 3 of the GNU General Public -License, supplemented by the additional permissions listed below. - - 0. Additional Definitions. - - As used herein, "this License" refers to version 3 of the GNU Lesser -General Public License, and the "GNU GPL" refers to version 3 of the GNU -General Public License. - - "The Library" refers to a covered work governed by this License, -other than an Application or a Combined Work as defined below. - - An "Application" is any work that makes use of an interface provided -by the Library, but which is not otherwise based on the Library. -Defining a subclass of a class defined by the Library is deemed a mode -of using an interface provided by the Library. - - A "Combined Work" is a work produced by combining or linking an -Application with the Library. The particular version of the Library -with which the Combined Work was made is also called the "Linked -Version". - - The "Minimal Corresponding Source" for a Combined Work means the -Corresponding Source for the Combined Work, excluding any source code -for portions of the Combined Work that, considered in isolation, are -based on the Application, and not on the Linked Version. - - The "Corresponding Application Code" for a Combined Work means the -object code and/or source code for the Application, including any data -and utility programs needed for reproducing the Combined Work from the -Application, but excluding the System Libraries of the Combined Work. - - 1. Exception to Section 3 of the GNU GPL. - - You may convey a covered work under sections 3 and 4 of this License -without being bound by section 3 of the GNU GPL. - - 2. Conveying Modified Versions. - - If you modify a copy of the Library, and, in your modifications, a -facility refers to a function or data to be supplied by an Application -that uses the facility (other than as an argument passed when the -facility is invoked), then you may convey a copy of the modified -version: - - a) under this License, provided that you make a good faith effort to - ensure that, in the event an Application does not supply the - function or data, the facility still operates, and performs - whatever part of its purpose remains meaningful, or - - b) under the GNU GPL, with none of the additional permissions of - this License applicable to that copy. - - 3. Object Code Incorporating Material from Library Header Files. - - The object code form of an Application may incorporate material from -a header file that is part of the Library. You may convey such object -code under terms of your choice, provided that, if the incorporated -material is not limited to numerical parameters, data structure -layouts and accessors, or small macros, inline functions and templates -(ten or fewer lines in length), you do both of the following: - - a) Give prominent notice with each copy of the object code that the - Library is used in it and that the Library and its use are - covered by this License. - - b) Accompany the object code with a copy of the GNU GPL and this license - document. - - 4. Combined Works. - - You may convey a Combined Work under terms of your choice that, -taken together, effectively do not restrict modification of the -portions of the Library contained in the Combined Work and reverse -engineering for debugging such modifications, if you also do each of -the following: - - a) Give prominent notice with each copy of the Combined Work that - the Library is used in it and that the Library and its use are - covered by this License. - - b) Accompany the Combined Work with a copy of the GNU GPL and this license - document. - - c) For a Combined Work that displays copyright notices during - execution, include the copyright notice for the Library among - these notices, as well as a reference directing the user to the - copies of the GNU GPL and this license document. - - d) Do one of the following: - - 0) Convey the Minimal Corresponding Source under the terms of this - License, and the Corresponding Application Code in a form - suitable for, and under terms that permit, the user to - recombine or relink the Application with a modified version of - the Linked Version to produce a modified Combined Work, in the - manner specified by section 6 of the GNU GPL for conveying - Corresponding Source. - - 1) Use a suitable shared library mechanism for linking with the - Library. A suitable mechanism is one that (a) uses at run time - a copy of the Library already present on the user's computer - system, and (b) will operate properly with a modified version - of the Library that is interface-compatible with the Linked - Version. - - e) Provide Installation Information, but only if you would otherwise - be required to provide such information under section 6 of the - GNU GPL, and only to the extent that such information is - necessary to install and execute a modified version of the - Combined Work produced by recombining or relinking the - Application with a modified version of the Linked Version. (If - you use option 4d0, the Installation Information must accompany - the Minimal Corresponding Source and Corresponding Application - Code. If you use option 4d1, you must provide the Installation - Information in the manner specified by section 6 of the GNU GPL - for conveying Corresponding Source.) - - 5. Combined Libraries. - - You may place library facilities that are a work based on the -Library side by side in a single library together with other library -facilities that are not Applications and are not covered by this -License, and convey such a combined library under terms of your -choice, if you do both of the following: - - a) Accompany the combined library with a copy of the same work based - on the Library, uncombined with any other library facilities, - conveyed under the terms of this License. - - b) Give prominent notice with the combined library that part of it - is a work based on the Library, and explaining where to find the - accompanying uncombined form of the same work. - - 6. Revised Versions of the GNU Lesser General Public License. - - The Free Software Foundation may publish revised and/or new versions -of the GNU Lesser General Public License from time to time. Such new -versions will be similar in spirit to the present version, but may -differ in detail to address new problems or concerns. - - Each version is given a distinguishing version number. If the -Library as you received it specifies that a certain numbered version -of the GNU Lesser General Public License "or any later version" -applies to it, you have the option of following the terms and -conditions either of that published version or of any later version -published by the Free Software Foundation. If the Library as you -received it does not specify a version number of the GNU Lesser -General Public License, you may choose any version of the GNU Lesser -General Public License ever published by the Free Software Foundation. - - If the Library as you received it specifies that a proxy can decide -whether future versions of the GNU Lesser General Public License shall -apply, that proxy's public statement of acceptance of any version is -permanent authorization for you to choose that version for the -Library. ---------------------------------------------------------------------------------------------------- - Apache License version 2.0 -Applies to: - - Copyright (c) 2012 Ammar Askar - All rights reserved. - - src/azul/encryption.py: encrypt_token_and_secret, generate_shared_secret functions - - src/azul/encrypted_event.py: read, write functions ---------------------------------------------------------------------------------------------------- - - - Version 2.0, January 2004 - http://www.apache.org/licenses/ - - TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION - - 1. Definitions. - - "License" shall mean the terms and conditions for use, reproduction, - and distribution as defined by Sections 1 through 9 of this document. - - "Licensor" shall mean the copyright owner or entity authorized by - the copyright owner that is granting the License. - - "Legal Entity" shall mean the union of the acting entity and all - other entities that control, are controlled by, or are under common - control with that entity. For the purposes of this definition, - "control" means (i) the power, direct or indirect, to cause the - direction or management of such entity, whether by contract or - otherwise, or (ii) ownership of fifty percent (50%) or more of the - outstanding shares, or (iii) beneficial ownership of such entity. - - "You" (or "Your") shall mean an individual or Legal Entity - exercising permissions granted by this License. - - "Source" form shall mean the preferred form for making modifications, - including but not limited to software source code, documentation - source, and configuration files. - - "Object" form shall mean any form resulting from mechanical - transformation or translation of a Source form, including but - not limited to compiled object code, generated documentation, - and conversions to other media types. - - "Work" shall mean the work of authorship, whether in Source or - Object form, made available under the License, as indicated by a - copyright notice that is included in or attached to the work - (an example is provided in the Appendix below). - - "Derivative Works" shall mean any work, whether in Source or Object - form, that is based on (or derived from) the Work and for which the - editorial revisions, annotations, elaborations, or other modifications - represent, as a whole, an original work of authorship. For the purposes - of this License, Derivative Works shall not include works that remain - separable from, or merely link (or bind by name) to the interfaces of, - the Work and Derivative Works thereof. - - "Contribution" shall mean any work of authorship, including - the original version of the Work and any modifications or additions - to that Work or Derivative Works thereof, that is intentionally - submitted to Licensor for inclusion in the Work by the copyright owner - or by an individual or Legal Entity authorized to submit on behalf of - the copyright owner. For the purposes of this definition, "submitted" - means any form of electronic, verbal, or written communication sent - to the Licensor or its representatives, including but not limited to - communication on electronic mailing lists, source code control systems, - and issue tracking systems that are managed by, or on behalf of, the - Licensor for the purpose of discussing and improving the Work, but - excluding communication that is conspicuously marked or otherwise - designated in writing by the copyright owner as "Not a Contribution." - - "Contributor" shall mean Licensor and any individual or Legal Entity - on behalf of whom a Contribution has been received by Licensor and - subsequently incorporated within the Work. - - 2. Grant of Copyright License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - copyright license to reproduce, prepare Derivative Works of, - publicly display, publicly perform, sublicense, and distribute the - Work and such Derivative Works in Source or Object form. - - 3. Grant of Patent License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - (except as stated in this section) patent license to make, have made, - use, offer to sell, sell, import, and otherwise transfer the Work, - where such license applies only to those patent claims licensable - by such Contributor that are necessarily infringed by their - Contribution(s) alone or by combination of their Contribution(s) - with the Work to which such Contribution(s) was submitted. If You - institute patent litigation against any entity (including a - cross-claim or counterclaim in a lawsuit) alleging that the Work - or a Contribution incorporated within the Work constitutes direct - or contributory patent infringement, then any patent licenses - granted to You under this License for that Work shall terminate - as of the date such litigation is filed. - - 4. Redistribution. You may reproduce and distribute copies of the - Work or Derivative Works thereof in any medium, with or without - modifications, and in Source or Object form, provided that You - meet the following conditions: - - (a) You must give any other recipients of the Work or - Derivative Works a copy of this License; and - - (b) You must cause any modified files to carry prominent notices - stating that You changed the files; and - - (c) You must retain, in the Source form of any Derivative Works - that You distribute, all copyright, patent, trademark, and - attribution notices from the Source form of the Work, - excluding those notices that do not pertain to any part of - the Derivative Works; and - - (d) If the Work includes a "NOTICE" text file as part of its - distribution, then any Derivative Works that You distribute must - include a readable copy of the attribution notices contained - within such NOTICE file, excluding those notices that do not - pertain to any part of the Derivative Works, in at least one - of the following places: within a NOTICE text file distributed - as part of the Derivative Works; within the Source form or - documentation, if provided along with the Derivative Works; or, - within a display generated by the Derivative Works, if and - wherever such third-party notices normally appear. The contents - of the NOTICE file are for informational purposes only and - do not modify the License. You may add Your own attribution - notices within Derivative Works that You distribute, alongside - or as an addendum to the NOTICE text from the Work, provided - that such additional attribution notices cannot be construed - as modifying the License. - - You may add Your own copyright statement to Your modifications and - may provide additional or different license terms and conditions - for use, reproduction, or distribution of Your modifications, or - for any such Derivative Works as a whole, provided Your use, - reproduction, and distribution of the Work otherwise complies with - the conditions stated in this License. - - 5. Submission of Contributions. Unless You explicitly state otherwise, - any Contribution intentionally submitted for inclusion in the Work - by You to the Licensor shall be under the terms and conditions of - this License, without any additional terms or conditions. - Notwithstanding the above, nothing herein shall supersede or modify - the terms of any separate license agreement you may have executed - with Licensor regarding such Contributions. - - 6. Trademarks. This License does not grant permission to use the trade - names, trademarks, service marks, or product names of the Licensor, - except as required for reasonable and customary use in describing the - origin of the Work and reproducing the content of the NOTICE file. - - 7. Disclaimer of Warranty. Unless required by applicable law or - agreed to in writing, Licensor provides the Work (and each - Contributor provides its Contributions) on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or - implied, including, without limitation, any warranties or conditions - of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A - PARTICULAR PURPOSE. You are solely responsible for determining the - appropriateness of using or redistributing the Work and assume any - risks associated with Your exercise of permissions under this License. - - 8. Limitation of Liability. In no event and under no legal theory, - whether in tort (including negligence), contract, or otherwise, - unless required by applicable law (such as deliberate and grossly - negligent acts) or agreed to in writing, shall any Contributor be - liable to You for damages, including any direct, indirect, special, - incidental, or consequential damages of any character arising as a - result of this License or out of the use or inability to use the - Work (including but not limited to damages for loss of goodwill, - work stoppage, computer failure or malfunction, or any and all - other commercial damages or losses), even if such Contributor - has been advised of the possibility of such damages. - - 9. Accepting Warranty or Additional Liability. While redistributing - the Work or Derivative Works thereof, You may choose to offer, - and charge a fee for, acceptance of support, warranty, indemnity, - or other liability obligations and/or rights consistent with this - License. However, in accepting such obligations, You may act only - on Your own behalf and on Your sole responsibility, not on behalf - of any other Contributor, and only if You agree to indemnify, - defend, and hold each Contributor harmless for any liability - incurred by, or claims asserted against, such Contributor by reason - of your accepting any such warranty or additional liability. - - END OF TERMS AND CONDITIONS - - APPENDIX: How to apply the Apache License to your work. - - To apply the Apache License to your work, attach the following - boilerplate notice, with the fields enclosed by brackets "[]" - replaced with your own identifying information. (Don't include - the brackets!) The text should be enclosed in the appropriate - comment syntax for the file format. We also recommend that a - file or class name and description of purpose be included on the - same "printed page" as the copyright notice for easier - identification within third-party archives. - - Copyright [yyyy] [name of copyright owner] - - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. --------------------------------------------------------------------------------------------------- CREATIVE COMMONS 0 Applies to: diff --git a/README.md b/README.md index a081d6c..a015365 100644 --- a/README.md +++ b/README.md @@ -6,8 +6,6 @@ Graphical Azul Game with AI support [![pre-commit.ci status](https://results.pre-commit.ci/badge/github/CoolCat467/Azul/main.svg)](https://results.pre-commit.ci/latest/github/CoolCat467/Azul/main) [![pre-commit](https://img.shields.io/badge/pre--commit-enabled-brightgreen?logo=pre-commit)](https://github.com/pre-commit/pre-commit) -[![code style: black](https://img.shields.io/badge/code_style-black-000000.svg)](https://github.com/psf/black) -[![Ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json)](https://github.com/astral-sh/ruff) diff --git a/check.sh b/check.sh index fb0c125..e33b51c 100755 --- a/check.sh +++ b/check.sh @@ -4,7 +4,6 @@ set -ex ON_GITHUB_CI=true EXIT_STATUS=0 -PROJECT='azul' # If not running on Github's CI, discard the summaries if [ -z "${GITHUB_STEP_SUMMARY+x}" ]; then @@ -13,17 +12,13 @@ if [ -z "${GITHUB_STEP_SUMMARY+x}" ]; then fi # Autoformatter *first*, to avoid double-reporting errors -# (we'd like to run further autoformatters but *after* merging; -# see https://forum.bors.tech/t/pre-test-and-pre-merge-hooks/322) -# autoflake --recursive --in-place . -# pyupgrade --py3-plus $(find . -name "*.py") -echo "::group::Black" -if ! black --check src/$PROJECT; then - echo "* Black found issues" >> "$GITHUB_STEP_SUMMARY" +echo "::group::Ruff format" +if ! ruff format --check; then + echo "* Ruff formatting found issues" >> "$GITHUB_STEP_SUMMARY" EXIT_STATUS=1 - black --diff src/$PROJECT + ruff format --diff echo "::endgroup::" - echo "::error:: Black found issues" + echo "::error:: Ruff formatting found issues" else echo "::endgroup::" fi @@ -73,15 +68,15 @@ fi # Check pip compile is consistent echo "::group::Pip Compile - Tests" -uv pip compile --universal --python-version=3.10 test-requirements.in -o test-requirements.txt +uv lock echo "::endgroup::" -if git status --porcelain | grep -q "requirements.txt"; then - echo "::error::requirements.txt changed." - echo "::group::requirements.txt changed" - echo "* requirements.txt changed" >> "$GITHUB_STEP_SUMMARY" +if git status --porcelain | grep -q "uv.lock"; then + echo "::error::uv.lock changed." + echo "::group::uv.lock changed" + echo "* uv.lock changed" >> "$GITHUB_STEP_SUMMARY" git status --porcelain - git --no-pager diff --color ./*requirements.txt + git --no-pager diff --color ./*uv.lock EXIT_STATUS=1 echo "::endgroup::" fi @@ -97,9 +92,9 @@ if [ $EXIT_STATUS -ne 0 ]; then Problems were found by static analysis (listed above). To fix formatting and see remaining errors, run - uv pip install -r test-requirements.txt - black src/$PROJECT - ruff check src/$PROJECT + uv sync --extra tools + ruff check src + mypy ./check.sh in your local checkout. diff --git a/ci.sh b/ci.sh index 81cc25a..973c039 100755 --- a/ci.sh +++ b/ci.sh @@ -19,18 +19,34 @@ python -c "import sys, struct; print('python:', sys.version); print('version_inf echo "::endgroup::" echo "::group::Install dependencies" -python -m pip install -U pip uv -c test-requirements.txt +python -m pip install -U pip tomli python -m pip --version +UV_VERSION=$(python -c 'import tomli; from pathlib import Path; print({p["name"]:p for p in tomli.loads(Path("uv.lock").read_text())["package"]}["uv"]["version"])') +python -m pip install uv==$UV_VERSION python -m uv --version -python -m uv pip install build - -python -m build -wheel_package=$(ls dist/*.whl) -python -m uv pip install "$PROJECT @ $wheel_package" -c test-requirements.txt +UV_VENV_SEED="pip" +python -m uv venv --seed --allow-existing + +# Determine the platform and activate the virtual environment accordingly +case "$OSTYPE" in + linux-gnu*|linux-musl*|darwin*) + source .venv/bin/activate + ;; + cygwin*|msys*) + source .venv/Scripts/activate + ;; + *) + echo "::error:: Unknown OS. Please add an activation method for '$OSTYPE'." + exit 1 + ;; +esac + +# Install uv in virtual environment +python -m pip install uv==$UV_VERSION if [ "$CHECK_FORMATTING" = "1" ]; then - python -m uv pip install -r test-requirements.txt exceptiongroup + python -m uv sync --locked --extra tests --extra tools echo "::endgroup::" source check.sh else @@ -38,10 +54,11 @@ else # expands to 0 != 1 if NO_TEST_REQUIREMENTS is not set, if set the `-0` has no effect # https://pubs.opengroup.org/onlinepubs/9699919799/utilities/V3_chap02.html#tag_18_06_02 if [ "${NO_TEST_REQUIREMENTS-0}" == 1 ]; then - python -m uv pip install pytest coverage -c test-requirements.txt - flags="--skip-optional-imports" + python -m uv sync --locked --extra tests + flags="" + #"--skip-optional-imports" else - python -m uv pip install -r test-requirements.txt + python -m uv sync --locked --extra tests --extra tools flags="" fi @@ -58,12 +75,6 @@ else INSTALLDIR=$(python -c "import os, $PROJECT; print(os.path.dirname($PROJECT.__file__))") cp ../pyproject.toml "$INSTALLDIR" - # get mypy tests a nice cache - MYPYPATH=".." mypy --config-file= --cache-dir=./.mypy_cache -c "import $PROJECT" >/dev/null 2>/dev/null || true - - # support subprocess spawning with coverage.py - # echo "import coverage; coverage.process_startup()" | tee -a "$INSTALLDIR/../sitecustomize.py" - echo "::endgroup::" echo "::group:: Run Tests" if coverage run --rcfile=../pyproject.toml -m pytest -ra --junitxml=../test-results.xml ../tests --verbose --durations=10 $flags; then @@ -71,10 +82,14 @@ else else PASSED=false fi + PREV_DIR="$PWD" + cd "$INSTALLDIR" + rm pyproject.toml + cd "$PREV_DIR" echo "::endgroup::" echo "::group::Coverage" - #coverage combine --rcfile ../pyproject.toml + coverage combine --rcfile ../pyproject.toml coverage report -m --rcfile ../pyproject.toml coverage xml --rcfile ../pyproject.toml diff --git a/pyproject.toml b/pyproject.toml index 1a0cedd..39879b7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -11,7 +11,7 @@ authors = [ description = "Graphical Azul Game with AI support" readme = {file = "README.md", content-type = "text/markdown"} license = {file = "LICENSE"} -requires-python = ">=3.10" +requires-python = ">=3.11" classifiers = [ "Development Status :: 4 - Beta", "Intended Audience :: End Users/Desktop", @@ -36,13 +36,13 @@ keywords = [ "ai", "multi-player", "azul", "ai-support", "networked-game" ] dependencies = [ - "pygame~=2.6.0", - "typing_extensions>=4.12.2", + "exceptiongroup>=1.2.2; python_version < '3.11'", + "libcomponent~=0.0.5", "mypy_extensions>=1.0.0", - "trio~=0.27.0", - "cryptography>=43.0.0", - "exceptiongroup; python_version < '3.11'", - "numpy~=2.1.3", + "numpy~=2.4.2", + "orjson>=3.10,<4", + "pygame~=2.6.0", + "trio~=0.32.0", ] [tool.setuptools.dynamic] @@ -52,31 +52,54 @@ version = {attr = "azul.game.__version__"} "Source" = "https://github.com/CoolCat467/Azul" "Bug Tracker" = "https://github.com/CoolCat467/Azul/issues" -[project.scripts] +[project.gui-scripts] azul_game = "azul.game:cli_run" +azul_game_server = "azul.server:cli_run" +azul_game_minimax_ai_client = "azul_computer_players.minimax_ai:run" + + +[project.optional-dependencies] +tests = [ + "pytest>=5.0", + "pytest-cov>=6.0.0", + "pytest-trio>=0.8.0", + "coverage>=7.2.5", +] +tools = [ + "uv>=0.10.0", + "mypy>=1.14.1", + "ruff>=0.9.2", + "codespell>=2.3.0", + "pre-commit>=4.2.0", +] [tool.setuptools.package-data] -azul = ["py.typed", "data/*"] +azul = ["py.typed", "data/*", "lang/*", "fonts/*"] + +[tool.uv] +package = true [tool.mypy] -files = ["src/azul/",] -check_untyped_defs = true -disallow_any_generics = true -disallow_untyped_calls = true -disallow_untyped_defs = true -ignore_missing_imports = true -no_implicit_optional = true -no_implicit_reexport = true +files = [ + "src/azul/", + "src/azul_computer_players", + "tests", +] +enable_error_code = [ + "truthy-bool", + "mutable-override", + "exhaustive-match", +] show_column_numbers = true show_error_codes = true show_traceback = true +disallow_any_decorated = true +disallow_any_unimported = true +ignore_missing_imports = true +local_partial_types = true +no_implicit_optional = true strict = true -strict_equality = true -warn_redundant_casts = true -warn_return_any = true warn_unreachable = true -warn_unused_configs = true -warn_unused_ignores = true [tool.ruff.lint.isort] combine-as-imports = true @@ -146,7 +169,7 @@ extend-ignore = [ ] [tool.pytest.ini_options] -addopts = "--cov-report=xml --cov-report=term-missing --cov=azul" +addopts = "--cov-report=term-missing --cov=azul" testpaths = [ "tests", ] @@ -157,6 +180,9 @@ source_pkgs = ["azul"] omit = [ "__init__.py", ] +parallel = true +relative_files = true +source = ["."] [tool.coverage.report] precision = 1 diff --git a/src/azul/__init__.py b/src/azul/__init__.py index 8e25fb5..55df39e 100644 --- a/src/azul/__init__.py +++ b/src/azul/__init__.py @@ -17,7 +17,6 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . - if __name__ == "__main__": from azul.game import cli_run as cli_run diff --git a/src/azul/async_clock.py b/src/azul/async_clock.py deleted file mode 100644 index 9e93365..0000000 --- a/src/azul/async_clock.py +++ /dev/null @@ -1,106 +0,0 @@ -"""Asynchronous Clock - Asynchronous version of pygame.time.Clock.""" - -# Programmed by CoolCat467 - -from __future__ import annotations - -# Copyright (C) 2023 CoolCat467 -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program. If not, see . - -__title__ = "Async Clock" -__author__ = "CoolCat467" -__license__ = "GNU General Public License Version 3" -__version__ = "0.0.0" - - -from time import perf_counter_ns -from typing import NewType - -import trio - -nanoseconds = NewType("nanoseconds", int) - - -def get_ticks() -> nanoseconds: - """Get Ticks.""" - return nanoseconds(perf_counter_ns()) - - -class Clock: - """pygame.time.Clock but with asynchronous tick.""" - - __slots__ = ( - "fps", - "fps_count", - "fps_tick", - "last_tick", - "rawpassed", - "timepassed", - ) - - def __init__(self) -> None: - """Initialize variables.""" - self.fps_tick = nanoseconds(0) - self.timepassed = nanoseconds(0) - self.rawpassed = nanoseconds(0) - self.last_tick: nanoseconds = get_ticks() - self.fps = 0.0 - self.fps_count = 0 - - def __repr__(self) -> str: - """Return representation of self.""" - return f"<{self.__class__.__name__}({self.fps:2f})>" - - def get_fps(self) -> float: - """Return the clock framerate in Frames Per Second.""" - return self.fps - - def get_rawtime(self) -> nanoseconds: - """Return the actual time used in the previous tick in nanoseconds (original was milliseconds).""" - return self.rawpassed - - def get_time(self) -> nanoseconds: - """Return time used in the previous tick (in nanoseconds, original was milliseconds).""" - return self.timepassed - - async def tick(self, framerate: int = 0) -> int: - """Tick the clock. Return time passed in nanoseconds, same as get_time (original was milliseconds).""" - endtime = 1000000000 // framerate if framerate > 0 else 0 - self.rawpassed = nanoseconds(get_ticks() - self.last_tick) - delay = endtime - self.rawpassed - if delay > 0: - await trio.sleep(delay / 1e9) # nanoseconds -> seconds - else: - await trio.lowlevel.checkpoint() - - nowtime: nanoseconds = get_ticks() - self.timepassed = nanoseconds(nowtime - self.last_tick) - self.fps_count += 1 - self.last_tick = nowtime - - if not self.fps_tick: - self.fps_count = 0 - self.fps_tick = nowtime - if self.fps_count >= 10: - self.fps = self.fps_count / ( - (nowtime - self.fps_tick) / 1e9 - ) # nanoseconds -> seconds - self.fps_count = 0 - self.fps_tick = nowtime - return self.timepassed - - -if __name__ == "__main__": # pragma: nocover - print(f"{__title__} v{__version__}\nProgrammed by {__author__}.\n") diff --git a/src/azul/base_io.py b/src/azul/base_io.py deleted file mode 100644 index da0b34e..0000000 --- a/src/azul/base_io.py +++ /dev/null @@ -1,701 +0,0 @@ -"""Base IO classes.""" - -# This is the base_io module from https://github.com/py-mine/mcproto v0.3.0, -# which is licensed under the GNU LESSER GENERAL PUBLIC LICENSE v3.0 - -from __future__ import annotations - -__author__ = "ItsDrike" -__license__ = "LGPL-3.0-only" - -import struct -from abc import ABC, abstractmethod -from enum import Enum -from itertools import count -from typing import TYPE_CHECKING, Literal, TypeAlias, TypeVar, overload - -from .utils import from_twos_complement, to_twos_complement - -if TYPE_CHECKING: - from collections.abc import Awaitable, Callable - -__all__ = [ - "FLOAT_FORMATS_TYPE", - "INT_FORMATS_TYPE", - "BaseAsyncReader", - "BaseAsyncWriter", - "BaseSyncReader", - "BaseSyncWriter", - "StructFormat", -] - -T = TypeVar("T") -R = TypeVar("R") - - -# region: Format types - - -class StructFormat(str, Enum): - """All possible write/read struct types. - - .. seealso: - :module:`struct` module documentation. - """ - - BOOL = "?" - CHAR = "c" - BYTE = "b" - UBYTE = "B" - SHORT = "h" - USHORT = "H" - INT = "i" - UINT = "I" - LONG = "l" - ULONG = "L" - FLOAT = "f" - DOUBLE = "d" - HALFFLOAT = "e" - LONGLONG = "q" - ULONGLONG = "Q" - - -INT_FORMATS_TYPE: TypeAlias = Literal[ - StructFormat.BYTE, - StructFormat.UBYTE, - StructFormat.SHORT, - StructFormat.USHORT, - StructFormat.INT, - StructFormat.UINT, - StructFormat.LONG, - StructFormat.ULONG, - StructFormat.LONGLONG, - StructFormat.ULONGLONG, -] - -FLOAT_FORMATS_TYPE: TypeAlias = Literal[ - StructFormat.FLOAT, - StructFormat.DOUBLE, - StructFormat.HALFFLOAT, -] - -# endregion - -# region: Writer classes - - -class BaseAsyncWriter(ABC): - """Base class holding asynchronous write buffer/connection interactions.""" - - __slots__ = () - - @abstractmethod - async def write(self, data: bytes, /) -> None: - """Underlying write method, sending/storing the data. - - All the writer functions will eventually call this method. - """ - - @overload - async def write_value( - self, - fmt: INT_FORMATS_TYPE, - value: int, - /, - ) -> None: ... - - @overload - async def write_value( - self, - fmt: FLOAT_FORMATS_TYPE, - value: float, - /, - ) -> None: ... - - @overload - async def write_value( - self, - fmt: Literal[StructFormat.BOOL], - value: bool, - /, - ) -> None: ... - - @overload - async def write_value( - self, - fmt: Literal[StructFormat.CHAR], - value: str, - /, - ) -> None: ... - - async def write_value(self, fmt: StructFormat, value: object, /) -> None: - """Write a given ``value`` as given struct format (``fmt``) in big-endian mode.""" - await self.write(struct.pack(">" + fmt.value, value)) - - async def _write_varuint( - self, - value: int, - /, - *, - max_bits: int | None = None, - ) -> None: - """Write an arbitrarily big unsigned integer in a variable length format. - - This is a standard way of transmitting ints, and it allows smaller numbers to take less bytes. - - Writing will be limited up to integer values of ``max_bits`` bits, and trying to write bigger values will rase - a :exc:`ValueError`. Note that setting ``max_bits`` to for example 32 bits doesn't mean that at most 4 bytes - will be sent, in this case it would actually take at most 5 bytes, due to the variable encoding overhead. - - Varints send bytes where 7 least significant bits are value bits, and the most significant bit is continuation - flag bit. If this continuation bit is set (1), it indicates that there will be another varint byte sent after - this one. The least significant group is written first, followed by each of the more significant groups, making - varints little-endian, however in groups of 7 bits, not 8. - """ - value_max = ( - (1 << (max_bits)) - 1 if max_bits is not None else float("inf") - ) - if value < 0 or value > value_max: - raise ValueError( - f"Tried to write varint outside of the range of {max_bits}-bit int.", - ) - - remaining = value - while True: - if remaining & -0x80 == 0: # final byte (~0x7F) - await self.write_value(StructFormat.UBYTE, remaining) - return - # Write only 7 least significant bits with the first bit being 1, marking there will be another byte - await self.write_value(StructFormat.UBYTE, remaining & 0x7F | 0x80) - # Subtract the value we've already sent (7 least significant bits) - remaining >>= 7 - - async def write_varint(self, value: int, /) -> None: - """Write a 32-bit signed integer in a variable length format. - - For more information about variable length format check :meth:`._write_varuint`. - - Raises ValueError if value is outside of the range of a 32-bit signed integer. - """ - val = to_twos_complement(value, bits=32) - await self._write_varuint(val, max_bits=32) - - async def write_varlong(self, value: int, /) -> None: - """Write a 64-bit signed integer in a variable length format. - - For more information about variable length format check :meth:`._write_varuint`. - - Raises ValueError if value is outside of the range of a 64-bit signed integer. - """ - val = to_twos_complement(value, bits=64) - await self._write_varuint(val, max_bits=64) - - async def write_bytearray(self, data: bytearray | bytes, /) -> None: - """Write an arbitrary sequence of bytes, prefixed with a varint of it's size. - - Raises ValueError if length is is outside of the range of a 32-bit signed integer. - """ - await self.write_varint(len(data)) - await self.write(bytes(data)) - - async def write_ascii(self, value: str, /) -> None: - """Write ISO-8859-1 encoded string, with NULL (0x00) at the end to indicate string end.""" - data = value.encode(encoding="ISO-8859-1") - await self.write(data) - await self.write(b"\x00") - - async def write_utf(self, value: str, /) -> None: - """Write a UTF-8 encoded string, prefixed with a varint of it's size (in bytes). - - The maximum amount of UTF-8 characters is limited to 32767. - - Individual UTF-8 characters can take up to 4 bytes, however most of the common ones take up less. Assuming the - worst case of 4 bytes per every character, at most 131068 data bytes will be written + 3 additional bytes from - the varint encoding overhead. - - :raises ValueError: - If the given string ``value`` has more characters than the allowed maximum (32767). - """ - if len(value) > 32767: - raise ValueError( - "Maximum character limit for writing strings is 32767 characters.", - ) - - data = value.encode(encoding="utf-8") - await self.write_varint(len(data)) - await self.write(data) - - async def write_optional( - self, - value: T | None, - /, - writer: Callable[[T], Awaitable[R]], - ) -> R | None: - """Write a bool showing if a ``value`` is present, if so, also writes this value with ``writer`` function. - - * When ``value`` is ``None``, a bool of ``False`` will be written, and ``None`` is returned. - * When ``value`` is not ``None``, a bool of ``True`` is written, after which the ``writer`` function is called, - and the return value is forwarded. - """ - if value is None: - await self.write_value(StructFormat.BOOL, False) - return None - - await self.write_value(StructFormat.BOOL, True) - return await writer(value) - - -class BaseSyncWriter(ABC): - """Base class holding synchronous write buffer/connection interactions.""" - - __slots__ = () - - @abstractmethod - def write(self, data: bytes, /) -> None: - """Write data.""" - ... - - @overload - def write_value(self, fmt: INT_FORMATS_TYPE, value: int, /) -> None: ... - - @overload - def write_value( - self, - fmt: FLOAT_FORMATS_TYPE, - value: float, - /, - ) -> None: ... - - @overload - def write_value( - self, - fmt: Literal[StructFormat.BOOL], - value: bool, - /, - ) -> None: ... - - @overload - def write_value( - self, - fmt: Literal[StructFormat.CHAR], - value: str, - /, - ) -> None: ... - - def write_value(self, fmt: StructFormat, value: object, /) -> None: - """Write a given ``value`` as given struct format (``fmt``) in big-endian mode.""" - self.write(struct.pack(">" + fmt.value, value)) - - def _write_varuint( - self, - value: int, - /, - *, - max_bits: int | None = None, - ) -> None: - """Write an arbitrarily big unsigned integer in a variable length format. - - This is a standard way of transmitting ints, and it allows smaller numbers to take less bytes. - - Writing will be limited up to integer values of ``max_bits`` bits, and trying to write bigger values will rase - a :exc:`ValueError`. Note that setting ``max_bits`` to for example 32 bits doesn't mean that at most 4 bytes - will be sent, in this case it would actually take at most 5 bytes, due to the variable encoding overhead. - - Varints send bytes where 7 least significant bits are value bits, and the most significant bit is continuation - flag bit. If this continuation bit is set (1), it indicates that there will be another varint byte sent after - this one. The least significant group is written first, followed by each of the more significant groups, making - varints little-endian, however in groups of 7 bits, not 8. - """ - value_max = ( - (1 << (max_bits)) - 1 if max_bits is not None else float("inf") - ) - if value < 0 or value > value_max: - raise ValueError( - f"Tried to write varint outside of the range of {max_bits}-bit int.", - ) - - remaining = value - while True: - if remaining & ~0x7F == 0: # final byte - self.write_value(StructFormat.UBYTE, remaining) - return - # Write only 7 least significant bits with the first bit being 1, marking there will be another byte - self.write_value(StructFormat.UBYTE, remaining & 0x7F | 0x80) - # Subtract the value we've already sent (7 least significant bits) - remaining >>= 7 - - def write_varint(self, value: int, /) -> None: - """Write a 32-bit signed integer in a variable length format. - - For more information about variable length format check :meth:`._write_varuint`. - - Raises ValueError if length is is outside of the range of a 32-bit signed integer. - """ - val = to_twos_complement(value, bits=32) - self._write_varuint(val, max_bits=32) - - def write_varlong(self, value: int, /) -> None: - """Write a 64-bit signed integer in a variable length format. - - For more information about variable length format check :meth:`._write_varuint` docstring. - - Raises ValueError if length is is outside of the range of a 64-bit signed integer. - """ - val = to_twos_complement(value, bits=64) - self._write_varuint(val, max_bits=64) - - def write_bytearray(self, data: bytearray | bytes, /) -> None: - """Write an arbitrary sequence of bytes, prefixed with a varint of it's size. - - Raises ValueError if length is is outside of the range of a 32-bit signed integer. - """ - self.write_varint(len(data)) - self.write(bytes(data)) - - def write_ascii(self, value: str, /) -> None: - """Write ISO-8859-1 encoded string, with NULL (0x00) at the end to indicate string end.""" - data = value.encode(encoding="ISO-8859-1") - self.write(data) - self.write(b"\x00") - - def write_utf(self, value: str, /) -> None: - """Write a UTF-8 encoded string, prefixed with a varint of it's size (in bytes). - - The maximum amount of UTF-8 characters is limited to 32767. - - Individual UTF-8 characters can take up to 4 bytes, however most of the common ones take up less. Assuming the - worst case of 4 bytes per every character, at most 131068 data bytes will be written + 3 additional bytes from - the varint encoding overhead. - - :raises ValueError: - If the given string ``value`` has more characters than the allowed maximum (32767). - """ - if len(value) > 32767: - raise ValueError( - "Maximum character limit for writing strings is 32767 characters.", - ) - - data = value.encode("utf-8") - self.write_varint(len(data)) - self.write(data) - - def write_optional( - self, - value: T | None, - /, - writer: Callable[[T], R], - ) -> R | None: - """Write a bool showing if a ``value`` is present, if so, also writes this value with ``writer`` function. - - * When ``value`` is ``None``, a bool of ``False`` will be written, and ``None`` is returned. - * When ``value`` is not ``None``, a bool of ``True`` is written, after which the ``writer`` function is called, - and the return value is forwarded. - """ - if value is None: - self.write_value(StructFormat.BOOL, False) - return None - - self.write_value(StructFormat.BOOL, True) - return writer(value) - - -# endregion -# region: Reader classes - - -class BaseAsyncReader(ABC): - """Base class holding asynchronous read buffer/connection interactions.""" - - __slots__ = () - - @abstractmethod - async def read(self, length: int, /) -> bytearray: - """Underlying read method, obtaining the raw data. - - All of the reader functions will eventually call this method. - """ - - @overload - async def read_value(self, fmt: INT_FORMATS_TYPE, /) -> int: ... - - @overload - async def read_value(self, fmt: FLOAT_FORMATS_TYPE, /) -> float: ... - - @overload - async def read_value(self, fmt: Literal[StructFormat.BOOL], /) -> bool: ... - - @overload - async def read_value(self, fmt: Literal[StructFormat.CHAR], /) -> str: ... - - async def read_value(self, fmt: StructFormat, /) -> object: - """Read a value as given struct format (``fmt``) in big-endian mode. - - The amount of bytes to read will be determined based on the struct format automatically. - """ - length = struct.calcsize(fmt.value) - data = await self.read(length) - unpacked = struct.unpack(">" + fmt.value, data) - return unpacked[0] - - async def _read_varuint(self, *, max_bits: int | None = None) -> int: - """Read an arbitrarily big unsigned integer in a variable length format. - - This is a standard way of transmitting ints, and it allows smaller numbers to take less bytes. - - Reading will be limited up to integer values of ``max_bits`` bits, and trying to read bigger values will rase - an :exc:`OSError`. Note that setting ``max_bits`` to for example 32 bits doesn't mean that at most 4 bytes - will be read, in this case we would actually read at most 5 bytes, due to the variable encoding overhead. - - Varints send bytes where 7 least significant bits are value bits, and the most significant bit is continuation - flag bit. If this continuation bit is set (1), it indicates that there will be another varint byte sent after - this one. The least significant group is written first, followed by each of the more significant groups, making - varints little-endian, however in groups of 7 bits, not 8. - """ - value_max = ( - (1 << (max_bits)) - 1 if max_bits is not None else float("inf") - ) - - result = 0 - for i in count(): - byte = await self.read_value(StructFormat.UBYTE) - # Read 7 least significant value bits in this byte, and shift them appropriately to be in the right place - # then simply add them (OR) as additional 7 most significant bits in our result - result |= (byte & 0x7F) << (7 * i) - - # Ensure that we stop reading and raise an error if the size gets over the maximum - # (if the current amount of bits is higher than allowed size in bits) - if result > value_max: - raise OSError( - f"Received varint was outside the range of {max_bits}-bit int.", - ) - - # If the most significant bit is 0, we should stop reading - if not byte & 0x80: - break - - return result - - async def read_varint(self) -> int: - """Read a 32-bit signed integer in a variable length format. - - For more information about variable length format check :meth:`._read_varuint`. - """ - unsigned_num = await self._read_varuint(max_bits=32) - return from_twos_complement(unsigned_num, bits=32) - - async def read_varlong(self) -> int: - """Read a 64-bit signed integer in a variable length format. - - For more information about variable length format check :meth:`._read_varuint`. - """ - unsigned_num = await self._read_varuint(max_bits=64) - return from_twos_complement(unsigned_num, bits=64) - - async def read_bytearray(self, /) -> bytearray: - """Read an arbitrary sequence of bytes, prefixed with a varint of it's size.""" - length = await self.read_varint() - return await self.read(length) - - async def read_ascii(self) -> str: - """Read ISO-8859-1 encoded string, until we encounter NULL (0x00) at the end indicating string end.""" - # Keep reading bytes until we find NULL - result = bytearray() - while len(result) == 0 or result[-1] != 0: - byte = await self.read(1) - result.extend(byte) - return result[:-1].decode("ISO-8859-1") - - async def read_utf(self) -> str: - """Read a UTF-8 encoded string, prefixed with a varint of it's size (in bytes). - - The maximum amount of UTF-8 characters is limited to 32767. - - Individual UTF-8 characters can take up to 4 bytes, however most of the common ones take up less. Assuming the - worst case of 4 bytes per every character, at most 131068 data bytes will be read + 3 additional bytes from - the varint encoding overhead. - - :raises IOError: - * If the prefix varint is bigger than the maximum (131068) bytes, the string will not be read at all, - and :exc:`IOError` will be raised immediately. - * If the received string has more than the maximum amount of characters (32767). Note that in this case, - the string will still get read in it's entirety, since it fits into the maximum bytes limit (131068), - which was simply read at once. This limitation is here only to replicate the behavior of minecraft's - implementation. - """ - length = await self.read_varint() - if length > 131068: - raise OSError( - f"Maximum read limit for utf strings is 131068 bytes, got {length}.", - ) - - data = await self.read(length) - chars = data.decode("utf-8") - - if len(chars) > 32767: - raise OSError( - f"Maximum read limit for utf strings is 32767 characters, got {len(chars)}.", - ) - - return chars - - async def read_optional( - self, - reader: Callable[[], Awaitable[R]], - ) -> R | None: - """Read a bool showing if a value is present, if so, also reads this value with ``reader`` function. - - * When ``False`` is read, the function will not read anything and ``None`` is returned. - * When ``True`` is read, the ``reader`` function is called, and it's return value is forwarded. - """ - if not await self.read_value(StructFormat.BOOL): - return None - - return await reader() - - -class BaseSyncReader(ABC): - """Base class holding synchronous read buffer/connection interactions.""" - - __slots__ = () - - @abstractmethod - def read(self, length: int, /) -> bytearray: - """Read ``length`` bytes and return in a bytearray.""" - ... - - @overload - def read_value(self, fmt: INT_FORMATS_TYPE, /) -> int: ... - - @overload - def read_value(self, fmt: FLOAT_FORMATS_TYPE, /) -> float: ... - - @overload - def read_value(self, fmt: Literal[StructFormat.BOOL], /) -> bool: ... - - @overload - def read_value(self, fmt: Literal[StructFormat.CHAR], /) -> str: ... - - def read_value(self, fmt: StructFormat, /) -> object: - """Read a value into given struct format in big-endian mode. - - The amount of bytes to read will be determined based on the struct format automatically. - """ - length = struct.calcsize(fmt.value) - data = self.read(length) - unpacked = struct.unpack(">" + fmt.value, data) - return unpacked[0] - - def _read_varuint(self, *, max_bits: int | None = None) -> int: - """Read an arbitrarily big unsigned integer in a variable length format. - - This is a standard way of transmitting ints, and it allows smaller numbers to take less bytes. - - Reading will be limited up to integer values of ``max_bits`` bits, and trying to read bigger values will rase - an :exc:`IOError`. Note that setting ``max_bits`` to for example 32 bits doesn't mean that at most 4 bytes - will be read, in this case we would actually read at most 5 bytes, due to the variable encoding overhead. - - Varints send bytes where 7 least significant bits are value bits, and the most significant bit is continuation - flag bit. If this continuation bit is set (1), it indicates that there will be another varint byte sent after - this one. The least significant group is written first, followed by each of the more significant groups, making - varints little-endian, however in groups of 7 bits, not 8. - """ - value_max = ( - (1 << (max_bits)) - 1 if max_bits is not None else float("inf") - ) - - result = 0 - for i in count(): - byte = self.read_value(StructFormat.UBYTE) - # Read 7 least significant value bits in this byte, and shift them appropriately to be in the right place - # then simply add them (OR) as additional 7 most significant bits in our result - result |= (byte & 0x7F) << (7 * i) - - # Ensure that we stop reading and raise an error if the size gets over the maximum - # (if the current amount of bits is higher than allowed size in bits) - if result > value_max: - raise OSError( - f"Received varint was outside the range of {max_bits}-bit int.", - ) - - # If the most significant bit is 0, we should stop reading - if not byte & 0x80: - break - - return result - - def read_varint(self) -> int: - """Read a 32-bit signed integer in a variable length format. - - For more information about variable length format check :meth:`._read_varuint`. - """ - unsigned_num = self._read_varuint(max_bits=32) - return from_twos_complement(unsigned_num, bits=32) - - def read_varlong(self) -> int: - """Read a 64-bit signed integer in a variable length format. - - For more information about variable length format check :meth:`._read_varuint`. - """ - unsigned_num = self._read_varuint(max_bits=64) - return from_twos_complement(unsigned_num, bits=64) - - def read_bytearray(self) -> bytearray: - """Read an arbitrary sequence of bytes, prefixed with a varint of it's size.""" - length = self.read_varint() - return self.read(length) - - def read_ascii(self) -> str: - """Read ISO-8859-1 encoded string, until we encounter NULL (0x00) at the end indicating string end.""" - # Keep reading bytes until we find NULL - result = bytearray() - while len(result) == 0 or result[-1] != 0: - byte = self.read(1) - result.extend(byte) - return result[:-1].decode("ISO-8859-1") - - def read_utf(self) -> str: - """Read a UTF-8 encoded string, prefixed with a varint of it's size (in bytes). - - The maximum amount of UTF-8 characters is limited to 32767. - - Individual UTF-8 characters can take up to 4 bytes, however most of the common ones take up less. Assuming the - worst case of 4 bytes per every character, at most 131068 data bytes will be read + 3 additional bytes from - the varint encoding overhead. - - :raises IOError: - * If the prefix varint is bigger than the maximum (131068) bytes, the string will not be read at all, - and :exc:`IOError` will be raised immediately. - * If the received string has more than the maximum amount of characters (32767). Note that in this case, - the string will still get read in it's entirety, since it fits into the maximum bytes limit (131068), - which was simply read at once. This limitation is here only to replicate the behavior of minecraft's - implementation. - """ - length = self.read_varint() - if length > 131068: - raise OSError( - f"Maximum read limit for utf strings is 131068 bytes, got {length}.", - ) - - data = self.read(length) - chars = data.decode("utf-8") - - if len(chars) > 32767: - raise OSError( - f"Maximum read limit for utf strings is 32767 characters, got {len(chars)}.", - ) - - return chars - - def read_optional(self, reader: Callable[[], R]) -> R | None: - """Read a bool showing if a value is present, if so, also reads this value with ``reader`` function. - - * When ``False`` is read, the function will not read anything and ``None`` is returned. - * When ``True`` is read, the ``reader`` function is called, and it's return value is forwarded. - """ - if not self.read_value(StructFormat.BOOL): - return None - - return reader() - - -# endregion diff --git a/src/azul/buffer.py b/src/azul/buffer.py deleted file mode 100644 index 8207bc2..0000000 --- a/src/azul/buffer.py +++ /dev/null @@ -1,101 +0,0 @@ -"""Buffer module.""" - -# This is the buffer module from https://github.com/py-mine/mcproto v0.5.0, -# which is licensed under the GNU LESSER GENERAL PUBLIC LICENSE v3.0 - -from __future__ import annotations - -__author__ = "ItsDrike" -__license__ = "LGPL-3.0-only" - -from typing import Any - -from .base_io import BaseSyncReader, BaseSyncWriter - -__all__ = ["Buffer"] - - -class Buffer(BaseSyncWriter, BaseSyncReader, bytearray): - """In-memory bytearray-like buffer supporting the common read/write operations.""" - - __slots__ = ("pos",) - - def __init__(self, *args: Any, **kwargs: Any) -> None: - """Initialize starting at position zero.""" - super().__init__(*args, **kwargs) - self.pos = 0 - - def write(self, data: bytes) -> None: - """Write/Store given ``data`` into the buffer.""" - self.extend(data) - - def read(self, length: int) -> bytearray: - """Read data stored in the buffer. - - Reading data doesn't remove that data, rather that data is treated as already read, and - next read will start from the first unread byte. If freeing the data is necessary, check - the :meth:`.clear` function. - - :param length: - Amount of bytes to be read. - - If the requested amount can't be read (buffer doesn't contain that much data/buffer - doesn't contain any data), an :exc:`IOError` will be re-raised. - - If there were some data in the buffer, but it was less than requested, this remaining - data will still be depleted and the partial data that was read will be a part of the - error message in the :exc:`IOError`. This behavior is here to mimic reading from a real - socket connection. - """ - end = self.pos + length - - if end > len(self): - data = self[self.pos : len(self)] - bytes_read = len(self) - self.pos - self.pos = len(self) - raise OSError( - "Requested to read more data than available." - f" Read {bytes_read} bytes: {data}, out of {length} requested bytes.", - ) - - try: - return self[self.pos : end] - finally: - self.pos = end - - def clear(self, only_already_read: bool = False) -> None: - """Clear out the stored data and reset position. - - :param only_already_read: - When set to ``True``, only the data that was already marked as read will be cleared, - and the position will be reset (to start at the remaining data). This can be useful - for avoiding needlessly storing large amounts of data in memory, if this data is no - longer useful. - - Otherwise, if set to ``False``, all of the data is cleared, and the position is reset, - essentially resulting in a blank buffer. - """ - if only_already_read: - del self[: self.pos] - else: - super().clear() - self.pos = 0 - - def reset(self) -> None: - """Reset the position in the buffer. - - Since the buffer doesn't automatically clear the already read data, it is possible to simply - reset the position and read the data it contains again. - """ - self.pos = 0 - - def flush(self) -> bytearray: - """Read all of the remaining data in the buffer and clear it out.""" - data = self[self.pos : len(self)] - self.clear() - return data - - @property - def remaining(self) -> int: - """Get the amount of bytes that's still remaining in the buffer to be read.""" - return len(self) - self.pos diff --git a/src/azul/client.py b/src/azul/client.py index 03815e5..7e9bf5f 100644 --- a/src/azul/client.py +++ b/src/azul/client.py @@ -1,490 +1,586 @@ -"""Azul Client.""" - -from __future__ import annotations - -import contextlib +"""Game Client.""" # Programmed by CoolCat467 -# Hide the pygame prompt -import os -import sys -from os import path -from pathlib import Path -from typing import TYPE_CHECKING, Any, Final - -import trio -from pygame.locals import K_ESCAPE, KEYUP, QUIT, RESIZABLE, WINDOWRESIZED -from pygame.rect import Rect - -from azul import conf, lang, objects, sprite -from azul.component import Component, ComponentManager, Event -from azul.statemachine import AsyncState, AsyncStateMachine -from azul.vector import Vector2 -os.environ["PYGAME_HIDE_SUPPORT_PROMPT"] = "True" -if os.environ["PYGAME_HIDE_SUPPORT_PROMPT"]: - import pygame -del os +# Copyright (C) 2023-2024 CoolCat467 +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +from __future__ import annotations -if TYPE_CHECKING: - from collections.abc import Iterator, Sequence - -__title__ = "Azul Client" +__title__ = "Game Client" __author__ = "CoolCat467" -__version__ = "2.0.0" - -SCREEN_SIZE = Vector2(800, 600) -FPS = 30 -# FPS = 60 -VSYNC = True -# PORT = server.PORT - -ROOT_FOLDER: Final = Path(__file__).absolute().parent -DATA_FOLDER: Final = ROOT_FOLDER / "data" -FONT_FOLDER: Final = ROOT_FOLDER / "fonts" - -FONT = FONT_FOLDER / "RuneScape-UF-Regular.ttf" +__license__ = "GNU General Public License Version 3" +__version__ = "0.0.0" +import struct +import traceback +from typing import TYPE_CHECKING -class AzulClient(sprite.GroupProcessor, AsyncStateMachine): - """Gear Runner and Layered Dirty Sprite group handler.""" - - def __init__(self) -> None: - """Initialize azul client.""" - sprite.GroupProcessor.__init__(self) - AsyncStateMachine.__init__(self) +import trio +from libcomponent import network +from libcomponent.base_io import StructFormat +from libcomponent.buffer import Buffer +from libcomponent.component import Event +from libcomponent.network_utils import ClientNetworkEventComponent + +from azul.network_shared import ( + ADVERTISEMENT_IP, + ADVERTISEMENT_PORT, + ClientBoundEvents, + ServerBoundEvents, + decode_cursor_location, + decode_int8_array, + decode_numeric_uint8_counter, + decode_tile_count, + encode_cursor_location, +) +from azul.vector import Vector2 - self.add_states( - ( - HaltState(), - AzulInitialize(), - ), +if TYPE_CHECKING: + from mypy_extensions import u8 + + from azul.state import Tile + + +async def read_advertisements( + timeout: int = 3, # noqa: ASYNC109 +) -> list[tuple[str, tuple[str, int]]]: + """Read server advertisements from network. Return tuples of (motd, (host, port)).""" + # Look up multicast group address in name server and find out IP version + addrinfo = (await trio.socket.getaddrinfo(ADVERTISEMENT_IP, None))[0] + + with trio.socket.socket( + family=trio.socket.AF_INET, # IPv4 + type=trio.socket.SOCK_DGRAM, # UDP + proto=trio.socket.IPPROTO_UDP, + ) as udp_socket: + # SO_REUSEADDR: allows binding to port potentially already in use + # Allow multiple copies of this program on one machine + # (not strictly needed) + udp_socket.setsockopt( + trio.socket.SOL_SOCKET, + trio.socket.SO_REUSEADDR, + 1, ) - @property - def running(self) -> bool: - """Boolean of if state machine is running.""" - return self.active_state is not None + await udp_socket.bind(("", ADVERTISEMENT_PORT)) + + # # Tell the kernel that we are a multicast socket + # udp_socket.setsockopt(trio.socket.IPPROTO_IP, trio.socket.IP_MULTICAST_TTL, 255) + + # socket.IPPROTO_IP works on Linux and Windows + # # IP_MULTICAST_IF: force sending network traffic over specific network adapter + # IP_ADD_MEMBERSHIP: join multicast group + # udp_socket.setsockopt( + # trio.socket.IPPROTO_IP, trio.socket.IP_MULTICAST_IF, + # trio.socket.inet_aton(network_adapter) + # ) + # udp_socket.setsockopt( + # trio.socket.IPPROTO_IP, + # trio.socket.IP_ADD_MEMBERSHIP, + # struct.pack( + # "4s4s", + # trio.socket.inet_aton(group), + # trio.socket.inet_aton(network_adapter), + # ), + # ) + addr_port = addrinfo[4][0] + assert isinstance(addr_port, str) + group_bin = trio.socket.inet_pton(addrinfo[0], addr_port) + # Join group + if addrinfo[0] == trio.socket.AF_INET: # IPv4 + mreq = group_bin + struct.pack("=I", trio.socket.INADDR_ANY) + udp_socket.setsockopt( + trio.socket.IPPROTO_IP, + trio.socket.IP_ADD_MEMBERSHIP, + mreq, + ) + else: # IPv6 + mreq = group_bin + struct.pack("@I", 0) + udp_socket.setsockopt( + trio.socket.IPPROTO_IPV6, + trio.socket.IPV6_JOIN_GROUP, + mreq, + ) + + host = "" + buffer = b"" + with trio.move_on_after(timeout): + buffer, address = await udp_socket.recvfrom(512) + host, _port = address + # print(f"{buffer = }") + # print(f"{address = }") + + response: list[tuple[str, tuple[str, int]]] = [] + + start = 0 + for _ in range(1024): + ad_start = buffer.find(b"[AD]", start) + if ad_start == -1: + break + ad_end = buffer.find(b"[/AD]", ad_start) + if ad_end == -1: + break + start_block = buffer.find(b"[AZUL]", ad_end) + if start_block == -1: + break + start_end = buffer.find(b"[/AZUL]", start_block) + if start_end == -1: + break + + start = start_end + + motd = buffer[start_block + 10 : start_end].decode("utf-8") + raw_port = buffer[ad_start + 4 : ad_end].decode("utf-8") + try: + port = int(raw_port) + except ValueError: + continue + response.append((motd, (host, port))) + return response - async def raise_event(self, event: Event[Any]) -> None: - """Raise component event in all groups.""" - if self.active_state is None: - return - manager = getattr(self.active_state, "manager", None) - assert isinstance(manager, ComponentManager | None) - if manager is None: - return - await manager.raise_event(event) +class GameClient(ClientNetworkEventComponent): + """Game Client Network Event Component. -class AzulState(AsyncState[AzulClient]): - """Azul Client Asynchronous base class.""" + This class handles connecting to the game server, transmitting events + to the server, and reading and raising incoming events from the server. + """ - __slots__ = ("id", "manager") + __slots__ = ("connect_event_lock", "running") def __init__(self, name: str) -> None: - """Initialize azul state.""" + """Initialize GameClient.""" super().__init__(name) - self.id: int = 0 - self.manager = ComponentManager(self.name) - - -class HaltState(AzulState): - """Halt state to set state to None so running becomes False.""" - - def __init__(self) -> None: - """Initialize halt state.""" - super().__init__("Halt") - - async def check_conditions(self) -> None: - """Set active state to None.""" - await self.machine.set_state(None) - + # Five seconds until timeout is generous, but it gives server end wiggle + # room. + self.timeout = 5 -class ClickDestinationComponent(Component): - """Component that will use targeting to go to wherever you click on the screen.""" - - __slots__ = ("selected",) - outline = pygame.color.Color(255, 220, 0) - - def __init__(self) -> None: - """Initialize click destination component.""" - super().__init__("click_dest") + sbe = ServerBoundEvents + self.register_network_write_events( + { + "encryption_response->server": sbe.encryption_response, + "factory_clicked->server[write]": sbe.factory_clicked, + "cursor_location->server[write]": sbe.cursor_location, + "pattern_row_clicked->server[write]": sbe.pattern_row_clicked, + "table_clicked->server[write]": sbe.table_clicked, + "floor_clicked->server[write]": sbe.floor_clicked, + }, + ) + cbe = ClientBoundEvents + self.register_read_network_events( + { + cbe.encryption_request: "server->encryption_request", + cbe.callback_ping: "server->callback_ping", + cbe.initial_config: "server->initial_config", + cbe.playing_as: "server->playing_as", + cbe.game_over: "server->game_over", + cbe.board_data: "server->board_data", + cbe.pattern_data: "server->pattern_data", + cbe.factory_data: "server->factory_data", + cbe.cursor_data: "server->cursor_data", + cbe.table_data: "server->table_data", + cbe.cursor_movement_mode: "server->cursor_movement_mode", + cbe.current_turn_change: "server->current_turn_change", + cbe.cursor_position: "server->cursor_position", + cbe.floor_data: "server->floor_data", + }, + ) - self.selected = False + self.connect_event_lock = trio.Lock() + self.running = False def bind_handlers(self) -> None: - """Register PygameMouseButtonDown and tick handlers.""" + """Register event handlers.""" + super().bind_handlers() self.register_handlers( { - "click": self.click, - "drag": self.drag, - "PygameMouseButtonDown": self.mouse_down, - "tick": self.move_towards_dest, - "init": self.cache_outline, - "test": self.test, + "server->encryption_request": self.read_encryption_request, + "server->callback_ping": self.read_callback_ping, + "server->initial_config": self.read_initial_config, + "server->playing_as": self.read_playing_as, + "server->game_over": self.read_game_over, + "server->board_data": self.read_board_data, + "server->pattern_data": self.read_pattern_data, + "server->factory_data": self.read_factory_data, + "server->cursor_data": self.read_cursor_data, + "server->table_data": self.read_table_data, + "server->cursor_movement_mode": self.read_cursor_movement_mode, + "server->current_turn_change": self.read_current_turn_change, + "server->cursor_position": self.read_cursor_position, + "server->floor_data": self.read_floor_data, + "client_connect": self.handle_client_connect, + "network_stop": self.handle_network_stop, + "game_factory_clicked": self.write_game_factory_clicked, + "game_cursor_location_transmit": self.write_game_cursor_location_transmit, + "game_pattern_row_clicked": self.write_game_pattern_row_clicked, + "game_table_clicked": self.write_game_table_clicked, + "game_floor_clicked": self.write_game_floor_clicked, + # "callback_ping": self.print_callback_ping, }, ) - async def test(self, event: Event[object]) -> None: - """Print out event data.""" - print(f"{event = }") - - async def cache_outline(self, _: Event[None]) -> None: - """Precalculate outlined images.""" - image: sprite.ImageComponent = self.get_component("image") - outline: sprite.OutlineComponent = image.get_component("outline") - outline.precalculate_all_outlined(self.outline) - - async def update_selected(self) -> None: - """Update selected.""" - image: sprite.ImageComponent = self.get_component("image") - outline: sprite.OutlineComponent = image.get_component("outline") - - color = (None, self.outline)[int(self.selected)] - outline.set_color(color) - - if not self.selected: - movement: sprite.MovementComponent = self.get_component("movement") - movement.speed = 0 + async def print_callback_ping(self, event: Event[int]) -> None: + """Print received `callback_ping` event from server. + + This event is used as a sort of keepalive heartbeat, because + it stops the connection from timing out. + """ + difference = event.data + print(f"[azul.client] print_callback_ping {difference * 1e-06:.03f}ms") + await trio.lowlevel.checkpoint() + + async def raise_disconnect(self, message_key: str) -> None: + """Raise client_disconnected event with given message.""" + print(f"{self.__class__.__name__}: {message_key}") + if not self.manager_exists: + print( + f"{self.__class__.__name__}: Manager does not exist, not raising disconnect event.", + ) + return + # self.unregister_all_network_write_events() + await self.raise_event(Event("client_disconnected", message_key)) + await self.close() + assert self.not_connected - async def click( + async def write_event( self, - event: Event[sprite.PygameMouseButtonEventData], + event: Event[bytes | bytearray], ) -> None: - """Toggle selected.""" - if event.data["button"] == 1: - self.selected = not self.selected - - await self.update_selected() + """Send event to network if running, otherwise does nothing. + + Raises: + RuntimeError: if unregistered packet id received from network + trio.BusyResourceError: if another task is already executing a + :meth:`send_all`, :meth:`wait_send_all_might_not_block`, or + :meth:`HalfCloseableStream.send_eof` on this stream. + trio.BrokenResourceError: if something has gone wrong, and the stream + is broken. + trio.ClosedResourceError: if you previously closed this stream + object, or if another task closes this stream object while + :meth:`send_all` is running. + + """ + if not self.running: + await trio.lowlevel.checkpoint() + print( + f"[azul.client.write_event] Skipping writing {event.name!r}, not running.", + ) + return + await super().write_event(event) + + async def handle_read_event(self) -> None: + """Raise events from server. + + Can raise following exceptions: + RuntimeError - Unhandled packet id + network.NetworkStreamNotConnectedError - Network stream is not connected + OSError - Stopped responding + trio.BrokenResourceError - Something is wrong and stream is broken + + Shouldn't happen with write lock but still: + trio.BusyResourceError - Another task is already writing data + + Handled exceptions: + trio.ClosedResourceError - Stream is closed or another task closes stream + network.NetworkTimeoutError - Timeout + network.NetworkEOFError - Server closed connection + """ + # print(f"{self.__class__.__name__}[{self.name}]: handle_read_event") + if not self.manager_exists: + return + if self.not_connected: + await self.raise_disconnect("error.not_connected") + return + # event: Event[bytearray] | None = None + try: + # print("handle_read_event start") + event = await self.read_event() + except trio.ClosedResourceError: + self.running = False + await self.close() + print(f"[{self.name}] Socket closed from another task.") + return + except network.NetworkTimeoutError as exc: + # print("[azul.client] Network timeout") + if self.running: + self.running = False + print(f"[{self.name}] NetworkTimeoutError") + await self.close() + traceback.print_exception(exc) + await self.raise_disconnect("error.read_event_fail") + return + except network.NetworkStreamNotConnectedError as exc: + self.running = False + print(f"[{self.name}] NetworkStreamNotConnectedError") + traceback.print_exception(exc) + await self.close() + assert self.not_connected + raise + except network.NetworkEOFError: + self.running = False + print(f"[{self.name}] NetworkEOFError") + await self.close() + await self.raise_disconnect("error.socket_eof") + return - async def drag(self, event: Event[None]) -> None: - """Drag sprite.""" - if not self.selected: - self.selected = True - await self.update_selected() - movement: sprite.MovementComponent = self.get_component("movement") - movement.speed = 0 + ## print(f'[azul.client] handle_read_event {event}') - async def mouse_down( - self, - event: Event[sprite.PygameMouseButtonEventData], - ) -> None: - """Target click pos if selected.""" - if not self.selected: - return - if event.data["button"] == 1: - movement: sprite.MovementComponent = self.get_component("movement") - movement.speed = 200 - target: sprite.TargetingComponent = self.get_component("targeting") - target.destination = Vector2.from_iter(event.data["pos"]) + await self.raise_event(event) - async def move_towards_dest( + async def handle_client_connect( self, - event: Event[sprite.TickEventData], + event: Event[tuple[str, int]], ) -> None: - """Move closer to destination.""" - target: sprite.TargetingComponent = self.get_component("targeting") - await target.move_destination_time(event.data.time_passed) - - -class MrFloppy(sprite.Sprite): - """Mr. Floppy test sprite.""" - - __slots__ = () - - def __init__(self) -> None: - """Initialize mr floppy sprite.""" - super().__init__("MrFloppy") - - self.add_components( - ( - sprite.MovementComponent(), - sprite.TargetingComponent(), - ClickDestinationComponent(), - sprite.ImageComponent(), - sprite.DragClickEventComponent(), + """Have client connect to address specified in event.""" + if self.connect_event_lock.locked(): + raise RuntimeError("2nd client connect fired!") + async with self.connect_event_lock: + # Mypy does not understand that self.not_connected becomes + # false after connect call. + if not TYPE_CHECKING and not self.not_connected: + raise RuntimeError("Already connected!") + try: + await self.connect(*event.data) + except OSError as ex: + traceback.print_exception(ex) + else: + self.running = True + while not self.not_connected and self.running: + await self.handle_read_event() + self.running = False + + await self.close() + if self.manager_exists: + await self.raise_event( + Event("client_connection_closed", None), + ) + else: + print( + "manager does not exist, cannot send client connection closed event.", + ) + return + await self.raise_disconnect("error.socket_connect_fail") + + async def read_initial_config(self, event: Event[bytearray]) -> None: + """Read initial_config event from server.""" + buffer = Buffer(event.data) + + variant_play: u8 = buffer.read_value(StructFormat.BOOL) + player_count: u8 = buffer.read_value(StructFormat.UBYTE) + factory_count: u8 = buffer.read_value(StructFormat.UBYTE) + current_turn: u8 = buffer.read_value(StructFormat.UBYTE) + floor_line_size: u8 = buffer.read_value(StructFormat.UBYTE) + + floor_line_data = decode_int8_array(buffer, (floor_line_size, 1)) + + await self.raise_event( + Event( + "game_initial_config", + ( + variant_play, + player_count, + factory_count, + current_turn, + floor_line_data, + ), ), ) - movement = self.get_component("movement") - targeting = self.get_component("targeting") - image = self.get_component("image") + async def read_playing_as(self, event: Event[bytearray]) -> None: + """Read playing_as event from server.""" + buffer = Buffer(event.data) - movement.speed = 200 + playing_as: u8 = buffer.read_value(StructFormat.UBYTE) - # lintcheck: c-extension-no-member (I1101): Module 'pygame.surface' has no 'Surface' member, but source is unavailable. Consider adding this module to extension-pkg-allow-list if you want to perform analysis based on run-time introspection of living objects. - floppy: pygame.surface.Surface = pygame.image.load( - path.join("data", "mr_floppy.png"), + await self.raise_event( + Event("game_playing_as", playing_as), ) - image.add_images( - { - 0: floppy, - # '1': pygame.transform.flip(floppy, False, True) - 1: pygame.transform.rotate(floppy, 270), - 2: pygame.transform.flip(floppy, True, True), - 3: pygame.transform.rotate(floppy, 90), - }, - ) - - anim = image.get_component("animation") - anim.controller = self.controller((0, 1, 2, 3)) - - image.set_image(0) - self.visible = True - - self.location = SCREEN_SIZE / 2 - targeting.destination = self.location - - self.register_handler("drag", self.drag) + async def read_game_over(self, event: Event[bytearray]) -> None: + """Read game_over event from server.""" + buffer = Buffer(event.data) - @staticmethod - def controller( - image_identifiers: Sequence[str | int], - ) -> Iterator[str | int | None]: - """Animation controller.""" - cidx = 0 - while True: - count = len(image_identifiers) - if not count: - yield None - continue - cidx = (cidx + 1) % count - yield image_identifiers[cidx] + winner: u8 = buffer.read_value(StructFormat.UBYTE) - async def drag(self, event: Event[sprite.DragEvent]) -> None: - """Move by relative from drag.""" - if event.data.button != 1: - return - self.location += event.data.rel - self.dirty = 1 + await self.raise_event(Event("game_winner", winner)) + self.running = False + async def read_board_data(self, event: Event[bytearray]) -> None: + """Read board_data event from server, reraise as `game_board_data`.""" + buffer = Buffer(event.data) -class FPSCounter(objects.Text): - """FPS counter.""" + player_id: u8 = buffer.read_value(StructFormat.UBYTE) + array = decode_int8_array(buffer, (5, 5)) - __slots__ = () + await self.raise_event(Event("game_board_data", (player_id, array))) - def __init__(self) -> None: - """Initialize fps counter.""" - font = pygame.font.Font(FONT, 28) - super().__init__("fps", font) + async def read_pattern_data(self, event: Event[bytearray]) -> None: + """Read pattern_data event from server, reraise as `game_pattern_data`.""" + buffer = Buffer(event.data) - async def on_tick(self, event: Event[sprite.TickEventData]) -> None: - """Update text.""" - # self.text = f'FPS: {event.data["fps"]:.2f}' - self.text = f"FPS: {event.data.fps:.0f}" + player_id: u8 = buffer.read_value(StructFormat.UBYTE) + row_id: u8 = buffer.read_value(StructFormat.UBYTE) + tile_data = decode_tile_count(buffer) - async def update_loc( - self, - event: Event[dict[str, tuple[int, int]]], - ) -> None: - """Move to top left corner.""" - self.location = Vector2.from_iter(event.data["size"]) / 2 + (5, 5) - - def bind_handlers(self) -> None: - """Register event handlers.""" - super().bind_handlers() - self.register_handlers( - { - "tick": self.on_tick, - "sprite_image_resized": self.update_loc, - }, + await self.raise_event( + Event("game_pattern_data", (player_id, row_id, tile_data)), ) + async def read_factory_data(self, event: Event[bytearray]) -> None: + """Read factory_data event from server, reraise as `game_factory_data`.""" + buffer = Buffer(event.data) -class AzulInitialize(AzulState): - """Initialize Azul.""" - - __slots__ = () - - def __init__(self) -> None: - """Initialize state.""" - super().__init__("initialize") + factory_id: u8 = buffer.read_value(StructFormat.UBYTE) + tiles = decode_numeric_uint8_counter(buffer) - def group_add(self, new_sprite: sprite.Sprite) -> None: - """Add new sprite to group.""" - group = self.machine.get_group(self.id) - assert group is not None, "Expected group from new group id" - group.add(new_sprite) - self.manager.add_component(new_sprite) + await self.raise_event(Event("game_factory_data", (factory_id, tiles))) - async def entry_actions(self) -> None: - """Create group and add mr floppy.""" - self.id = self.machine.new_group("test") - floppy = MrFloppy() - print(floppy) - self.group_add(floppy) - self.group_add(FPSCounter()) + async def read_cursor_data(self, event: Event[bytearray]) -> None: + """Read cursor_data event from server, reraise as `game_cursor_data`.""" + buffer = Buffer(event.data) - await self.machine.raise_event(Event("init", None)) + tiles = decode_numeric_uint8_counter(buffer) - async def exit_actions(self) -> None: - """Remove group and unbind components.""" - self.machine.remove_group(self.id) - self.manager.unbind_components() + await self.raise_event(Event("game_cursor_data", tiles)) + async def read_table_data(self, event: Event[bytearray]) -> None: + """Read table_data event from server, reraise as `game_table_data`.""" + buffer = Buffer(event.data) -def save_crash_img() -> None: - """Save the last frame before the game crashed.""" - surface = pygame.display.get_surface().copy() - # strTime = '-'.join(time.asctime().split(' ')) - # filename = f'Crash_at_{strTime}.png' - filename = "screenshot.png" + tiles = decode_numeric_uint8_counter(buffer) - pygame.image.save(surface, path.join("screenshots", filename)) - del surface + await self.raise_event(Event("game_table_data", tiles)) + async def read_cursor_movement_mode(self, event: Event[bytearray]) -> None: + """Read cursor_movement_mode event from server, reraise as `game_cursor_set_movement_mode`.""" + buffer = Buffer(event.data) -async def async_run() -> None: - """Run client.""" - global SCREEN_SIZE - # global client - config = conf.load_config(path.join("conf", "main.conf")) - lang.load_lang(config["Language"]["lang_name"]) + client_mode = buffer.read_value(StructFormat.BOOL) - screen = pygame.display.set_mode( - tuple(SCREEN_SIZE), - RESIZABLE, - vsync=VSYNC, - ) - pygame.display.set_caption(f"{__title__} v{__version__}") - pygame.key.set_repeat(1000, 30) - screen.fill((0xFF, 0xFF, 0xFF)) - - client = AzulClient() - - background = pygame.image.load( - path.join("data", "background.png"), - ).convert() - client.clear(screen, background) - - client.set_timing_threshold(1000 / FPS) - - await client.set_state("initialize") - - clock = pygame.time.Clock() - - while client.running: - resized_window = False - - async with trio.open_nursery() as nursery: - for event in pygame.event.get(): - # pylint: disable=undefined-variable - if event.type == QUIT: - await client.set_state("Halt") - elif event.type == KEYUP and event.key == K_ESCAPE: - pygame.event.post(pygame.event.Event(QUIT)) - elif event.type == WINDOWRESIZED: - SCREEN_SIZE = Vector2(event.x, event.y) - resized_window = True - sprite_event = sprite.convert_pygame_event(event) - # print(sprite_event) - nursery.start_soon(client.raise_event, sprite_event) - await client.think() + await self.raise_event( + Event("game_cursor_set_movement_mode", client_mode), + ) - time_passed = clock.tick(FPS) + async def read_current_turn_change(self, event: Event[bytearray]) -> None: + """Read current_turn_change event from server, reraise as `game_pattern_current_turn_change`.""" + buffer = Buffer(event.data) - await client.raise_event( - Event( - "tick", - sprite.TickEventData( - time_passed / 1000, - clock.get_fps(), - ), - ), + pattern_id: u8 = buffer.read_value(StructFormat.UBYTE) + await self.raise_event( + Event("game_pattern_current_turn_change", pattern_id), ) - if resized_window: - screen.fill((0xFF, 0xFF, 0xFF)) - rects = [Rect((0, 0), tuple(SCREEN_SIZE))] - client.repaint_rect(rects[0]) - rects.extend(client.draw(screen)) - else: - rects = client.draw(screen) - pygame.display.update(rects) - client.clear_groups() - + async def read_cursor_position(self, event: Event[bytearray]) -> None: + """Read current_turn_change event from server, reraise as `game_cursor_set_destination`.""" + location = decode_cursor_location(event.data) + unit_location = Vector2.from_iter(x / 0xFFF for x in location) -class Tracer(trio.abc.Instrument): - """Tracer instrument.""" + await self.raise_event( + Event("game_cursor_set_destination", unit_location), + ) - __slots__ = ("_sleep_time",) + async def read_floor_data(self, event: Event[bytearray]) -> None: + """Read floor_data event from server, reraise as `game_floor_data`.""" + buffer = Buffer(event.data) - def before_run(self) -> None: - """Before run.""" - print("!!! run started") + floor_id: u8 = buffer.read_value(StructFormat.UBYTE) + floor_line = decode_numeric_uint8_counter(buffer) - def _print_with_task(self, msg: str, task: trio.lowlevel.Task) -> None: - """Print message with task name.""" - # repr(task) is perhaps more useful than task.name in general, - # but in context of a tutorial the extra noise is unhelpful. - print(f"{msg}: {task.name}") + await self.raise_event( + Event("game_floor_data", (floor_id, floor_line)), + ) - def task_spawned(self, task: trio.lowlevel.Task) -> None: - """Task spawned.""" - self._print_with_task("### new task spawned", task) + async def write_game_factory_clicked( + self, + event: Event[tuple[int, Tile]], + ) -> None: + """Write factory_clicked event to server.""" + factory_id, tile = event.data + buffer = Buffer() - def task_scheduled(self, task: trio.lowlevel.Task) -> None: - """Task scheduled.""" - self._print_with_task("### task scheduled", task) + buffer.write_value(StructFormat.UBYTE, factory_id) + buffer.write_value(StructFormat.UBYTE, tile) - def before_task_step(self, task: trio.lowlevel.Task) -> None: - """Before task step.""" - self._print_with_task(">>> about to run one step of task", task) + await self.write_event(Event("factory_clicked->server[write]", buffer)) - def after_task_step(self, task: trio.lowlevel.Task) -> None: - """After task step.""" - self._print_with_task("<<< task step finished", task) + async def write_game_cursor_location_transmit( + self, + event: Event[Vector2], + ) -> None: + """Write cursor_location_transmit event to server.""" + scaled_location = event.data - def task_exited(self, task: trio.lowlevel.Task) -> None: - """Task exited.""" - self._print_with_task("### task exited", task) + x, y = map(int, (scaled_location * 0xFFF).floored()) + buffer = encode_cursor_location((x, y)) - def before_io_wait(self, timeout: float) -> None: - """Before IO wait.""" - if timeout: - print(f"### waiting for I/O for up to {timeout} seconds") - else: - print("### doing a quick check for I/O") - self._sleep_time = trio.current_time() + await self.write_event(Event("cursor_location->server[write]", buffer)) - def after_io_wait(self, timeout: float) -> None: - """After IO wait.""" - duration = trio.current_time() - self._sleep_time - print(f"### finished I/O check (took {duration} seconds)") + async def write_game_pattern_row_clicked( + self, + event: Event[tuple[int, Vector2]], + ) -> None: + """Write factory_clicked event to server.""" + row_id, location = event.data + buffer = Buffer() - def after_run(self) -> None: - """After run.""" - print("!!! run finished") + buffer.write_value(StructFormat.UBYTE, row_id) + buffer.write_value(StructFormat.UBYTE, int(location.x)) + buffer.write_value(StructFormat.UBYTE, int(location.y)) + await self.write_event( + Event("pattern_row_clicked->server[write]", buffer), + ) -def run() -> None: - """Run asynchronous side of everything.""" - trio.run(async_run) # , instruments=[Tracer()]) + async def write_game_table_clicked( + self, + event: Event[Tile], + ) -> None: + """Write table_clicked event to server.""" + tile = event.data + buffer = Buffer() + buffer.write_value(StructFormat.UBYTE, tile) -# save_crash_img() + await self.write_event(Event("table_clicked->server[write]", buffer)) -if __name__ == "__main__": - print(f"{__title__} v{__version__}\nProgrammed by {__author__}.\n") + async def write_game_floor_clicked( + self, + event: Event[tuple[int, int]], + ) -> None: + """Write floor_clicked event to server.""" + floor_line_id, location_x = event.data + buffer = Buffer() - # Make sure the game will display correctly on high DPI monitors on Windows. - if sys.platform == "win32": - # Exists on windows but not on linux or macos - # Windows raises attr-defined - # others say unused-ignore - from ctypes import windll # type: ignore[attr-defined,unused-ignore] + buffer.write_value(StructFormat.UBYTE, floor_line_id) + buffer.write_value(StructFormat.UBYTE, location_x) - with contextlib.suppress(AttributeError): - windll.user32.SetProcessDPIAware() - del windll + await self.write_event(Event("floor_clicked->server[write]", buffer)) - try: - pygame.init() - run() - finally: - pygame.quit() + async def handle_network_stop(self, event: Event[None]) -> None: + """Send EOF if connected and close socket.""" + if self.not_connected: + return + self.running = False + try: + await self.send_eof() + finally: + await self.close() + assert self.not_connected + + def __del__(self) -> None: + """Print debug message.""" + print(f"del {self.__class__.__name__}") diff --git a/src/azul/component.py b/src/azul/component.py deleted file mode 100644 index 3e6865b..0000000 --- a/src/azul/component.py +++ /dev/null @@ -1,429 +0,0 @@ -"""Component system module - Components instead of chaotic class hierarchy mess.""" - -# Programmed by CoolCat467 - -# Copyright (C) 2023-2024 CoolCat467 -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program. If not, see . - -from __future__ import annotations - -__title__ = "Component" -__author__ = "CoolCat467" -__license__ = "GNU General Public License Version 3" -__version__ = "0.0.0" - -from contextlib import contextmanager -from typing import TYPE_CHECKING, Any, Generic, TypeVar -from weakref import ref - -import trio - -if TYPE_CHECKING: - from collections.abc import Awaitable, Callable, Generator, Iterable - - from mypy_extensions import u8 - -T = TypeVar("T") - - -class Event(Generic[T]): - """Event with name, data, and re-raise levels.""" - - __slots__ = ("data", "level", "name") - - def __init__( - self, - name: str, - data: T, - levels: u8 = 0, - ) -> None: - """Initialize event.""" - self.name = name - self.data = data - self.level = levels - - def __repr__(self) -> str: - """Return representation of self.""" - return f"{self.__class__.__name__}({self.name!r}, {self.data!r}, {self.level!r})" - - def pop_level(self) -> bool: - """Travel up one level and return True if event should continue or not.""" - continue_level = self.level > 0 - self.level = max(0, self.level - 1) - return continue_level - - -class Component: - """Component base class.""" - - __slots__ = ("__manager", "name") - - def __init__(self, name: object) -> None: - """Initialise with name.""" - self.name = name - self.__manager: ref[ComponentManager] | None = None - - def __repr__(self) -> str: - """Return representation of self.""" - return f"{self.__class__.__name__}({self.name!r})" - - @property - def manager(self) -> ComponentManager: - """ComponentManager if bound to one, otherwise raise AttributeError.""" - if self.__manager is not None: - manager = self.__manager() - if manager is not None: - return manager - raise AttributeError(f"No component manager bound for {self.name}") - - def _unbind(self) -> None: - """If you use this you are evil. This is only for ComponentManagers!.""" - self.__manager = None - - @property - def manager_exists(self) -> bool: - """Return if manager is bound or not.""" - return self.__manager is not None and self.__manager() is not None - - def register_handler( - self, - event_name: str, - handler_coro: Callable[[Event[Any]], Awaitable[Any]], - ) -> None: - """Register handler with bound component manager. - - Raises AttributeError if this component is not bound. - """ - self.manager.register_component_handler( - event_name, - handler_coro, - self.name, - ) - - def register_handlers( - self, - handlers: dict[str, Callable[[Event[Any]], Awaitable[Any]]], - ) -> None: - """Register multiple handler Coroutines. - - Raises AttributeError if this component is not bound. - """ - for name, coro in handlers.items(): - self.register_handler(name, coro) - - def bind_handlers(self) -> None: - """Add handlers in subclass.""" - - def bind(self, manager: ComponentManager) -> None: - """Bind self to manager. - - Raises RuntimeError if component is already bound to a manager. - """ - if self.manager_exists: - raise RuntimeError( - f"{self.name} component is already bound to {self.manager}", - ) - self.__manager = ref(manager) - self.bind_handlers() - - def has_handler(self, event_name: str) -> bool: - """Return if manager has event handlers registered for a given event. - - Raises AttributeError if this component is not bound. - """ - return self.manager.has_handler(event_name) - - async def raise_event(self, event: Event[Any]) -> None: - """Raise event for bound manager. - - Raises AttributeError if this component is not bound. - """ - await self.manager.raise_event(event) - - def component_exists(self, component_name: str) -> bool: - """Return if component exists in manager. - - Raises AttributeError if this component is not bound. - """ - return self.manager.component_exists(component_name) - - def components_exist(self, component_names: Iterable[str]) -> bool: - """Return if all component names given exist in manager. - - Raises AttributeError if this component is not bound. - """ - return self.manager.components_exist(component_names) - - def get_component(self, component_name: str) -> Any: - """Get Component from manager. - - Raises AttributeError if this component is not bound. - """ - return self.manager.get_component(component_name) - - def get_components( - self, - component_names: Iterable[str], - ) -> list[Component]: - """Return Components from manager. - - Raises AttributeError if this component is not bound. - """ - return self.manager.get_components(component_names) - - -ComponentPassthrough = TypeVar("ComponentPassthrough", bound=Component) - - -class ComponentManager(Component): - """Component manager class.""" - - __slots__ = ("__components", "__event_handlers", "__weakref__") - - def __init__(self, name: object, own_name: object | None = None) -> None: - """If own_name is set, add self to list of components as specified name.""" - super().__init__(name) - self.__event_handlers: dict[ - str, - set[tuple[Callable[[Event[Any]], Awaitable[Any]], object]], - ] = {} - self.__components: dict[object, Component] = {} - - if own_name is not None: - self.__add_self_as_component(own_name) - self.bind_handlers() - - def __repr__(self) -> str: - """Return representation of self.""" - return f"<{self.__class__.__name__} Components: {self.__components}>" - - def __add_self_as_component(self, name: object) -> None: - """Add this manager as component to self without binding. - - Raises ValueError if a component with given name already exists. - """ - if self.component_exists(name): # pragma: nocover - raise ValueError(f'Component named "{name}" already exists!') - self.__components[name] = self - - def register_handler( - self, - event_name: str, - handler_coro: Callable[[Event[Any]], Awaitable[None]], - ) -> None: - """Register handler_func as handler for event_name (self component).""" - self.register_component_handler(event_name, handler_coro, self.name) - - def register_component_handler( - self, - event_name: str, - handler_coro: Callable[[Event[Any]], Awaitable[None]], - component_name: object, - ) -> None: - """Register handler_func as handler for event_name. - - Raises ValueError if no component with given name is registered. - """ - if ( - component_name != self.name - and component_name not in self.__components - ): - raise ValueError( - f"Component named {component_name!r} is not registered!", - ) - if event_name not in self.__event_handlers: - self.__event_handlers[event_name] = set() - self.__event_handlers[event_name].add((handler_coro, component_name)) - - def has_handler(self, event_name: str) -> bool: - """Return if there are event handlers registered for a given event.""" - return bool(self.__event_handlers.get(event_name)) - - async def raise_event_in_nursery( - self, - event: Event[Any], - nursery: trio.Nursery, - ) -> None: - """Raise event in a particular trio nursery. - - Could raise RuntimeError if given nursery is no longer open. - """ - await trio.lowlevel.checkpoint() - - # Forward leveled events up; They'll come back to us soon enough. - if self.manager_exists and event.pop_level(): - await super().raise_event(event) - return - # Make sure events not raised twice - # if not self.manager_exists: - # while event.level > 0: - # event.pop_level() - - # if not event.name.startswith("Pygame") and event.name not in {"tick", "gameboard_create_piece", "server->create_piece", "create_piece->network"}: - # print(f'''{self.__class__.__name__}({self.name!r}):\n{event = }''') - - # Call all registered handlers for this event - if event.name in self.__event_handlers: - for handler, _name in self.__event_handlers[event.name]: - nursery.start_soon(handler, event) - - # Forward events to contained managers - for component in self.get_all_components(): - # Skip self component if exists - if component is self: - continue - if isinstance(component, ComponentManager): - nursery.start_soon(component.raise_event, event) - - async def raise_event(self, event: Event[Any]) -> None: - """Raise event for all components that have handlers registered.""" - async with trio.open_nursery() as nursery: - await self.raise_event_in_nursery(event, nursery) - - def add_component(self, component: Component) -> None: - """Add component to this manager. - - Raises ValueError if component already exists with component name. - `component` must be an instance of Component. - """ - assert isinstance(component, Component), "Must be component instance" - if self.component_exists(component.name): - raise ValueError( - f'Component named "{component.name}" already exists!', - ) - self.__components[component.name] = component - component.bind(self) - - def add_components(self, components: Iterable[Component]) -> None: - """Add multiple components to this manager. - - Raises ValueError if any component already exists with component name. - `component`s must be instances of Component. - """ - for component in components: - self.add_component(component) - - def remove_component(self, component_name: object) -> None: - """Remove a component. - - Raises ValueError if component name does not exist. - """ - if not self.component_exists(component_name): - raise ValueError(f"Component {component_name!r} does not exist!") - # Remove component from registered components - component = self.__components.pop(component_name) - # Tell component they need to unbind - component._unbind() - - # Unregister component's event handlers - # List of events that will have no handlers once we are done - empty = [] - for event_name, handlers in self.__event_handlers.items(): - for item in tuple(handlers): - _handler, handler_component = item - if handler_component == component_name: - self.__event_handlers[event_name].remove(item) - if not self.__event_handlers[event_name]: - empty.append(event_name) - # Remove event handler table keys that have no items anymore - for name in empty: - self.__event_handlers.pop(name) - - def component_exists(self, component_name: object) -> bool: - """Return if component exists in this manager.""" - return component_name in self.__components - - @contextmanager - def temporary_component( - self, - component: ComponentPassthrough, - ) -> Generator[ComponentPassthrough, None, None]: - """Temporarily add given component but then remove after exit.""" - name = component.name - self.add_component(component) - try: - yield component - finally: - if self.component_exists(name): - self.remove_component(name) - - def components_exist(self, component_names: Iterable[object]) -> bool: - """Return if all component names given exist in this manager.""" - return all(self.component_exists(name) for name in component_names) - - def get_component(self, component_name: object) -> Any: - """Return Component or raise ValueError because it doesn't exist.""" - if not self.component_exists(component_name): - raise ValueError(f'"{component_name}" component does not exist') - return self.__components[component_name] - - def get_components(self, component_names: Iterable[object]) -> list[Any]: - """Return iterable of components asked for or raise ValueError.""" - return [self.get_component(name) for name in component_names] - - def list_components(self) -> tuple[object, ...]: - """Return tuple of the names of components bound to this manager.""" - return tuple(self.__components) - - def get_all_components(self) -> tuple[Component, ...]: - """Return tuple of all components bound to this manager.""" - return tuple(self.__components.values()) - - def unbind_components(self) -> None: - """Unbind all components, allows things to get garbage collected.""" - self.__event_handlers.clear() - for component in iter(self.__components.values()): - if ( - isinstance(component, ComponentManager) - and component is not self - ): - component.unbind_components() - component._unbind() - self.__components.clear() - - def __del__(self) -> None: - """Unbind components.""" - self.unbind_components() - - -class ExternalRaiseManager(ComponentManager): - """Component Manager, but raises events in an external nursery.""" - - __slots__ = ("nursery",) - - def __init__( - self, - name: object, - nursery: trio.Nursery, - own_name: object | None = None, - ) -> None: - """Initialize with name, own component name, and nursery.""" - super().__init__(name, own_name) - self.nursery = nursery - - async def raise_event(self, event: Event[Any]) -> None: - """Raise event in nursery. - - Could raise RuntimeError if self.nursery is no longer open. - """ - await self.raise_event_in_nursery(event, self.nursery) - - async def raise_event_internal(self, event: Event[Any]) -> None: - """Raise event in internal nursery.""" - await super().raise_event(event) - - -if __name__ == "__main__": # pragma: nocover - print(f"{__title__}\nProgrammed by {__author__}.") diff --git a/src/azul/conf.py b/src/azul/conf.py deleted file mode 100644 index 4f4400a..0000000 --- a/src/azul/conf.py +++ /dev/null @@ -1,32 +0,0 @@ -"""Config module.""" - -# Programmed by CoolCat467 - -__title__ = "Conf" -__author__ = "CoolCat467" -__version__ = "0.0.0" - - -from configparser import ConfigParser - - -def load_config(config_file: str) -> dict[str, dict[str, str]]: - """Return a config object from config_file.""" - config = ConfigParser() - config.read((config_file,)) - - data: dict[str, dict[str, str]] = {} - for section, values in dict(config.items()).items(): - data[section] = dict(values) - - # config.clear() - # config.update(data) - ## - # with open(config_file, mode='w', encoding='utf-8') as conf_file: - # config.write(conf_file) - - return data - - -if __name__ == "__main__": - print(f"{__title__}\nProgrammed by {__author__}.") diff --git a/src/azul/conf/main.conf b/src/azul/conf/main.conf deleted file mode 100644 index f2f1c51..0000000 --- a/src/azul/conf/main.conf +++ /dev/null @@ -1,6 +0,0 @@ -[Font] -font_folder = data -font_file = RuneScape-UF-Regular.ttf - -[Language] -lang_name = en_us diff --git a/src/azul/crop.py b/src/azul/crop.py index 9aeb132..cbdbbab 100644 --- a/src/azul/crop.py +++ b/src/azul/crop.py @@ -2,14 +2,37 @@ # Programmed by CoolCat467 +from __future__ import annotations + +# Copyright (C) 2020-2024 CoolCat467 +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + __title__ = "Crop Functions" __author__ = "CoolCat467" __version__ = "0.0.0" + +from typing import TYPE_CHECKING + from pygame.color import Color from pygame.rect import Rect from pygame.surface import Surface +if TYPE_CHECKING: + from collections.abc import Callable, Generator, Iterable + def crop_color(surface: Surface, color: Color) -> Surface: """Crop out color from surface.""" @@ -46,10 +69,40 @@ def crop_color(surface: Surface, color: Color) -> Surface: return surf -def run() -> None: - """Run test of module.""" +def auto_crop_clear( + surface: Surface, + clear: Color | None = None, +) -> Surface: + """Remove unneccicary pixels from image.""" + if clear is None: + clear = Color(0, 0, 0, 0) + surface = surface.convert_alpha() + w, h = surface.get_size() + surface.lock() + + def find_end( + iterfunc: Callable[[int], Iterable[Color]], + rangeobj: Iterable[int], + ) -> int: + for x in rangeobj: + if not all(y == clear for y in iterfunc(x)): + return x + return x + + def column(x: int) -> Generator[Color, None, None]: + return (surface.get_at((x, y)) for y in range(h)) + + def row(y: int) -> Generator[Color, None, None]: + return (surface.get_at((x, y)) for x in range(w)) + + leftc = find_end(column, range(w)) + rightc = find_end(column, range(w - 1, -1, -1)) + topc = find_end(row, range(h)) + floorc = find_end(row, range(h - 1, -1, -1)) + surface.unlock() + dim = Rect(leftc, topc, rightc - leftc, floorc - topc) + return surface.subsurface(dim) if __name__ == "__main__": print(f"{__title__}\nProgrammed by {__author__}.\n") - run() diff --git a/src/azul/data/tiles/black.png b/src/azul/data/tiles/black.png deleted file mode 100644 index d19d971..0000000 Binary files a/src/azul/data/tiles/black.png and /dev/null differ diff --git a/src/azul/data/tiles/blue.png b/src/azul/data/tiles/blue.png deleted file mode 100644 index 97cfd8e..0000000 Binary files a/src/azul/data/tiles/blue.png and /dev/null differ diff --git a/src/azul/data/tiles/cyan.png b/src/azul/data/tiles/cyan.png deleted file mode 100644 index e49b3db..0000000 Binary files a/src/azul/data/tiles/cyan.png and /dev/null differ diff --git a/src/azul/data/tiles/grey.png b/src/azul/data/tiles/grey.png deleted file mode 100644 index 0427e8f..0000000 Binary files a/src/azul/data/tiles/grey.png and /dev/null differ diff --git a/src/azul/data/tiles/grey_black.png b/src/azul/data/tiles/grey_black.png deleted file mode 100644 index aab594e..0000000 Binary files a/src/azul/data/tiles/grey_black.png and /dev/null differ diff --git a/src/azul/data/tiles/grey_blue.png b/src/azul/data/tiles/grey_blue.png deleted file mode 100644 index 3e5d1a2..0000000 Binary files a/src/azul/data/tiles/grey_blue.png and /dev/null differ diff --git a/src/azul/data/tiles/grey_cyan.png b/src/azul/data/tiles/grey_cyan.png deleted file mode 100644 index 6d4c01f..0000000 Binary files a/src/azul/data/tiles/grey_cyan.png and /dev/null differ diff --git a/src/azul/data/tiles/grey_red.png b/src/azul/data/tiles/grey_red.png deleted file mode 100644 index 7ac7401..0000000 Binary files a/src/azul/data/tiles/grey_red.png and /dev/null differ diff --git a/src/azul/data/tiles/grey_yellow.png b/src/azul/data/tiles/grey_yellow.png deleted file mode 100644 index 43f00e6..0000000 Binary files a/src/azul/data/tiles/grey_yellow.png and /dev/null differ diff --git a/src/azul/data/tiles/number_one.png b/src/azul/data/tiles/number_one.png deleted file mode 100644 index 2640208..0000000 Binary files a/src/azul/data/tiles/number_one.png and /dev/null differ diff --git a/src/azul/data/tiles/red.png b/src/azul/data/tiles/red.png deleted file mode 100644 index 478d266..0000000 Binary files a/src/azul/data/tiles/red.png and /dev/null differ diff --git a/src/azul/data/tiles/yellow.png b/src/azul/data/tiles/yellow.png deleted file mode 100644 index 1341ce2..0000000 Binary files a/src/azul/data/tiles/yellow.png and /dev/null differ diff --git a/src/azul/database.py b/src/azul/database.py new file mode 100644 index 0000000..fcc1755 --- /dev/null +++ b/src/azul/database.py @@ -0,0 +1,328 @@ +"""Database - Read and write json files.""" + +# Programmed by CoolCat467 + +from __future__ import annotations + +# Database - Read and write json files +# Copyright (C) 2024-2026 CoolCat467 +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +__title__ = "Database" +__author__ = "CoolCat467" + +from os import makedirs, path +from pathlib import Path +from typing import TYPE_CHECKING, Any + +import orjson +import trio + +if TYPE_CHECKING: + from collections.abc import Generator, Iterable, Iterator + from types import TracebackType + from typing import Self + + +_LOADED: dict[str, Records] = {} + + +class Database(dict[str, Any]): + """Database dict with file read write functions.""" + + __slots__ = ("__weakref__", "file") + + def __init__( + self, + file_path: str | Path | trio.Path, + auto_load: bool = True, + ) -> None: + """Initialize and set file path. + + If auto_load is True, automatically load file contents synchronously + if file exists. + """ + super().__init__() + self.file = file_path + + if auto_load and path.exists(self.file): + self.reload_file() + + def reload_file(self) -> None: + """Reload database file. + + Will raise FileNotFoundError in the event file does not exist. + """ + self.update(orjson.loads(Path(self.file).read_bytes())) + + async def reload_async(self) -> None: + """Reload database file asynchronously. + + Does not decode json data if file is empty. + Will raise FileNotFoundError in the event file does not exist. + """ + async with await trio.open_file(self.file, "rb") as file: + data = await file.read() + if not data: + return + self.update(orjson.loads(data)) + + def serialize(self) -> bytes: + """Return this object's data serialized as bytes.""" + return orjson.dumps( + self, + option=orjson.OPT_APPEND_NEWLINE + | orjson.OPT_NON_STR_KEYS + | orjson.OPT_NAIVE_UTC, + ) + + def write_file(self) -> None: + """Write database file. + + May raise PermissionError in the event of insufficient permissions. + """ + folder = path.dirname(self.file) + if not path.exists(folder): + makedirs(folder, exist_ok=False) + Path(self.file).write_bytes(self.serialize()) + + async def write_async(self) -> None: + """Write database file asynchronously. + + May raise PermissionError in the event of insufficient permissions. + """ + folder = trio.Path(self.file).parent + if not await folder.exists(): + await folder.mkdir(parents=True, exist_ok=False) + async with await trio.open_file( + self.file, + "wb", + ) as file: + await file.write(self.serialize()) + + def __enter__(self) -> Self: + """Enter context manager.""" + return self + + def __exit__( + self, + exc_type: type[BaseException] | None, + exc_value: BaseException | None, + traceback: TracebackType | None, + ) -> None: + """Context manager exit.""" + self.write_file() + + async def __aenter__(self) -> Self: + """Enter async context manager. + + Automatically reloads file if it exists. + """ + if await trio.Path(self.file).exists(): + await self.reload_async() + return self + + async def __aexit__( + self, + exc_type: type[BaseException] | None, + exc_value: BaseException | None, + traceback: TracebackType | None, + ) -> None: + """Async context manager exit, write file contents asynchronously.""" + await self.write_async() + + +class Table: + """Table from dictionary. + + Allows getting and setting entire columns of a database + """ + + __slots__ = ("_key_name", "_records") + + def __init__(self, records: dict[str, Any], key_name: str) -> None: + """Initialize and set records and key name.""" + self._records = records + self._key_name = key_name + + def __repr__(self) -> str: + """Get text representation of table.""" + size: dict[str, int] = {} + columns = self.keys() + for column in columns: + size[column] = len(column) + for value in self[column]: + if value is None: + continue + length = ( + len(value) + if hasattr(value, "__len__") + else len(repr(value)) + ) + size[column] = max(size[column], length) + num_pad = len(str(len(self))) + lines = [] + column_names = " ".join(c.ljust(length) for c, length in size.items()) + lines.append("".rjust(num_pad) + " " + column_names) + for index in range(len(self)): + line = [str(index).ljust(num_pad)] + for column in columns: + line.append(str(self[column][index]).ljust(size[column])) + lines.append(" ".join(line)) + return "\n".join(lines) + + def __getitem__(self, column: str) -> tuple[Any, ...]: + """Get column data.""" + if column not in self.keys(): + return tuple(None for _ in range(len(self))) + if column == self._key_name: + return tuple(self._records.keys()) + return tuple(row.get(column) for row in self._records.values()) + + def __setitem__(self, column: str, value: Iterable[Any]) -> None: + """Set column data to value.""" + if column == self._key_name: + for old, new in zip(tuple(self._records), value, strict=False): + self._records[new] = self._records.pop(old) + else: + for key, set_value in zip(self._records, value, strict=True): + if set_value is None: + continue + self._records[key][column] = set_value + + def _raw_keys(self) -> set[str]: + """Return the name of every column.""" + keys = set() + for row in self._records.values(): + keys |= set(row.keys()) + return keys + + def keys(self) -> set[str]: + """Return the name of every column.""" + return self._raw_keys() | {self._key_name} + + def __iter__(self) -> Iterator[str]: + """Return iterator for column names.""" + return iter(self.keys()) + + def values(self) -> tuple[Any, ...]: + """Return every column.""" + values = [] + for key in self.keys(): + values.append(self[key]) + return tuple(values) + + def items(self) -> tuple[tuple[str, Any], ...]: + """Return tuples of column names and columns.""" + items = [] + for key in sorted(self.keys()): + items.append((key, self[key])) + return tuple(items) + + def _rows( + self, + columns: list[str], + ) -> Generator[tuple[Any, ...], None, None]: + """Yield columns in order from each row.""" + for key, value in self._records.items(): + yield (key, *tuple(value.get(col) for col in columns)) + + def rows(self) -> Generator[tuple[Any, ...], None, None]: + """Yield each row.""" + yield from self._rows(sorted(self.keys())) + + def column_and_rows(self) -> Generator[tuple[str | Any, ...], None, None]: + """Yield tuple of column row and then rows in column order.""" + columns = sorted(self._raw_keys()) + yield (self._key_name, *columns) + yield from self._rows(columns) + + def __len__(self) -> int: + """Return number of records.""" + return len(self._records) + + def get_id(self, key: str, value: object) -> int | None: + """Return index of value in column key or None if not found.""" + try: + return self[key].index(value) + except ValueError: + return None + + +class Records(Database): + """Records dict with columns.""" + + __slots__ = () + + def table(self, element_name: str) -> Table: + """Get table object given that keys are named element name.""" + return Table(self, element_name) + + +def load(file_path: str | Path | trio.Path) -> Records: + """Load database from file path or return already loaded instance.""" + file = path.abspath(file_path) + if file not in _LOADED: + _LOADED[file] = Records(file) + return _LOADED[file] + + +async def load_async(file_path: str | Path | trio.Path) -> Records: + """Load database from file path or return already loaded instance.""" + await trio.lowlevel.checkpoint() + file = str(await trio.Path(file_path).absolute()) + if file not in _LOADED: + _LOADED[file] = Records(file, auto_load=False) + if await trio.Path(file).exists(): + await _LOADED[file].reload_async() + return _LOADED[file] + + +def get_loaded() -> set[str]: + """Return set of loaded database files.""" + return set(_LOADED) + + +def unload(file_path: str | Path | trio.Path) -> None: + """If database loaded, write file and unload.""" + file = path.abspath(file_path) + if file not in get_loaded(): + return + database = load(file) + database.write_file() + del _LOADED[file] + + +async def async_unload(file_path: str | Path | trio.Path) -> None: + """If database loaded, write file and unload.""" + file = str(await trio.Path(file_path).absolute()) + if file not in get_loaded(): + return + database = load(file) + await database.write_async() + del _LOADED[file] + + +def unload_all() -> None: + """Unload all loaded databases.""" + for file_path in get_loaded(): + unload(file_path) + + +async def async_unload_all() -> None: + """Unload all loaded databases.""" + async with trio.open_nursery() as nursery: + for file_path in get_loaded(): + nursery.start_soon(async_unload, file_path) diff --git a/src/azul/element_list.py b/src/azul/element_list.py new file mode 100644 index 0000000..665e8c8 --- /dev/null +++ b/src/azul/element_list.py @@ -0,0 +1,140 @@ +"""Element List - List of element sprites.""" + +# Programmed by CoolCat467 + +from __future__ import annotations + +# Element List - List of element sprites. +# Copyright (C) 2024 CoolCat467 +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +__title__ = "Element List" +__author__ = "CoolCat467" +__version__ = "0.0.0" +__license__ = "GNU General Public License Version 3" + + +from typing import TYPE_CHECKING + +from azul import sprite +from azul.vector import Vector2 + +if TYPE_CHECKING: + from collections.abc import Generator + + +class Element(sprite.Sprite): + """Element sprite.""" + + __slots__ = () + + def self_destruct(self) -> None: + """Remove this element.""" + self.kill() + if self.manager_exists: + self.manager.remove_component(self.name) + + def __del__(self) -> None: + """Clean up this element for garbage collecting.""" + self.self_destruct() + super().__del__() + + +class ElementList(sprite.Sprite): + """Element List sprite.""" + + __slots__ = ("_order",) + + def __init__(self, name: object) -> None: + """Initialize connection list.""" + super().__init__(name) + + self._order: list[object] = [] + + def add_element(self, element: Element) -> None: + """Add element to this list.""" + group = self.groups()[-1] + group.add(element) + self.add_component(element) + self._order.append(element.name) + + def delete_element(self, element_name: object) -> None: + """Delete an element (only from component).""" + element = self.get_component(element_name) + index = self._order.index(element_name) + if element.visible: + assert element.image is not None + height = element.image.get_height() + self.offset_elements_after(index, (0, -height)) + self._order.pop(index) + assert isinstance(element, Element) + element.self_destruct() + + def yield_elements(self) -> Generator[Element, None, None]: + """Yield bound Element components in order.""" + for component_name in iter(self._order): + # Kind of strange to mutate in yield, maybe shouldn't do that? + if not self.component_exists(component_name): + self._order.remove(component_name) + continue + component = self.get_component(component_name) + assert isinstance(component, Element) + yield component + + def get_last_rendered_element(self) -> Element | None: + """Return last bound Element sprite or None.""" + for component_name in reversed(self._order): + if not self.component_exists(component_name): + self._order.remove(component_name) + continue + component = self.get_component(component_name) + assert isinstance(component, Element) + if component.visible: + assert component.image is not None + return component + return None + + def get_new_connection_position(self) -> Vector2: + """Return location for new connection.""" + last_element = self.get_last_rendered_element() + if last_element is None: + return Vector2.from_iter(self.rect.topleft) + location = Vector2.from_iter(last_element.rect.topleft) + assert last_element.image is not None + location += (0, last_element.image.get_height()) + return location + + def offset_elements(self, diff: tuple[int, int]) -> None: + """Offset all element locations by given difference.""" + for element in self.yield_elements(): + element.location += diff + + def offset_elements_after(self, index: int, diff: tuple[int, int]) -> None: + """Offset elements after index by given difference.""" + for idx, element in enumerate(self.yield_elements()): + if idx <= index: + continue + element.location += diff + + def _set_location(self, value: tuple[int, int]) -> None: + """Set rect center from tuple of integers.""" + current = self.location + super()._set_location(value) + diff = Vector2.from_iter(value) - current + self.offset_elements(diff) + + +if __name__ == "__main__": + print(f"{__title__} v{__version__}\nProgrammed by {__author__}.\n") diff --git a/src/azul/encrypted_event.py b/src/azul/encrypted_event.py deleted file mode 100644 index c307652..0000000 --- a/src/azul/encrypted_event.py +++ /dev/null @@ -1,130 +0,0 @@ -"""Encrypted Event - Encrypt and decrypt event data.""" - -# Programmed by CoolCat467 - -from __future__ import annotations - -# Encrypted Event - Encrypt and decrypt event data. -# Copyright (C) 2024 CoolCat467 -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program. If not, see . - -__title__ = "Encrypted Event" -__author__ = "CoolCat467, ItsDrike, and Ammar Askar" -__version__ = "0.0.0" -__license__ = "GNU General Public License Version 3" - - -from cryptography.hazmat.backends import default_backend -from cryptography.hazmat.primitives.ciphers import ( - Cipher, - CipherContext, - algorithms, - modes, -) - -from azul.network import NetworkEventComponent - - -class EncryptedNetworkEventComponent(NetworkEventComponent): - """Encrypted Network Event Component.""" - - __slots__ = ( - "cipher", - "decryptor", - "encryptor", - "shared_secret", - ) - - def __init__(self, name: str) -> None: - """Initialize Encrypted Network Event Component.""" - super().__init__(name) - - self.cipher: Cipher[modes.CFB8] | None = None - self.encryptor: CipherContext - self.decryptor: CipherContext - - @property - def encryption_enabled(self) -> bool: - """Return if encryption is enabled.""" - return self.cipher is not None - - def enable_encryption( - self, - shared_secret: bytes, - initialization_vector: bytes, - ) -> None: - """Enable encryption for this connection, using the ``shared_secret``. - - After calling this method, the reading and writing process for this connection - will be altered, and any future communication will be encrypted/decrypted there. - - :param shared_secret: - This is the cipher key for the AES symmetric cipher used for the encryption. - - See :func:`azul.encryption.generate_shared_secret`. - """ - self.cipher = Cipher( - algorithms.AES256(bytes(shared_secret)), - modes.CFB8(bytes(initialization_vector)), - backend=default_backend(), - ) - self.encryptor = self.cipher.encryptor() - self.decryptor = self.cipher.decryptor() - - async def write(self, data: bytes | bytearray | memoryview[int]) -> None: - """Send the given data, encrypted through the stream, blocking if necessary. - - Args: - data (bytes, bytearray, or memoryview): The data to send. - - Raises: - trio.BusyResourceError: if another task is already executing a - :meth:`send_all`, :meth:`wait_send_all_might_not_block`, or - :meth:`HalfCloseableStream.send_eof` on this stream. - trio.BrokenResourceError: if something has gone wrong, and the stream - is broken. - trio.ClosedResourceError: if you previously closed this stream - object, or if another task closes this stream object while - :meth:`send_all` is running. - - Most low-level operations in Trio provide a guarantee: if they raise - :exc:`trio.Cancelled`, this means that they had no effect, so the - system remains in a known state. This is **not true** for - :meth:`send_all`. If this operation raises :exc:`trio.Cancelled` (or - any other exception for that matter), then it may have sent some, all, - or none of the requested data, and there is no way to know which. - - Copied from Trio docs. - - """ - if self.encryption_enabled: - data = self.encryptor.update(data) - return await super().write(data) - - async def read(self, length: int) -> bytearray: - """Read `length` bytes from stream. - - Can raise following exceptions: - NetworkStreamNotConnectedError - NetworkTimeoutError - Timeout or no data - OSError - Stopped responding - trio.BusyResourceError - Another task is already writing data - trio.BrokenResourceError - Something is wrong and stream is broken - trio.ClosedResourceError - Stream is closed or another task closes stream - """ - data = await super().read(length) - if self.encryption_enabled: - return bytearray(self.decryptor.update(data)) - return data diff --git a/src/azul/encryption.py b/src/azul/encryption.py deleted file mode 100644 index e2ad0d2..0000000 --- a/src/azul/encryption.py +++ /dev/null @@ -1,140 +0,0 @@ -"""Encryption module.""" - -# This is the buffer module from https://github.com/py-mine/mcproto v0.5.0, -# which is licensed under the GNU LESSER GENERAL PUBLIC LICENSE v3.0 - -from __future__ import annotations - -__author__ = "ItsDrike" -__license__ = "LGPL-3.0-only" - -import os - -from cryptography.hazmat.backends import default_backend -from cryptography.hazmat.primitives.asymmetric.padding import MGF1, OAEP -from cryptography.hazmat.primitives.asymmetric.rsa import ( - RSAPrivateKey as RSAPrivateKey, - RSAPublicKey as RSAPublicKey, - generate_private_key, -) -from cryptography.hazmat.primitives.hashes import SHA256 -from cryptography.hazmat.primitives.serialization import ( - Encoding, - PublicFormat, - load_der_public_key, -) - - -def generate_shared_secret() -> bytes: # pragma: no cover - """Generate a random shared secret for client. - - This secret will be sent to the server in :class:`~mcproto.packets.login.login.LoginEncryptionResponse` packet, - and used to encrypt all future communication afterwards. - - This will be symmetric encryption using AES/CFB8 stream cipher. And this shared secret will be 256-bits long. - """ - return os.urandom(256 // 8) - - -def generate_verify_token() -> bytes: # pragma: no cover - """Generate a random verify token. - - This token will be sent by the server in :class:`~mcproto.packets.login.login.LoginEncryptionRequest`, to be - encrypted by the client as a form of verification. - - This token doesn't need to be cryptographically secure, it's just a sanity check that - the client has encrypted the data correctly. - """ - return os.urandom(16) - - -def generate_rsa_key() -> RSAPrivateKey: # pragma: no cover - """Generate a random RSA key pair for server. - - This key pair will be used for :class:`~mcproto.packets.login.login.LoginEncryptionRequest` packet, - where the client will be sent the public part of this key pair, which will be used to encrypt the - shared secret (and verification token) sent in :class:`~mcproto.packets.login.login.LoginEncryptionResponse` - packet. The server will then use the private part of this key pair to decrypt that. - - This will be a 2048-bit RSA key pair. - """ - return generate_private_key( - public_exponent=65537, - key_size=2048, - backend=default_backend(), - ) - - -def encrypt_with_rsa( - public_key: RSAPublicKey, - data: bytes, -) -> bytes: - """Encrypt given data with given RSA public key.""" - return public_key.encrypt( - bytes(data), - OAEP(MGF1(SHA256()), SHA256(), None), - ) - - -def encrypt_token_and_secret( - public_key: RSAPublicKey, - verification_token: bytes, - shared_secret: bytes, -) -> tuple[bytes, bytes]: - """Encrypts the verification token and shared secret with the server's public key. - - :param public_key: The RSA public key provided by the server - :param verification_token: The verification token provided by the server - :param shared_secret: The generated shared secret - :return: A tuple containing (encrypted token, encrypted secret) - """ - encrypted_token = encrypt_with_rsa(public_key, verification_token) - encrypted_secret = encrypt_with_rsa(public_key, shared_secret) - return encrypted_token, encrypted_secret - - -def decrypt_with_rsa( - private_key: RSAPrivateKey, - data: bytes, -) -> bytes: - """Decrypt given data with given RSA private key.""" - return private_key.decrypt( - bytes(data), - OAEP(MGF1(SHA256()), SHA256(), None), - ) - - -def decrypt_token_and_secret( - private_key: RSAPrivateKey, - verification_token: bytes, - shared_secret: bytes, -) -> tuple[bytes, bytes]: - """Decrypts the verification token and shared secret with the server's private key. - - :param private_key: The RSA private key generated by the server - :param verification_token: The verification token encrypted and sent by the client - :param shared_secret: The shared secret encrypted and sent by the client - :return: A tuple containing (decrypted token, decrypted secret) - """ - decrypted_token = decrypt_with_rsa(private_key, verification_token) - decrypted_secret = decrypt_with_rsa(private_key, shared_secret) - return decrypted_token, decrypted_secret - - -def serialize_public_key( - public_key: RSAPublicKey, -) -> bytes: - """Return public key serialize as bytes.""" - return public_key.public_bytes( - encoding=Encoding.DER, - format=PublicFormat.SubjectPublicKeyInfo, - ) - - -def deserialize_public_key(serialized_public_key: bytes) -> RSAPublicKey: - """Return deserialized public key.""" - # Key type is determined by the passed key itself. - # Should be be an RSA public key in this case. - key = load_der_public_key(serialized_public_key, default_backend()) - assert isinstance(key, RSAPublicKey) - return key diff --git a/src/azul/errorbox.py b/src/azul/errorbox.py index 6c6b3e4..5b12b41 100644 --- a/src/azul/errorbox.py +++ b/src/azul/errorbox.py @@ -52,7 +52,7 @@ def __wxpython(title: str, message: str) -> None: """Error with wxPython.""" from wxPython.wx import wxApp, wxICON_EXCLAMATION, wxMessageDialog, wxOK - class LameApp(wxApp): # type: ignore[misc] + class LameApp(wxApp): # type: ignore[misc,no-any-unimported] __slots__ = () def OnInit(self) -> int: # noqa: N802 @@ -70,7 +70,6 @@ def __tkinter(title: str, message: str) -> None: from tkinter import messagebox tk.Tk().wm_withdraw() - # types: attr-defined error: Module has no attribute "messagebox" messagebox.showerror(title, message) diff --git a/src/azul/game.py b/src/azul/game.py index 84da90f..a136401 100644 --- a/src/azul/game.py +++ b/src/azul/game.py @@ -21,53 +21,78 @@ __title__ = "Azul" __author__ = "CoolCat467" +__license__ = "GNU General Public License Version 3" __version__ = "2.0.0" +import contextlib import importlib import math -import operator import os -import random +import sys import time -from collections import Counter, deque -from functools import lru_cache, wraps +import traceback +from collections import Counter +from functools import lru_cache from pathlib import Path -from typing import TYPE_CHECKING, Final, NamedTuple, TypeVar, cast +from typing import TYPE_CHECKING, Any, Final, TypeVar import pygame +import trio +from libcomponent.async_clock import Clock +from libcomponent.component import ( + Component, + ComponentManager, + Event, + ExternalRaiseManager, +) +from libcomponent.network_utils import find_ip from numpy import array, int8 +from pygame.color import Color from pygame.locals import ( - KEYDOWN, + K_ESCAPE, KEYUP, QUIT, RESIZABLE, SRCALPHA, USEREVENT, - VIDEORESIZE, + WINDOWRESIZED, ) from pygame.rect import Rect - +from pygame.sprite import LayeredDirty + +from azul import database, element_list, objects, sprite +from azul.client import GameClient, read_advertisements +from azul.crop import auto_crop_clear +from azul.network_shared import DEFAULT_PORT +from azul.server import GameServer +from azul.sound import SoundData, play_sound as base_play_sound +from azul.state import Tile +from azul.statemachine import AsyncState from azul.tools import ( - floor_line_subtract_generator, - gen_random_proper_seq, lerp_color, - randomize, - saturate, - sort_tiles, ) from azul.vector import Vector2 if TYPE_CHECKING: - from collections.abc import Callable, Generator, Iterable, Sequence + from collections.abc import ( + Awaitable, + Callable, + Generator, + Iterable, + Sequence, + ) - from typing_extensions import TypeVarTuple, Unpack + from mypy_extensions import u8 + from numpy.typing import NDArray + from typing_extensions import TypeVarTuple P = TypeVarTuple("P") T = TypeVar("T") RT = TypeVar("RT") -SCREENSIZE = (650, 600) +SCREEN_SIZE = (650, 600) +VSYNC = True FPS: Final = 48 @@ -97,6 +122,9 @@ ROOT_FOLDER: Final = Path(__file__).absolute().parent DATA_FOLDER: Final = ROOT_FOLDER / "data" FONT_FOLDER: Final = ROOT_FOLDER / "fonts" +LANG_FOLDER: Final = ROOT_FOLDER / "lang" +# TODO: Way to change language +LANGUAGE: Final = "en_us" # Game stuff # Tiles @@ -111,7 +139,8 @@ ("&", ORANGE), ("1", BLUE), ) -NUMBERONETILE = 5 + + TILESIZE = 15 # Colors @@ -119,17 +148,76 @@ TILEDEFAULT = ORANGE SCORECOLOR = BLACK PATSELECTCOLOR = DARKGREEN -BUTTONTEXTCOLOR = DARKCYAN +BUTTON_TEXT_COLOR = DARKCYAN +BUTTON_TEXT_OUTLINE = BLACK BUTTONBACKCOLOR = WHITE GREYSHIFT = 0.75 # 0.65 # Font -FONT: Final = FONT_FOLDER / "RuneScape-UF-Regular.ttf" +FONT: Final = FONT_FOLDER / "VeraSerif.ttf" SCOREFONTSIZE = 30 BUTTONFONTSIZE = 60 +SOUND_LOOKUP: Final = { + "delete_piece": "pop.mp3", + "piece_move": "slide.mp3", + "piece_update": "ding.mp3", + "game_won": "newthingget.ogg", + "button_click": "select.mp3", + "tick": "tick.mp3", +} +SOUND_DATA: Final = { + "delete_piece": SoundData( + volume=50, + ), +} + + +def decode_localization_entry(localization_string: str) -> list[str]: + """Return localization entry path.""" + return localization_string.split(".") + + +def s_(localization_string: str, **kwargs: object) -> str: + """Return localization string entry, or path to it if it doesn't exist.""" + language_filename = f"{LANGUAGE}.json" + language_file = LANG_FOLDER / language_filename + # Load keeps copy in-memory, so only performance hit first time. + language_data = database.load(language_file) + + localization_entry = decode_localization_entry(localization_string) + + current: dict[str, Any] = language_data + new: dict[str, Any] | str | None + final: str | None = None + for entry in localization_entry: + new = current.get(entry) + if new is None: + break + if isinstance(new, str): + final = new + break + assert isinstance( + new, + dict, + ), f"Unexpected value in {language_file!r} for {localization_string!r}" + current = new + if final is None: + # Key does not exist + localization_key = f"[{LANGUAGE}] {localization_string}" + if kwargs: + args = ",".join(f"{k}={v!r}" for k, v in kwargs.items()) + return f"{localization_key}<{args}>" + return localization_key + return final.format(**kwargs) + + +def vec2_to_location(vec: Vector2) -> tuple[int, int]: + """Return rounded location tuple from Vector2.""" + x, y = map(int, vec.rounded()) + return x, y + -@lru_cache def make_square_surf( color: ( pygame.color.Color @@ -148,6 +236,21 @@ def make_square_surf( return surf +def play_sound( + sound_name: str, +) -> tuple[pygame.mixer.Sound, int | float]: + """Play sound effect.""" + sound_filename = SOUND_LOOKUP.get(sound_name) + if sound_filename is None: + raise RuntimeError(f"Error: Sound with ID `{sound_name}` not found.") + sound_data = SOUND_DATA.get(sound_name, SoundData()) + + return base_play_sound( + DATA_FOLDER / sound_filename, + sound_data, + ) + + def outline_rectangle( surface: pygame.surface.Surface, color: ( @@ -174,41 +277,6 @@ def outline_rectangle( return surface -def auto_crop_clear( - surface: pygame.surface.Surface, - clear: pygame.color.Color | None = None, -) -> pygame.surface.Surface: - """Remove unneccicary pixels from image.""" - if clear is None: - clear = pygame.color.Color(0, 0, 0, 0) - surface = surface.convert_alpha() - w, h = surface.get_size() - surface.lock() - - def find_end( - iterfunc: Callable[[int], Iterable[pygame.color.Color]], - rangeobj: Iterable[int], - ) -> int: - for x in rangeobj: - if not all(y == clear for y in iterfunc(x)): - return x - return x - - def column(x: int) -> Generator[pygame.color.Color, None, None]: - return (surface.get_at((x, y)) for y in range(h)) - - def row(y: int) -> Generator[pygame.color.Color, None, None]: - return (surface.get_at((x, y)) for x in range(w)) - - leftc = find_end(column, range(w)) - rightc = find_end(column, range(w - 1, -1, -1)) - topc = find_end(row, range(h)) - floorc = find_end(row, range(h - 1, -1, -1)) - surface.unlock() - dim = pygame.rect.Rect(leftc, topc, rightc - leftc, floorc - topc) - return surface.subsurface(dim) - - @lru_cache def get_tile_color( tile_color: int, @@ -216,7 +284,7 @@ def get_tile_color( ) -> tuple[int, int, int] | tuple[tuple[int, int, int], tuple[int, int, int]]: """Return the color a given tile should be.""" if tile_color < 0: - if tile_color == -6: + if tile_color == Tile.blank: return GREY color = tile_colors[abs(tile_color + 1)] assert len(color) == 3 @@ -234,7 +302,7 @@ def get_tile_symbol_and_color( ) -> tuple[str, tuple[int, int, int]]: """Return the color a given tile should be.""" if tile_color < 0: - if tile_color == -6: + if tile_color == Tile.blank: return " ", GREY symbol, scolor = TILESYMBOLS[abs(tile_color + 1)] r, g, b = lerp_color(scolor, GREY, greyshift) @@ -264,6 +332,7 @@ def add_symbol_to_tile_surf( symbolsurf, (width * scale_factor, height * scale_factor), ) + # symbolsurf = pygame.transform.scale(symbolsurf, (tilesize, tilesize)) # sw, sh = symbolsurf.get_size() @@ -282,23 +351,18 @@ def add_symbol_to_tile_surf( surf.blit(symbolsurf, (int(x), int(y))) -# surf.blit(symbolsurf, (0, 0)) - - -@lru_cache def get_tile_image( - tile: Tile, + tile_color: int, tilesize: int, greyshift: float = GREYSHIFT, outline_size: float = 0.2, ) -> pygame.surface.Surface: """Return a surface of a given tile.""" - cid = tile.color - if cid < 5: - color = get_tile_color(cid, greyshift) + if tile_color < 5: + color = get_tile_color(tile_color, greyshift) - elif cid >= 5: - color_data = tile_colors[cid] + elif tile_color >= 5: + color_data = tile_colors[tile_color] assert len(color_data) == 2 color, outline = color_data surf = outline_rectangle( @@ -307,31 +371,18 @@ def get_tile_image( outline_size, ) # Add tile symbol - add_symbol_to_tile_surf(surf, cid, tilesize, greyshift) + add_symbol_to_tile_surf(surf, tile_color, tilesize, greyshift) return surf + assert isinstance(color[0], int) surf = make_square_surf(color, tilesize) # Add tile symbol - add_symbol_to_tile_surf(surf, cid, tilesize, greyshift) + add_symbol_to_tile_surf(surf, tile_color, tilesize, greyshift) return surf -def set_alpha( - surface: pygame.surface.Surface, - alpha: int, -) -> pygame.surface.Surface: - """Return a surface by replacing the alpha channel of it with given alpha value, preserve color.""" - surface = surface.copy().convert_alpha() - w, h = surface.get_size() - for y in range(h): - for x in range(w): - r, g, b = cast("tuple[int, int, int]", surface.get_at((x, y))[:3]) - surface.set_at((x, y), pygame.Color(r, g, b, alpha)) - return surface - - def get_tile_container_image( - wh: tuple[int, int], + width_height: tuple[int, int], back: ( pygame.color.Color | int @@ -343,7 +394,7 @@ def get_tile_container_image( ), ) -> pygame.surface.Surface: """Return a tile container image from a width and a height and a background color, and use a game's cache to help.""" - image = pygame.surface.Surface(wh, flags=SRCALPHA) + image = pygame.surface.Surface(width_height, flags=SRCALPHA) if back is not None: image.fill(back) else: @@ -351,712 +402,354 @@ def get_tile_container_image( return image -class Font: - """Font object, simplify using text.""" +class TileRenderer(sprite.Sprite): + """Base class for all objects that need to render tiles.""" + + __slots__ = ("background", "tile_separation") + greyshift = GREYSHIFT + tile_size = TILESIZE def __init__( self, - font_name: str | Path, - fontsize: int = 20, - color: tuple[int, int, int] = (0, 0, 0), - cx: bool = True, - cy: bool = True, - antialias: bool = False, - background: tuple[int, int, int] | None = None, - do_cache: bool = True, + name: str, + tile_separation: int | None = None, + background: tuple[int, int, int] | None = TILEDEFAULT, ) -> None: - """Initialize font.""" - self.font = font_name - self.size = int(fontsize) - self.color = color - self.center = [cx, cy] - self.antialias = bool(antialias) - self.background = background - self.do_cache = bool(do_cache) - self.cache: pygame.surface.Surface | None = None - self.last_text: str | None = None - self._change_font() - - def __repr__(self) -> str: - """Return representation of self.""" - return f"{self.__class__.__name__}(%r, %i, %r, %r, %r, %r, %r, %r)" % ( - self.font, - self.size, - self.color, - self.center[0], - self.center[1], - self.antialias, - self.background, - self.do_cache, - ) - - def _change_font(self) -> None: - """Set self.pyfont to a new pygame.font.Font object from data we have.""" - self.pyfont = pygame.font.Font(self.font, self.size) - - def _cache(self, surface: pygame.surface.Surface) -> None: - """Set self.cache to surface.""" - self.cache = surface - - def get_height(self) -> int: - """Return the height of font.""" - return self.pyfont.get_height() + """Initialize renderer.""" + super().__init__(name) - def render_nosurf( - self, - text: str | None, - size: int | None = None, - color: tuple[int, int, int] | None = None, - background: tuple[int, int, int] | None = None, - force_update: bool = False, - ) -> pygame.surface.Surface: - """Render and return a surface of given text. Use stored data to render, if arguments change internal data and render.""" - update_cache = ( - self.cache is None or force_update or text != self.last_text - ) - # Update internal data if new values given - if size is not None: - self.size = int(size) - self._change_font() - update_cache = True - if color is not None: - self.color = color - update_cache = True - if self.background != background: - self.background = background - update_cache = True - - if self.do_cache: - if update_cache: - self.last_text = text - surf = self.pyfont.render( - text, - self.antialias, - self.color, - self.background, - ).convert_alpha() - self._cache(surf.copy()) - else: - assert self.cache is not None - surf = self.cache + if tile_separation is None: + self.tile_separation = self.tile_size / 3.75 else: - # Render the text using the pygame font - surf = self.pyfont.render( - text, - self.antialias, - self.color, - self.background, - ).convert_alpha() - return surf + self.tile_separation = tile_separation + + self.background = background - def render( + def clear_image( self, - surface: pygame.surface.Surface, - text: str, - xy: tuple[int, int], - size: int | None = None, - color: tuple[int, int, int] | None = None, - background: tuple[int, int, int] | None = None, - force_update: bool = False, + tile_dimensions: tuple[int, int], + extra: tuple[int, int] | None = None, ) -> None: - """Render given text, use stored data to render, if arguments change internal data and render.""" - surf = self.render_nosurf(text, size, color, background, force_update) + """Reset self.image using tile_dimensions tuple and fills with self.background. Also updates self.width_height.""" + size = Vector2.from_iter(tile_dimensions) + tile_full = self.tile_size + self.tile_separation + size *= tile_full - if True in self.center: - x, y = xy - cx, cy = self.center - w, h = surf.get_size() - if cx: - x -= w // 2 - if cy: - y -= h // 2 - xy = (int(x), int(y)) + offset = Vector2(self.tile_separation, self.tile_separation) - surface.blit(surf, xy) + if extra is not None: + offset += extra + size += offset -class ObjectHandler: - """ObjectHandler class, meant to be used for other classes.""" - - # __slots__ = ("objects", "next_id", "cache") - - def __init__(self) -> None: - """Initialize object handler.""" - self.objects: dict[int, Object] = {} - self.next_id = 0 - self.cache: dict[str, int] = {} - - self.recalculate_render = True - self._render_order: tuple[int, ...] = () - - def add_object(self, obj: Object) -> None: - """Add an object to the game.""" - obj.id = self.next_id - self.objects[self.next_id] = obj - self.next_id += 1 - self.recalculate_render = True - - def rm_object(self, obj: Object) -> None: - """Remove an object from the game.""" - del self.objects[obj.id] - self.recalculate_render = True - - def rm_star(self) -> None: - """Remove all objects from self.objects.""" - for oid in list(self.objects): - self.rm_object(self.objects[oid]) - self.next_id = 0 - - def get_object(self, object_id: int) -> Object | None: - """Return the object associated with object id given. Return None if object not found.""" - if object_id in self.objects: - return self.objects[object_id] - return None - - def get_objects_with_attr(self, attribute: str) -> tuple[int, ...]: - """Return a tuple of object ids with given attribute.""" - return tuple( - oid - for oid in self.objects - if hasattr(self.objects[oid], attribute) + self.image = get_tile_container_image( + vec2_to_location(size), + self.background, ) - def get_object_by_attr( + def get_tile_topleft( self, - attribute: str, - value: object, - ) -> tuple[int, ...]: - """Return a tuple of object ids with that are equal to .""" - matches = [] - for oid in self.get_objects_with_attr(attribute): - if getattr(self.objects[oid], attribute) == value: - matches.append(oid) - return tuple(matches) - - def get_object_given_name(self, name: str) -> tuple[int, ...]: - """Return a tuple of object ids with names matching .""" - return self.get_object_by_attr("name", name) - - def reset_cache(self) -> None: - """Reset the cache.""" - self.cache = {} - - def get_object_by_name(self, object_name: str) -> Object: - """Get object by name, with cache.""" - if object_name not in self.cache: - ids = self.get_object_given_name(object_name) - if ids: - self.cache[object_name] = min(ids) - else: - raise RuntimeError(f"{object_name} Object Not Found!") - result = self.get_object(self.cache[object_name]) - if result is None: - raise RuntimeError(f"{object_name} Object Not Found!") - return result - - def set_attr_all(self, attribute: str, value: object) -> None: - """Set given attribute in all of self.objects to given value in all objects with that attribute.""" - for oid in self.get_objects_with_attr(attribute): - setattr(self.objects[oid], attribute, value) - - def recalculate_render_order(self) -> None: - """Recalculate the order in which to render objects to the screen.""" - new: dict[int, int] = {} - cur = 0 - for oid in reversed(self.objects): - obj = self.objects[oid] - if hasattr(obj, "Render_Priority"): - prior = obj.Render_Priority - if isinstance(prior, str): - add = 0 - if prior[:4] == "last": - try: - add = int(prior[4:] or 0) - except ValueError: - add = 0 - pos = len(self.objects) + add - if prior[:5] == "first": - try: - add = int(prior[5:] or 0) - except ValueError: - add = 0 - pos = -1 + add - if pos not in new.values(): - new[oid] = pos - else: - while True: - if add < 0: - pos -= 1 - else: - pos += 1 - if pos not in new.values(): - new[oid] = pos - break - else: - try: - prior = int(prior) - except ValueError: - prior = cur - while True: - if prior in new.values(): - prior += 1 - else: - break - new[oid] = prior - else: - while True: - if cur in new.values(): - cur += 1 - else: - break - new[oid] = cur - cur += 1 - revnew = {new[k]: k for k in new} - self._render_order = tuple(revnew[key] for key in sorted(revnew)) - - def process_objects(self, time_passed: float) -> None: - """Call the process function on all objects.""" - if self.recalculate_render: - self.recalculate_render_order() - self.recalculate_render = False - for oid in iter(self.objects): - self.objects[oid].process(time_passed) - - def render_objects(self, surface: pygame.surface.Surface) -> None: - """Render all objects to surface.""" - if not self._render_order or self.recalculate_render: - self.recalculate_render_order() - self.recalculate_render = False - for oid in self._render_order: # reversed(list(self.objects.keys())): - self.objects[oid].render(surface) - - def __del__(self) -> None: - """Cleanup.""" - self.reset_cache() - self.rm_star() - - -class Object: - """Object object.""" - - __slots__ = ( - "Render_Priority", - "game", - "hidden", - "id", - "image", - "location", - "location_mode_on_resize", - "name", - "screen_size_last", - "wh", - ) - - def __init__(self, name: str) -> None: - """Set self.name to name, and other values for rendering. - - Defines the following attributes: - self.name - self.image - self.location - self.wh - self.hidden - self.location_mode_on_resize - self.id - """ - self.name = str(name) - self.image: pygame.surface.Surface | None = None - self.location = Vector2( - round(SCREENSIZE[0] / 2), - round(SCREENSIZE[1] / 2), - ) - self.wh = 0, 0 - self.hidden = False - self.location_mode_on_resize = "Scale" - self.screen_size_last = SCREENSIZE + tile_location: tuple[int, int], + offset: tuple[int, int] | None = None, + ) -> tuple[int, int]: + """Return top left corner location of tile given its location and optional offset.""" + tile_full = self.tile_size + self.tile_separation - self.id = 0 - self.game: Game - self.Render_Priority: str | int + position = Vector2.from_iter(tile_location) * tile_full + if offset is not None: + position += offset + position += (self.tile_separation, self.tile_separation) - def __repr__(self) -> str: - """Return representation of self.""" - return f"{self.__class__.__name__}()" + return vec2_to_location(position) - def get_image_zreo_no_fix(self) -> tuple[float, float]: - """Return the screen location of the topleft point of self.image.""" - return ( - self.location[0] - self.wh[0] / 2, - self.location[1] - self.wh[1] / 2, + def get_tile_rect( + self, + tile_location: tuple[int, int], + offset: tuple[int, int] | None = None, + ) -> Rect: + """Return Rect of area given tile exists in.""" + topleft = self.get_tile_topleft(tile_location, offset) + return Rect( + topleft, + (self.tile_size, self.tile_size), ) - def get_image_zero(self) -> tuple[int, int]: - """Return the screen location of the topleft point of self.image fixed to integer values.""" - x, y = self.get_image_zreo_no_fix() - return int(x), int(y) + def blit_tile( + self, + tile_color: int, + tile_location: tuple[int, int], + offset: tuple[int, int] | None = None, + ) -> None: + """Blit the surface of a given tile object onto self.image at given tile location. It is assumed that all tile locations are xy tuples.""" + position = self.get_tile_topleft(tile_location, offset) - def get_rect(self) -> Rect: - """Return a Rect object representing this Object's area.""" - return Rect(self.get_image_zero(), self.wh) + surf = get_tile_image(tile_color, self.tile_size, self.greyshift) + assert self.image is not None - def point_intersects( - self, - screen_location: tuple[int, int] | Vector2, - ) -> bool: - """Return True if this Object intersects with a given screen location.""" - return self.get_rect().collidepoint(tuple(screen_location)) + self.image.blit( + surf, + position, + ) def to_image_surface_location( self, screen_location: tuple[int, int] | Vector2, - ) -> tuple[int, int]: - """Return the location a screen location would be at on the objects image. Can return invalid data.""" - # Get zero zero in image locations - zx, zy = self.get_image_zero() # Zero x and y - sx, sy = screen_location # Screen x and y - return ( - int(sx) - zx, - int(sy) - zy, - ) # Location with respect to image dimensions - - def process(self, time_passed: float) -> None: - """Process Object. Replace when calling this class.""" - - def render(self, surface: pygame.surface.Surface) -> None: - """Render self.image to surface if self.image is not None. Updates self.wh.""" - if self.image is None or self.hidden: - return - self.wh = self.image.get_size() - x, y = self.get_image_zero() - surface.blit(self.image, (int(x), int(y))) - - # pygame.draw.rect(surface, MAGENTA, self.get_rect(), 1) - - def __del__(self) -> None: - """Delete self.image.""" - del self.image - - def screen_size_update(self) -> None: - """Handle screensize is changes.""" - nx, ny = self.location - - if self.location_mode_on_resize == "Scale": - ow, oh = self.screen_size_last - nw, nh = SCREENSIZE - - x, y = self.location - nx, ny = x * (nw / ow), y * (nh / oh) - - self.location = Vector2(nx, ny) - self.screen_size_last = SCREENSIZE + ) -> Vector2: + """Return screen location with respect to top left of image.""" + return Vector2.from_points(self.rect.topleft, screen_location) - -class MultipartObject(Object, ObjectHandler): - """Thing that is both an Object and an ObjectHandler, and is meant to be an Object made up of multiple Objects.""" - - def __init__(self, name: str): - """Initialize Object and ObjectHandler of self. - - Also set self._lastloc and self._lasthidden to None - """ - Object.__init__(self, name) - ObjectHandler.__init__(self) - - self._lastloc: Vector2 | None = None - self._lasthidden: bool | None = None - - def reset_position(self) -> None: - """Reset the position of all objects within.""" - raise NotImplementedError - - def get_intersection( + def get_tile_point( self, - point: tuple[int, int] | Vector2, - ) -> tuple[str, tuple[int, int]] | tuple[None, None]: - """Return where a given point touches in self. Returns (None, None) with no intersections.""" - for oid in self.objects: - obj = self.objects[oid] - if hasattr(obj, "get_tile_point"): - output = obj.get_tile_point(point) - if output is not None: - return obj.name, output - else: - raise Warning( - "Not all of self.objects have the get_tile_point attribute!", - ) - return None, None - - def process(self, time_passed: float) -> None: - """Process Object self and ObjectHandler self and call self.reset_position on location change.""" - Object.process(self, time_passed) - ObjectHandler.process_objects(self, time_passed) - - if self.location != self._lastloc: - self.reset_position() - self._lastloc = self.location - - if self.hidden != self._lasthidden: - self.set_attr_all("hidden", self.hidden) - self._lasthidden = self.hidden + screen_location: tuple[int, int] | Vector2, + ) -> Vector2 | None: + """Return the xy choordinates of which tile intersects given a point or None.""" + # Can't get tile if screen location doesn't intersect our hitbox! + if isinstance(screen_location, Vector2): + screen_location = vec2_to_location(screen_location) + if not self.is_selected(screen_location): + return None - def render(self, surface: pygame.surface.Surface) -> None: - """Render self and all parts to the surface.""" - Object.render(self, surface) - ObjectHandler.render_objects(self, surface) + # Find out where screen point is in image locations + # board x and y + surface_pos = self.to_image_surface_location(screen_location) + # Subtract separation boarder offset + surface_pos -= (self.tile_separation, self.tile_separation) - def __del__(self) -> None: - """Delete data.""" - Object.__del__(self) - ObjectHandler.__del__(self) + tile_full = self.tile_size + self.tile_separation + # Get tile position and offset into that tile + tile_position, offset = divmod(surface_pos, tile_full) + for value in offset: + # If in separation region, not selected + if value > self.tile_size: + return None + # Otherwise, not in separation region, so we should be good + return tile_position.floored() -class Tile(NamedTuple): - """Represents a Tile.""" - color: int +class EventClock(Component): + """Event Clock Component. + Will raise `self.event_to_raise` every `self.duration` seconds. + If more than duration seconds pass before ticks, will only raise + one event. -class TileRenderer(Object): - """Base class for all objects that need to render tiles.""" + Do not pass leveled events, event reference is maintained and + when first event is mutated with pop_level it will be same object + and only first run will be leveled event. + """ - __slots__ = ("back", "image_update", "tile_full", "tile_seperation") - greyshift = GREYSHIFT - tile_size = TILESIZE + __slots__ = ("duration", "event_to_raise", "time_passed") def __init__( self, name: str, - game: Game, - tile_seperation: int | None = None, - background: tuple[int, int, int] | None = TILEDEFAULT, + duration: float, + event_to_raise: Event[Any], ) -> None: - """Initialize renderer. Needs a game object for its cache and optional tile separation value and background RGB color. - - Defines the following attributes during initialization and uses throughout: - self.game - self.wh - self.tile_seperation - self.tile_full - self.back - and finally, self.image_update - - The following functions are also defined: - self.clear_image - self.render_tile - self.update_image (but not implemented) - self.process - """ + """Initialize with name, duration, and event to raise.""" super().__init__(name) - self.game = game - if tile_seperation is None: - self.tile_seperation = self.tile_size / 3.75 - else: - self.tile_seperation = tile_seperation - - self.tile_full = self.tile_size + self.tile_seperation - self.back = background + self.time_passed: float = 0.0 + self.duration = duration + self.event_to_raise = event_to_raise - self.image_update = True + def bind_handlers(self) -> None: + """Register tick event handler.""" + self.register_handler("tick", self.handle_tick) - def get_rect(self) -> Rect: - """Return a Rect object representing this row's area.""" - wh = ( - self.wh[0] - self.tile_seperation * 2, - self.wh[1] - self.tile_seperation * 2, - ) - location = self.location[0] - wh[0] / 2, self.location[1] - wh[1] / 2 - return Rect(location, wh) - - def clear_image(self, tile_dimensions: tuple[int, int]) -> None: - """Reset self.image using tile_dimensions tuple and fills with self.back. Also updates self.wh.""" - tw, th = tile_dimensions - self.wh = ( - round(tw * self.tile_full + self.tile_seperation), - round(th * self.tile_full + self.tile_seperation), - ) - self.image = get_tile_container_image(self.wh, self.back) + async def handle_tick(self, event: Event[sprite.TickEventData]) -> None: + """Handle tick event.""" + self.time_passed += event.data.time_passed + truediv, self.time_passed = divmod(self.time_passed, self.duration) - def render_tile( - self, - tile_object: Tile, - tile_location: tuple[int, int], - ) -> None: - """Blit the surface of a given tile object onto self.image at given tile location. It is assumed that all tile locations are xy tuples.""" - x, y = tile_location - surf = get_tile_image(tile_object, self.tile_size, self.greyshift) - assert self.image is not None - self.image.blit( - surf, - ( - round(x * self.tile_full + self.tile_seperation), - round(y * self.tile_full + self.tile_seperation), - ), - ) + # Could raise multiple times, but I am deciding that we will + # only raise at most once even if we miss the train + if truediv: + # Known issue: Event to raise cannot be a leveled event, + # because event.pop_level mutates the event object in place + await self.raise_event(self.event_to_raise) - def update_image(self) -> None: - """Process image changes, directed by self.image_update being True.""" - raise NotImplementedError - def process(self, time_passed: float) -> None: - """Call self.update_image() if self.image_update is True, then set self.update_image to False.""" - if self.image_update: - self.update_image() - self.image_update = False +class Cursor(TileRenderer): + """Cursor TileRenderer. + Registers following event handlers: + - cursor_drag + - cursor_reached_destination + - cursor_set_destination + - cursor_set_movement_mode + - client_disconnected -class Cursor(TileRenderer): - """Cursor Object.""" + Sometimes registered: + - PygameMouseMotion + """ - __slots__ = ("holding_number_one", "tiles") + __slots__ = ( + "client_mode", + "last_transmit_pos", + "tiles", + "time_passed", + ) greyshift = GREYSHIFT - Render_Priority = "last" + duration = 0.25 - def __init__(self, game: Game) -> None: + def __init__(self) -> None: """Initialize cursor with a game it belongs to.""" - super().__init__("Cursor", game, background=None) + super().__init__("Cursor", background=None) + self.update_location_on_resize = True + + self.add_components( + ( + sprite.MovementComponent(speed=600), + sprite.TargetingComponent("cursor_reached_destination"), + ), + ) - self.holding_number_one = False - self.tiles: deque[Tile] = deque() + # Stored in reverse render order + self.tiles: list[int] = [] + self.last_transmit_pos = self.location + self.time_passed = 0.0 + self.client_mode = False def update_image(self) -> None: """Update self.image.""" - self.clear_image((len(self.tiles), 1)) - - for x in range(len(self.tiles)): - self.render_tile(self.tiles[x], (x, 0)) - - def is_pressed(self) -> bool: - """Return True if the right mouse button is pressed.""" - return bool(pygame.mouse.get_pressed()[0]) - - def get_held_count(self, count_number_one: bool = False) -> int: - """Return the number of held tiles, can be discounting number one tile.""" - length = len(self.tiles) - if self.holding_number_one and not count_number_one: - return length - 1 - return length - - def is_holding(self, count_number_one: bool = False) -> bool: - """Return True if the mouse is dragging something.""" - return self.get_held_count(count_number_one) > 0 + tile_count = len(self.tiles) + self.clear_image((tile_count, 1)) + + # Render in reverse order so keeping number one on end is easier + for x in range(tile_count): + self.blit_tile(self.tiles[tile_count - x - 1], (x, 0)) + if tile_count: + self.dirty = 1 + self.visible = bool(tile_count) + + def bind_handlers(self) -> None: + """Register handlers.""" + self.register_handlers( + { + "game_cursor_data": self.handle_cursor_drag, + "cursor_reached_destination": self.handle_cursor_reached_destination, + "game_cursor_set_destination": self.handle_cursor_set_destination, + "game_cursor_set_movement_mode": self.handle_cursor_set_movement_mode, + "client_disconnected": self.handle_client_disconnected, + }, + ) - def get_held_info( - self, - count_number_one_tile: bool = False, - ) -> tuple[Tile | None, int]: - """Return color of tiles are and number of tiles held.""" - if not self.is_holding(count_number_one_tile): - return None, 0 - return self.tiles[0], self.get_held_count(count_number_one_tile) - - def process(self, time_passed: float) -> None: - """Process cursor.""" - x, y = pygame.mouse.get_pos() - x = saturate(x, 0, SCREENSIZE[0]) - y = saturate(y, 0, SCREENSIZE[1]) - self.location = Vector2(x, y) - if self.image_update: - if len(self.tiles): - self.update_image() - else: - self.image = None - self.image_update = False - - def force_hold(self, tiles: Iterable[Tile]) -> None: - """Pretty much it's drag but with no constraints.""" - for tile in tiles: - if tile.color == NUMBERONETILE: - self.holding_number_one = True - self.tiles.append(tile) + async def handle_cursor_drag(self, event: Event[Counter[int]]) -> None: + """Drag one or more tiles.""" + self.tiles.clear() + for tile_color in event.data.elements(): + if tile_color == Tile.one: + self.tiles.insert(0, tile_color) else: - self.tiles.appendleft(tile) - self.image_update = True - - def drag(self, tiles: Iterable[Tile]) -> None: - """Drag one or more tiles, as long as it's a list.""" - for tile in tiles: - if tile is not None and tile.color == NUMBERONETILE: - self.holding_number_one = True - self.tiles.append(tile) - else: - self.tiles.appendleft(tile) - self.image_update = True + self.tiles.append(tile_color) + self.update_image() + await trio.lowlevel.checkpoint() - def drop( + async def handle_cursor_reached_destination( self, - number: int | None = None, - allow_number_one_tile: bool = False, - ) -> list[Tile]: - """Return all of the tiles the Cursor is carrying.""" - if self.is_holding(allow_number_one_tile): - if number is None: - number = self.get_held_count(allow_number_one_tile) - else: - number = saturate( - number, - 0, - self.get_held_count(allow_number_one_tile), - ) - - tiles = [] - for tile in (self.tiles.popleft() for i in range(number)): - if tile.color == NUMBERONETILE and not allow_number_one_tile: - self.tiles.append(tile) - continue - tiles.append(tile) - self.image_update = True - - self.holding_number_one = NUMBERONETILE in { - tile.color for tile in self.tiles - } - return tiles - return [] - - def drop_one_tile(self) -> Tile | None: - """If holding the number one tile, drop it (returns it).""" - if self.holding_number_one: - not_number_one_tile = self.drop(None, False) - one = self.drop(1, True) - self.drag(not_number_one_tile) - self.holding_number_one = False - return one[0] - return None + event: Event[None], + ) -> None: + """Stop ticking.""" + self.unregister_handler_type("tick") + await trio.lowlevel.checkpoint() + def move_to_front(self) -> None: + """Move this sprite to front.""" + group = self.groups()[-1] + assert isinstance(group, LayeredDirty) + group.move_to_front(self) -G = TypeVar("G", bound="Grid") + async def handle_cursor_set_destination( + self, + event: Event[tuple[int, int]], + ) -> None: + """Start moving towards new destination.""" + destination = Vector2.from_iter( + x * y for x, y in zip(event.data, SCREEN_SIZE, strict=True) + ).floored() + # print(f"handle_cursor_set_destination {destination = }") + + targeting: sprite.TargetingComponent = self.get_component("targeting") + targeting.destination = destination + if not self.has_handler("tick"): + self.register_handler( + "tick", + self.handle_tick, + ) + self.move_to_front() + await trio.lowlevel.checkpoint() -def gsc_bound_index( - bounds_failure_return: T, -) -> Callable[ - [Callable[[G, tuple[int, int], *P], RT]], - Callable[[G, tuple[int, int], *P], RT | T], -]: - """Return a decorator for any grid or grid subclass that will keep index positions within bounds.""" + async def handle_pygame_mouse_motion( + self, + event: Event[sprite.PygameMouseMotion], + ) -> None: + """Set location to event data.""" + self.move_to_front() + self.location = event.data["pos"] + await trio.lowlevel.checkpoint() + + async def handle_tick(self, event: Event[sprite.TickEventData]) -> None: + """Handle tick event.""" + if self.client_mode: + self.time_passed += event.data.time_passed + truediv, self.time_passed = divmod(self.time_passed, self.duration) + + if self.last_transmit_pos != self.location and truediv: + self.last_transmit_pos = self.location + else: + await trio.lowlevel.checkpoint() + return - def gsc_bounds_keeper( - function: Callable[[G, tuple[int, int], *P], RT], - ) -> Callable[[G, tuple[int, int], *P], RT | T]: - """Grid or Grid Subclass Decorator that keeps index positions within bounds, as long as index is first argument after self arg.""" + transmit_location = Vector2.from_iter( + x / y for x, y in zip(self.location, SCREEN_SIZE, strict=True) + ) - @wraps(function) - def keep_within_bounds( - self: G, - index: tuple[int, int], - *args: Unpack[P], - ) -> RT | T: - """Ensure a index position tuple is valid.""" - x, y = index - if x < 0 or x >= self.size[0]: - return bounds_failure_return - if y < 0 or y >= self.size[1]: - return bounds_failure_return - return function(self, index, *args) + # Transmit to server + # Event level to so reaches client + await self.raise_event( + Event( + "game_cursor_location_transmit", + transmit_location, + 2, + ), + ) + else: + # Server mode + targeting: sprite.TargetingComponent = self.get_component( + "targeting", + ) + await targeting.move_destination_time(event.data.time_passed) - return keep_within_bounds + async def handle_cursor_set_movement_mode( + self, + event: Event[bool], + ) -> None: + """Change cursor movement mode. True if client mode, False if server mode.""" + self.client_mode = event.data + # print(f'handle_cursor_set_movement_mode {self.client_mode = }') + if self.client_mode: + self.register_handlers( + { + "PygameMouseMotion": self.handle_pygame_mouse_motion, + "tick": self.handle_tick, + }, + ) + else: + self.unregister_handler_type("PygameMouseMotion") + self.unregister_handler_type("tick") + await trio.lowlevel.checkpoint() - return gsc_bounds_keeper + async def handle_client_disconnected( + self, + event: Event[None], + ) -> None: + """Unregister tick event handler.""" + # print("[azul.game.Cursor] Got client disconnect, unregistering tick") + self.unregister_handler_type("tick") + await trio.lowlevel.checkpoint() class Grid(TileRenderer): @@ -1066,1746 +759,670 @@ class Grid(TileRenderer): def __init__( self, + name: str, size: tuple[int, int], - game: Game, - tile_seperation: int | None = None, + tile_separation: int | None = None, background: tuple[int, int, int] | None = TILEDEFAULT, ) -> None: """Grid Objects require a size and game at least.""" - super().__init__("Grid", game, tile_seperation, background) + super().__init__(name, tile_separation, background) self.size = size self.data = array( - [-6 for i in range(int(self.size[0] * self.size[1]))], + [Tile.blank for i in range(int(self.size[0] * self.size[1]))], int8, ).reshape(self.size) - def update_image(self) -> None: + def get_tile(self, xy: tuple[int, int]) -> int: + """Return tile color at given index.""" + x, y = xy + return int(self.data[y, x]) + + def update_image( + self, + offset: tuple[int, int] | None = None, + extra_space: tuple[int, int] | None = None, + ) -> None: """Update self.image.""" - self.clear_image(self.size) + self.clear_image(self.size, extra_space) - for y in range(self.size[1]): - for x in range(self.size[0]): - self.render_tile(Tile(self.data[y, x]), (x, y)) + width, height = self.size - def get_tile_point( - self, - screen_location: tuple[int, int] | Vector2, - ) -> tuple[int, int] | None: - """Return the xy choordinates of which tile intersects given a point. Returns None if no intersections.""" - # Can't get tile if screen location doesn't intersect our hitbox! - if not self.point_intersects(screen_location): - return None - # Otherwise, find out where screen point is in image locations - # board x and y - bx, by = self.to_image_surface_location(screen_location) - # Finally, return the full divides (no decimals) of xy location by self.tile_full. - return int(bx // self.tile_full), int(by // self.tile_full) + for y in range(height): + for x in range(width): + pos = (x, y) + self.blit_tile(self.get_tile(pos), pos, offset) - @gsc_bound_index(None) - def place_tile(self, xy: tuple[int, int], tile: Tile) -> bool: - """Place a Tile Object if permitted to do so. Return True if success.""" - x, y = xy - if self.data[y, x] < 0: - self.data[y, x] = tile.color - del tile - self.image_update = True - return True - return False - - @gsc_bound_index(None) - def get_tile(self, xy: tuple[int, int], replace: int = -6) -> Tile | None: - """Return a Tile Object from a given position in the grid if permitted. Return None on failure.""" - x, y = xy - tile_color = int(self.data[y, x]) - if tile_color < 0: - return None - self.data[y, x] = replace - self.image_update = True - return Tile(tile_color) + def fake_tile_exists(self, xy: tuple[int, int]) -> bool: + """Return if tile at given position is a fake tile.""" + return self.get_tile(xy) < 0 - @gsc_bound_index(None) - def get_info(self, xy: tuple[int, int]) -> Tile: - """Return the Tile Object at a given position without deleting it from the Grid.""" + def place_tile(self, xy: tuple[int, int], tile_color: int) -> None: + """Place tile at given position.""" x, y = xy - color = int(self.data[y, x]) - return Tile(color) + self.data[y, x] = tile_color + self.update_image() - def get_colors(self) -> list[int]: - """Return a list of the colors of tiles within self.""" + def pop_tile(self, xy: tuple[int, int], replace: int = Tile.blank) -> int: + """Return popped tile from given position in the grid.""" + tile_color = self.get_tile(xy) + self.place_tile(xy, replace) + return tile_color + + def get_colors(self) -> set[int]: + """Return a set of the colors of tiles within self.""" colors = set() - for y in range(self.size[1]): - for x in range(self.size[0]): - info_color = int(self.data[y, x]) - assert info_color is not None - colors.add(info_color) - return list(colors) - - def is_empty(self, empty_color: int = -6) -> bool: + width, height = self.size + for y in range(height): + for x in range(width): + colors.add(self.get_tile((x, y))) + return colors + + def is_empty(self, empty_color: int = Tile.blank) -> bool: """Return True if Grid is empty (all tiles are empty_color).""" colors = self.get_colors() - # Colors should only be [-6] if empty - return colors == [empty_color] - - def __del__(self) -> None: - """Delete data.""" - super().__del__() - del self.data + return len(colors) == 1 and colors.pop() == empty_color class Board(Grid): """Represents the board in the Game.""" - __slots__ = ("additions", "player", "variant_play", "wall_tiling") - bcolor = ORANGE + __slots__ = ("board_id",) - def __init__(self, player: Player, variant_play: bool = False) -> None: + def __init__(self, board_id: int) -> None: """Initialize player's board.""" - super().__init__((5, 5), player.game, background=self.bcolor) - self.name = "Board" - self.player = player + super().__init__(f"board_{board_id}", (5, 5), background=ORANGE) + + self.board_id = board_id - self.variant_play = variant_play - self.additions: dict[int, Tile | int | None] = {} + self.update_location_on_resize = True - self.wall_tiling = False + # Clear image so rect is set + self.clear_image((5, 5)) def __repr__(self) -> str: """Return representation of self.""" - return ( - f"{self.__class__.__name__}({self.player!r}, {self.variant_play})" - ) - - def set_colors(self, keep_read: bool = True) -> None: - """Reset tile colors.""" - for y in range(self.size[1]): - for x in range(self.size[0]): - if not keep_read or self.data[y, x] < 0: - self.data[y, x] = -( - (self.size[1] - y + x) % REGTILECOUNT + 1 - ) - - # print(self.data[y, x], end=' ') - # print() - # print('-'*10) - - def get_row(self, index: int) -> Generator[Tile, None, None]: - """Return a row from self. Does not delete data from internal grid.""" - for x in range(self.size[0]): - tile = self.get_info((x, index)) - assert tile is not None - yield tile - - def get_column(self, index: int) -> Generator[Tile, None, None]: - """Return a column from self. Does not delete data from internal grid.""" - for y in range(self.size[1]): - tile = self.get_info((index, y)) - assert tile is not None - yield tile - - def get_colors_in_row( - self, - index: int, - exclude_negatives: bool = True, - ) -> list[int]: - """Return the colors placed in a given row in internal grid.""" - row_colors = [tile.color for tile in self.get_row(index)] - if exclude_negatives: - row_colors = [c for c in row_colors if c >= 0] - ccolors = Counter(row_colors) - return sorted(ccolors.keys()) - - def get_colors_in_column( - self, - index: int, - exclude_negatives: bool = True, - ) -> list[int]: - """Return the colors placed in a given row in internal grid.""" - column_colors = [tile.color for tile in self.get_column(index)] - if exclude_negatives: - column_colors = [c for c in column_colors if c >= 0] - ccolors = Counter(column_colors) - return sorted(ccolors.keys()) - - def is_wall_tiling(self) -> bool: - """Return True if in Wall Tiling Mode.""" - return self.wall_tiling - - def get_tile_for_cursor_by_row(self, row: int) -> Tile | None: - """Return A COPY OF tile the mouse should hold. Returns None on failure.""" - if row in self.additions: - data = self.additions[row] - if isinstance(data, Tile): - return data - return None - - @gsc_bound_index(False) - def can_place_tile_color_at_point( - self, - position: tuple[int, int], - tile: Tile, - ) -> bool: - """Return True if tile's color is valid at given position.""" - column, row = position - colors = set( - self.get_colors_in_column(column) + self.get_colors_in_row(row), + return f"{self.__class__.__name__}({self.board_id})" + + def bind_handlers(self) -> None: + """Register event handlers.""" + self.register_handlers( + { + "game_board_data": self.handle_game_board_data, + }, ) - return tile.color not in colors - - def get_rows_to_tile_map(self) -> dict[int, int]: - """Return a dictionary of row numbers and row color to be wall tiled.""" - rows = {} - for row, tile in self.additions.items(): - if not isinstance(tile, Tile): - continue - rows[row] = tile.color - return rows - - def calculate_valid_locations_for_tile_row( - self, - row: int, - ) -> tuple[int, ...]: - """Return the valid drop columns of the additions tile for a given row.""" - valid = [] - # ??? Why overwriting row? - if row in self.additions: - tile = self.additions[row] - if isinstance(tile, Tile): - for column in range(self.size[0]): - if self.can_place_tile_color_at_point((column, row), tile): - valid.append(column) - return tuple(valid) - return () - - def remove_invalid_additions(self) -> None: - """Remove invalid additions that would not be placeable.""" - # In the wall-tiling phase, it may happen that you - # are not able to move the rightmost tile of a certain - # pattern line over to the wall because there is no valid - # space left for it. In this case, you must immediately - # place all tiles of that pattern line in your floor line. - for row in range(self.size[1]): - row_tile = self.additions[row] - if not isinstance(row_tile, Tile): - continue - valid = self.calculate_valid_locations_for_tile_row(row) - if not valid: - floor = self.player.get_object_by_name("floor_line") - assert isinstance(floor, FloorLine) - floor.place_tile(row_tile) - self.additions[row] = None - - @gsc_bound_index(False) - def wall_tile_from_point(self, position: tuple[int, int]) -> bool: - """Given a position, wall tile. Return success on placement. Also updates if in wall tiling mode.""" - success = False - column, row = position - at_point = self.get_info(position) - assert at_point is not None - if at_point.color <= 0 and row in self.additions: - tile = self.additions[row] - if isinstance(tile, Tile) and self.can_place_tile_color_at_point( - position, - tile, - ): - self.place_tile(position, tile) - self.additions[row] = column - # Update invalid placements after new placement - self.remove_invalid_additions() - success = True - if not self.get_rows_to_tile_map(): - self.wall_tiling = False - return success - - def wall_tiling_mode(self, moved_table: dict[int, Tile]) -> None: - """Set self into Wall Tiling Mode. Finishes automatically if not in variant play mode.""" - self.wall_tiling = True - for key, value in moved_table.items(): - key = int(key) - 1 - if key in self.additions: - raise RuntimeError( - f"Key {key!r} Already in additions dictionary!", - ) - self.additions[key] = value - if not self.variant_play: - for row in range(self.size[1]): - if row in self.additions: - rowdata = [tile.color for tile in self.get_row(row)] - tile = self.additions[row] - if not isinstance(tile, Tile): - continue - negative_tile_color = -(tile.color + 1) - if negative_tile_color in rowdata: - column = rowdata.index(negative_tile_color) - self.place_tile((column, row), tile) - # Set data to the column placed in, use for scoring - self.additions[row] = column - else: - raise RuntimeError( - f"{negative_tile_color} not in row {row}!", - ) - else: - raise RuntimeError(f"{row} not in moved_table!") - self.wall_tiling = False - else: - # Invalid additions can only happen in variant play mode. - self.remove_invalid_additions() - @gsc_bound_index(([], [])) - def get_touches_continuous( + async def handle_game_board_data( self, - xy: tuple[int, int], - ) -> tuple[list[Tile], list[Tile]]: - """Return two lists, each of which contain all the tiles that touch the tile at given x y position, including that position.""" - rs, cs = self.size - x, y = xy - # Get row and column tile color data - row = [tile.color for tile in self.get_row(y)] - column = [tile.color for tile in self.get_column(x)] - - # Both - def get_greater_than(v: int, size: int, data: list[int]) -> list[int]: - """Go through data forward and backward from point v out by size, and return all points from data with a value >= 0.""" - - def try_range(range_: Iterable[int]) -> list[int]: - """Try range. Return all of data in range up to when indexed value is < 0.""" - ret = [] - for tv in range_: - if data[tv] < 0: - break - ret.append(tv) - return ret - - nt = try_range(reversed(range(v))) - pt = try_range(range(v + 1, size)) - return nt + pt - - def comb(one: Iterable[T], two: Iterable[RT]) -> list[tuple[T, RT]]: - """Combine two lists by zipping together and returning list object.""" - return list(zip(one, two, strict=False)) - - def get_all(lst: list[tuple[int, int]]) -> Generator[Tile, None, None]: - """Return all of the self.get_info points for each value in lst.""" - for pos in lst: - tile = self.get_info(pos) - assert tile is not None - yield tile - - # Get row touches - row_touches = comb(get_greater_than(x, rs, row), [y] * rs) - # Get column touches - column_touches = comb([x] * cs, get_greater_than(y, cs, column)) - # Get real tiles from indexes and return - return list(get_all(row_touches)), list(get_all(column_touches)) - - def score_additions(self) -> int: - """Return the number of points the additions scored. - - Uses self.additions, which is set in self.wall_tiling_mode() - """ - score = 0 - for x, y in ((self.additions[y], y) for y in range(self.size[1])): - if x is not None: - assert isinstance(x, int) - rowt, colt = self.get_touches_continuous((x, y)) - horiz = len(rowt) - verti = len(colt) - if horiz > 1: - score += horiz - if verti > 1: - score += verti - if horiz <= 1 and verti <= 1: - score += 1 - del self.additions[y] - return score - - def get_filled_rows(self) -> int: - """Return the number of filled rows on this board.""" - count = 0 - for row in range(self.size[1]): - real = (t.color >= 0 for t in self.get_row(row)) - if all(real): - count += 1 - return count - - def has_filled_row(self) -> bool: - """Return True if there is at least one completely filled horizontal line.""" - return self.get_filled_rows() >= 1 + event: Event[tuple[int, NDArray[int8]]], + ) -> None: + """Handle `game_board_data` event.""" + board_id, array = event.data - def get_filled_columns(self) -> int: - """Return the number of filled rows on this board.""" - count = 0 - for column in range(self.size[0]): - real = (t.color >= 0 for t in self.get_column(column)) - if all(real): - count += 1 - return count - - def get_filled_colors(self) -> int: - """Return the number of completed colors on this board.""" - tiles = ( - self.get_info((x, y)) - for x in range(self.size[0]) - for y in range(self.size[1]) - ) - color_count = Counter(t.color for t in tiles if t is not None) - count = 0 - for fill_count in color_count.values(): - if fill_count >= 5: - count += 1 - return count + if board_id != self.board_id: + await trio.lowlevel.checkpoint() + return - def end_of_game_scoreing(self) -> int: - """Return the additional points for this board at the end of the game.""" - score = 0 - score += self.get_filled_rows() * 2 - score += self.get_filled_columns() * 7 - score += self.get_filled_colors() * 10 - return score + assert array.ndim == 2 + assert len(array.shape) == 2 + # w, h = array.shape + self.data = array + self.update_image() + self.visible = True - def process(self, time_passed: float) -> None: - """Process board.""" - if self.image_update and not self.variant_play: - self.set_colors(True) - super().process(time_passed) + await trio.lowlevel.checkpoint() -class Row(TileRenderer): +class PatternRows(TileRenderer): """Represents one of the five rows each player has.""" - __slots__ = ("color", "player", "size", "tiles") + __slots__ = ( + "rows", + "rows_id", + ) greyshift = GREYSHIFT def __init__( self, - player: Player, - size: int, - tile_seperation: int | None = None, - background: tuple[int, int, int] | None = None, + rows_id: int, ) -> None: """Initialize row.""" - super().__init__( - "Row", - player.game, - tile_seperation, - background, + super().__init__(f"Pattern_Rows_{rows_id}", background=None) + + self.add_component(sprite.DragClickEventComponent()) + + self.rows_id = rows_id + self.rows: dict[int, tuple[int, int]] = dict.fromkeys( + range(5), + (Tile.blank, 0), ) - self.player = player - self.size = int(size) - self.color = -6 - self.tiles = deque([Tile(self.color)] * self.size) + self.update_image() + self.visible = True def __repr__(self) -> str: """Return representation of self.""" - return f"{self.__class__.__name__}(%r, %i, ...)" % ( - self.game, - self.size, + return f"{self.__class__.__name__}({self.rows_id})" + + def bind_handlers(self) -> None: + """Register event handlers.""" + self.register_handlers( + { + "click": self.handle_click, + "game_pattern_current_turn_change": self.handle_game_pattern_current_turn_change, + "game_pattern_data": self.handle_game_pattern_data, + }, ) def update_image(self) -> None: """Update self.image.""" - self.clear_image((self.size, 1)) - - for x in range(len(self.tiles)): - self.render_tile(self.tiles[x], (x, 0)) - - def get_tile_point(self, screen_location: tuple[int, int]) -> int | None: - """Return the xy choordinates of which tile intersects given a point. Returns None if no intersections.""" - # `Grid.get_tile_point` inlined - # Can't get tile if screen location doesn't intersect our hitbox! - if not self.point_intersects(screen_location): - return None - # Otherwise, find out where screen point is in image locations - # board x and y - bx, _by = self.to_image_surface_location(screen_location) - # Finally, return the full divides (no decimals) of xy location by self.tile_full. + self.clear_image((5, 5)) - return self.size - 1 - int(bx // self.tile_full) + for y in range(5): + tile_color, count = self.rows[y] + for x in range(count, (y + 1)): + self.blit_tile(Tile.blank, (4 - x, y)) + for x in range(count): + self.blit_tile(tile_color, (4 - x, y)) + self.dirty = 1 - def get_placed(self) -> int: - """Return the number of tiles in self that are not fake tiles, like grey ones.""" - return len([tile for tile in self.tiles if tile.color >= 0]) + def set_row_data( + self, + row_id: int, + tile_color: Tile, + tile_count: int, + ) -> None: + """Set row data and update image.""" + assert row_id in self.rows + self.rows[row_id] = (tile_color, tile_count) + self.update_image() - def get_placeable(self) -> int: - """Return the number of tiles permitted to be placed on self.""" - return self.size - self.get_placed() + def get_tile_point( + self, + screen_location: tuple[int, int] | Vector2, + ) -> Vector2 | None: + """Return the x choordinate of which tile intersects given a point. Returns None if no intersections.""" + point = super().get_tile_point(screen_location) + if point is None: + return None + # If point is not valid for that row, say invalid + if (4 - point.x) > point.y: + return None + return point - def is_full(self) -> bool: - """Return True if this row is full.""" - return self.get_placed() == self.size + def set_background(self, color: tuple[int, int, int] | None) -> None: + """Set the background color for this row.""" + self.background = color + self.update_image() - def get_info(self, location: int) -> Tile | None: - """Return tile at location without deleting it. Return None on invalid location.""" - index = self.size - 1 - location - if index < 0 or index > len(self.tiles): - return None - return self.tiles[index] + async def handle_click( + self, + event: Event[sprite.PygameMouseButtonEventData], + ) -> None: + """Handle click event.""" + point = self.get_tile_point(event.data["pos"]) + if point is None: + await trio.lowlevel.checkpoint() + return - def can_place(self, tile: Tile) -> bool: - """Return True if permitted to place given tile object on self.""" - placeable = (tile.color == self.color) or ( - self.color < 0 and tile.color >= 0 - ) - if not placeable: - return False - color_correct = tile.color >= 0 and tile.color < 5 - if not color_correct: - return False - number_correct = self.get_placeable() > 0 - if not number_correct: - return False - - board = self.player.get_object_by_name("Board") - assert isinstance(board, Board) - # Is color not present? - return tile.color not in board.get_colors_in_row( - self.size - 1, + # Transmit to server + await self.raise_event( + Event( + "game_pattern_row_clicked", + ( + self.rows_id, + point.floored(), + ), + 2, + ), ) - def get_tile(self, replace: int = -6) -> Tile: - """Return the leftmost tile while deleting it from self.""" - self.tiles.appendleft(Tile(replace)) - self.image_update = True - return self.tiles.pop() - - def place_tile(self, tile: Tile) -> None: - """Place a given Tile Object on self if permitted.""" - if self.can_place(tile): - self.color = tile.color - self.tiles.append(tile) - end = self.tiles.popleft() - if not end.color < 0: - raise RuntimeError( - "Attempted deletion of real tile from Row!", - ) - self.image_update = True - else: - raise ValueError("Not allowed to place.") - - def can_place_tiles(self, tiles: list[Tile]) -> bool: - """Return True if permitted to place all of given tiles objects on self.""" - if len(tiles) > self.get_placeable(): - return False - for tile in tiles: - if not self.can_place(tile): - return False - tile_colors = [] - for tile in tiles: - if tile.color not in tile_colors: - tile_colors.append(tile.color) - return not len(tile_colors) > 1 - - def place_tiles(self, tiles: list[Tile]) -> None: - """Place multiple tile objects on self if permitted.""" - if self.can_place_tiles(tiles): - for tile in tiles: - self.place_tile(tile) + async def handle_game_pattern_current_turn_change( + self, + event: Event[int], + ) -> None: + """Handle game_pattern_current_turn_change event.""" + player_id = event.data + + if player_id == self.rows_id: + self.set_background(DARKGREEN) else: - raise ValueError("Not allowed to place tiles.") + self.set_background(None) - def wall_tile( + async def handle_game_pattern_data( self, - add_to_table: dict[str, list[Tile] | Tile | None], - empty_color: int = -6, + event: Event[tuple[int, int, tuple[int, int]]], ) -> None: - """Move tiles around and into add dictionary for the wall tiling phase of the game. Removes tiles from self.""" - if "tiles_for_box" not in add_to_table: - add_to_table["tiles_for_box"] = [] - if not self.is_full(): - add_to_table[str(self.size)] = None + """Handle game_pattern_data event.""" + player_id, row_id, (raw_tile_color, tile_count) = event.data + + if player_id != self.rows_id: + await trio.lowlevel.checkpoint() return - self.color = empty_color - add_to_table[str(self.size)] = self.get_tile() - for_box = add_to_table["tiles_for_box"] - assert isinstance(for_box, list) - for _i in range(self.size - 1): - for_box.append(self.get_tile()) + tile_color = Tile(raw_tile_color) + self.set_row_data(row_id, tile_color, tile_count) + await trio.lowlevel.checkpoint() - def set_background(self, color: tuple[int, int, int] | None) -> None: - """Set the background color for this row.""" - self.back = color - self.image_update = True +class FloorLine(TileRenderer): + """Represents a player's floor line.""" -class PatternLine(MultipartObject): - """Represents multiple rows to make the pattern line.""" + __slots__ = ("floor_line_id", "numbers") - __slots__ = ("player", "row_seperation") - size = (5, 5) + def __init__(self, floor_line_id: int, numbers: NDArray[int8]) -> None: + """Initialize floor line.""" + super().__init__( + f"floor_line_{floor_line_id}", + background=RED, + ) - def __init__(self, player: Player, row_seperation: int = 0) -> None: - """Initialize pattern line.""" - super().__init__("PatternLine") - self.player = player - self.row_seperation = row_seperation + self.add_component(sprite.DragClickEventComponent()) - for x, _y in zip( - range(self.size[0]), - range(self.size[1]), - strict=True, - ): - self.add_object(Row(self.player, x + 1)) + self.floor_line_id = floor_line_id - self.set_background(None) + self.numbers = tuple(numbers.flat) + self.tiles: Counter[Tile] = Counter() - self._lastloc = Vector2(0, 0) - - def set_background(self, color: tuple[int, int, int] | None) -> None: - """Set the background color for all rows in the pattern line.""" - self.set_attr_all("back", color) - self.set_attr_all("image_update", True) - - def get_row(self, row: int) -> Row: - """Return given row.""" - object_ = self.get_object(row) - assert isinstance(object_, Row) - return object_ - - def reset_position(self) -> None: - """Reset Locations of Rows according to self.location.""" - last = self.size[1] - w = self.get_row(last - 1).wh[0] - if w is None: - raise RuntimeError( - "Image Dimensions for Row Object (row.wh) are None!", - ) - h1 = self.get_row(0).tile_full - h = int(last * h1) - self.wh = w, h - w1 = h1 / 2 - - x, y = self.location - y -= h / 2 - w1 - for rid in self.objects: - row = self.get_row(rid) - diff = last - row.size - row.location = Vector2(x + (diff * w1), y + rid * h1) - - def get_tile_point( - self, - screen_location: tuple[int, int], - ) -> tuple[int, int] | None: - """Return the xy choordinates of which tile intersects given a point. Returns None if no intersections.""" - for y in range(self.size[1]): - x = self.get_row(y).get_tile_point(screen_location) - if x is not None: - return x, y - return None - - def is_full(self) -> bool: - """Return True if self is full.""" - return all(self.get_row(rid).is_full() for rid in range(self.size[1])) - - def wall_tiling(self) -> dict[str, list[Tile] | Tile | None]: - """Return a dictionary to be used with wall tiling. Removes tiles from rows.""" - values: dict[str, list[Tile] | Tile | None] = {} - for rid in range(self.size[1]): - self.get_row(rid).wall_tile(values) - return values - - def process(self, time_passed: float) -> None: - """Process all the rows that make up the pattern line.""" - if self.hidden != self._lasthidden: - self.set_attr_all("image_update", True) - super().process(time_passed) - - -class Text(Object): - """Text object, used to render text with a given font.""" - - __slots__ = ("_cxy", "_last", "font") - - def __init__( - self, - font_size: int, - color: tuple[int, int, int], - background: tuple[int, int, int] | None = None, - cx: bool = True, - cy: bool = True, - name: str = "", - ) -> None: - """Initialize text.""" - super().__init__(f"Text{name}") - self.font = Font( - FONT, - font_size, - color, - cx, - cy, - True, - background, - True, - ) - self._cxy = cx, cy - self._last: str | None = None - - def get_image_zero(self) -> tuple[int, int]: - """Return the screen location of the topleft point of self.image.""" - x = int(self.location[0]) - y = int(self.location[1]) - if self._cxy[0]: - x -= self.wh[0] // 2 - if self._cxy[1]: - y -= self.wh[1] // 2 - return x, y + self.update_image() + self.visible = True def __repr__(self) -> str: """Return representation of self.""" - return f"<{self.__class__.__name__} Object>" + return f"{self.__class__.__name__}({self.floor_line_id})" - @staticmethod - def get_font_height(font: str | Path, size: int) -> int: - """Return the height of font at fontsize size.""" - return pygame.font.Font(font, size).get_height() + def update_image(self) -> None: + """Update self.image.""" + self.clear_image((len(self.numbers), 1)) + + font = pygame.font.Font(FONT, size=self.tile_size) + + for x, tile in enumerate(sorted(self.tiles.elements(), reverse=True)): + self.blit_tile(tile, (x, 0)) + for x in range(self.tiles.total(), len(self.numbers)): + self.blit_tile(Tile.blank, (x, 0)) + # Draw number on top + number_surf = font.render(str(self.numbers[x]), False, BLACK) + tile_topleft = self.get_tile_topleft((x, 0)) + self.image.blit(number_surf, tile_topleft) + self.dirty = 1 + + def bind_handlers(self) -> None: + """Register event handlers.""" + self.register_handlers( + { + "game_floor_data": self.handle_game_floor_data, + "click": self.handle_click, + }, + ) - def update_value( + async def handle_game_floor_data( self, - text: str | None, - size: int | None = None, - color: tuple[int, int, int] | None = None, - background: tuple[int, int, int] | None = None, - ) -> pygame.surface.Surface: - """Return a surface of given text rendered in FONT.""" - self.image = self.font.render_nosurf(text, size, color, background) - return self.image - - def get_surface(self) -> pygame.surface.Surface: - """Return self.image.""" - assert self.image is not None - return self.image - - def get_tile_point(self, location: tuple[int, int]) -> None: - """Set get_tile_point attribute so that errors are not raised.""" - return + event: Event[tuple[int, Counter[u8]]], + ) -> None: + """Handle game_floor_data event.""" + line_id, floor_data = event.data - def process(self, time_passed: float) -> None: - """Process text.""" - if self.font.last_text != self._last: - self.update_value(self.font.last_text) - self._last = self.font.last_text + if line_id != self.floor_line_id: + await trio.lowlevel.checkpoint() + return + self.tiles.clear() + self.tiles.update({Tile(k): v for k, v in floor_data.items()}) + self.update_image() -class FloorLine(Row): - """Represents a player's floor line.""" + await trio.lowlevel.checkpoint() - size = 7 - number_one_color = NUMBERONETILE + async def handle_click( + self, + event: Event[sprite.PygameMouseButtonEventData], + ) -> None: + """Handle click event.""" + point = self.get_tile_point(event.data["pos"]) + if point is None: + await trio.lowlevel.checkpoint() + return - def __init__(self, player: Player) -> None: - """Initialize floor line.""" - super().__init__(player, self.size, background=ORANGE) - self.name = "floor_line" - - # self.font = Font(FONT, round(self.tile_size*1.2), color=BLACK, cx=False, cy=False) - self.text = Text( - round(self.tile_size * 1.2), - BLACK, - cx=False, - cy=False, + # Transmit to server + await self.raise_event( + Event( + "game_floor_clicked", + ( + self.floor_line_id, + int(point.floored().x), + ), + 2, + ), ) - self.has_number_one_tile = False - gen = floor_line_subtract_generator(1) - self.numbers = [next(gen) for i in range(self.size)] - def __repr__(self) -> str: - """Return representation of self.""" - return f"{self.__class__.__name__}({self.player!r})" - - def render(self, surface: pygame.surface.Surface) -> None: - """Update self.image.""" - super().render(surface) - - sx, sy = self.location - assert self.wh is not None, "Should be impossible." - w, h = self.wh - for x in range(self.size): - xy = round( - x * self.tile_full + self.tile_seperation + sx - w / 2, - ), round( - self.tile_seperation + sy - h / 2, - ) - self.text.update_value(str(self.numbers[x])) - self.text.location = Vector2(*xy) - self.text.render(surface) - - # self.font.render(surface, str(self.numbers[x]), xy) - - def place_tile(self, tile: Tile) -> None: - """Place a given Tile Object on self if permitted.""" - self.tiles.insert(self.get_placed(), tile) - - if tile.color == self.number_one_color: - self.has_number_one_tile = True - - box_lid = self.player.game.get_object_by_name("BoxLid") - assert isinstance(box_lid, BoxLid) - - def handle_end(end: Tile) -> None: - """Handle the end tile we are replacing. Ensures number one tile is not removed.""" - if not end.color < 0: - if end.color == self.number_one_color: - handle_end(self.tiles.pop()) - self.tiles.appendleft(end) - return - box_lid.add_tile(end) - - handle_end(self.tiles.pop()) - - self.image_update = True - - def score_tiles(self) -> int: - """Score self.tiles and return how to change points.""" - running_total = 0 - for x in range(self.size): - if self.tiles[x].color >= 0: - running_total += self.numbers[x] - elif x < self.size - 1 and self.tiles[x + 1].color >= 0: - raise RuntimeError( - "Player is likely cheating! Invalid placement of floor_line tiles!", - ) - return running_total - - def get_tiles( - self, - empty_color: int = -6, - ) -> tuple[list[Tile], Tile | None]: - """Return tuple of tiles gathered, and then either the number one tile or None.""" - tiles = [] - number_one_tile = None - for tile in (self.tiles.pop() for i in range(len(self.tiles))): - if tile.color == self.number_one_color: - number_one_tile = tile - self.has_number_one_tile = False - elif tile.color >= 0: - tiles.append(tile) - - for _i in range(self.size): - self.tiles.append(Tile(empty_color)) - self.image_update = True - return tiles, number_one_tile - - def can_place_tiles(self, tiles: list[Tile]) -> bool: - """Return True.""" - return True - - -class Factory(Grid): +class Factory(TileRenderer): """Represents a Factory.""" - size = (2, 2) + __slots__ = ("factory_id", "tiles") color = WHITE outline = BLUE - out_size = 0.1 - def __init__(self, game: Game, factory_id: int) -> None: + def __init__(self, factory_id: int) -> None: """Initialize factory.""" - super().__init__(self.size, game, background=None) - self.number = factory_id - self.name = f"Factory{self.number}" + super().__init__(f"Factory_{factory_id}", background=None) - self.radius = math.ceil( - self.tile_full * self.size[0] * self.size[1] / 3 + 3, - ) + self.factory_id = factory_id + self.tiles: Counter[int] = Counter() + + self.update_location_on_resize = True + + self.add_component(sprite.DragClickEventComponent()) def __repr__(self) -> str: """Return representation of self.""" - return f"{self.__class__.__name__}(%r, %i)" % (self.game, self.number) - - def add_circle(self, surface: pygame.surface.Surface) -> None: - """Add circle to self.image.""" - # if f"FactoryCircle{self.radius}" not in self.game.cache: - rad = math.ceil(self.radius) - surf = set_alpha(pygame.surface.Surface((2 * rad, 2 * rad)), 1) - pygame.draw.circle(surf, self.outline, (rad, rad), rad) - pygame.draw.circle( - surf, - self.color, - (rad, rad), - math.ceil(rad * (1 - self.out_size)), - ) - # self.game.cache[f"FactoryCircle{self.radius}"] = surf - # surf = self.game.cache[f"FactoryCircle{self.radius}"].copy() - surface.blit( - surf, - ( - round(self.location[0] - self.radius), - round(self.location[1] - self.radius), - ), + return f"{self.__class__.__name__}({self.factory_id})" + + def bind_handlers(self) -> None: + """Register event handlers.""" + self.register_handlers( + { + "game_factory_data": self.handle_factory_data, + "click": self.handle_click, + }, ) - def render(self, surface: pygame.surface.Surface) -> None: - """Render Factory.""" - if not self.hidden: - self.add_circle(surface) - super().render(surface) - - def fill(self, tiles: list[Tile]) -> None: - """Fill self with tiles. Will raise exception if insufficiant tiles.""" - if len(tiles) < self.size[0] * self.size[1]: - size = self.size[0] * self.size[1] - raise RuntimeError( - f"Insufficiant quantity of tiles! Needs {size}!", - ) - for y in range(self.size[1]): - for tile, x in zip( - (tiles.pop() for i in range(self.size[0])), - range(self.size[0]), - strict=True, - ): - self.place_tile((x, y), tile) - if tiles: - raise RuntimeError("Too many tiles!") - - def grab(self) -> list[Tile]: - """Return all tiles on this factory.""" - return [ - tile - for tile in ( - self.get_tile((x, y), -6) - for x in range(self.size[0]) - for y in range(self.size[1]) - ) - if tile is not None and tile.color != -6 - ] - - def grab_color(self, color: int) -> tuple[list[Tile], list[Tile]]: - """Return all tiles of color given in the first list, and all non-matches in the second list.""" - tiles = self.grab() - right, wrong = [], [] - for tile in tiles: - if tile.color == color: - right.append(tile) - else: - wrong.append(tile) - return right, wrong - - def process(self, time_passed: float) -> None: - """Process self.""" - if self.image_update: - self.radius = int( - self.tile_full * self.size[0] * self.size[1] // 3 + 3, - ) - super().process(time_passed) + def update_image(self) -> None: + """Update image.""" + self.clear_image((2, 2), extra=(16, 16)) + radius = 29 + pygame.draw.circle( + self.image, + self.outline, + (radius, radius), + radius, + ) + pygame.draw.circle( + self.image, + self.color, + (radius, radius), + math.ceil(radius * 0.9), + ) -class Factories(MultipartObject): - """Factories Multipart Object, made of multiple Factory Objects.""" + for index, tile_color in enumerate(self.tiles.elements()): + y, x = divmod(index, 2) + self.blit_tile(tile_color, (x, y), (8, 8)) + self.dirty = 1 - teach = 4 + def get_tile_point( + self, + screen_location: tuple[int, int] | Vector2, + ) -> Vector2 | None: + """Get tile point accounting for offset.""" + point = super().get_tile_point( + Vector2.from_iter(screen_location) - (8, 8), + ) + if point is None: + return None + if any(x >= 2 for x in point): + return None + return point - def __init__( + async def handle_factory_data( self, - game: Game, - factories: int, - size: int | None = None, + event: Event[tuple[int, Counter[int]]], ) -> None: - """Initialize factories.""" - super().__init__("Factories") - - self.game = game - self.count = factories - - for i in range(self.count): - self.add_object(Factory(self.game, i)) - - if size is None: - factory = self.objects[0] - assert isinstance(factory, Factory) - factory.process(0) - rad = factory.radius - self.size = rad * 5 - else: - self.size = size - self.size = math.ceil(self.size) + """Handle `game_factory_data` event.""" + factory_id, tiles = event.data - self.play_tiles_from_bag() + if factory_id != self.factory_id: + await trio.lowlevel.checkpoint() + return - def __repr__(self) -> str: - """Return representation of self.""" - return f"{self.__class__.__name__}(%r, %i, ...)" % ( - self.game, - self.count, - ) + self.tiles = tiles + self.update_image() + self.visible = True - def reset_position(self) -> None: - """Reset the position of all factories within.""" - degrees = 360 / self.count - for i in range(self.count): - radians = math.radians(degrees * i) - self.objects[i].location = Vector2( - math.sin(radians) * self.size + self.location[0], - math.cos(radians) * self.size + self.location[1], - ) + await trio.lowlevel.checkpoint() - def process(self, time_passed: float) -> None: - """Process factories. Does not react to cursor if hidden.""" - super().process(time_passed) - if self.hidden: - return - cursor = self.game.get_object_by_name("Cursor") - assert isinstance(cursor, Cursor) - if not cursor.is_pressed() or cursor.is_holding(): - return - obj, point = self.get_intersection(cursor.location) - if obj is None or point is None: + async def handle_click( + self, + event: Event[sprite.PygameMouseButtonEventData], + ) -> None: + """Handle click event.""" + point = self.get_tile_point(event.data["pos"]) + if point is None: + await trio.lowlevel.checkpoint() return - oid = int(obj[7:]) - - factory = self.objects[oid] - assert isinstance(factory, Factory) - tile_at_point = factory.get_info(point) - if tile_at_point is None or tile_at_point.color < 0: + index = int(point.y * 2 + point.x) + tiles = tuple(self.tiles.elements()) + if not tiles: + await trio.lowlevel.checkpoint() return - table = self.game.get_object_by_name("TableCenter") - assert isinstance(table, TableCenter) - select, tocenter = factory.grab_color( - tile_at_point.color, - ) - if tocenter: - table.add_tiles(tocenter) - cursor.drag(select) - - def play_tiles_from_bag(self, empty_color: int = -6) -> None: - """Divy up tiles to each factory from the bag.""" - # For every factory we have, - for fid in range(self.count): - # Draw tiles for the factory - drawn = [] - for _i in range(self.teach): - # If the bag is not empty, - if not self.game.bag.is_empty(): - # Draw a tile from the bag. - tile = self.game.bag.draw_tile() - assert tile is not None - drawn.append(tile) - else: # Otherwise, get the box lid - box_lid = self.game.get_object_by_name("BoxLid") - assert isinstance(box_lid, BoxLid) - # If the box lid is not empty, - if not box_lid.is_empty(): - # Add all the tiles from the box lid to the bag - self.game.bag.add_tiles(box_lid.get_tiles()) - # and shake the bag to randomize everything - self.game.bag.reset() - # Then, grab a tile from the bag like usual. - tile = self.game.bag.draw_tile() - assert tile is not None - drawn.append(tile) - else: - # "In the rare case that you run out of tiles again - # while there are none left in the lid, start a new - # round as usual even though are not all factory - # displays are properly filled." - drawn.append(Tile(empty_color)) - # Place drawn tiles on factory - factory = self.objects[fid] - assert isinstance(factory, Factory) - factory.fill(drawn) - - def is_all_empty(self) -> bool: - """Return True if all factories are empty.""" - for fid in range(self.count): - factory = self.objects[fid] - assert isinstance(factory, Factory) - if not factory.is_empty(): - return False - return True - - -class TableCenter(Grid): - """Object that represents the center of the table.""" - - size = (6, 6) - first_tile_color = NUMBERONETILE - - def __init__(self, game: Game, has_number_one_tile: bool = True) -> None: - """Initialize center of table.""" - super().__init__(self.size, game, background=None) - self.game = game - self.name = "TableCenter" + tile_color = tiles[index] - self.number_one_tile_exists = False - if has_number_one_tile: - self.add_number_one_tile() - - self.next_position = (0, 0) - - def __repr__(self) -> str: - """Return representation of self.""" - return f"{self.__class__.__name__}({self.game!r})" - - def add_number_one_tile(self) -> None: - """Add the number one tile to the internal grid.""" - if not self.number_one_tile_exists: - x, y = self.size - self.place_tile((x - 1, y - 1), Tile(self.first_tile_color)) - self.number_one_tile_exists = True - - def add_tile(self, tile: Tile) -> None: - """Add a Tile Object to the Table Center Grid.""" - self.place_tile(self.next_position, tile) - x, y = self.next_position - x += 1 - y += int(x // self.size[0]) - x %= self.size[0] - y %= self.size[1] - self.next_position = (x, y) - self.image_update = True - - def add_tiles(self, tiles: Iterable[Tile], sort: bool = True) -> None: - """Add multiple Tile Objects to the Table Center Grid.""" - for tile in tiles: - self.add_tile(tile) - if sort and tiles: - self.reorder_tiles() - - def reorder_tiles(self, replace: int = -6) -> None: - """Re-organize tiles by Color.""" - full = [] - for y in range(self.size[1]): - for x in range(self.size[0]): - if self.number_one_tile_exists: - tile = self.get_info((x, y)) - assert tile is not None - if tile.color == self.first_tile_color: - continue - at = self.get_tile((x, y), replace) - - if at is not None: - full.append(at) - sorted_tiles = sorted(full, key=sort_tiles) - self.next_position = (0, 0) - self.add_tiles(sorted_tiles, False) - - def pull_tiles(self, tile_color: int, replace: int = -6) -> list[Tile]: - """Remove all of the tiles of tile_color from the Table Center Grid.""" - to_pull: list[tuple[int, int]] = [] - for y in range(self.size[1]): - for x in range(self.size[0]): - info_tile = self.get_info((x, y)) - assert info_tile is not None - if info_tile.color == tile_color: - to_pull.append((x, y)) - elif ( - self.number_one_tile_exists - and info_tile.color == self.first_tile_color - ): - to_pull.append((x, y)) - self.number_one_tile_exists = False - tiles = [] - for pos in to_pull: - tile = self.get_tile(pos, replace) - assert tile is not None - tiles.append(tile) - self.reorder_tiles(replace) - return tiles - - def process(self, time_passed: float) -> None: - """Process factories.""" - if self.hidden: - super().process(time_passed) + if tile_color < 0: + # Do not send non-real tiles + await trio.lowlevel.checkpoint() return - cursor = self.game.get_object_by_name("Cursor") - assert isinstance(cursor, Cursor) - if ( - cursor.is_pressed() - and not cursor.is_holding() - and not self.is_empty() - and self.point_intersects(cursor.location) - ): - point = self.get_tile_point(cursor.location) - # Shouldn't return none anymore since we have point_intersects now. - assert point is not None - tile = self.get_info(point) - assert isinstance(tile, Tile) - color_at_point = tile.color - if color_at_point >= 0 and color_at_point < 5: - cursor.drag(self.pull_tiles(color_at_point)) - super().process(time_passed) - - -class Bag: - """Represents the bag full of tiles.""" - - __slots__ = ( - "percent_each", - "tile_count", - "tile_names", - "tile_types", - "tiles", - ) - def __init__(self, tile_count: int = 100, tile_types: int = 5) -> None: - """Initialize bag of tiles.""" - self.tile_count = int(tile_count) - self.tile_types = int(tile_types) - self.tile_names = [chr(65 + i) for i in range(self.tile_types)] - self.percent_each = (self.tile_count / self.tile_types) / 100 - self.tiles: deque[str] - self.full_reset() - - def full_reset(self) -> None: - """Reset the bag to a full, re-randomized bag.""" - self.tiles = deque( - gen_random_proper_seq( - self.tile_count, - **dict.fromkeys(self.tile_names, self.percent_each), + # Transmit to server + # Needs level 2 to reach server client + await self.raise_event( + Event( + "game_factory_clicked", + ( + self.factory_id, + Tile(tile_color), + ), + 2, ), ) - def __repr__(self) -> str: - """Return representation of self.""" - return f"{self.__class__.__name__}(%i, %i)" % ( - self.tile_count, - self.tile_types, - ) - - def reset(self) -> None: - """Randomize all the tiles in the bag.""" - self.tiles = deque(randomize(self.tiles)) - - def get_color(self, tile_name: str) -> int: - """Return the color of a named tile.""" - if tile_name not in self.tile_names: - raise ValueError(f"Tile Name {tile_name} Not Found!") - return self.tile_names.index(tile_name) - - def get_tile(self, tile_name: str) -> Tile: - """Return a Tile Object from a tile name.""" - return Tile(self.get_color(tile_name)) - - def get_count(self) -> int: - """Return number of tiles currently held.""" - return len(self.tiles) - - def is_empty(self) -> bool: - """Return True if no tiles are currently held.""" - return self.get_count() == 0 - - def draw_tile(self) -> Tile | None: - """Return a random Tile Object from the bag. Return None if no tiles to draw.""" - if not self.is_empty(): - return self.get_tile(self.tiles.pop()) - return None - def get_name(self, tile_color: int) -> str: - """Return the name of a tile given it's color.""" - try: - return self.tile_names[tile_color] - except IndexError as exc: - raise ValueError("Invalid Tile Color!") from exc - - def add_tile(self, tile_object: Tile) -> None: - """Add a Tile Object to the bag.""" - name = self.get_name(int(tile_object.color)) - range_ = (0, len(self.tiles) - 1) - if range_[1] - range_[0] <= 1: - index = 0 - else: - # S311 Standard pseudo-random generators are not suitable for cryptographic purposes - index = random.randint(range_[0], range_[1]) # noqa: S311 - # self.tiles.insert(random.randint(0, len(self.tiles)-1), self.get_name(int(tile_object.color))) - self.tiles.insert(index, name) - del tile_object +class TableCenter(TileRenderer): + """sprite.Sprite that represents the center of the table.""" - def add_tiles(self, tile_objects: Iterable[Tile]) -> None: - """Add multiple Tile Objects to the bag.""" - for tile_object in tile_objects: - self.add_tile(tile_object) + __slots__ = ("tiles",) + size = (6, 6) + def __init__(self) -> None: + """Initialize center of table.""" + super().__init__("TableCenter", background=None) -class BoxLid(Object): - """BoxLid Object, represents the box lid were tiles go before being added to the bag again.""" + self.tiles: Counter[int] = Counter() + self.update_image() + self.visible = True - def __init__(self, game: Game) -> None: - """Initialize box lid.""" - super().__init__("BoxLid") - self.game = game - self.tiles: deque[Tile] = deque() + self.add_component(sprite.DragClickEventComponent()) def __repr__(self) -> str: """Return representation of self.""" - return f"{self.__class__.__name__}({self.game!r})" + return f"{self.__class__.__name__}()" - def add_tile(self, tile: Tile) -> None: - """Add a tile to self.""" - if tile.color >= 0 and tile.color < 5: - self.tiles.append(tile) - return - raise ValueError( - f"BoxLid.add_tile tried to add an invalid tile to self ({tile.color = }).", + def bind_handlers(self) -> None: + """Register event handlers.""" + self.register_handlers( + { + "game_table_data": self.update_board_data, + "click": self.handle_click, + }, ) - def add_tiles(self, tiles: Iterable[Tile]) -> None: - """Add multiple tiles to self.""" - for tile in tiles: - self.add_tile(tile) - - def get_tiles(self) -> list[Tile]: - """Return all tiles in self while deleting them from self.""" - return [self.tiles.popleft() for i in range(len(self.tiles))] + def iter_tiles(self) -> Generator[int, None, None]: + """Yield tile colors.""" + count = 0 + for tile_type in sorted(set(self.tiles) - {Tile.one}): + tile_count = self.tiles[tile_type] + for _ in range(tile_count): + yield tile_type + count += 1 - def is_empty(self) -> bool: - """Return True if self is empty (no tiles on it).""" - return len(self.tiles) == 0 + width, height = self.size + remaining = width * height - count + one_count = self.tiles.get(Tile.one, 0) + remaining = max(remaining - one_count, 0) + for _ in range(remaining): + yield Tile.blank + for _ in range(one_count): + yield Tile.one -class Player(MultipartObject): - """Represents a player. Made of lots of objects.""" + def update_image(self) -> None: + """Reset/update image.""" + self.clear_image(self.size) - def __init__( + width, height = self.size + tile_generator = self.iter_tiles() + for y in range(height): + for x in range(width): + tile = next(tile_generator) + # if tile == Tile.blank: + # continue + self.blit_tile(tile, (x, y)) + self.dirty = 1 + + def add_tile(self, tile: int) -> None: + """Add a tile to the center of the table.""" + self.tiles.update((tile,)) + self.update_image() + + def add_tiles(self, tiles: Iterable[int]) -> None: + """Add multiple int Objects to the Table Center Grid.""" + self.tiles.update(tiles) + self.update_image() + + def pull_tiles(self, tile_color: int) -> list[int]: + """Pop all of tile_color. Raises KeyError if not exists.""" + tile_count = self.tiles.pop(tile_color) + return [tile_color] * tile_count + + async def update_board_data(self, event: Event[Counter[int]]) -> None: + """Update table center board data.""" + self.tiles = event.data + self.update_image() + await trio.lowlevel.checkpoint() + + async def handle_click( self, - game: Game, - player_id: int, - networked: bool = False, - varient_play: bool = False, + event: Event[sprite.PygameMouseButtonEventData], ) -> None: - """Initialize player.""" - super().__init__(f"Player{player_id}") + """Handle click event.""" + point = self.get_tile_point(event.data["pos"]) + if point is None: + await trio.lowlevel.checkpoint() + return - self.game = game - self.player_id = player_id - self.networked = networked - self.varient_play = varient_play + index = int(point.y * 6 + point.x) + tile_color = tuple(self.iter_tiles())[index] - self.add_object(Board(self, self.varient_play)) - self.add_object(PatternLine(self)) - self.add_object(FloorLine(self)) - self.add_object(Text(SCOREFONTSIZE, SCORECOLOR)) + if tile_color < 0: + # Do not send non-real tiles + await trio.lowlevel.checkpoint() + return - self.score = 0 - self.is_turn = False - self.is_wall_tiling = False - self.just_held = False - self.just_dropped = False + # Transmit to server + # Needs level 2 to reach server client + await self.raise_event( + Event( + "game_table_clicked", + Tile(tile_color), + 2, + ), + ) - self.update_score() - self._lastloc = Vector2(0, 0) +class HaltState(AsyncState["AzulClient"]): + """Halt state to set state to None so running becomes False.""" - def __repr__(self) -> str: - """Return representation of self.""" - return f"{self.__class__.__name__}(%r, %i, %s, %s)" % ( - self.game, - self.player_id, - self.networked, - self.varient_play, - ) + __slots__ = () - def update_score(self) -> None: - """Update the scorebox for this player.""" - score_box = self.get_object_by_name("Text") - assert isinstance(score_box, Text) - score_box.update_value(f"Player {self.player_id + 1}: {self.score}") - - def trigger_turn_now(self) -> None: - """Handle start of turn.""" - if not self.is_turn: - pattern_line = self.get_object_by_name("PatternLine") - assert isinstance(pattern_line, PatternLine) - if self.is_wall_tiling: - board = self.get_object_by_name("Board") - assert isinstance(board, Board) - rows = board.get_rows_to_tile_map() - for rowpos, value in rows.items(): - color = get_tile_color(value, board.greyshift) - assert isinstance(color[0], int) - pattern_line.get_row(rowpos).set_background( - color, - ) - else: - pattern_line.set_background(PATSELECTCOLOR) - self.is_turn = True - - def end_of_turn(self) -> None: - """Handle end of turn.""" - if self.is_turn: - pattern_line = self.get_object_by_name("PatternLine") - assert isinstance(pattern_line, PatternLine) - pattern_line.set_background(None) - self.is_turn = False - - def end_of_game_trigger(self) -> None: - """Handle end of game. - - Called by end state when game is over - Hide pattern lines and floor line. - """ - pattern = self.get_object_by_name("PatternLine") - floor = self.get_object_by_name("floor_line") - - pattern.hidden = True - floor.hidden = True - - def reset_position(self) -> None: - """Reset positions of all parts of self based off self.location.""" - x, y = self.location - - board = self.get_object_by_name("Board") - assert isinstance(board, Board) - bw, bh = board.wh - board.location = Vector2(x + bw // 2, y) - - pattern_line = self.get_object_by_name("PatternLine") - assert isinstance(pattern_line, PatternLine) - lw = pattern_line.wh[0] // 2 - pattern_line.location = Vector2(x - lw, y) - - floor_line = self.get_object_by_name("floor_line") - assert isinstance(floor_line, FloorLine) - floor_line.wh[0] - floor_line.location = Vector2( - int(x - lw * (2 / 3) + TILESIZE / 3.75), - int(y + bh * (2 / 3)), - ) + def __init__(self) -> None: + """Initialize Halt State.""" + super().__init__("Halt") - text = self.get_object_by_name("Text") - assert isinstance(text, Text) - text.location = Vector2(x - (bw // 3), y - (bh * 2 // 3)) - - def wall_tiling(self) -> None: - """Do the wall tiling phase of the game for this player.""" - self.is_wall_tiling = True - pattern_line = self.get_object_by_name("PatternLine") - assert isinstance(pattern_line, PatternLine) - board = self.get_object_by_name("Board") - assert isinstance(board, Board) - box_lid = self.game.get_object_by_name("BoxLid") - assert isinstance(box_lid, BoxLid) - - data = pattern_line.wall_tiling() - tiles_for_box = data["tiles_for_box"] - assert isinstance(tiles_for_box, list) - box_lid.add_tiles(tiles_for_box) - del data["tiles_for_box"] - - cleaned = {} - for key, value in data.items(): - if not isinstance(value, Tile): - continue - cleaned[int(key)] = value - - board.wall_tiling_mode(cleaned) - - def done_wall_tiling(self) -> bool: - """Return True if internal Board is done wall tiling.""" - board = self.get_object_by_name("Board") - assert isinstance(board, Board) - return not board.is_wall_tiling() - - def next_round(self) -> None: - """Handle end of wall tiling.""" - self.is_wall_tiling = False - - def score_phase(self) -> Tile | None: - """Do the scoring phase of the game for this player. Return number one tile or None.""" - board = self.get_object_by_name("Board") - floor_line = self.get_object_by_name("floor_line") - box_lid = self.game.get_object_by_name("BoxLid") - assert isinstance(board, Board) - assert isinstance(floor_line, FloorLine) - assert isinstance(box_lid, BoxLid) - - def saturatescore() -> None: - if self.score < 0: - self.score = 0 - - self.score += board.score_additions() - self.score += floor_line.score_tiles() - saturatescore() - - tiles_for_box, number_one = floor_line.get_tiles() - box_lid.add_tiles(tiles_for_box) - - self.update_score() - - return number_one - - def end_of_game_scoring(self) -> None: - """Update final score with additional end of game points.""" - board = self.get_object_by_name("Board") - assert isinstance(board, Board) - - self.score += board.end_of_game_scoreing() - - self.update_score() - - def has_horzontal_line(self) -> bool: - """Return True if this player has a horizontal line on their game board filled.""" - board = self.get_object_by_name("Board") - assert isinstance(board, Board) - - return board.has_filled_row() - - def get_horizontal_lines(self) -> int: - """Return the number of filled horizontal lines this player has on their game board.""" - board = self.get_object_by_name("Board") - assert isinstance(board, Board) - - return board.get_filled_rows() - - def process(self, time_passed: float) -> None: - """Process Player.""" - if not self.is_turn: # Is our turn? - self.set_attr_all("hidden", self.hidden) - super().process(time_passed) - return - if self.hidden and self.is_wall_tiling and self.varient_play: - # If hidden, not anymore. Our turn. - self.hidden = False - if self.networked: # We are networked. - self.set_attr_all("hidden", self.hidden) - super().process(time_passed) - return + async def check_conditions(self) -> None: + """Set active state to None.""" + assert self.machine is not None + await self.machine.set_state(None) - cursor = self.game.get_object_by_name("Cursor") - assert isinstance(cursor, Cursor) - box_lid = self.game.get_object_by_name("BoxLid") - assert isinstance(box_lid, BoxLid) - pattern_line = self.get_object_by_name("PatternLine") - assert isinstance(pattern_line, PatternLine) - floor_line = self.get_object_by_name("floor_line") - assert isinstance(floor_line, FloorLine) - board = self.get_object_by_name("Board") - assert isinstance(board, Board) - - if not cursor.is_pressed(): - # Mouse up - if self.just_held: - self.just_held = False - if self.just_dropped: - self.just_dropped = False - self.set_attr_all("hidden", self.hidden) - super().process(time_passed) - return - # Mouse down - obj, point = self.get_intersection(cursor.location) - if obj is None or point is None: - if self.is_wall_tiling and self.done_wall_tiling(): - self.next_round() - self.game.next_turn() - self.set_attr_all("hidden", self.hidden) - super().process(time_passed) - return - # Something pressed - if cursor.is_holding(): # Cursor holding tiles - move_made = False - if not self.is_wall_tiling: # Is wall tiling: - if obj == "PatternLine": - pos, row_number = point - row = pattern_line.get_row(row_number) - if not row.is_full(): - info = row.get_info(pos) - if info is not None and info.color < 0: - _color, _held = cursor.get_held_info() - todrop = min( - pos + 1, - row.get_placeable(), - ) - tiles = cursor.drop(todrop) - if row.can_place_tiles(tiles): - row.place_tiles(tiles) - move_made = True - else: - cursor.force_hold(tiles) - elif obj == "floor_line": - tiles_to_add = cursor.drop() - if floor_line.is_full(): - # Floor is full, - # Add tiles to box instead. - box_lid.add_tiles(tiles_to_add) - elif floor_line.get_placeable() < len( - tiles_to_add, - ): - # Floor is not full but cannot fit all in floor line. - # Add tiles to floor line and then to box - while len(tiles_to_add) > 0: - if floor_line.get_placeable() > 0: - floor_line.place_tile( - tiles_to_add.pop(), - ) - else: - box_lid.add_tile( - tiles_to_add.pop(), - ) - else: - # Otherwise add to floor line for all. - floor_line.place_tiles(tiles_to_add) - move_made = True - elif not self.just_held and obj == "Board": - tile = board.get_info(point) - assert isinstance(tile, Tile) - if tile.color == -6: - # Cursor holding and wall tiling - _column, row_id = point - cursor_tile = cursor.drop(1)[0] - board_tile = board.get_tile_for_cursor_by_row( - row_id, - ) - if ( - board_tile is not None - and cursor_tile.color == board_tile.color - and board.wall_tile_from_point(point) - ): - self.just_dropped = True - pattern_line.get_row( - row_id, - ).set_background(None) - if move_made and not self.is_wall_tiling: - if cursor.holding_number_one: - one_tile = cursor.drop_one_tile() - assert one_tile is not None - floor_line.place_tile(one_tile) - if cursor.get_held_count(True) == 0: - self.game.next_turn() - elif self.is_wall_tiling and obj == "Board" and not self.just_dropped: - # Mouse down, something pressed, and not holding anything - # Wall tiling, pressed, not holding - _column_number, row_number = point - tile = board.get_tile_for_cursor_by_row( - row_number, - ) - if tile is not None: - cursor.drag([tile]) - self.just_held = True - if self.is_wall_tiling and self.done_wall_tiling(): - self.next_round() - self.game.next_turn() - self.set_attr_all("hidden", self.hidden) - super().process(time_passed) +class GameState(AsyncState["AzulClient"]): + """Checkers Game Asynchronous State base class.""" + __slots__ = ("id", "manager") -class Button(Text): - """Button Object.""" + def __init__(self, name: str) -> None: + """Initialize Game State.""" + super().__init__(name) - textcolor = BUTTONTEXTCOLOR - backcolor = BUTTONBACKCOLOR + self.id: int = 0 + self.manager = ComponentManager(self.name) + + def add_actions(self) -> None: + """Add internal component manager to state machine's component manager.""" + assert self.machine is not None + self.machine.manager.add_component(self.manager) + + def group_add(self, new_sprite: sprite.Sprite) -> None: + """Add new sprite to state machine's group.""" + assert self.machine is not None + group = self.machine.get_group(self.id) + assert group is not None, "Expected group from new group id" + group.add(new_sprite) + self.manager.add_component(new_sprite) + + async def exit_actions(self) -> None: + """Remove group and unbind all components.""" + assert self.machine is not None + self.machine.remove_group(self.id) + self.manager.unbind_components() + self.id = 0 - def __init__( + def change_state( self, - state: MenuState, - name: str, - minimum_size: int = 10, - initial_value: str = "", - font_size: int = BUTTONFONTSIZE, - ) -> None: - """Initialize button.""" - super().__init__(font_size, self.textcolor, background=None) - self.name = f"Button{name}" - self.state = state + new_state: str | None, + ) -> Callable[[Event[Any]], Awaitable[None]]: + """Return an async function that will change state to `new_state`.""" - self.minsize = int(minimum_size) - self.update_value(initial_value) + async def set_state(*args: object, **kwargs: object) -> None: + play_sound("button_click") + await self.machine.set_state(new_state) - self.borderWidth = math.floor(font_size / 12) # 5 + return set_state - self.delay = 0.6 - self.cur_time = 1.0 - self.action: Callable[[], None] = lambda: None +class KwargOutlineText(objects.OutlinedText): + """Outlined objects.Text with attributes settable via keyword arguments.""" - def __repr__(self) -> str: - """Return representation of self.""" - return f"Button({self.name[6:]}, {self.state}, {self.minsize}, {self.font.last_text}, {self.font.pyfont})" - - def get_height(self) -> int: - """Return font height.""" - return self.font.get_height() - - def bind_action(self, function: Callable[[], None]) -> None: - """When self is pressed, call given function exactly once. Function takes no arguments.""" - self.action = function + __slots__ = () - def update_value( + def __init__( self, - text: str | None, - size: int | None = None, - color: tuple[int, int, int] | None = None, - background: tuple[int, int, int] | None = None, - ) -> pygame.surface.Surface: - """Update button text.""" - disp = str(text or "").center(self.minsize) - surface = super().update_value(f" {disp} ", size, color, background) - self.font.last_text = disp - return surface - - def render(self, surface: pygame.surface.Surface) -> None: - """Render button.""" - if not self.hidden: - text_rect = self.get_rect() - # if PYGAME_VERSION < 201: - # pygame.draw.rect(surface, self.backcolor, text_rect) - # pygame.draw.rect(surface, BLACK, text_rect, self.borderWidth) - # else: - pygame.draw.rect( - surface, - self.backcolor, - text_rect, - border_radius=20, - ) - pygame.draw.rect( - surface, - BLACK, - text_rect, - width=self.borderWidth, - border_radius=20, - ) - super().render(surface) - - def is_pressed(self) -> bool: - """Return True if this button is pressed.""" - assert self.state.game is not None - cursor = self.state.game.get_object_by_name("Cursor") - assert isinstance(cursor, Cursor) - return ( - not self.hidden - and cursor.is_pressed() - and self.point_intersects(cursor.location) - ) - - def process(self, time_passed: float) -> None: - """Call self.action one time when pressed, then wait self.delay to call again.""" - if self.cur_time > 0: - self.cur_time = max(self.cur_time - time_passed, 0) - elif self.is_pressed(): - self.action() - self.cur_time = self.delay - if self.font.last_text != self._last: - self.textSize = self.font.pyfont.size(f" {self.font.last_text} ") - super().process(time_passed) - - -class GameState: - """Base class for all game states.""" + name: str, + font: pygame.font.Font, + **kwargs: object, + ) -> None: + """Initialize attributes via keyword arguments.""" + super().__init__(name, font) - __slots__ = ("game", "name") + for key, value in kwargs.items(): + setattr(self, key, value) - def __init__(self, name: str) -> None: - """Initialize state with a name, set self.game to None to be overwritten later.""" - self.game: Game | None = None - self.name = name - def __repr__(self) -> str: - """Return representation of self.""" - return f"<{self.__class__.__name__} {self.name}>" +class KwargButton(objects.Button): + """objects.Button with attributes settable via keyword arguments.""" - def entry_actions(self) -> None: - """Perform entry actions for this GameState.""" - - def do_actions(self) -> None: - """Perform actions for this GameState.""" + __slots__ = () - def check_state(self) -> str | None: - """Check state and return new state. None remains in current state.""" - return None + def __init__( + self, + name: str, + font: pygame.font.Font, + **kwargs: object, + ) -> None: + """Initialize attributes via keyword arguments.""" + super().__init__(name, font) - def exit_actions(self) -> None: - """Perform exit actions for this GameState.""" + for key, value in kwargs.items(): + setattr(self, key, value) class MenuState(GameState): @@ -2815,11 +1432,8 @@ class MenuState(GameState): fontsize = BUTTONFONTSIZE def __init__(self, name: str) -> None: - """Initialize GameState and set up self.bh.""" + """Initialize GameState and set up 30.""" super().__init__(name) - self.bh = Text.get_font_height(FONT, self.fontsize) - - self.next_state: str | None = None def add_button( self, @@ -2829,54 +1443,51 @@ def add_button( location: tuple[int, int] | None = None, size: int = fontsize, minlen: int = button_minimum, - ) -> int: - """Add a new Button object to self.game with arguments. Return button id.""" - button = Button(self, name, minlen, value, size) - button.bind_action(action) - if location is not None: - button.location = Vector2(*location) - assert self.game is not None - self.game.add_object(button) - return button.id + ) -> None: + """Add a new objects.Button object to group.""" + button = KwargButton( + name, + font=pygame.font.Font(FONT, size), + visible=True, + color=Color(0, 0, 0), + text=value, + location=location, + handle_click=action, + ) + self.group_add(button) def add_text( self, name: str, value: str, location: tuple[int, int], - color: tuple[int, int, int] = BUTTONTEXTCOLOR, - cx: bool = True, - cy: bool = True, + color: tuple[int, int, int] = BUTTON_TEXT_COLOR, size: int = fontsize, - ) -> int: - """Add a new Text object to self.game with arguments. Return text id.""" - text = Text(size, color, None, cx, cy, name) - text.location = Vector2(*location) - text.update_value(value) - assert self.game is not None - self.game.add_object(text) - return text.id - - def entry_actions(self) -> None: - """Clear all objects, add cursor object, and set up next_state.""" - self.next_state = None - - assert self.game is not None - self.game.rm_star() - self.game.add_object(Cursor(self.game)) + outline: tuple[int, int, int] = BUTTON_TEXT_OUTLINE, + ) -> None: + """Add a new objects.Text object to self.game with arguments. Return text id.""" + text = KwargOutlineText( + name, + font=pygame.font.Font(FONT, size), + visible=True, + color=color, + text=value, + location=location, + ) + self.group_add(text) def set_var(self, attribute: str, value: object) -> None: """Set MenuState.{attribute} to {value}.""" setattr(self, attribute, value) - def to_state(self, state_name: str) -> Callable[[], None]: + def to_state(self, new_state: str) -> Callable[[], Awaitable[None]]: """Return a function that will change game state to state_name.""" - def to_state_name() -> None: - """Set MenuState.next_state to {state_name}.""" - self.next_state = state_name + async def set_state(*args: object, **kwargs: object) -> None: + play_sound("button_click") + await self.machine.set_state(new_state) - return to_state_name + return set_state def var_dependant_to_state( self, @@ -2901,1067 +1512,715 @@ def to_state_by_attributes() -> None: return to_state_by_attributes - def with_update( - self, - update_function: Callable[[], None], - ) -> Callable[[Callable[[], None]], Callable[[], None]]: - """Return a wrapper for a function that will call update_function after function.""" - - def update_wrapper(function: Callable[[], None]) -> Callable[[], None]: - """Wrap anything that might require a screen update.""" - - @wraps(function) - def function_with_update() -> None: - """Call main function, then update function.""" - function() - update_function() - - return function_with_update - - return update_wrapper - - def update_text( - self, - text_name: str, - value_function: Callable[[], str], - ) -> Callable[[], None]: - """Update text object with text_name's display value.""" - - def updater() -> None: - """Update text object {text_name}'s value with {value_function}.""" - assert self.game is not None - text = self.game.get_object_by_name(f"Text{text_name}") - assert isinstance(text, Text) - text.update_value(value_function()) - return updater - - def toggle_button_state( - self, - textname: str, - boolattr: str, - textfunc: Callable[[bool], str], - ) -> Callable[[], None]: - """Return function that will toggle the value of text object , toggling attribute , and setting text value with textfunc.""" - - def valfunc() -> str: - """Return the new value for the text object. Gets called AFTER value is toggled.""" - return textfunc(getattr(self, boolattr)) - - @self.with_update(self.update_text(textname, valfunc)) - def toggle_value() -> None: - """Toggle the value of boolattr.""" - self.set_var(boolattr, not getattr(self, boolattr)) - - return toggle_value - - def check_state(self) -> str | None: - """Return self.next_state.""" - return self.next_state - - -class InitState(GameState): +class InitializeState(AsyncState["AzulClient"]): """Initialize state.""" __slots__ = () def __init__(self) -> None: """Initialize self.""" - super().__init__("Init") - - def entry_actions(self) -> None: - """Register keyboard handlers.""" - assert self.game is not None - assert self.game.keyboard is not None - self.game.keyboard.add_listener("\x7f", "Delete") - self.game.keyboard.bind_action("Delete", "screenshot", 5) - - self.game.keyboard.add_listener("\x1b", "Escape") - self.game.keyboard.bind_action("Escape", "raise_close", 5) + super().__init__("initialize") - self.game.keyboard.add_listener("0", "Debug") - self.game.keyboard.bind_action("Debug", "debug", 5) - - def check_state(self) -> str: + async def check_conditions(self) -> str: """Go to title state.""" - return "Title" + return "title" -class TitleScreen(MenuState): +class TitleState(MenuState): """Game state when the title screen is up.""" __slots__ = () def __init__(self) -> None: """Initialize title.""" - super().__init__("Title") + super().__init__("title") - def entry_actions(self) -> None: + async def entry_actions(self) -> None: """Set up buttons.""" - super().entry_actions() - sw, sh = SCREENSIZE - self.add_button( - "ToSettings", - "New Game", - self.to_state("Settings"), - (sw // 2, sh // 2 - self.bh // 2), + assert self.machine is not None + self.id = self.machine.new_group("title") + + button_font = pygame.font.Font(FONT, 28) + title_font = pygame.font.Font(FONT, 56) + + title_text = KwargOutlineText( + "title_text", + title_font, + visible=True, + color=Color(0, 0, 0), + outline=(255, 0, 0), + border_width=4, + text=s_("title.game_title"), + ) + title_text.location = (SCREEN_SIZE[0] // 2, title_text.rect.h) + self.group_add(title_text) + + hosting_button = KwargButton( + "hosting_button", + button_font, + visible=True, + color=Color(0, 0, 0), + text=s_("title.host_game"), + location=[x // 2 for x in SCREEN_SIZE], + handle_click=self.change_state("play_hosting"), ) - self.add_button( - "ToCredits", - "Credits", - self.to_state("Credits"), - (sw // 2, sh // 2 + self.bh * 3), - int(self.fontsize / 1.5), + self.group_add(hosting_button) + + join_button = KwargButton( + "join_button", + button_font, + visible=True, + color=Color(0, 0, 0), + text=s_("title.join_game"), + location=hosting_button.location + + Vector2( + 0, + hosting_button.rect.h + 10, + ), + handle_click=self.change_state("play_joining"), ) - assert self.game is not None - self.add_button( - "Quit", - "Quit", - self.game.raise_close, - (sw // 2, sh // 2 + self.bh * 4), - int(self.fontsize / 1.5), + self.group_add(join_button) + + internal_button = KwargButton( + "internal_hosting", + button_font, + visible=True, + color=Color(0, 0, 0), + text=s_("title.singleplayer"), + location=hosting_button.location + - Vector2( + 0, + hosting_button.rect.h + 10, + ), + handle_click=self.change_state("play_hosting_internal"), ) + self.group_add(internal_button) + + quit_button = KwargButton( + "quit_button", + button_font, + visible=True, + color=Color(0, 0, 0), + text=s_("title.quit"), + location=join_button.location + + Vector2( + 0, + join_button.rect.h + 10, + ), + handle_click=self.change_state("Halt"), + ) + self.group_add(quit_button) -class CreditsScreen(MenuState): +class CreditsState(MenuState): """Game state when credits for original game are up.""" __slots__ = () def __init__(self) -> None: """Initialize credits.""" - super().__init__("Credits") + super().__init__("credits") def check_state(self) -> str: """Return to title.""" - return "Title" + return "title" -class SettingsScreen(MenuState): - """Game state when user is defining game type, players, etc.""" +class EndScreen(MenuState): + """End screen state.""" + + __slots__ = () def __init__(self) -> None: - """Initialize settings.""" - super().__init__("Settings") - - self.player_count = 0 # 2 - self.host_mode = True - self.variant_play = False - - def entry_actions(self) -> None: - """Add cursor object and tons of button and text objects to the game.""" - super().entry_actions() - - def add_numbers( - start: int, - end: int, - width_each: int, - cx: int, - cy: int, - ) -> None: - """Add numbers.""" - count = end - start + 1 - evencount = count % 2 == 0 - mid = count // 2 - - def add_number( - number: int, - display: str | int, - ) -> None: - """Add number.""" - if evencount: - if number < mid: - x = number - start - 0.5 - else: - x = number - mid + 0.5 - else: - if number < mid: - x = number - start + 1 - elif number == mid: - x = 0 - else: - x = number - mid - - @self.with_update( - self.update_text( - "Players", - lambda: f"Players: {self.player_count}", - ), - ) - def set_player_count() -> None: - """Set variable player_count to {display} while updating text.""" - return self.set_var("player_count", display) - - self.add_button( - f"SetCount{number}", - str(display), - set_player_count, - (int(cx + (width_each * x)), int(cy)), - size=int(self.fontsize / 1.5), - minlen=3, - ) + """Initialize end screen.""" + super().__init__("End") - for i in range(count): - add_number(i, start + i) - sw, sh = SCREENSIZE - cx = sw // 2 - cy = sh // 2 +class PlayHostingState(AsyncState["AzulClient"]): + """Start running server.""" - def host_text(x: object) -> str: - return f"Host Mode: {x}" + __slots__ = ("address",) - self.add_text( - "Host", - host_text(self.host_mode), - (cx, cy - self.bh * 3), - ) - self.add_button( - "ToggleHost", - "Toggle", - self.toggle_button_state("Host", "host_mode", host_text), - (cx, cy - self.bh * 2), - size=int(self.fontsize / 1.5), + internal_server = False + + def __init__(self) -> None: + """Initialize Play internal hosting / hosting State.""" + extra = "_internal" if self.internal_server else "" + super().__init__(f"play_hosting{extra}") + + async def entry_actions(self) -> None: + """Start hosting server.""" + assert self.machine is not None + self.machine.manager.add_components( + ( + GameServer(self.internal_server), + GameClient("network"), + ), ) - # TEMPORARY: Hide everything to do with "Host Mode", networked games aren't done yet. - assert self.game is not None - self.game.set_attr_all("hidden", True) + host = "localhost" if self.internal_server else await find_ip() + port = DEFAULT_PORT - def varient_text(x: object) -> str: - return f"Variant Play: {x}" + self.address = (host, port) - self.add_text( - "Variant", - varient_text(self.variant_play), - (cx, cy - self.bh), - ) - self.add_button( - "ToggleVarient", - "Toggle", - self.toggle_button_state("Variant", "variant_play", varient_text), - (cx, cy), - size=int(self.fontsize / 1.5), - ) - - self.add_text( - "Players", - f"Players: {self.player_count}", - (cx, cy + self.bh), - ) - add_numbers(2, 4, 70, cx, int(cy + self.bh * 2)) + await self.machine.raise_event(Event("server_start", self.address)) - var_to_state = self.var_dependant_to_state( - FactoryOffer=("host_mode", True), - FactoryOfferNetworked=("host_mode", False), - ) - self.add_button( - "StartGame", - "Start Game", - var_to_state, - (cx, cy + self.bh * 3), + async def exit_actions(self) -> None: + """Have client connect.""" + assert self.machine is not None + await self.machine.raise_event( + Event("client_connect", self.address), ) - def exit_actions(self) -> None: - """Start game.""" - assert self.game is not None - self.game.start_game( - self.player_count, - self.variant_play, - self.host_mode, - ) - self.game.bag.full_reset() + async def check_conditions(self) -> str | None: + """Return to Play state when server is up and running.""" + server: GameServer = self.machine.manager.get_component("GameServer") + return "play" if server.running else None -class PhaseFactoryOffer(GameState): - """Game state when it's the Factory Offer Stage.""" +class PlayInternalHostingState(PlayHostingState): + """Host server with internal server mode.""" __slots__ = () - def __init__(self) -> None: - """Initialize factory offer phase.""" - super().__init__("FactoryOffer") - - def entry_actions(self) -> None: - """Advance turn.""" - assert self.game is not None - self.game.next_turn() - - def check_state(self) -> str | None: - """If all tiles are gone, go to wall tiling. Otherwise keep waiting for that to happen.""" - assert self.game is not None - fact = self.game.get_object_by_name("Factories") - assert isinstance(fact, Factories) - table = self.game.get_object_by_name("TableCenter") - assert isinstance(table, TableCenter) - cursor = self.game.get_object_by_name("Cursor") - assert isinstance(cursor, Cursor) - if ( - fact.is_all_empty() - and table.is_empty() - and not cursor.is_holding(True) - ): - return "WallTiling" - return None + internal_server = True -class PhaseFactoryOfferNetworked(PhaseFactoryOffer): - """Factory offer phase but networked.""" +class ReturnElement(element_list.Element, objects.Button): + """Connection list return to title element sprite.""" __slots__ = () - def __init__(self) -> None: - """Initialize factory offer networked.""" - GameState.__init__(self, "FactoryOfferNetworked") - - def check_state(self) -> str: - """Go to networked wall tiling.""" - return "WallTilingNetworked" - - -class PhaseWallTiling(GameState): - """Wall tiling game phase.""" + def __init__(self, name: str, font: pygame.font.Font) -> None: + """Initialize return element.""" + super().__init__(name, font) - # __slots__ = () - def __init__(self) -> None: - """Initialize will tiling phase.""" - super().__init__("WallTiling") - - def entry_actions(self) -> None: - """Start wall tiling.""" - assert self.game is not None - self.next_starter: int = 0 - self.not_processed = [] - - self.game.player_turn_over() - - # For each player, - for player_id in range(self.game.players): - # Activate wall tiling mode. - player = self.game.get_player(player_id) - player.wall_tiling() - # Add that player's player_id to the list of not-processed players. - self.not_processed.append(player.player_id) - - # Start processing players. - self.game.next_turn() - - def do_actions(self) -> None: - """Do game actions.""" - assert self.game is not None - if self.not_processed: - if self.game.player_turn in self.not_processed: - player = self.game.get_player(self.game.player_turn) - if player.done_wall_tiling(): - # Once player is done wall tiling, score their moves. - # Also gets if they had the number one tile. - number_one = player.score_phase() - - if number_one: - # If player had the number one tile, remember that. - self.next_starter = self.game.player_turn - # Then, add the number one tile back to the table center. - table = self.game.get_object_by_name("TableCenter") - assert isinstance(table, TableCenter) - table.add_number_one_tile() - # After calculating their score, delete player from un-processed list - self.not_processed.remove(self.game.player_turn) - # and continue to the next un-processed player. - self.game.next_turn() - else: - self.game.next_turn() - - def check_state(self) -> str | None: - """Go to next state if ready.""" - assert self.game is not None - cursor = self.game.get_object_by_name("Cursor") - assert isinstance(cursor, Cursor) - if not self.not_processed and not cursor.is_holding(): - return "PrepareNext" - return None + self.update_location_on_resize = False + self.border_width = 4 + self.outline = RED + self.text = s_("connect.return_title") + self.visible = True + self.location = (SCREEN_SIZE[0] // 2, self.location.y + 10) - def exit_actions(self) -> None: - """Update who's turn it is.""" - assert self.game is not None - # Set up the player that had the number one tile to be the starting player next round. - self.game.player_turn_over() - # Goal: make (self.player_turn + 1) % self.players = self.next_starter - nturn = self.next_starter - 1 - if nturn < 0: - nturn += self.game.players - self.game.player_turn = nturn + async def handle_click( + self, + _: Event[sprite.PygameMouseButtonEventData], + ) -> None: + """Handle Click Event.""" + await self.raise_event( + Event("return_to_title", None, 2), + ) -class PhaseWallTilingNetworked(PhaseWallTiling): - """Wall tiling networked state.""" +class ConnectionElement(element_list.Element, objects.Button): + """Connection list element sprite.""" __slots__ = () - def __init__(self) -> None: - """Initialize will tiling networked.""" - GameState.__init__(self, "WallTilingNetworked") + def __init__( + self, + name: tuple[str, int], + font: pygame.font.Font, + motd: str, + ) -> None: + """Initialize connection element.""" + super().__init__(name, font) - def check_state(self) -> str: - """Go to networked next prepare.""" - return "PrepareNextNetworked" + self.text = f"[{name[0]}:{name[1]}]\n{motd}" + self.visible = True + + async def handle_click( + self, + _: Event[sprite.PygameMouseButtonEventData], + ) -> None: + """Handle Click Event.""" + details = self.name + await self.raise_event( + Event("join_server", details, 2), + ) -class PhasePrepareNext(GameState): - """Prepare next phase of game.""" +class PlayJoiningState(GameState): + """Start running client.""" - __slots__ = ("new_round",) + __slots__ = ("font",) def __init__(self) -> None: - """Initialize prepare next state.""" - super().__init__("PrepareNext") - self.new_round = False - - def entry_actions(self) -> None: - """Find out if game continues.""" - assert self.game is not None - players = ( - self.game.get_player(player_id) - for player_id in range(self.game.players) + """Initialize Joining State.""" + super().__init__("play_joining") + + self.font = pygame.font.Font( + FONT, + 12, ) - complete = (player.has_horzontal_line() for player in players) - self.new_round = not any(complete) - - def do_actions(self) -> None: - """Perform actions of state.""" - assert self.game is not None - if self.new_round: - fact = self.game.get_object_by_name("Factories") - assert isinstance(fact, Factories) - # This also handles bag re-filling from box lid. - fact.play_tiles_from_bag() - def check_state(self) -> str: - """Go to factory offer if new round else end screen.""" - if self.new_round: - return "FactoryOffer" - return "End" + async def entry_actions(self) -> None: + """Add game client component.""" + await super().entry_actions() + assert self.machine is not None + self.id = self.machine.new_group("join") + client = GameClient("network") + # Add network to higher level manager + self.machine.manager.add_component(client) -class PhasePrepareNextNetworked(PhasePrepareNext): - """Prepare for next, networked.""" + connections = element_list.ElementList("connection_list") + self.manager.add_component(connections) + group = self.machine.get_group(self.id) + assert group is not None + group.add(connections) - __slots__ = () + return_font = pygame.font.Font( + FONT, + 30, + ) + return_button = ReturnElement("return_button", return_font) + connections.add_element(return_button) + + self.manager.register_handlers( + { + "update_listing": self.handle_update_listing, + "return_to_title": self.handle_return_to_title, + "join_server": self.handle_join_server, + }, + ) - def __init__(self) -> None: - """Initialize prepare for next stage.""" - GameState.__init__(self, "PrepareNextNetworked") + await self.manager.raise_event(Event("update_listing", None)) - def check_state(self) -> str: - """Go to networked end.""" - return "EndNetworked" + async def handle_update_listing(self, _: Event[None]) -> None: + """Update server listing.""" + assert self.machine is not None + connections = self.manager.get_component("connection_list") -class EndScreen(MenuState): - """End screen state.""" + old: list[tuple[str, int]] = [] + current: list[tuple[str, int]] = [] - def __init__(self) -> None: - """Initialize end screen.""" - super().__init__("End") - self.ranking: dict[int, list[int]] = {} - self.wininf = "" - - def get_winners(self) -> None: - """Update self.ranking by player scores.""" - assert self.game is not None - self.ranking.clear() - scpid = {} - for player_id in range(self.game.players): - player = self.game.get_player(player_id) - assert isinstance(player, Player) - player.end_of_game_trigger() - if player.score not in scpid: - scpid[player.score] = [player_id] - else: - scpid[player.score] += [player_id] - # make sure no ties and establish rank - rank = 1 - for score in sorted(scpid, reverse=True): - pids = scpid[score] - if len(pids) > 1: - # If players have same score, - # most horizontal lines is tie breaker. - players = [ - self.game.get_player(player_id) for player_id in pids - ] - lines = [ - (p.get_horizontal_lines(), p.player_id) for p in players - ] - last = None - for c, player_id in sorted( - lines, - key=operator.itemgetter(0), - reverse=True, - ): - if last == c: - self.ranking[rank - 1] += [player_id + 1] - continue - last = c - self.ranking[rank] = [player_id + 1] - rank += 1 - else: - self.ranking[rank] = [pids[0] + 1] - rank += 1 - # Finally, make nice text. - text = "" - for rank in sorted(self.ranking): - line = "Player" - players_rank = self.ranking[rank] - cnt = len(players_rank) - if cnt > 1: - line += "s" - line += " " - if cnt == 1: - line += "{}" - elif cnt == 2: - line += "{} and {}" - elif cnt >= 3: - tmp = (["{}"] * (cnt - 1)) + ["and {}"] - line += ", ".join(tmp) - line += " " - if cnt == 1: - line += "got" - else: - line += "tied for" - line += " " - if rank <= 2: - line += ("1st", "2nd")[rank - 1] - else: - line += f"{rank}th" - line += " place!\n" - text += line.format(*players_rank) - self.wininf = text[:-1] - - def entry_actions(self) -> None: - """Set up end screen.""" - assert self.game is not None - # Figure out who won the game by points. - self.get_winners() - # Hide everything - table = self.game.get_object_by_name("TableCenter") - assert isinstance(table, TableCenter) - table.hidden = True - - fact = self.game.get_object_by_name("Factories") - assert isinstance(fact, Factories) - fact.set_attr_all("hidden", True) - - # Add buttons - bid = self.add_button( - "ReturnTitle", - "Return to Title", - self.to_state("Title"), - (SCREENSIZE[0] // 2, SCREENSIZE[1] * 4 // 5), - ) - buttontitle = self.game.get_object(bid) - assert isinstance(buttontitle, Button) - buttontitle.Render_Priority = "last-1" - buttontitle.cur_time = 2 - - # Add score board - x = SCREENSIZE[0] // 2 - y = 10 - for idx, line in enumerate(self.wininf.split("\n")): - self.add_text(f"Line{idx}", line, (x, y), cx=True, cy=False) - # self.game.get_object(bid).Render_Priority = f'last{-(2+idx)}' - button = self.game.get_object(bid) - assert isinstance(button, Button) - button.Render_Priority = "last-2" - y += self.bh - - -class EndScreenNetworked(EndScreen): - """Networked end screen.""" + # print(f'{self.machine.active_state = }') + # print(f'{self.name = }') + while ( + self.machine.active_state is not None + and self.machine.active_state is self + ): + # print("handle_update_listing click") - def __init__(self) -> None: - """Initialize end screen.""" - MenuState.__init__(self, "EndNetworked") - self.ranking = {} - self.wininf = "" + for motd, details in await read_advertisements(): + current.append(details) + if connections.component_exists(details): + continue + element = ConnectionElement(details, self.font, motd) + element.rect.topleft = ( + connections.get_new_connection_position() + ) + element.rect.topleft = (10, element.location.y + 3) + connections.add_element(element) + for details in old: + if details in current: + continue + connections.delete_element(details) + old, current = current, [] + + async def handle_join_server(self, event: Event[tuple[str, int]]) -> None: + """Handle join server event.""" + details = event.data + await self.machine.raise_event( + Event("client_connect", details), + ) + await self.machine.set_state("play") - def check_state(self) -> str: - """Go to title.""" - return "Title" + async def handle_return_to_title(self, _: Event[None]) -> None: + """Handle return to title event.""" + # Fire server stop event so server shuts down if it exists + await self.machine.raise_event_internal(Event("network_stop", None)) + if self.machine.manager.component_exists("network"): + self.machine.manager.remove_component("network") -class Game(ObjectHandler): - """Game object, contains most of what's required for Azul.""" + await self.machine.set_state("title") - tile_size = 30 - def __init__(self) -> None: - """Initialize game.""" - super().__init__() - # Gets overwritten by Keyboard object - self.keyboard: Keyboard | None = None +class PlayState(GameState): + """Game Play State.""" - self.states: dict[str, GameState] = {} - self.active_state: GameState | None = None + __slots__ = ("current_turn", "exit_data") - self.add_states( - [ - InitState(), - TitleScreen(), - CreditsScreen(), - SettingsScreen(), - PhaseFactoryOffer(), - PhaseWallTiling(), - PhasePrepareNext(), - EndScreen(), - PhaseFactoryOfferNetworked(), - PhaseWallTilingNetworked(), - PhasePrepareNextNetworked(), - EndScreenNetworked(), - ], + def __init__(self) -> None: + """Initialize Play State.""" + super().__init__("play") + + self.current_turn: int = 0 + + # (0: normal | 1: error) + self.exit_data: tuple[int, str, bool] | None = None + + def register_handlers(self) -> None: + """Register event handlers.""" + self.manager.register_handlers( + { + "game_initial_config": self.handle_game_initial_config, + "client_disconnected": self.handle_client_disconnected, + "game_winner": self.handle_game_over, + }, ) - self.initialized_state = False - self.background_color = BACKGROUND + def add_actions(self) -> None: + """Register handlers.""" + super().add_actions() + self.register_handlers() - self.is_host = True - self.players = 0 - self.factories = 0 + async def entry_actions(self) -> None: + """Add GameBoard and raise init event.""" + assert self.machine is not None + if self.id == 0: + self.id = self.machine.new_group("play") - self.player_turn: int = 0 + self.group_add(Cursor()) - # Tiles - self.bag = Bag(TILECOUNT, REGTILECOUNT) + center = TableCenter() + center.location = Vector2.from_iter(SCREEN_SIZE) // 2 + self.group_add(center) - # # Cache - # self.cache: dict[int, pygame.surface.Surface] = {} + # self.group_add(()) + ##gameboard = GameBoard( + ## 45, + ##) + ##gameboard.location = [x // 2 for x in SCREEN_SIZE] + ##self.group_add(gameboard) - def __repr__(self) -> str: - """Return representation of self.""" - return f"{self.__class__.__name__}()" - - def debug(self) -> None: - """Debug.""" + async def handle_game_initial_config( + self, + event: Event[tuple[bool, int, int, int, NDArray[int8]]], + ) -> None: + """Handle `game_initial_config` event.""" + ( + _variant_play, + player_count, + factory_count, + self.current_turn, + floor_line_data, + ) = event.data + + center = Vector2.from_iter(SCREEN_SIZE) // 2 + + # Add factories + each = 360 / factory_count + degrees: float = -90 + for index in range(factory_count): + factory = Factory(index) + factory.location = vec2_to_location( + Vector2.from_degrees( + degrees, + 145, + ) + + center, + ) + self.group_add(factory) + + degrees += each + + # Add players + each = 360 / player_count + degrees = -(90 / player_count) + for index in range(player_count): + board = Board(index) + board.rect.midleft = vec2_to_location( + Vector2.from_degrees( + degrees, + 300, + ) + + center, + ) + self.group_add(board) - def screenshot(self) -> None: - """Save a screenshot of this game's most recent frame.""" - surface = pygame.surface.Surface(SCREENSIZE) - self.render(surface) - str_time = "-".join(time.asctime().split(" ")) - filename = f"Screenshot_at_{str_time}.png" + pattern_rows = PatternRows(index) + pattern_rows.rect.bottomright = board.rect.bottomleft + if index == self.current_turn: + pattern_rows.set_background(DARKGREEN) + self.group_add(pattern_rows) - if not os.path.exists("Screenshots"): - os.mkdir("Screenshots") + floor_line = FloorLine(index, floor_line_data) + floor_line.rect.topleft = pattern_rows.rect.bottomleft + self.group_add(floor_line) - surface.unlock() - pygame.image.save( - surface, - os.path.join("Screenshots", filename), - filename, - ) - del surface + degrees += each - savepath = os.path.join(os.getcwd(), "Screenshots") + async def check_conditions(self) -> str | None: + """Return to title if client component doesn't exist.""" + if not self.machine.manager.component_exists("network"): + return "title" + return None - print(f'Saved screenshot as "{filename}" in "{savepath}".') + async def exit_actions(self) -> None: + """Raise network stop event and remove components.""" + # Fire server stop event so server shuts down if it exists + # await self.machine.raise_event(Event("network_stop", None)) + await self.machine.raise_event_internal(Event("network_stop", None)) - def raise_close(self) -> None: - """Raise a window close event.""" - pygame.event.post(pygame.event.Event(QUIT)) + if self.machine.manager.component_exists("network"): + self.machine.manager.remove_component("network") + if self.machine.manager.component_exists("GameServer"): + self.machine.manager.remove_component("GameServer") - def add_states(self, states: Iterable[GameState]) -> None: - """Add game states to self.""" - for state in states: - if not isinstance(state, GameState): - raise ValueError( - f'"{state}" Object is not a subclass of GameState!', - ) - state.game = self - self.states[state.name] = state - - def set_state(self, new_state_name: str) -> None: - """Change states and perform any exit / entry actions.""" - # Ensure the new state is valid. - if new_state_name not in self.states: - raise ValueError(f'State "{new_state_name}" does not exist!') - - # If we have an active state, - if self.active_state is not None: - # Perform exit actions - self.active_state.exit_actions() - - # The active state is the new state - self.active_state = self.states[new_state_name] - # Perform entry actions for new active state - self.active_state.entry_actions() - - def update_state(self) -> None: - """Perform the actions of the active state and potentially change states.""" - # Only continue if there is an active state - if self.active_state is None: - return + # Unbind components and remove group + await super().exit_actions() - # Perform the actions of the active state and check conditions - self.active_state.do_actions() - - new_state_name = self.active_state.check_state() - if new_state_name is not None: - self.set_state(new_state_name) - - def add_object(self, obj: Object) -> None: - """Add an object to the game.""" - obj.game = self - super().add_object(obj) - - def render(self, surface: pygame.surface.Surface) -> None: - """Render all of self.objects to the screen.""" - surface.fill(self.background_color) - self.render_objects(surface) - - def process(self, time_passed: float) -> None: - """Process all the objects and self.""" - if not self.initialized_state and self.keyboard is not None: - self.set_state("Init") - self.initialized_state = True - self.process_objects(time_passed) - self.update_state() - - def get_player(self, player_id: int) -> Player: - """Get the player with player id player_id.""" - if self.players: - player = self.get_object_by_name(f"Player{player_id}") - assert isinstance(player, Player) - return player - raise RuntimeError("No players!") - - def player_turn_over(self) -> None: - """Call end_of_turn for current player.""" - if self.player_turn >= 0 and self.player_turn < self.players: - old_player = self.get_player(self.player_turn) - if old_player.is_turn: - old_player.end_of_turn() - - def next_turn(self) -> None: - """Tell current player it's the end of their turn, and update who's turn it is and now it's their turn.""" - if self.is_host: - self.player_turn_over() - last = self.player_turn - self.player_turn = (self.player_turn + 1) % self.players - if self.player_turn == last and self.players > 1: - self.next_turn() - return - new_player = self.get_player(self.player_turn) - new_player.trigger_turn_now() + self.register_handlers() - def start_game( - self, - players: int, - varient_play: bool = False, - host_mode: bool = True, - address: str = "", - ) -> None: - """Start a new game.""" - self.reset_cache() - max_players = 4 - self.players = saturate(players, 1, max_players) - self.is_host = host_mode - self.factories = self.players * 2 + 1 - - self.rm_star() - - self.add_object(Cursor(self)) - self.add_object(TableCenter(self)) - self.add_object(BoxLid(self)) - - if self.is_host: - self.bag.reset() - # S311 Standard pseudo-random generators are not suitable for cryptographic purposes - self.player_turn = random.randint( # noqa: S311 - -1, - self.players - 1, - ) - else: - raise NotImplementedError() + assert self.manager.has_handler("game_winner") - cx, cy = SCREENSIZE[0] / 2, SCREENSIZE[1] / 2 - out = math.sqrt(cx**2 + cy**2) // 3 * 2 + async def handle_game_over(self, event: Event[int]) -> None: + """Handle game over event.""" + winner = event.data + self.exit_data = (0, s_("play.win", winner=winner), False) - mdeg = 360 // max_players + await self.machine.raise_event_internal(Event("network_stop", None)) - for player_id in range(self.players): - networked = False - newp = Player(self, player_id, networked, varient_play) + async def handle_client_disconnected(self, event: Event[str]) -> None: + """Handle client disconnected error.""" + error = event.data + print(f"[azul.game.PlayState] handle_client_disconnected {error = }") - truedeg = (self.players + 1 - player_id) * (360 / self.players) - closedeg = truedeg // mdeg * mdeg + 45 - rad = math.radians(closedeg) + client_disconnected = s_("error.client_disconnected") + error_text = s_(error) + self.exit_data = (1, f"{client_disconnected}$${error_text}", False) - newp.location = Vector2( - round(cx + out * math.sin(rad)), - round( - cy + out * math.cos(rad), - ), - ) - self.add_object(newp) - if self.is_host: - self.next_turn() + async def do_actions(self) -> None: + """Perform actions for this State.""" + # print(f"{self.__class__.__name__} do_actions tick") + if self.exit_data is None: + return - factory = Factories(self, self.factories) - factory.location = Vector2(cx, cy) - self.add_object(factory) - self.process_objects(0) + exit_status, raw_message, handled = self.exit_data - if self.is_host: - self.next_turn() + if handled: + return + self.exit_data = (exit_status, raw_message, True) - def screen_size_update(self) -> None: - """Handle screen size updates.""" - objs_with_attr = self.get_objects_with_attr("screen_size_update") - for oid in objs_with_attr: - obj = self.get_object(oid) - assert obj is not None - obj.screen_size_update() + font = pygame.font.Font( + FONT, + 28, + ) + error_message = "" + if exit_status == 1: + message, error_message = raw_message.split("$$", 1) + else: + message = raw_message + + if not self.manager.component_exists("continue_button"): + continue_button = KwargButton( + "continue_button", + font, + visible=True, + color=Color(0, 0, 0), + text=s_("play.return_title_msg", message=message), + location=[x // 2 for x in SCREEN_SIZE], + handle_click=self.change_state("title"), + ) + self.group_add(continue_button) + group = continue_button.groups()[0] + # LayeredDirty, not just AbstractGroup + group.move_to_front(continue_button) # type: ignore[attr-defined] + else: + continue_button = self.manager.get_component("continue_button") -class Keyboard: - """Keyboard object, handles keyboard input.""" + if exit_status == 1: + if not self.manager.component_exists("error_text"): + error_text = objects.OutlinedText("error_text", font) + error_text.text = "" + else: + error_text = self.manager.get_component("error_text") + error_text.visible = True + error_text.color = Color(255, 0, 0) + error_text.border_width = 1 + error_text.text += error_message + "\n" + error_text.location = continue_button.location + Vector2( + 0, + continue_button.rect.h + 10, + ) - __slots__ = ("actions", "active", "delay", "keys", "target", "time") + if not self.manager.component_exists("error_text"): + self.group_add(error_text) - def __init__( - self, - target: Game, - **kwargs: tuple[str, str], - ) -> None: - """Initialize keyboard.""" - self.target = target - self.target.keyboard = self - - # Map of keyboard events to names - self.keys: dict[str, str] = {} - # Map of keyboard event names to functions - self.actions: dict[str, Callable[[], None]] = {} - # Map of names to time until function should be called again - self.time: dict[str, float] = {} - # Map of names to duration timer waits for function recalls - self.delay: dict[str, float | None] = {} - # Map of names to boolian of pressed or not - self.active: dict[str, bool] = {} - - for name in kwargs: - if not hasattr(kwargs[name], "__iter__"): - raise ValueError( - "Keyword arguments must be given as name=[key, self.target.function_name, delay]", - ) - # if len(kwargs[name]) == 2: - key, function_name = kwargs[name] - # elif len(kwargs[name]) == 3: - # key, function_name, _delay = kwargs[name] - # else: - # raise ValueError - self.add_listener(key, name) - self.bind_action(name, function_name) - def __repr__(self) -> str: - """Return representation of self.""" - return f"{self.__class__.__name__}({self.target!r})" +class AzulClient(sprite.GroupProcessor): + """Azul Game Client.""" - def is_pressed(self, key: str) -> bool: - """Return True if is pressed.""" - return self.active.get(key, False) + __slots__ = ("manager",) - def add_listener(self, key: str, name: str) -> None: - """Listen for key down events with event.key == key argument and when that happens set self.actions[name] to true.""" - self.keys[key] = name # key to name - self.actions[name] = lambda: None # name to function - self.time[name] = 0 # name to time until function recall - self.delay[name] = None # name to function recall delay - self.active[name] = False # name to boolian of pressed + def __init__(self, manager: ExternalRaiseManager) -> None: + """Initialize Checkers Client.""" + super().__init__() + self.manager = manager - def get_function_from_target( - self, - function_name: str, - ) -> Callable[[], None]: - """Return function with name function_name from self.target.""" - if hasattr(self.target, function_name): - attribute = getattr(self.target, function_name) - assert callable(attribute) - return cast("Callable[[], None]", attribute) - return lambda: None - - def bind_action( - self, - name: str, - target_function_name: str, - delay: float | None = None, - ) -> None: - """Bind an event we are listening for to calling a function, can call multiple times if delay is not None.""" - self.actions[name] = self.get_function_from_target( - target_function_name, + self.add_states( + ( + HaltState(), + InitializeState(), + TitleState(), + CreditsState(), + PlayHostingState(), + PlayInternalHostingState(), + PlayJoiningState(), + PlayState(), + ), ) - self.delay[name] = delay - - def set_active(self, name: str, value: bool) -> None: - """Set active value for key name to .""" - if name in self.active: - self.active[name] = bool(value) - if not value: - self.time[name] = 0 - - def set_key(self, key: str, value: bool) -> None: - """Set active value for key to .""" - if key in self.keys: - self.set_active(self.keys[key], value) - - # elif isinstance(key, int) and key < 0x110000: - # self.set_key(chr(key), value) - - def read_event(self, event: pygame.event.Event) -> None: - """Handle an event.""" - if event.type == KEYDOWN: - self.set_key(event.key, True) - elif event.type == KEYUP: - self.set_key(event.key, False) - - def read_events(self, events: Iterable[pygame.event.Event]) -> None: - """Handle a list of events.""" - for event in events: - self.read_event(event) - - def process(self, time_passed: float) -> None: - """Send commands to self.target based on pressed keys and time.""" - for name in self.active: - if self.active[name]: - self.time[name] = max(self.time[name] - time_passed, 0) - if self.time[name] == 0: - self.actions[name]() - delay = self.delay[name] - if delay is not None: - self.time[name] = delay - else: - self.time[name] = math.inf - - -def network_shutdown() -> None: - """Handle network shutdown.""" + async def raise_event(self, event: Event[Any]) -> None: + """Raise component event in all groups.""" + await self.manager.raise_event(event) -def run() -> None: + async def raise_event_internal(self, event: Event[Any]) -> None: + """Raise component event in all groups.""" + await self.manager.raise_event_internal(event) + + +async def async_run() -> None: """Run program.""" - # global game - global SCREENSIZE + # Set up globals + global SCREEN_SIZE + # Set up the screen - screen = pygame.display.set_mode(SCREENSIZE, RESIZABLE, 16) - pygame.display.set_caption(f"{__title__} {__version__}") + screen = pygame.display.set_mode(SCREEN_SIZE, RESIZABLE, 16, vsync=VSYNC) + pygame.display.set_caption(f"{__title__} v{__version__}") # pygame.display.set_icon(pygame.image.load('icon.png')) - pygame.display.set_icon(get_tile_image(Tile(5), 32)) - - # Set up the FPS clock - clock = pygame.time.Clock() - - game = Game() - keyboard = Keyboard(game) - - music_end = USEREVENT + 1 # This event is sent when a music track ends - - # Set music end event to our new event - pygame.mixer.music.set_endevent(music_end) - - # Load and start playing the music - # pygame.mixer.music.load('sound/') - # pygame.mixer.music.play() - - running = True - - # While the game is active - while running: - # Event handler - for event in pygame.event.get(): - if event.type == QUIT: - running = False - elif event.type == music_end: - # If the music ends, stop it and play it again. - pygame.mixer.music.stop() - pygame.mixer.music.play() - elif event.type == VIDEORESIZE: - SCREENSIZE = event.size - game.screen_size_update() - else: - # If it's not a quit or music end event, tell the keyboard handler about it. - keyboard.read_event(event) + pygame.display.set_icon(get_tile_image(Tile.one, 32)) + screen.fill((0xFF, 0xFF, 0xFF)) + + ## try: + async with trio.open_nursery() as main_nursery: + event_manager = ExternalRaiseManager( + "azul", + main_nursery, # "client" + ) + client = AzulClient(event_manager) + + background = pygame.image.load( + DATA_FOLDER / "background.png", + ).convert() + client.clear(screen, background) + + client.set_timing_threshold(1000 / 80) + + await client.set_state("initialize") + + music_end = USEREVENT + 1 # This event is sent when a music track ends + + # Set music end event to our new event + pygame.mixer.music.set_endevent(music_end) + + # Load and start playing the music + # pygame.mixer.music.load('sound/') + # pygame.mixer.music.play() + + clock = Clock() + + resized_window = False + while client.running: + async with trio.open_nursery() as event_nursery: + for event in pygame.event.get(): + if event.type == QUIT: + await client.set_state("Halt") + elif event.type == KEYUP and event.key == K_ESCAPE: + pygame.event.post(pygame.event.Event(QUIT)) + elif event.type == music_end: + # If the music ends, stop it and play it again. + pygame.mixer.music.stop() + pygame.mixer.music.play() + elif event.type == WINDOWRESIZED: + SCREEN_SIZE = (event.x, event.y) + resized_window = True + sprite_event = sprite.convert_pygame_event(event) + # print(sprite_event) + event_nursery.start_soon( + event_manager.raise_event, + sprite_event, + ) + event_nursery.start_soon(client.think) + event_nursery.start_soon(clock.tick, FPS) + + await client.raise_event( + Event( + "tick", + sprite.TickEventData( + time_passed=clock.get_time() + / 1e9, # nanoseconds -> seconds + fps=clock.get_fps(), + ), + ), + ) - # Get the time passed from the FPS clock - time_passed = clock.tick(FPS) - time_passed_secconds = time_passed / 1000 + if resized_window: + resized_window = False + screen.fill((0xFF, 0xFF, 0xFF)) + rects = [Rect((0, 0), SCREEN_SIZE)] + client.repaint_rect(rects[0]) + rects.extend(client.draw(screen)) + else: + rects = client.draw(screen) + pygame.display.update(rects) + client.clear_groups() - # Process the game - game.process(time_passed_secconds) - keyboard.process(time_passed_secconds) + # Once the game has ended, stop the music + pygame.mixer.music.stop() - # Render the grid to the screen. - game.render(screen) - # Update the display - pygame.display.update() - # Once the game has ended, stop the music and de-initalize pygame. - pygame.mixer.music.stop() +def run() -> None: + """Start asynchronous run.""" + trio.run(async_run) -def save_crash_img() -> None: +def screenshot_last_frame() -> None: """Save the last frame before the game crashed.""" surface = pygame.display.get_surface().copy() - str_time = "-".join(time.asctime().split(" ")) + str_time = "_".join(time.asctime().split(" ")).replace(":", "_") filename = f"Crash_at_{str_time}.png" - if not os.path.exists("Screenshots"): - os.mkdir("Screenshots") + path = Path("screenshots").absolute() + if not path.exists(): + os.mkdir(path) - # surface.lock() - pygame.image.save(surface, os.path.join("Screenshots", filename), filename) - # surface.unlock() - del surface + fullpath = path / filename - savepath = os.path.join(os.getcwd(), "Screenshots") + pygame.image.save(surface, fullpath, filename) + del surface - print(f'Saved screenshot as "{filename}" in "{savepath}".') + print(s_("screenshot_save", fullpath=fullpath)) def cli_run() -> None: """Run from command line interface.""" - # Linebreak before, as pygame prints a message on import. - print(f"\n{__title__} v{__version__}\nProgrammed by {__author__}.") + print(f"{__title__} v{__version__}\nProgrammed by {__author__}.\n") + + # Make sure the game will display correctly on high DPI monitors on Windows. + if sys.platform == "win32": + from ctypes import windll + + with contextlib.suppress(AttributeError): + windll.user32.SetProcessDPIAware() + del windll + + exception: str | None = None try: # Initialize Pygame _success, fail = pygame.init() if fail > 0: - print( - "Warning! Some modules of Pygame have not initialized properly!", - ) - print( - "This can occur when not all required modules of SDL, which pygame utilizes, are installed.", - ) + print(s_("error.pygame_uninitialized")) run() - # except BaseException as ex: - # reraise = True#False - ## - # print('Debug: Activating Post motem.') - # import pdb - # pdb.post_mortem() - ## - # try: - # save_crash_img() - # except BaseException as svex: - # print(f'Could not save crash screenshot: {", ".join(svex.args)}') - # try: - # import errorbox - # except ImportError: - # reraise = True - # print(f'A {type(ex).__name__} Error Has Occored: {", ".join(ex.args)}') - # else: - # errorbox.errorbox('Error', f'A {type(ex).__name__} Error Has Occored: {", ".join(ex.args)}') - # if reraise: - # raise + except ExceptionGroup as exc: + ## print(exc) + exception = "".join(traceback.format_exception(exc)) + ## print(exception) + ## raise + ## except BaseException as ex: + ## screenshot_last_frame() + ## # errorbox.errorbox('Error', f'A {type(ex).__name__} Error Has Occored: {", ".join(ex.args)}') + ## raise finally: pygame.quit() - network_shutdown() + if exception is not None: + print(exception, file=sys.stderr) if __name__ == "__main__": diff --git a/src/azul/keyboard.py b/src/azul/keyboard.py index daeda5b..a305365 100644 --- a/src/azul/keyboard.py +++ b/src/azul/keyboard.py @@ -9,7 +9,7 @@ __version__ = "0.0.0" -from azul.component import ComponentManager +from libcomponent.component import ComponentManager class Keyboard(ComponentManager): diff --git a/src/azul/lang.py b/src/azul/lang.py deleted file mode 100644 index b97d6c0..0000000 --- a/src/azul/lang.py +++ /dev/null @@ -1,30 +0,0 @@ -"""Language file handler.""" - -from __future__ import annotations - -# Programmed by CoolCat467 - -__title__ = "lang" -__author__ = "CoolCat467" -__version__ = "0.0.0" - -import json -from functools import cache -from os.path import exists, join - - -def load_json(filename: str) -> dict[str, str]: - """Return json data loaded from filename.""" - with open(filename, encoding="utf-8") as loaded: - data = json.load(loaded) - assert isinstance(data, dict) - return data - - -@cache -def load_lang(name: str) -> dict[str, str] | None: - """Return full data for language with given name.""" - filename = join("lang", f"{name}.json") - if not exists(filename): - return None - return load_json(filename) diff --git a/src/azul/lang/en_us.json b/src/azul/lang/en_us.json index 67ac85b..35b1356 100644 --- a/src/azul/lang/en_us.json +++ b/src/azul/lang/en_us.json @@ -1,24 +1,25 @@ { - "main_menu": { - "title": "Azul", - "host_server": "Host Game", - "join_server": "Join Game", - "close": "Close" - }, - "host_server_menu": { - "title": "Host Server", - "port_input": "Enter Host Port ({})", - "start_server": "Start Server", + "connect": { "return_title": "Return to Title" }, - "join_server_menu": { - "title": "Join Server", - "host_input": "Enter Host Address", - "port_input": "Enter Host Port ({})", - "join_server": "Connect to Server", - "return_title": "Return to Title" + "error": { + "client_disconnected": "Client Disconnected", + "not_connected": "Not connected to server.", + "pygame_uninitialized": "Warning! Some modules of Pygame have not initialized properly!\nThis can occur when not all required modules of SDL are installed.", + "read_event_fail": "Failed to read event from server.", + "socket_connect_fail": "Error connecting to server.", + "socket_eof": "Server closed connection." + }, + "play": { + "return_title_msg": "{message} - Return to Title", + "win": "{winner} Won" }, - "connect_server": { - "connecting": "Connecting to Server..." + "screenshot_save": "Saved screenshot to \"{fullpath}\".", + "title": { + "game_title": "Azul", + "host_game": "Host Networked Game", + "join_game": "Join Networked Game", + "quit": "Quit", + "singleplayer": "Singleplayer Game" } } diff --git a/src/azul/mr_floppy_test.py b/src/azul/mr_floppy_test.py new file mode 100644 index 0000000..3b55725 --- /dev/null +++ b/src/azul/mr_floppy_test.py @@ -0,0 +1,513 @@ +"""Azul Client.""" + +from __future__ import annotations + +import contextlib + +# Programmed by CoolCat467 +# Hide the pygame prompt +import os +import sys +from os import path +from pathlib import Path +from typing import TYPE_CHECKING, Any, Final + +import trio +from libcomponent.component import Component, ComponentManager, Event +from pygame.locals import K_ESCAPE, KEYUP, QUIT, RESIZABLE, WINDOWRESIZED +from pygame.rect import Rect + +from azul import objects, sprite +from azul.statemachine import AsyncState, AsyncStateMachine +from azul.vector import Vector2 + +os.environ["PYGAME_HIDE_SUPPORT_PROMPT"] = "True" +if os.environ["PYGAME_HIDE_SUPPORT_PROMPT"]: + import pygame +del os + + +if TYPE_CHECKING: + from collections.abc import Iterator, Sequence + +__title__ = "Azul Client" +__author__ = "CoolCat467" +__version__ = "2.0.0" + +SCREEN_SIZE = Vector2(800, 600) +FPS = 30 +# FPS = 60 +VSYNC = True +# PORT = server.PORT + +ROOT_FOLDER: Final = Path(__file__).absolute().parent +DATA_FOLDER: Final = ROOT_FOLDER / "data" +FONT_FOLDER: Final = ROOT_FOLDER / "fonts" + +FONT = FONT_FOLDER / "RuneScape-UF-Regular.ttf" + + +class GameClient(sprite.GroupProcessor, AsyncStateMachine): + """Gear Runner and Layered Dirty Sprite group handler.""" + + def __init__(self) -> None: + """Initialize azul client.""" + sprite.GroupProcessor.__init__(self) + AsyncStateMachine.__init__(self) + + self.add_states( + ( + HaltState(), + AzulInitialize(), + ), + ) + + @property + def running(self) -> bool: + """Boolean of if state machine is running.""" + return self.active_state is not None + + async def raise_event(self, event: Event[Any]) -> None: + """Raise component event in all groups.""" + if self.active_state is None: + return + manager = getattr(self.active_state, "manager", None) + assert isinstance(manager, ComponentManager | None) + if manager is None: + return + await manager.raise_event(event) + + +class AzulState(AsyncState[GameClient]): + """Azul Client Asynchronous base class.""" + + __slots__ = ("id", "manager") + + def __init__(self, name: str) -> None: + """Initialize azul state.""" + super().__init__(name) + + self.id: int = 0 + self.manager = ComponentManager(self.name) + + +class HaltState(AzulState): + """Halt state to set state to None so running becomes False.""" + + def __init__(self) -> None: + """Initialize halt state.""" + super().__init__("Halt") + + async def check_conditions(self) -> None: + """Set active state to None.""" + await self.machine.set_state(None) + + +class ClickDestinationComponent(Component): + """Component that will use targeting to go to wherever you click on the screen.""" + + __slots__ = ("selected",) + outline = pygame.color.Color(255, 220, 0) + + def __init__(self) -> None: + """Initialize click destination component.""" + super().__init__("click_dest") + + self.selected = False + + def bind_handlers(self) -> None: + """Register PygameMouseButtonDown and tick handlers.""" + self.register_handlers( + { + "click": self.click, + # "drag": self.drag, + "PygameMouseButtonDown": self.mouse_down, + "tick": self.move_towards_dest, + "init": self.cache_outline, + "test": self.test, + }, + ) + + async def test(self, event: Event[object]) -> None: + """Print out event data.""" + print(f"{event = }") + + await trio.lowlevel.checkpoint() + + async def cache_outline(self, _: Event[None]) -> None: + """Precalculate outlined images.""" + image: sprite.ImageComponent = self.get_component("image") + outline: sprite.OutlineComponent = image.get_component("outline") + outline.precalculate_all_outlined(self.outline) + + await trio.lowlevel.checkpoint() + + async def update_selected(self) -> None: + """Update selected.""" + image: sprite.ImageComponent = self.get_component("image") + outline: sprite.OutlineComponent = image.get_component("outline") + + color = (None, self.outline)[int(self.selected)] + outline.set_color(color) + + if not self.selected: + movement: sprite.MovementComponent = self.get_component("movement") + movement.speed = 0 + + await trio.lowlevel.checkpoint() + + async def click( + self, + event: Event[sprite.PygameMouseButtonEventData], + ) -> None: + """Toggle selected.""" + if event.data["button"] != 1: + await trio.lowlevel.checkpoint() + return + self.selected = not self.selected + + await self.update_selected() + + async def drag(self, event: Event[None]) -> None: + """Drag sprite.""" + if not self.selected: + self.selected = True + await self.update_selected() + movement: sprite.MovementComponent = self.get_component("movement") + movement.speed = 0 + + await trio.lowlevel.checkpoint() + + async def mouse_down( + self, + event: Event[sprite.PygameMouseButtonEventData], + ) -> None: + """Target click pos if selected.""" + if not self.selected: + await trio.lowlevel.checkpoint() + return + if event.data["button"] == 1: + movement: sprite.MovementComponent = self.get_component("movement") + movement.speed = 200 + target: sprite.TargetingComponent = self.get_component("targeting") + target.destination = Vector2.from_iter(event.data["pos"]) + + await trio.lowlevel.checkpoint() + + async def move_towards_dest( + self, + event: Event[sprite.TickEventData], + ) -> None: + """Move closer to destination.""" + target: sprite.TargetingComponent = self.get_component("targeting") + await target.move_destination_time(event.data.time_passed) + + +class MrFloppy(sprite.Sprite): + """Mr. Floppy test sprite.""" + + __slots__ = () + + def __init__(self) -> None: + """Initialize mr floppy sprite.""" + super().__init__("MrFloppy") + + image_component = sprite.ImageComponent() + image_component.add_component(sprite.AnimationComponent()) + self.add_components( + ( + sprite.MovementComponent(), + sprite.TargetingComponent(), + ClickDestinationComponent(), + image_component, + sprite.DragClickEventComponent(), + ), + ) + + movement = self.get_component("movement") + targeting = self.get_component("targeting") + image = self.get_component("image") + + movement.speed = 200 + + # lintcheck: c-extension-no-member (I1101): Module 'pygame.surface' has no 'Surface' member, but source is unavailable. Consider adding this module to extension-pkg-allow-list if you want to perform analysis based on run-time introspection of living objects. + floppy: pygame.surface.Surface = pygame.image.load( + path.join("data", "mr_floppy.png"), + ) + + image.add_images( + { + 0: floppy, + # '1': pygame.transform.flip(floppy, False, True) + 1: pygame.transform.rotate(floppy, 270), + 2: pygame.transform.flip(floppy, True, True), + 3: pygame.transform.rotate(floppy, 90), + }, + ) + + anim = image.get_component("animation") + anim.controller = self.controller((0, 1, 2, 3)) + + image.set_image(0) + self.visible = True + + self.location = SCREEN_SIZE / 2 + targeting.destination = self.location + + self.register_handler("drag", self.drag) + + @staticmethod + def controller( + image_identifiers: Sequence[str | int], + ) -> Iterator[str | int | None]: + """Animation controller.""" + cidx = 0 + while True: + count = len(image_identifiers) + if not count: + yield None + continue + cidx = (cidx + 1) % count + yield image_identifiers[cidx] + + async def drag(self, event: Event[sprite.DragEvent]) -> None: + """Move by relative from drag.""" + if not event.data.buttons.get(1): + await trio.lowlevel.checkpoint() + return + self.location += event.data.rel + self.dirty = 1 + + await trio.lowlevel.checkpoint() + + +class FPSCounter(objects.Text): + """FPS counter.""" + + __slots__ = () + + def __init__(self) -> None: + """Initialize fps counter.""" + font = pygame.font.Font(FONT, 28) + super().__init__("fps", font) + + self.text = "FPS: ???" + self.visible = True + + async def on_tick(self, event: Event[sprite.TickEventData]) -> None: + """Update text.""" + # self.text = f'FPS: {event.data["fps"]:.2f}' + self.text = f"FPS: {event.data.fps:.0f}" + + await trio.lowlevel.checkpoint() + + async def update_loc( + self, + event: Event[dict[str, tuple[int, int]]], + ) -> None: + """Move to top left corner.""" + self.location = Vector2.from_iter(event.data["size"]) / 2 + (5, 5) + + def bind_handlers(self) -> None: + """Register event handlers.""" + super().bind_handlers() + self.register_handlers( + { + "tick": self.on_tick, + "sprite_image_resized": self.update_loc, + }, + ) + + +class AzulInitialize(AzulState): + """Initialize Azul.""" + + __slots__ = () + + def __init__(self) -> None: + """Initialize state.""" + super().__init__("initialize") + + def group_add(self, new_sprite: sprite.Sprite) -> None: + """Add new sprite to group.""" + group = self.machine.get_group(self.id) + assert group is not None, "Expected group from new group id" + group.add(new_sprite) + self.manager.add_component(new_sprite) + + async def entry_actions(self) -> None: + """Create group and add mr floppy.""" + self.id = self.machine.new_group("test") + floppy = MrFloppy() + print(f"{floppy = }") + self.group_add(floppy) + self.group_add(FPSCounter()) + + await self.machine.raise_event(Event("init", None)) + + async def exit_actions(self) -> None: + """Remove group and unbind components.""" + self.machine.remove_group(self.id) + self.manager.unbind_components() + + await trio.lowlevel.checkpoint() + + +def save_crash_img() -> None: + """Save the last frame before the game crashed.""" + surface = pygame.display.get_surface().copy() + # strTime = '-'.join(time.asctime().split(' ')) + # filename = f'Crash_at_{strTime}.png' + filename = "screenshot.png" + + pygame.image.save(surface, path.join("screenshots", filename)) + del surface + + +async def async_run() -> None: + """Run client.""" + global SCREEN_SIZE + # global client + + screen = pygame.display.set_mode( + tuple(SCREEN_SIZE), + RESIZABLE, + vsync=VSYNC, + ) + pygame.display.set_caption(f"{__title__} v{__version__}") + pygame.key.set_repeat(1000, 30) + screen.fill((0xFF, 0xFF, 0xFF)) + + client = GameClient() + + background = pygame.image.load( + path.join("data", "background.png"), + ).convert() + client.clear(screen, background) + + client.set_timing_threshold(1000 / FPS) + + await client.set_state("initialize") + + clock = pygame.time.Clock() + + while client.running: + resized_window = False + + async with trio.open_nursery() as nursery: + for event in pygame.event.get(): + # pylint: disable=undefined-variable + if event.type == QUIT: + await client.set_state("Halt") + elif event.type == KEYUP and event.key == K_ESCAPE: + pygame.event.post(pygame.event.Event(QUIT)) + elif event.type == WINDOWRESIZED: + SCREEN_SIZE = Vector2(event.x, event.y) + resized_window = True + sprite_event = sprite.convert_pygame_event(event) + # print(sprite_event) + nursery.start_soon(client.raise_event, sprite_event) + await client.think() + + time_passed = clock.tick(FPS) + + await client.raise_event( + Event( + "tick", + sprite.TickEventData( + time_passed / 1000, + clock.get_fps(), + ), + ), + ) + + if resized_window: + screen.fill((0xFF, 0xFF, 0xFF)) + rects = [Rect((0, 0), tuple(SCREEN_SIZE))] + client.repaint_rect(rects[0]) + rects.extend(client.draw(screen)) + else: + rects = client.draw(screen) + pygame.display.update(rects) + client.clear_groups() + + +class Tracer(trio.abc.Instrument): + """Tracer instrument.""" + + __slots__ = ("_sleep_time",) + + def before_run(self) -> None: + """Before run.""" + print("!!! run started") + + def _print_with_task(self, msg: str, task: trio.lowlevel.Task) -> None: + """Print message with task name.""" + # repr(task) is perhaps more useful than task.name in general, + # but in context of a tutorial the extra noise is unhelpful. + print(f"{msg}: {task.name}") + + def task_spawned(self, task: trio.lowlevel.Task) -> None: + """Task spawned.""" + self._print_with_task("### new task spawned", task) + + def task_scheduled(self, task: trio.lowlevel.Task) -> None: + """Task scheduled.""" + self._print_with_task("### task scheduled", task) + + def before_task_step(self, task: trio.lowlevel.Task) -> None: + """Before task step.""" + self._print_with_task(">>> about to run one step of task", task) + + def after_task_step(self, task: trio.lowlevel.Task) -> None: + """After task step.""" + self._print_with_task("<<< task step finished", task) + + def task_exited(self, task: trio.lowlevel.Task) -> None: + """Task exited.""" + self._print_with_task("### task exited", task) + + def before_io_wait(self, timeout: float) -> None: + """Before IO wait.""" + if timeout: + print(f"### waiting for I/O for up to {timeout} seconds") + else: + print("### doing a quick check for I/O") + self._sleep_time = trio.current_time() + + def after_io_wait(self, timeout: float) -> None: + """After IO wait.""" + duration = trio.current_time() - self._sleep_time + print(f"### finished I/O check (took {duration} seconds)") + + def after_run(self) -> None: + """After run.""" + print("!!! run finished") + + +def run() -> None: + """Run asynchronous side of everything.""" + trio.run(async_run) # , instruments=[Tracer()]) + + +# save_crash_img() + +if __name__ == "__main__": + print(f"{__title__} v{__version__}\nProgrammed by {__author__}.\n") + + # Make sure the game will display correctly on high DPI monitors on Windows. + if sys.platform == "win32": + # Exists on windows but not on linux or macos + # Windows raises attr-defined + # others say unused-ignore + from ctypes import windll # type: ignore[attr-defined,unused-ignore] + + with contextlib.suppress(AttributeError): + windll.user32.SetProcessDPIAware() + del windll + + try: + pygame.init() + run() + finally: + pygame.quit() diff --git a/src/azul/namedtuple_mod.py b/src/azul/namedtuple_mod.py index 1832697..594dc25 100644 --- a/src/azul/namedtuple_mod.py +++ b/src/azul/namedtuple_mod.py @@ -1,4 +1,14 @@ -"""typing.NamedTupleMeta mod.""" +"""typing.NamedTupleMeta modification. + +Removes the requirement that NamedTuple can only inherit from +NamedTuple or Generic + +Licensed under the Python Software Foundation License +(see https://github.com/python/cpython/blob/main/LICENSE) + +Original source that this is a modified portion of: +https://github.com/python/cpython/blob/main/Lib/typing.py +""" from __future__ import annotations @@ -17,7 +27,10 @@ def __new__( ns: dict[str, typing.Any], ) -> typing.Any: # pragma: nocover """Create NamedTuple.""" - bases = tuple(tuple if base is typing._NamedTuple else base for base in bases) # type: ignore[attr-defined] + bases = tuple( + tuple if base is typing._NamedTuple else base # type: ignore[attr-defined] + for base in bases + ) for base in bases: if tuple not in base.__mro__: continue @@ -43,7 +56,7 @@ def __new__( module=ns["__module__"], ) nm_tpl.__bases__ = bases - if typing.Generic in bases: # type: ignore[comparison-overlap] + if typing.Generic in bases: class_getitem = typing._generic_class_getitem # type: ignore[attr-defined] nm_tpl.__class_getitem__ = classmethod(class_getitem) # update from user namespace without overriding special namedtuple attributes @@ -54,7 +67,7 @@ def __new__( ) if key not in typing._special and key not in nm_tpl._fields: # type: ignore[attr-defined] setattr(nm_tpl, key, ns[key]) - if typing.Generic in bases: # type: ignore[comparison-overlap] + if typing.Generic in bases: nm_tpl.__init_subclass__() return nm_tpl diff --git a/src/azul/network.py b/src/azul/network.py deleted file mode 100644 index 165a270..0000000 --- a/src/azul/network.py +++ /dev/null @@ -1,512 +0,0 @@ -"""Network - Module for sending events over the network.""" - -# Programmed by CoolCat467 - -from __future__ import annotations - -# Copyright (C) 2023-2024 CoolCat467 -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program. If not, see . - -__title__ = "Network" -__author__ = "CoolCat467" -__license__ = "GNU General Public License Version 3" -__version__ = "0.0.0" - - -import contextlib -from typing import ( - TYPE_CHECKING, - Any, - Literal, - NoReturn, -) - -import trio - -from azul.base_io import ( - BaseAsyncReader, - BaseAsyncWriter, - StructFormat, -) -from azul.buffer import Buffer -from azul.component import ( - Component, - ComponentManager, - Event, -) - -if TYPE_CHECKING: - from types import TracebackType - - from typing_extensions import Self - - -class NetworkTimeoutError(Exception): - """Network Timeout Error.""" - - __slots__ = () - - -class NetworkEOFError(Exception): - """Network End of File Error.""" - - __slots__ = () - - -class NetworkStreamNotConnectedError(Exception): - """Network Stream Not Connected Error.""" - - __slots__ = () - - -class NetworkComponent(Component, BaseAsyncReader, BaseAsyncWriter): - """Network Component (client).""" - - __slots__ = ("_stream", "timeout") - - def __init__(self, name: str) -> None: - """Initialize Network Component.""" - super().__init__(name) - - self.timeout: int | float = 3 - self._stream: trio.SocketStream | None = None - - @property - def not_connected(self) -> bool: - """Is stream None?.""" - return self._stream is None - - @property - def stream(self) -> trio.SocketStream: - """Trio SocketStream or raise NetworkStreamNotConnectedError.""" - if self._stream is None: - raise NetworkStreamNotConnectedError("Stream not connected!") - return self._stream - - @classmethod - def from_stream( - cls, - *args: object, - kwargs: dict[str, object] | None = None, - stream: trio.SocketStream, - ) -> Self: - """Initialize from stream.""" - if kwargs is None: - kwargs = {} - self = cls(*args, **kwargs) # type: ignore[arg-type] - self._stream = stream - return self - - async def connect(self, host: str, port: int) -> None: - """Connect to host:port on TCP. - - Raises: - OSError: if the connection fails. - RuntimeError: if stream is already connected - - """ - if not self.not_connected: - raise RuntimeError("Already connected!") - try: # pragma: nocover - self._stream = await trio.open_tcp_stream(host, port) - except OSError: # pragma: nocover - await self.close() - raise - - async def read(self, length: int) -> bytearray: - """Read `length` bytes from stream. - - Can raise following exceptions: - NetworkStreamNotConnectedError - Network stream is not connected - NetworkTimeoutError - Timeout - NetworkEOFError - End of File - OSError - Stopped responding - trio.BusyResourceError - Another task is already writing data - trio.BrokenResourceError - Something is wrong and stream is broken - trio.ClosedResourceError - Stream is closed or another task closes stream - """ - content = bytearray() - while max_read_count := length - len(content): - received = b"" - # try: - with trio.move_on_after(self.timeout) as cancel_scope: - received = await self.stream.receive_some(max_read_count) - cancel_called = cancel_scope.cancel_called - # except (trio.BrokenResourceError, trio.ClosedResourceError): - # await self.close() - # raise - if len(received) == 0: - # No information at all - if len(content) == 0: - if cancel_called: - raise NetworkTimeoutError("Read timed out.") - raise NetworkEOFError( - "Server did not respond with any information.", - ) - # Only sent a few bytes, but we requested more - raise OSError( - f"Server stopped responding (got {len(content)} bytes, " - f"but expected {length} bytes)." - f" Partial obtained packet: {content!r}", - ) - content.extend(received) - return content - - async def write(self, data: bytes | bytearray | memoryview) -> None: - """Send the given data through the stream, blocking if necessary. - - Args: - data (bytes, bytearray, or memoryview): The data to send. - - Raises: - trio.BusyResourceError: if another task is already executing a - :meth:`send_all`, :meth:`wait_send_all_might_not_block`, or - :meth:`HalfCloseableStream.send_eof` on this stream. - trio.BrokenResourceError: if something has gone wrong, and the stream - is broken. - trio.ClosedResourceError: if you previously closed this stream - object, or if another task closes this stream object while - :meth:`send_all` is running. - - Most low-level operations in Trio provide a guarantee: if they raise - :exc:`trio.Cancelled`, this means that they had no effect, so the - system remains in a known state. This is **not true** for - :meth:`send_all`. If this operation raises :exc:`trio.Cancelled` (or - any other exception for that matter), then it may have sent some, all, - or none of the requested data, and there is no way to know which. - - Copied from Trio docs. - - """ - await self.stream.send_all(data) - - # try: - # await self.stream.send_all(data) - # except (trio.BrokenResourceError, trio.ClosedResourceError): - # await self.close() - # raise - - async def close(self) -> None: - """Close the stream, possibly blocking.""" - if self._stream is None: - await trio.lowlevel.checkpoint() - return - await self._stream.aclose() - self._stream = None - - async def send_eof(self) -> None: - """Close the sending half of the stream. - - This corresponds to ``shutdown(..., SHUT_WR)`` (`man - page `__). - - If an EOF has already been sent, then this method should silently - succeed. - - Raises: - trio.BusyResourceError: if another task is already executing a - :meth:`~SendStream.send_all`, - :meth:`~SendStream.wait_send_all_might_not_block`, or - :meth:`send_eof` on this stream. - trio.BrokenResourceError: if something has gone wrong, and the stream - is broken. - - Suppresses: - trio.ClosedResourceError: if you previously closed this stream - object, or if another task closes this stream object while - :meth:`send_eof` is running. - - Copied from trio docs. - - """ - with contextlib.suppress(trio.ClosedResourceError): - await self.stream.send_eof() - - async def wait_write_might_not_block(self) -> None: - """Block until it's possible that :meth:`write` might not block. - - This method may return early: it's possible that after it returns, - :meth:`send_all` will still block. (In the worst case, if no better - implementation is available, then it might always return immediately - without blocking. It's nice to do better than that when possible, - though.) - - This method **must not** return *late*: if it's possible for - :meth:`send_all` to complete without blocking, then it must - return. When implementing it, err on the side of returning early. - - Raises: - trio.BusyResourceError: if another task is already executing a - :meth:`send_all`, :meth:`wait_send_all_might_not_block`, or - :meth:`HalfCloseableStream.send_eof` on this stream. - trio.BrokenResourceError: if something has gone wrong, and the stream - is broken. - trio.ClosedResourceError: if you previously closed this stream - object, or if another task closes this stream object while - :meth:`wait_send_all_might_not_block` is running. - - Note: - This method is intended to aid in implementing protocols that want - to delay choosing which data to send until the last moment. E.g., - suppose you're working on an implementation of a remote display server - like `VNC - `__, and - the network connection is currently backed up so that if you call - :meth:`send_all` now then it will sit for 0.5 seconds before actually - sending anything. In this case it doesn't make sense to take a - screenshot, then wait 0.5 seconds, and then send it, because the - screen will keep changing while you wait; it's better to wait 0.5 - seconds, then take the screenshot, and then send it, because this - way the data you deliver will be more - up-to-date. Using :meth:`wait_send_all_might_not_block` makes it - possible to implement the better strategy. - - If you use this method, you might also want to read up on - ``TCP_NOTSENT_LOWAT``. - - Further reading: - - * `Prioritization Only Works When There's Pending Data to Prioritize - `__ - - * WWDC 2015: Your App and Next Generation Networks: `slides - `__, - `video and transcript - `__ - - Copied from Trio docs. - - """ - return await self.stream.wait_send_all_might_not_block() - - async def __aenter__(self) -> Self: - """Async context manager enter.""" - return self - - async def __aexit__( - self, - exc_type: type[BaseException] | None, - exc_value: BaseException | None, - traceback: TracebackType | None, - ) -> None: - """Async context manager exit. Close connection.""" - await self.close() - - -# async def send_eof_and_close(self) -> None: -# """Send EOF and close.""" -# await self.send_eof() -# await self.close() - - -class NetworkEventComponent(NetworkComponent): - """Network Event Component - Send events over the network.""" - - __slots__ = ( - "_read_packet_id_to_event_name", - "_write_event_name_to_packet_id", - "read_lock", - "write_lock", - ) - - # Max of 255 packet ids - # Next higher is USHORT with 65535 packet ids - packet_id_format: Literal[StructFormat.UBYTE] = StructFormat.UBYTE - - def __init__(self, name: str) -> None: - """Initialize Network Event Component.""" - super().__init__(name) - self._read_packet_id_to_event_name: dict[int, str] = {} - self._write_event_name_to_packet_id: dict[str, int] = {} - self.read_lock = trio.Lock() - self.write_lock = trio.Lock() - - def bind_handlers(self) -> None: - """Register serverbound event handlers.""" - self.register_handlers( - dict.fromkeys( - self._write_event_name_to_packet_id, - self.write_event, - ), - ) - - def register_network_write_event( - self, - event_name: str, - packet_id: int, - ) -> None: - """Map event name to serverbound packet id. - - Raises: - ValueError: Event name already registered or infinite network loop. - - """ - if event_name in self._write_event_name_to_packet_id: - raise ValueError(f"{event_name!r} event already registered!") - if self._read_packet_id_to_event_name.get(packet_id) == event_name: - raise ValueError( - f"{event_name!r} events are also being received " - f"from server with packet id {packet_id!r}, " - "which will would lead to infinite looping over network", - ) - self._write_event_name_to_packet_id[event_name] = packet_id - if self.manager_exists: - self.register_handler(event_name, self.write_event) - - def register_network_write_events(self, event_map: dict[str, int]) -> None: - """Map event names to serverbound packet ids.""" - for event_name, packet_id in event_map.items(): - self.register_network_write_event(event_name, packet_id) - - async def write_event(self, event: Event[bytearray | bytes]) -> None: - """Send event to network. - - Raises: - RuntimeError: if unregistered packet id received from network - trio.BusyResourceError: if another task is already executing a - :meth:`send_all`, :meth:`wait_send_all_might_not_block`, or - :meth:`HalfCloseableStream.send_eof` on this stream. - trio.BrokenResourceError: if something has gone wrong, and the stream - is broken. - trio.ClosedResourceError: if you previously closed this stream - object, or if another task closes this stream object while - :meth:`send_all` is running. - - """ - packet_id = self._write_event_name_to_packet_id.get(event.name) - if packet_id is None: - raise RuntimeError(f"Unhandled network event name {event.name!r}") - buffer = Buffer() - buffer.write_value(self.packet_id_format, packet_id) - buffer.write_bytearray(event.data) - async with self.write_lock: - await self.write(buffer) - - async def read_event(self) -> Event[bytearray]: - """Receive event from network. - - Can raise following exceptions: - RuntimeError - Unhandled packet id - NetworkStreamNotConnectedError - Network stream is not connected - NetworkTimeoutError - Timeout or no data - OSError - Stopped responding - trio.BrokenResourceError - Something is wrong and stream is broken - trio.ClosedResourceError - Stream is closed or another task closes stream - - Shouldn't happen with write lock but still: - trio.BusyResourceError - Another task is already writing data - """ - async with self.read_lock: - packet_id = await self.read_value(self.packet_id_format) - event_data = await self.read_bytearray() - event_name = self._read_packet_id_to_event_name.get(packet_id) - if event_name is None: - raise RuntimeError(f"Unhandled packet ID {packet_id!r}") - return Event(event_name, event_data) - - def register_read_network_event( - self, - packet_id: int, - event_name: str, - ) -> None: - """Map clientbound packet id to event name.""" - if packet_id in self._read_packet_id_to_event_name: - raise ValueError(f"Packet ID {packet_id!r} already registered!") - if self._write_event_name_to_packet_id.get(event_name) == packet_id: - raise ValueError( - f"Packet id {packet_id!r} packets are also being received " - f"from server with as {event_name!r} events, " - "which will would lead to infinite looping over network", - ) - self._read_packet_id_to_event_name[packet_id] = event_name - - def register_read_network_events(self, packet_map: dict[int, str]) -> None: - """Map clientbound packet ids to event names.""" - for packet_id, event_name in packet_map.items(): - self.register_read_network_event(packet_id, event_name) - - -class Server(ComponentManager): - """Asynchronous TCP Server.""" - - __slots__ = ("serve_cancel_scope",) - - def __init__(self, name: str, own_name: str | None = None) -> None: - """Initialize Server.""" - super().__init__(name, own_name) - self.serve_cancel_scope: trio.CancelScope | None = None - - def stop_serving(self) -> None: - """Cancel serve scope immediately. - - This method is idempotent, i.e., if the scope was already - cancelled then this method silently does nothing. - """ - if self.serve_cancel_scope is None: - return - self.serve_cancel_scope.cancel() - - # "Implicit return in function which does not return" - async def serve( # type: ignore[misc] # pragma: nocover - self, - port: int, - host: str | bytes | None = None, - backlog: int | None = None, - ) -> NoReturn: - """Serve over TCP. See trio.open_tcp_listeners for argument details.""" - self.serve_cancel_scope = trio.CancelScope() - async with trio.open_nursery() as nursery: - listeners = await trio.open_tcp_listeners( - port, - host=host, - backlog=backlog, - ) - - async def handle_serve( - task_status: trio.TaskStatus[Any] = trio.TASK_STATUS_IGNORED, - ) -> None: - assert self.serve_cancel_scope is not None - try: - with self.serve_cancel_scope: - await trio.serve_listeners( - self.handler, - listeners, - handler_nursery=nursery, - task_status=task_status, - ) - except trio.Cancelled: - # Close all listeners - async with trio.open_nursery() as cancel_nursery: - for listener in listeners: - cancel_nursery.start_soon(listener.aclose) - - await nursery.start(handle_serve) - - async def handler( - self, - stream: trio.SocketStream, - ) -> None: # pragma: nocover - """Handle new client streams. - - Override in a subclass - Default only closes the stream - """ - try: - await stream.send_eof() - finally: - await stream.aclose() - - -if __name__ == "__main__": # pragma: nocover - print(f"{__title__}\nProgrammed by {__author__}.\n") diff --git a/src/azul/network_shared.py b/src/azul/network_shared.py index ce56c88..65b2ba1 100644 --- a/src/azul/network_shared.py +++ b/src/azul/network_shared.py @@ -24,11 +24,18 @@ __license__ = "GNU General Public License Version 3" +from collections import Counter from enum import IntEnum, auto -from typing import Final, NamedTuple, TypeAlias +from typing import TYPE_CHECKING, Final, TypeAlias -import trio +from libcomponent.base_io import StructFormat +from libcomponent.buffer import Buffer from mypy_extensions import u8 +from numpy import int8, zeros + +if TYPE_CHECKING: + from numpy.typing import NDArray + ADVERTISEMENT_IP: Final = "224.0.2.60" ADVERTISEMENT_PORT: Final = 4445 @@ -38,41 +45,83 @@ Pos: TypeAlias = tuple[u8, u8] -class TickEventData(NamedTuple): - """Tick Event Data.""" +def encode_tile_count(tile_color: u8, tile_count: u8) -> Buffer: + """Return buffer from tile color and count.""" + buffer = Buffer() + + buffer.write_value(StructFormat.UBYTE, tile_color) + buffer.write_value(StructFormat.UBYTE, tile_count) + + return buffer + + +def decode_tile_count(buffer: Buffer) -> tuple[u8, u8]: + """Read and return tile color and count from buffer.""" + tile_color = buffer.read_value(StructFormat.UBYTE) + tile_count = buffer.read_value(StructFormat.UBYTE) + + return (tile_color, tile_count) + + +def encode_numeric_uint8_counter(counter: Counter[int]) -> Buffer: + """Return buffer from uint8 counter.""" + buffer = Buffer() + + buffer.write_value(StructFormat.UBYTE, len(counter)) + for key, value in counter.items(): + assert isinstance(key, int) + assert value >= 0 + buffer.extend(encode_tile_count(key, value)) + + return buffer + + +def decode_numeric_uint8_counter(buffer: Buffer) -> Counter[u8]: + """Read and return uint8 counter from buffer.""" + data: dict[u8, u8] = {} + + pair_count = buffer.read_value(StructFormat.UBYTE) + for _ in range(pair_count): + key, value = decode_tile_count(buffer) + assert key not in data + data[key] = value + + return Counter(data) + + +def encode_int8_array(array: NDArray[int8]) -> Buffer: + """Return buffer from int8 array flat values.""" + buffer = Buffer() + + for value in array.flat: + buffer.write_value(StructFormat.BYTE, int(value)) + + return buffer - time_passed: float - fps: float +def decode_int8_array(buffer: Buffer, size: tuple[int, ...]) -> NDArray[int8]: + """Return flattened int8 array from buffer.""" + array = zeros(size, dtype=int8) -# Stolen from WOOF (Web Offer One File), Copyright (C) 2004-2009 Simon Budig, -# available at http://www.home.unix-ag.org/simon/woof -# with modifications + for index in range(array.size): + array.flat[index] = buffer.read_value(StructFormat.BYTE) -# Utility function to guess the IP (as a string) where the server can be -# reached from the outside. Quite nasty problem actually. + return array -async def find_ip() -> str: # pragma: nocover - """Guess the IP where the server can be found from the network.""" - # we get a UDP-socket for the TEST-networks reserved by IANA. - # It is highly unlikely, that there is special routing used - # for these networks, hence the socket later should give us - # the IP address of the default route. - # We're doing multiple tests, to guard against the computer being - # part of a test installation. +def encode_cursor_location(scaled_location: tuple[int, int]) -> bytes: + """Return buffer from cursor location.""" + x, y = scaled_location + position = ((x & 0xFFF) << 12) | (y & 0xFFF) + return (position & 0xFFFFFF).to_bytes(3) - candidates: list[str] = [] - for test_ip in ("192.0.2.0", "198.51.100.0", "203.0.113.0"): - sock = trio.socket.socket(trio.socket.AF_INET, trio.socket.SOCK_DGRAM) - await sock.connect((test_ip, 80)) - ip_addr: str = sock.getsockname()[0] - sock.close() - if ip_addr in candidates: - return ip_addr - candidates.append(ip_addr) - return candidates[0] +def decode_cursor_location(buffer: bytes | bytearray) -> tuple[int, int]: + """Return cursor location from buffer.""" + value = int.from_bytes(buffer) & 0xFFFFFF + x = (value >> 12) & 0xFFF + y = value & 0xFFF + return (x, y) class ClientBoundEvents(IntEnum): @@ -82,21 +131,24 @@ class ClientBoundEvents(IntEnum): callback_ping = auto() initial_config = auto() playing_as = auto() - create_piece = auto() - select_piece = auto() - create_tile = auto() - delete_tile = auto() - animation_state = auto() - delete_piece_animation = auto() - update_piece_animation = auto() - move_piece_animation = auto() - action_complete = auto() game_over = auto() + board_data = auto() + pattern_data = auto() + factory_data = auto() + cursor_data = auto() + table_data = auto() + cursor_movement_mode = auto() + current_turn_change = auto() + cursor_position = auto() + floor_data = auto() class ServerBoundEvents(IntEnum): """Server bound event IDs.""" encryption_response = 0 - select_piece = auto() - select_tile = auto() + factory_clicked = auto() + cursor_location = auto() + pattern_row_clicked = auto() + table_clicked = auto() + floor_clicked = auto() diff --git a/src/azul/objects.py b/src/azul/objects.py index 0f89f07..77d71a8 100644 --- a/src/azul/objects.py +++ b/src/azul/objects.py @@ -35,10 +35,9 @@ from azul import sprite if TYPE_CHECKING: + from libcomponent.component import Event from pygame.font import Font - from azul.component import Event - class Text(sprite.Sprite): """Text element. diff --git a/src/azul/screenshots/Crash_at_Wed-Nov-13-15_30_42-2024.png b/src/azul/screenshots/Crash_at_Wed-Nov-13-15_30_42-2024.png new file mode 100644 index 0000000..2f51576 Binary files /dev/null and b/src/azul/screenshots/Crash_at_Wed-Nov-13-15_30_42-2024.png differ diff --git a/src/azul/screenshots/Crash_at_Wed_Nov_13_16_02_46_2024.png b/src/azul/screenshots/Crash_at_Wed_Nov_13_16_02_46_2024.png new file mode 100644 index 0000000..61fe23d Binary files /dev/null and b/src/azul/screenshots/Crash_at_Wed_Nov_13_16_02_46_2024.png differ diff --git a/src/azul/screenshots/Crash_at_Wed_Nov_13_16_20_10_2024.png b/src/azul/screenshots/Crash_at_Wed_Nov_13_16_20_10_2024.png new file mode 100644 index 0000000..0d712ac Binary files /dev/null and b/src/azul/screenshots/Crash_at_Wed_Nov_13_16_20_10_2024.png differ diff --git a/src/azul/screenshots/Screenshot_at_Sun-Jun-13-10:46:34-2021.png b/src/azul/screenshots/Screenshot_at_Sun-Jun-13-10_46_34-2021.png similarity index 100% rename from src/azul/screenshots/Screenshot_at_Sun-Jun-13-10:46:34-2021.png rename to src/azul/screenshots/Screenshot_at_Sun-Jun-13-10_46_34-2021.png diff --git a/src/azul/server.py b/src/azul/server.py new file mode 100755 index 0000000..a569bf6 --- /dev/null +++ b/src/azul/server.py @@ -0,0 +1,1461 @@ +#!/usr/bin/env python3 +# Azul Game Server + +"""Azul Game Server.""" + +# Programmed by CoolCat467 + +from __future__ import annotations + +# Copyright (C) 2023-2026 CoolCat467 +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +__title__ = "Server" +__author__ = "CoolCat467" +__license__ = "GNU General Public License Version 3" + +import traceback +from collections import deque +from enum import IntEnum, auto +from functools import partial +from typing import TYPE_CHECKING, NoReturn + +import trio +from libcomponent import network +from libcomponent.base_io import StructFormat +from libcomponent.buffer import Buffer +from libcomponent.component import ( + ComponentManager, + Event, + ExternalRaiseManager, +) +from libcomponent.network_utils import ( + ServerClientNetworkEventComponent, + find_ip, +) +from numpy import array, int8 + +from azul.network_shared import ( + ADVERTISEMENT_IP, + ADVERTISEMENT_PORT, + DEFAULT_PORT, + ClientBoundEvents, + ServerBoundEvents, + decode_cursor_location, + encode_cursor_location, + encode_int8_array, + encode_numeric_uint8_counter, + encode_tile_count, +) +from azul.state import FLOOR_LINE_DATA, Phase, State, Tile + +if TYPE_CHECKING: + from collections import Counter + from collections.abc import Awaitable, Callable + + from numpy.typing import NDArray + + +# cursor_set_movement_mode +# cursor_set_destination + + +class ServerClient(ServerClientNetworkEventComponent): + """Server Client Network Event Component. + + When clients connect to server, this class handles the incoming + connections to the server in the way of reading and raising events + that are transferred over the network. + """ + + __slots__ = ("client_id",) + + def __init__(self, client_id: int) -> None: + """Initialize Server Client.""" + self.client_id = client_id + super().__init__(f"client_{client_id}") + + self.timeout = 3 + + cbe = ClientBoundEvents + self.register_network_write_events( + { + "server[write]->encryption_request": cbe.encryption_request, + "server[write]->callback_ping": cbe.callback_ping, + "server[write]->initial_config": cbe.initial_config, + "server[write]->playing_as": cbe.playing_as, + "server[write]->game_over": cbe.game_over, + "server[write]->board_data": cbe.board_data, + "server[write]->pattern_data": cbe.pattern_data, + "server[write]->factory_data": cbe.factory_data, + "server[write]->cursor_data": cbe.cursor_data, + "server[write]->table_data": cbe.table_data, + "server[write]->cursor_movement_mode": cbe.cursor_movement_mode, + "server[write]->current_turn_change": cbe.current_turn_change, + "server[write]->cursor_position": cbe.cursor_position, + "server[write]->floor_data": cbe.floor_data, + }, + ) + sbe = ServerBoundEvents + self.register_read_network_events( + { + sbe.encryption_response: f"client[{self.client_id}]->encryption_response", + sbe.factory_clicked: f"client[{self.client_id}]->factory_clicked", + sbe.cursor_location: f"client[{self.client_id}]->cursor_location", + sbe.pattern_row_clicked: f"client[{self.client_id}]->pattern_row_clicked", + sbe.table_clicked: f"client[{self.client_id}]->table_clicked", + sbe.floor_clicked: f"client[{self.client_id}]->floor_clicked", + }, + ) + + def bind_handlers(self) -> None: + """Bind event handlers.""" + super().bind_handlers() + self.register_handlers( + { + f"client[{self.client_id}]->encryption_response": self.handle_encryption_response, + f"client[{self.client_id}]->factory_clicked": self.read_factory_clicked, + f"client[{self.client_id}]->cursor_location": self.read_cursor_location, + f"client[{self.client_id}]->pattern_row_clicked": self.read_pattern_row_clicked, + f"client[{self.client_id}]->table_clicked": self.read_table_clicked, + f"client[{self.client_id}]->floor_clicked": self.read_floor_clicked, + f"callback_ping->network[{self.client_id}]": self.handle_callback_ping, + "initial_config->network": self.write_initial_config, + f"playing_as->network[{self.client_id}]": self.write_playing_as, + "game_over->network": self.write_game_over, + "board_data->network": self.write_board_data, + "factory_data->network": self.write_factory_data, + "cursor_data->network": self.write_cursor_data, + "table_data->network": self.write_table_data, + f"cursor_movement_mode->network[{self.client_id}]": self.write_cursor_movement_mode, + f"cursor_position->network[{self.client_id}]": self.write_cursor_position, + "current_turn_change->network": self.write_current_turn_change, + "pattern_data->network": self.write_pattern_data, + "floor_data->network": self.write_floor_data, + }, + ) + + async def start_encryption_request(self) -> None: + """Start encryption request and raise as `server[write]->encryption_request`.""" + await super().start_encryption_request() + + event = await self.read_event() + if event.name != f"client[{self.client_id}]->encryption_response": + raise RuntimeError( + f"Expected encryption response, got but {event.name!r}", + ) + await self.handle_encryption_response(event) + + async def read_factory_clicked(self, event: Event[bytearray]) -> None: + """Read factory_clicked event from client. Raise as `factory_clicked->server`.""" + buffer = Buffer(event.data) + + factory_id = buffer.read_value(StructFormat.UBYTE) + tile_color = Tile(buffer.read_value(StructFormat.UBYTE)) + + await self.raise_event( + Event( + "factory_clicked->server", + ( + self.client_id, + factory_id, + tile_color, + ), + ), + ) + + async def read_cursor_location(self, event: Event[bytearray]) -> None: + """Read factory_clicked event from client. Raise as `factory_clicked->server`.""" + x, y = decode_cursor_location(event.data) + + await self.raise_event( + Event( + "cursor_location->server", + ( + self.client_id, + (x, y), + ), + ), + ) + + async def read_pattern_row_clicked(self, event: Event[bytearray]) -> None: + """Read pattern_row_clicked event from client. Raise as `pattern_row_clicked->server`.""" + buffer = Buffer(event.data) + + row_id = buffer.read_value(StructFormat.UBYTE) + row_pos_x = buffer.read_value(StructFormat.UBYTE) + row_pos_y = buffer.read_value(StructFormat.UBYTE) + + await self.raise_event( + Event( + "pattern_row_clicked->server", + ( + self.client_id, + row_id, + (row_pos_x, row_pos_y), + ), + ), + ) + + async def read_table_clicked(self, event: Event[bytearray]) -> None: + """Read table_clicked event from client. Raise as `table_clicked->server`.""" + buffer = Buffer(event.data) + + tile_color = Tile(buffer.read_value(StructFormat.UBYTE)) + + await self.raise_event( + Event( + "table_clicked->server", + ( + self.client_id, + tile_color, + ), + ), + ) + + async def read_floor_clicked(self, event: Event[bytearray]) -> None: + """Read floor_clicked event from client. Raise as `floor_clicked->server`.""" + buffer = Buffer(event.data) + + floor_line_id = buffer.read_value(StructFormat.UBYTE) + floor_line_pos_x = buffer.read_value(StructFormat.UBYTE) + + await self.raise_event( + Event( + "floor_clicked->server", + ( + self.client_id, + floor_line_id, + floor_line_pos_x, + ), + ), + ) + + async def handle_callback_ping( + self, + _: Event[None], + ) -> None: + """Reraise as server[write]->callback_ping.""" + await self.write_callback_ping() + + async def write_initial_config( + self, + event: Event[tuple[bool, int, int, int, tuple[int, ...]]], + ) -> None: + """Read initial config event and reraise as server[write]->initial_config.""" + variant_play, player_count, factory_count, current_turn, floor_data = ( + event.data + ) + + buffer = Buffer() + + buffer.write_value(StructFormat.BOOL, variant_play) + buffer.write_value(StructFormat.UBYTE, player_count) + buffer.write_value(StructFormat.UBYTE, factory_count) + buffer.write_value(StructFormat.UBYTE, current_turn) + buffer.write_value(StructFormat.UBYTE, len(floor_data)) + buffer.extend(encode_int8_array(array(floor_data, dtype=int8))) + + await self.write_event(Event("server[write]->initial_config", buffer)) + + async def write_playing_as( + self, + event: Event[int], + ) -> None: + """Read playing as event and reraise as server[write]->playing_as.""" + playing_as = event.data + + buffer = Buffer() + buffer.write_value(StructFormat.UBYTE, playing_as) + await self.write_event(Event("server[write]->playing_as", buffer)) + + async def write_game_over(self, event: Event[int]) -> None: + """Read game over event and reraise as server[write]->game_over.""" + winner = event.data + + buffer = Buffer() + + buffer.write_value(StructFormat.UBYTE, winner) + + await self.write_event(Event("server[write]->game_over", buffer)) + + async def write_board_data( + self, + event: Event[tuple[int, NDArray[int8]]], + ) -> None: + """Reraise as server[write]->board_data.""" + player_id, array = event.data + + buffer = Buffer() + buffer.write_value(StructFormat.UBYTE, player_id) + buffer.extend(encode_int8_array(array)) + + await self.write_event(Event("server[write]->board_data", buffer)) + + async def write_factory_data( + self, + event: Event[tuple[int, Counter[int]]], + ) -> None: + """Reraise as server[write]->factory_data.""" + factory_id, tiles = event.data + + buffer = Buffer() + buffer.write_value(StructFormat.UBYTE, factory_id) + buffer.extend(encode_numeric_uint8_counter(tiles)) + + await self.write_event(Event("server[write]->factory_data", buffer)) + + async def write_cursor_data( + self, + event: Event[Counter[int]], + ) -> None: + """Reraise as server[write]->cursor_data.""" + tiles = event.data + + buffer = encode_numeric_uint8_counter(tiles) + + await self.write_event(Event("server[write]->cursor_data", buffer)) + + async def write_table_data( + self, + event: Event[Counter[int]], + ) -> None: + """Reraise as server[write]->table_data.""" + tiles = event.data + + buffer = encode_numeric_uint8_counter(tiles) + + await self.write_event(Event("server[write]->table_data", buffer)) + + async def write_cursor_movement_mode( + self, + event: Event[bool], + ) -> None: + """Reraise as server[write]->cursor_movement_mode.""" + client_mode = event.data + + buffer = Buffer() + buffer.write_value(StructFormat.BOOL, client_mode) + + await self.write_event( + Event("server[write]->cursor_movement_mode", buffer), + ) + + async def write_cursor_position( + self, + event: Event[tuple[int, int]], + ) -> None: + """Reraise as server[write]->cursor_position.""" + buffer = encode_cursor_location(event.data) + + await self.write_event( + Event("server[write]->cursor_position", buffer), + ) + + async def write_current_turn_change( + self, + event: Event[int], + ) -> None: + """Reraise as server[write]->current_turn_change.""" + pattern_id = event.data + + buffer = Buffer() + buffer.write_value(StructFormat.UBYTE, pattern_id) + + await self.write_event( + Event("server[write]->current_turn_change", buffer), + ) + + async def write_pattern_data( + self, + event: Event[tuple[int, int, tuple[int, int]]], + ) -> None: + """Reraise as server[write]->board_data.""" + player_id, row_id, (tile_color, tile_count) = event.data + + buffer = Buffer() + buffer.write_value(StructFormat.UBYTE, player_id) + buffer.write_value(StructFormat.UBYTE, row_id) + assert tile_color >= 0 + buffer.extend(encode_tile_count(tile_color, tile_count)) + + await self.write_event( + Event("server[write]->pattern_data", buffer), + ) + + async def write_floor_data( + self, + event: Event[tuple[int, Counter[int]]], + ) -> None: + """Reraise as server[write]->floor_data.""" + floor_id, floor_line = event.data + + buffer = Buffer() + buffer.write_value(StructFormat.UBYTE, floor_id) + buffer.extend(encode_numeric_uint8_counter(floor_line)) + + await self.write_event( + Event("server[write]->floor_data", buffer), + ) + + +class ServerPlayer(IntEnum): + """Server Player enum.""" + + one = 0 + two = auto() + three = auto() + four = auto() + singleplayer_all = auto() + spectator = auto() + + +class GameServer(network.Server): + """Azul server. + + Handles accepting incoming connections from clients and handles + main game logic via State subclass above. + """ + + __slots__ = ( + "actions_queue", + "advertisement_scope", + "client_count", + "client_players", + "internal_singleplayer_mode", + "players_can_interact", + "running", + "state", + ) + + max_clients = 4 + + def __init__(self, internal_singleplayer_mode: bool = False) -> None: + """Initialize server.""" + super().__init__("GameServer") + + self.client_count: int = 0 + self.state = State.blank() + + self.client_players: dict[int, int] = {} + self.players_can_interact: bool = False + + self.internal_singleplayer_mode = internal_singleplayer_mode + self.advertisement_scope: trio.CancelScope | None = None + self.running = False + + def bind_handlers(self) -> None: + """Register start_server and stop_server.""" + self.register_handlers( + { + "server_start": self.start_server, + "network_stop": self.stop_server, + "server_send_game_start": self.handle_server_start_new_game, + "factory_clicked->server": self.handle_client_factory_clicked, + "pattern_row_clicked->server": self.handle_client_pattern_row_clicked, + "cursor_location->server": self.handle_cursor_location, + "table_clicked->server": self.handle_client_table_clicked, + "floor_clicked->server": self.handle_client_floor_clicked, + }, + ) + + async def stop_server(self, event: Event[None] | None = None) -> None: + """Stop serving and disconnect all NetworkEventComponents.""" + self.stop_serving() + self.stop_advertising() + + close_methods: deque[Callable[[], Awaitable[object]]] = deque() + for component in self.get_all_components(): + if isinstance(component, network.NetworkEventComponent): + close_methods.append(component.close) + print(f"stop_server {component.name = }") + self.remove_component(component.name) + async with trio.open_nursery() as nursery: + while close_methods: + nursery.start_soon(close_methods.popleft()) + self.running = False + + async def post_advertisement( + self, + udp_socket: trio.socket.SocketType, + send_to_ip: str | int, + hosting_port: int, + ) -> None: + """Post server advertisement packet.""" + motd = "Azul Game" + advertisement = ( + f"[AD]{hosting_port}[/AD][AZUL]{motd}[/AZUL]" + ).encode() + # print("post_advertisement") + await udp_socket.sendto( + advertisement, + (send_to_ip, ADVERTISEMENT_PORT), + ) + + def stop_advertising(self) -> None: + """Cancel self.advertisement_scope.""" + if self.advertisement_scope is None: + return + self.advertisement_scope.cancel() + + async def post_advertisements(self, hosting_port: int) -> None: + """Post lan UDP packets so server can be found.""" + self.stop_advertising() + self.advertisement_scope = trio.CancelScope() + + # Look up multicast group address in name server and find out IP version + addrinfo = (await trio.socket.getaddrinfo(ADVERTISEMENT_IP, None))[0] + send_to_ip = addrinfo[4][0] + + with trio.socket.socket( + family=trio.socket.AF_INET, # IPv4 + type=trio.socket.SOCK_DGRAM, # UDP + proto=trio.socket.IPPROTO_UDP, # UDP + ) as udp_socket: + # Set Time-to-live (optional) + # ttl_bin = struct.pack('@i', MYTTL) + # if addrinfo[0] == trio.socket.AF_INET: # IPv4 + # udp_socket.setsockopt( + # trio.socket.IPPROTO_IP, trio.socket.IP_MULTICAST_TTL, ttl_bin) + # else: + # udp_socket.setsockopt( + # trio.socket.IPPROTO_IPV6, trio.socket.IPV6_MULTICAST_HOPS, ttl_bin) + with self.advertisement_scope: + print("Starting advertisement posting.") + while True: # not self.can_start(): + try: + await self.post_advertisement( + udp_socket, + send_to_ip, + hosting_port, + ) + except OSError as exc: + traceback.print_exception(exc) + print( + f"{self.__class__.__name__}: Failed to post server advertisement", + ) + break + await trio.sleep(1.5) + print("Stopped advertisement posting.") + + @staticmethod + def setup_teams_internal(client_ids: list[int]) -> dict[int, int]: + """Return teams for internal server mode given sorted client ids.""" + players: dict[int, int] = {} + for idx, client_id in enumerate(client_ids): + if idx == 0: + players[client_id] = ServerPlayer.singleplayer_all + else: + players[client_id] = ServerPlayer.spectator + return players + + @staticmethod + def setup_teams(client_ids: list[int]) -> dict[int, int]: + """Return teams given sorted client ids.""" + players: dict[int, int] = {} + for idx, client_id in enumerate(client_ids): + if idx < 4: + players[client_id] = ServerPlayer(idx % 4) + else: + players[client_id] = ServerPlayer.spectator + return players + + def new_game_init(self, variant_play: bool = False) -> None: + """Start new game.""" + print("server new_game_init") + self.client_players.clear() + + self.state = State.new_game( + max(2, min(4, self.client_count)), + variant_play, + ) + + # Why keep track of another object just to know client ID numbers + # if we already have that with the components? No need! + client_ids: set[int] = set() + for component in self.get_all_components(): + if isinstance(component, ServerClient): + client_ids.add(component.client_id) + + sorted_client_ids = sorted(client_ids) + if self.internal_singleplayer_mode: + self.client_players = self.setup_teams_internal(sorted_client_ids) + else: + self.client_players = self.setup_teams(sorted_client_ids) + + self.players_can_interact = True + + # "Implicit return in function which does not return" + async def start_server( # type: ignore[misc] + self, + event: Event[tuple[str | None, int]], + ) -> NoReturn: + """Serve clients.""" + print(f"{self.__class__.__name__}: Closing old server clients") + await self.stop_server() + print(f"{self.__class__.__name__}: Starting Server") + + host, port = event.data + + self.running = True + async with trio.open_nursery() as nursery: + # Do not post advertisements when using internal singleplayer mode + if not self.internal_singleplayer_mode: + nursery.start_soon(self.post_advertisements, port) + # Serve runs forever until canceled + nursery.start_soon(partial(self.serve, port, host, backlog=0)) + + async def transmit_new_round_data(self) -> None: + """Transmit all player board data, factory data, and table center data.""" + async with trio.open_nursery() as nursery: + for player_id, player_data in self.state.player_data.items(): + # Transmit board data + nursery.start_soon( + self.raise_event, + Event( + "board_data->network", + ( + player_id, + player_data.wall, + ), + ), + ) + # Transmit floor line data + nursery.start_soon( + self.raise_event, + Event( + "floor_data->network", + ( + player_id, + player_data.floor, + ), + ), + ) + # Transmit factory data + for ( + factory_id, + factory_tiles, + ) in self.state.factory_displays.items(): + nursery.start_soon( + self.raise_event, + Event( + "factory_data->network", + ( + factory_id, + factory_tiles, + ), + ), + ) + + # Transmit table center data + await self.raise_event( + Event( + "table_data->network", + self.state.table_center, + ), + ) + + async def transmit_pattern_line_data(self) -> None: + """Transmit all pattern line data for all players.""" + async with trio.open_nursery() as nursery: + # Transmit pattern line data + for player_id, player_data in self.state.player_data.items(): + for line_id, line_data in enumerate(player_data.lines): + nursery.start_soon( + self.raise_event, + Event( + "pattern_data->network", + ( + player_id, + line_id, + ( + max(0, int(line_data.color)), + line_data.count_, + ), + ), + ), + ) + + async def transmit_cursor_movement_mode(self) -> None: + """Update current cursor movement mode for all clients.""" + client_id = self.find_client_id_from_state_turn( + self.state.current_turn, + ) + + await self.raise_event( + Event( + f"cursor_movement_mode->network[{client_id}]", + True, + ), + ) + + async with trio.open_nursery() as nursery: + for other_client_id in self.client_players: + if other_client_id != client_id: + nursery.start_soon( + self.raise_event, + Event( + f"cursor_movement_mode->network[{other_client_id}]", + False, + ), + ) + + async def transmit_playing_as(self) -> None: + """Transmit playing as.""" + async with trio.open_nursery() as nursery: + for client_id, team in self.client_players.items(): + nursery.start_soon( + self.raise_event, + Event(f"playing_as->network[{client_id}]", team), + ) + + async def handle_server_start_new_game(self, event: Event[bool]) -> None: + """Handle game start.""" + variant_play = event.data + ## # Delete all pieces from last state (shouldn't be needed but still.) + ## async with trio.open_nursery() as nursery: + ## for piece_pos, _piece_type in self.state.get_pieces(): + ## nursery.start_soon( + ## self.raise_event, + ## Event("delete_piece->network", piece_pos), + ## ) + + # Choose which team plays first + # Using non-cryptographically secure random because it doesn't matter + self.new_game_init(variant_play) + + ## # Send create_piece events for all pieces + ## async with trio.open_nursery() as nursery: + ## for piece_pos, piece_type in self.state.get_pieces(): + ## nursery.start_soon( + ## self.raise_event, + ## Event("create_piece->network", (piece_pos, piece_type)), + ## ) + + # Raise initial config event with board size and initial turn. + await self.raise_event( + Event( + "initial_config->network", + ( + self.state.variant_play, + len(self.state.player_data), + len(self.state.factory_displays), + self.state.current_turn, + FLOOR_LINE_DATA, + ), + ), + ) + + await self.transmit_new_round_data() + + await self.transmit_cursor_movement_mode() + + await self.transmit_playing_as() + + async def client_network_loop( + self, + client: ServerClient, + controls_lobby: bool = False, + ) -> None: + """Network loop for given ServerClient. + + Could raise the following exceptions: + trio.BrokenResourceError: if something has gone wrong, and the stream + is broken. + trio.ClosedResourceError: if stream was previously closed + + Probably couldn't raise because of write lock but still: + trio.BusyResourceError: More than one task is trying to write + to socket at once. + """ + while not self.can_start() and not client.not_connected: + try: + await client.write_callback_ping() + await trio.sleep(1.5) + except ( + trio.BrokenResourceError, + trio.ClosedResourceError, + network.NetworkStreamNotConnectedError, + ): + print(f"{client.name} Disconnected in lobby.") + return + while not client.not_connected: + event: Event[bytearray] | None = None + try: + # await client.write_callback_ping() + with trio.move_on_after(1.5): + event = await client.read_event() + except network.NetworkTimeoutError: + print(f"{client.name} Timeout") + break + except network.NetworkEOFError: + print(f"{client.name} EOF") + break + except RuntimeError as exc: + traceback.print_exception(exc) + print(f"{client.name} Bad packet") + break + except ( + trio.BrokenResourceError, + trio.ClosedResourceError, + ) as exc: + traceback.print_exception(exc) + print(f"{client.name} Socket connection issue") + break + except Exception as exc: + traceback.print_exception(exc) + print(f"{client.name} Unhandled exception") + break + if event is not None: + # if controls_lobby: + # print(f"{client.name} client_network_loop tick") + # print(f"{client.name} {event = }") + await client.raise_event(event) + await client.write_callback_ping() + + def can_start(self) -> bool: + """Return if game can start.""" + if self.internal_singleplayer_mode: + return self.client_count >= 1 + return self.client_count >= 2 + + def game_active(self) -> bool: + """Return if game is active.""" + return self.state.current_phase != Phase.end + + async def send_spectator_join_packets( + self, + client: ServerClient, + ) -> None: + """Send spectator start data.""" + print("send_spectator_join_packets") + + private_events_pocket = ComponentManager( + f"private_events_pocket for {client.client_id}", + ) + with self.temporary_component(private_events_pocket): + with private_events_pocket.temporary_component(client): + # Raise initial config event with board size and initial turn. + await client.raise_event( + Event( + "initial_config->network", + ( + self.state.variant_play, + len(self.state.player_data), + len(self.state.factory_displays), + self.state.current_turn, + FLOOR_LINE_DATA, + ), + ), + ) + + await client.raise_event( + Event(f"playing_as->network[{client.client_id}]", 255), + ) + + async def handler(self, stream: trio.SocketStream) -> None: + """Accept clients. Called by network.Server.serve.""" + if self.client_count == 0 and self.game_active(): + # Old game was running but everyone left, restart + print("TODO: restart") + self.new_game_init() + new_client_id = self.client_count + + # Is controlling player? + is_zee_capitan = new_client_id == 0 + + print( + f"{self.__class__.__name__}: client connected [client_id {new_client_id}]", + ) + self.client_count += 1 + + can_start = self.can_start() + print(f"[azul.server] {can_start = }") + game_active = self.game_active() + print(f"[azul.server] {game_active = }") + # if can_start: + # self.stop_serving() + + if self.client_count > self.max_clients: + print( + f"{self.__class__.__name__}: client disconnected, too many clients", + ) + await stream.aclose() + self.client_count -= 1 + return + + async with ServerClient.from_stream( + new_client_id, + stream=stream, + ) as client: + # Encrypt traffic + await client.start_encryption_request() + assert client.encryption_enabled + + if can_start and game_active: + print("TODO: Joined as spectator") + # await self.send_spectator_join_packets(client) + with self.temporary_component(client): + if can_start and not game_active: # and is_zee_capitan: + print("[azul.server] game start trigger.") + variant_play = False + await self.raise_event( + Event("server_send_game_start", variant_play), + ) + try: + await self.client_network_loop(client, is_zee_capitan) + finally: + print( + f"{self.__class__.__name__}: client disconnected [client_id {new_client_id}]", + ) + self.client_count -= 1 + # ServerClient's `with` block handles closing stream. + + def find_client_id_from_server_player_id( + self, + server_player_id: ServerPlayer, + ) -> int | None: + """Return client id from server player id or None if not found.""" + for client_id, current_server_player_id in self.client_players.items(): + if current_server_player_id == server_player_id: + return client_id + # Return singleplayer client id if exists + if current_server_player_id == ServerPlayer.singleplayer_all: + return client_id + return None + + def find_server_player_id_from_state_turn( + self, + state_turn: int, + ) -> ServerPlayer: + """Return ServerPlayer id from game state turn.""" + if self.internal_singleplayer_mode: + return ServerPlayer.singleplayer_all + return ServerPlayer(state_turn) + + def find_client_id_from_state_turn(self, state_turn: int) -> int | None: + """Return client id from state turn or None if not found.""" + server_player_id = self.find_server_player_id_from_state_turn( + state_turn, + ) + return self.find_client_id_from_server_player_id(server_player_id) + + async def handle_client_factory_clicked( + self, + event: Event[tuple[int, int, Tile]], + ) -> None: + """Handle client clicked a factory tile.""" + if not self.players_can_interact: + print("Players are not allowed to interact.") + await trio.lowlevel.checkpoint() + return + + client_id, factory_id, tile = event.data + + server_player_id = self.client_players[client_id] + + if server_player_id == ServerPlayer.spectator: + print(f"Spectator cannot select {factory_id = } {tile}") + await trio.lowlevel.checkpoint() + return + + player_id = int(server_player_id) + if server_player_id == ServerPlayer.singleplayer_all: + player_id = self.state.current_turn + + if player_id != self.state.current_turn: + print( + f"Player {player_id} (client ID {client_id}) cannot select factory tile, not their turn.", + ) + await trio.lowlevel.checkpoint() + return + + if self.state.current_phase != Phase.factory_offer: + print( + f"Player {player_id} (client ID {client_id}) cannot select factory tile, not in factory offer phase.", + ) + await trio.lowlevel.checkpoint() + return + + factory_display = self.state.factory_displays.get(factory_id) + if factory_display is None: + print( + f"Player {player_id} (client ID {client_id}) cannot select invalid factory {factory_id!r}.", + ) + await trio.lowlevel.checkpoint() + return + + if tile < 0 or tile not in factory_display: + print( + f"Player {player_id} (client ID {client_id}) cannot select nonexistent color {tile}.", + ) + await trio.lowlevel.checkpoint() + return + + if not self.state.can_cursor_select_factory_color( + factory_id, + int(tile), + ): + print( + f"Player {player_id} (client ID {client_id}) cannot select factory tile, state says no.", + ) + await trio.lowlevel.checkpoint() + return + + # Perform move + self.state = self.state.cursor_selects_factory(factory_id, int(tile)) + + # Send updates to client + # Send factory display changes + await self.raise_event( + Event( + "factory_data->network", + ( + factory_id, + self.state.factory_displays[factory_id], + ), + ), + ) + await self.raise_event( + Event( + "cursor_data->network", + self.state.cursor_contents, + ), + ) + await self.raise_event( + Event( + "table_data->network", + self.state.table_center, + ), + ) + + async def handle_client_table_clicked( + self, + event: Event[tuple[int, Tile]], + ) -> None: + """Handle client clicked a table center tile.""" + if not self.players_can_interact: + print("Players are not allowed to interact.") + await trio.lowlevel.checkpoint() + return + + client_id, tile = event.data + + server_player_id = self.client_players[client_id] + + if server_player_id == ServerPlayer.spectator: + print(f"Spectator cannot select table center {tile}") + await trio.lowlevel.checkpoint() + return + + player_id = int(server_player_id) + if server_player_id == ServerPlayer.singleplayer_all: + player_id = self.state.current_turn + + if player_id != self.state.current_turn: + print( + f"Player {player_id} (client ID {client_id}) cannot select table center tile, not their turn.", + ) + await trio.lowlevel.checkpoint() + return + + if self.state.current_phase != Phase.factory_offer: + print( + f"Player {player_id} (client ID {client_id}) cannot select table center tile, not in factory offer phase.", + ) + await trio.lowlevel.checkpoint() + return + + if not self.state.can_cursor_select_center( + int(tile), + ): + print( + f"Player {player_id} (client ID {client_id}) cannot select table center tile, state says no.", + ) + await trio.lowlevel.checkpoint() + return + + # Perform move + self.state = self.state.cursor_selects_table_center(int(tile)) + + # Send updates to client + await self.raise_event( + Event( + "cursor_data->network", + self.state.cursor_contents, + ), + ) + await self.raise_event( + Event( + "table_data->network", + self.state.table_center, + ), + ) + + async def handle_client_pattern_row_clicked( + self, + event: Event[tuple[int, int, tuple[int, int]]], + ) -> None: + """Handle client clicking on pattern row.""" + if not self.players_can_interact: + print("Players are not allowed to interact.") + await trio.lowlevel.checkpoint() + return + + client_id, row_id, row_pos = event.data + + server_player_id = self.client_players[client_id] + + if server_player_id == ServerPlayer.spectator: + print(f"Spectator cannot select {row_id = } {row_pos}") + await trio.lowlevel.checkpoint() + return + + player_id = int(server_player_id) + if server_player_id == ServerPlayer.singleplayer_all: + player_id = self.state.current_turn + + if player_id != self.state.current_turn: + print( + f"Player {player_id} (client ID {client_id}) cannot select pattern row, not their turn.", + ) + await trio.lowlevel.checkpoint() + return + + if self.state.current_phase != Phase.factory_offer: + print( + f"Player {player_id} (client ID {client_id}) cannot select pattern row, not in factory offer phase.", + ) + await trio.lowlevel.checkpoint() + return + + if player_id != row_id: + print( + f"Player {player_id} (client ID {client_id}) cannot select pattern row {row_id} that does not belong to them.", + ) + await trio.lowlevel.checkpoint() + return + + column, line_id = row_pos + + if line_id >= 5: + print( + f"Player {player_id} (client ID {client_id}) cannot select pattern row {row_id} line {line_id} (invalid line id).", + ) + await trio.lowlevel.checkpoint() + return + if column >= 5: + print( + f"Player {player_id} (client ID {client_id}) cannot select pattern row {row_id} column {column} (invalid column).", + ) + await trio.lowlevel.checkpoint() + return + + currently_placed = self.state.get_player_line_current_place_count( + line_id, + ) + + place_count = 5 - column - currently_placed + + if self.state.is_cursor_empty(): + print( + f"Player {player_id} (client ID {client_id}) cannot select pattern row {row_id} when not holding tiles.", + ) + await trio.lowlevel.checkpoint() + return + color = self.state.get_cursor_holding_color() + + current_hold_count = self.state.cursor_contents[color] + place_count = min(place_count, current_hold_count) + + if not self.state.can_player_select_line(line_id, color, place_count): + print( + f"Player {player_id} (client ID {client_id}) cannot select pattern line {line_id} placing {place_count} {Tile(color)} tiles.", + ) + await trio.lowlevel.checkpoint() + return + + prev_player_turn = self.state.current_turn + + self.state = self.state.player_selects_pattern_line( + line_id, + place_count, + ) + + if ( + self.state.current_turn != player_id + and not self.internal_singleplayer_mode + ): + new_client_id = self.find_client_id_from_state_turn( + self.state.current_turn, + ) + assert new_client_id is not None + await self.raise_event( + Event( + f"cursor_movement_mode->network[{client_id}]", + False, + ), + ) + await self.raise_event( + Event( + f"cursor_movement_mode->network[{new_client_id}]", + True, + ), + ) + + await self.raise_event( + Event( + "floor_data->network", + ( + player_id, + self.state.player_data[player_id].floor, + ), + ), + ) + + if self.state.current_phase != Phase.wall_tiling: + raw_tile_color, tile_count = self.state.player_data[ + prev_player_turn + ].lines[line_id] + # Do not send blank colors, clamp to zero + tile_color = max(0, int(raw_tile_color)) + await self.raise_event( + Event( + "pattern_data->network", + ( + prev_player_turn, + line_id, + (tile_color, tile_count), + ), + ), + ) + + await self.raise_event( + Event( + "cursor_data->network", + self.state.cursor_contents, + ), + ) + + did_auto_wall_tile = False + if self.state.current_phase == Phase.wall_tiling: + if not self.state.variant_play: + did_auto_wall_tile = True + self.state = self.state.apply_auto_wall_tiling() + await self.transmit_new_round_data() + await self.transmit_pattern_line_data() + + if self.state.current_phase == Phase.end: + print("TODO: Handle end of game.") + + if self.state.current_turn != player_id or did_auto_wall_tile: + await self.raise_event( + Event( + "current_turn_change->network", + self.state.current_turn, + ), + ) + + async def handle_cursor_location( + self, + event: Event[tuple[int, tuple[int, int]]], + ) -> None: + """Handle cursor location sent from client.""" + if not self.players_can_interact: + print("Players are not allowed to interact.") + await trio.lowlevel.checkpoint() + return + + client_id, location = event.data + + server_player_id = self.client_players[client_id] + + if server_player_id == ServerPlayer.spectator: + print("Spectator cannot control cursor") + await trio.lowlevel.checkpoint() + return + + player_id = int(server_player_id) + if server_player_id == ServerPlayer.singleplayer_all: + player_id = self.state.current_turn + + if player_id != self.state.current_turn: + print( + f"Player {player_id} (client ID {client_id}) cannot move cursor, not their turn.", + ) + await trio.lowlevel.checkpoint() + return + + # print(f"handle_cursor_location {client_id = } {location = }") + + if self.internal_singleplayer_mode: + await trio.lowlevel.checkpoint() + return + + async with trio.open_nursery() as nursery: + for other_client_id in self.client_players: + if other_client_id != client_id: + nursery.start_soon( + self.raise_event, + Event( + f"cursor_position->network[{other_client_id}]", + location, + ), + ) + + async def handle_client_floor_clicked( + self, + event: Event[tuple[int, int, int]], + ) -> None: + """Handle client clicking floor line.""" + if not self.players_can_interact: + print("Players are not allowed to interact.") + await trio.lowlevel.checkpoint() + return + + client_id, floor_line_id, location_x = event.data + + server_player_id = self.client_players[client_id] + + if server_player_id == ServerPlayer.spectator: + print("Spectator cannot select floor line") + await trio.lowlevel.checkpoint() + return + + player_id = int(server_player_id) + if server_player_id == ServerPlayer.singleplayer_all: + player_id = self.state.current_turn + + if player_id != self.state.current_turn: + print( + f"Player {player_id} (client ID {client_id}) cannot select floor line, not their turn.", + ) + await trio.lowlevel.checkpoint() + return + + if self.state.current_phase != Phase.factory_offer: + print( + f"Player {player_id} (client ID {client_id}) cannot select floor line, not in factory offer phase.", + ) + await trio.lowlevel.checkpoint() + return + + if player_id != floor_line_id: + print( + f"Player {player_id} (client ID {client_id}) cannot select floor line {floor_line_id} that does not belong to them.", + ) + await trio.lowlevel.checkpoint() + return + + if self.state.is_cursor_empty(): + print( + f"Player {player_id} (client ID {client_id}) cannot select floor line when not holding tiles.", + ) + await trio.lowlevel.checkpoint() + return + + color = self.state.get_cursor_holding_color() + + place_count = min(location_x + 1, self.state.cursor_contents[color]) + + self.state = self.state.player_select_floor_line(color, place_count) + + await self.raise_event( + Event( + "cursor_data->network", + self.state.cursor_contents, + ), + ) + + await self.raise_event( + Event( + "floor_data->network", + ( + player_id, + self.state.player_data[player_id].floor, + ), + ), + ) + + did_auto_wall_tile = False + if self.state.current_phase == Phase.wall_tiling: + if not self.state.variant_play: + did_auto_wall_tile = True + self.state = self.state.apply_auto_wall_tiling() + await self.transmit_new_round_data() + await self.transmit_pattern_line_data() + + if self.state.current_phase == Phase.end: + print("TODO: Handle end of game.") + + if self.state.current_turn != player_id or did_auto_wall_tile: + await self.raise_event( + Event( + "current_turn_change->network", + self.state.current_turn, + ), + ) + + def __del__(self) -> None: + """Debug print.""" + print(f"del {self.__class__.__name__}") + super().__del__() + + +async def run_server( + server_class: type[GameServer], + host: str, + port: int, +) -> None: + """Run machine client and raise tick events.""" + async with trio.open_nursery() as main_nursery: + event_manager = ExternalRaiseManager( + "azul", + main_nursery, + ) + server = server_class() + event_manager.add_component(server) + + await event_manager.raise_event(Event("server_start", (host, port))) + while not server.running: + print("Server starting...") + await trio.sleep(1) + + print("\nServer running.") + + try: + while server.running: # noqa: ASYNC110 # sleep in while loop + # Process background tasks in the main nursery + await trio.sleep(0.01) + except KeyboardInterrupt: + print("\nClosing from keyboard interrupt.") + await server.stop_server() + server.unbind_components() + + +async def cli_run_async() -> None: + """Run game server.""" + host = await find_ip() + port = DEFAULT_PORT + await run_server(GameServer, host, port) + + +def cli_run() -> None: + """Run game server.""" + trio.run(cli_run_async) + + +if __name__ == "__main__": + cli_run() diff --git a/src/azul/sound.py b/src/azul/sound.py new file mode 100644 index 0000000..1576592 --- /dev/null +++ b/src/azul/sound.py @@ -0,0 +1,67 @@ +"""Sound - Play sounds.""" + +# Programmed by CoolCat467 + +from __future__ import annotations + +# Sound - Play sounds +# Copyright (C) 2024 CoolCat467 +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +__title__ = "sound" +__author__ = "CoolCat467" +__version__ = "0.0.0" +__license__ = "GNU General Public License Version 3" + + +from typing import TYPE_CHECKING, NamedTuple + +from pygame import mixer + +if TYPE_CHECKING: + from os import PathLike + + +class SoundData(NamedTuple): + """Sound data container.""" + + loops: int = 0 + maxtime: int = 0 + fade_ms: int = 0 + volume: int = 100 + # volume_left: int = 100 + # volume_right: int = 100 + + +def play_sound( # pragma: nocover + filename: PathLike[str] | str, + sound_data: SoundData, +) -> tuple[mixer.Sound, int | float]: + """Play sound with pygame.""" + sound_object = mixer.Sound(filename) + sound_object.set_volume(sound_data.volume) + seconds: int | float = sound_object.get_length() + if sound_data.maxtime > 0: + seconds = sound_data.maxtime + _channel = sound_object.play( + loops=sound_data.loops, + maxtime=sound_data.maxtime, + fade_ms=sound_data.fade_ms, + ) + # channel.set_volume( + # sound_data.volume_left, + # sound_data.volume_right, + # ) + return sound_object, seconds diff --git a/src/azul/sprite.py b/src/azul/sprite.py index 8dc9aa6..edab339 100644 --- a/src/azul/sprite.py +++ b/src/azul/sprite.py @@ -27,6 +27,7 @@ from typing import TYPE_CHECKING, Any, ClassVar, NamedTuple, TypedDict, cast import trio +from libcomponent.component import Component, ComponentManager, Event from pygame.color import Color from pygame.event import Event as PygameEvent, event_name from pygame.mask import Mask, from_surface as mask_from_surface @@ -34,7 +35,6 @@ from pygame.sprite import LayeredDirty, LayeredUpdates, WeakDirtySprite from pygame.surface import Surface -from azul.component import Component, ComponentManager, Event from azul.statemachine import AsyncStateMachine from azul.vector import Vector2 @@ -99,13 +99,9 @@ def _set_location(self, value: tuple[int, int]) -> None: """Set rect center from tuple of integers.""" self.rect.center = value - def __set_location(self, value: tuple[int, int]) -> None: - """Set rect center from tuple of integers.""" - self._set_location(value) - location = property( __get_location, - __set_location, + _set_location, doc="Location (Center of image)", ) @@ -253,7 +249,7 @@ def get_mask(self, identifier: int | str) -> Mask: while True: if not self.image_exists(identifier): raise ValueError( - f'No image saved for identifier "{identifier}"', + f'No mask saved for identifier "{identifier}"', ) mask = self.__masks[identifier] if isinstance(mask, Mask): @@ -325,7 +321,7 @@ def set_color(self, color: Color | None) -> None: assert manager.set_surface is not None manager.set_image(manager.set_surface) - def get_outline_discriptor(self, identifier: str | int) -> str: + def get_outline_descriptor(self, identifier: str | int) -> str: """Return outlined identifier for given original identifier.""" color = "_".join(map(str, self.__color)) return f"{identifier}{self.mod}{color}_{self.size}" @@ -334,7 +330,7 @@ def save_outline(self, identifier: str | int) -> None: """Save outlined version of given identifier image.""" manager = cast("ImageComponent", self.manager) - outlined = self.get_outline_discriptor(identifier) + outlined = self.get_outline_descriptor(identifier) if manager.image_exists(outlined): return @@ -367,7 +363,7 @@ def save_outline(self, identifier: str | int) -> None: def get_outline(self, identifier: str | int) -> str: """Return saved outline effect identifier.""" self.save_outline(identifier) - return self.get_outline_discriptor(identifier) + return self.get_outline_descriptor(identifier) def precalculate_outline( self, @@ -429,7 +425,7 @@ async def tick(self, tick_event: Event[TickEventData]) -> None: await trio.lowlevel.checkpoint() passed = tick_event.data.time_passed - new = None + new: int | str | None = None if self.update_every == 0: new = self.fetch_controller_new_state() else: @@ -501,6 +497,9 @@ def move_heading_time(self, time_passed: float) -> None: class TargetingComponent(Component): """Sprite that moves toward a destination and then stops. + Registered Component Name: + targeting + Requires components: Sprite MovementComponent @@ -526,6 +525,7 @@ def update_heading(self) -> None: """Update the heading of the movement component.""" movement = cast("MovementComponent", self.get_component("movement")) to_dest = self.to_destination() + # If magnitude is zero if to_dest @ to_dest == 0: movement.heading = Vector2(0, 0) return @@ -555,6 +555,7 @@ def to_destination(self) -> Vector2: async def move_destination_time(self, time_passed: float) -> None: """Move with time_passed.""" if self.__reached: + await trio.lowlevel.checkpoint() return sprite, movement = cast( @@ -566,16 +567,27 @@ async def move_destination_time(self, time_passed: float) -> None: self.__reached = True await self.raise_event(Event(self.event_raise_name, None)) return - await trio.lowlevel.checkpoint() - travel_distance = min( - self.to_destination().magnitude(), - movement.speed * time_passed, - ) + to_destination = self.to_destination() + dest_magnitude = to_destination.magnitude() + travel_distance = movement.speed * time_passed if travel_distance > 0: - movement.move_heading_distance(travel_distance) - self.update_heading() # Fix imprecision + if travel_distance > dest_magnitude: + sprite.location = self.destination + else: + # Fix imprecision + self.update_heading() + if travel_distance > 0: + movement.move_heading_distance(travel_distance) + await trio.lowlevel.checkpoint() + + async def move_destination_time_ticks( + self, + event: Event[TickEventData], + ) -> None: + """Move with tick data.""" + await self.move_destination_time(event.data.time_passed) class DragEvent(NamedTuple): @@ -583,7 +595,7 @@ class DragEvent(NamedTuple): pos: tuple[int, int] rel: tuple[int, int] - button: int + buttons: dict[int, bool] class DragClickEventComponent(Component): @@ -658,20 +670,17 @@ async def motion( if not self.manager_exists: return async with trio.open_nursery() as nursery: - for button, pressed in self.pressed.items(): - if not pressed: - continue - nursery.start_soon( - self.raise_event, - Event( - "drag", - DragEvent( - event.data["pos"], - event.data["rel"], - button, - ), + nursery.start_soon( + self.raise_event, + Event( + "drag", + DragEvent( + event.data["pos"], + event.data["rel"], + self.pressed, ), - ) + ), + ) class GroupProcessor(AsyncStateMachine): @@ -679,13 +688,12 @@ class GroupProcessor(AsyncStateMachine): __slots__ = ("_clear", "_timing", "group_names", "groups", "new_gid") sub_renderer_class: ClassVar = LayeredDirty - groups: dict[int, sub_renderer_class] def __init__(self) -> None: """Initialize group processor.""" super().__init__() - self.groups = {} + self.groups: dict[int, LayeredDirty[Sprite]] = {} self.group_names: dict[str, int] = {} self.new_gid = 0 self._timing = 1000 / 80 @@ -737,7 +745,7 @@ def remove_group(self, gid: int) -> None: del self.group_names[name] return - def get_group(self, gid_name: str | int) -> sub_renderer_class | None: + def get_group(self, gid_name: str | int) -> LayeredDirty[Sprite] | None: """Return group from group ID or name.""" named = None if isinstance(gid_name, str): @@ -772,12 +780,12 @@ def clear_groups(self) -> None: for group_id in tuple(self.groups): self.remove_group(group_id) - def __del__(self) -> None: + def __del__(self) -> None: # pragma: nocover """Clear groups.""" self.clear_groups() -def convert_pygame_event(event: PygameEvent) -> Event[Any]: +def convert_pygame_event(event: PygameEvent) -> Event[Any]: # pragma: nocover """Convert Pygame Event to Component Event.""" # data = event.dict # data['type_int'] = event.type diff --git a/src/azul/state.py b/src/azul/state.py new file mode 100644 index 0000000..d19934f --- /dev/null +++ b/src/azul/state.py @@ -0,0 +1,1288 @@ +"""Azul State.""" + +# Programmed by CoolCat467 + +from __future__ import annotations + +# Copyright (C) 2024 CoolCat467 +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +__title__ = "Azul State" +__author__ = "CoolCat467" +__license__ = "GNU General Public License Version 3" +__version__ = "0.0.0" + + +import random +from collections import Counter +from enum import IntEnum, auto +from typing import ( + TYPE_CHECKING, + Any, + Final, + NamedTuple, + TypeVar, +) + +from numpy import full, int8 + +if TYPE_CHECKING: + from collections.abc import Generator + from typing import Self + + from numpy.typing import NDArray + +T = TypeVar("T") + +FLOOR_LINE_COUNT: Final = 7 + + +def floor_line_subtract_generator(seed: int = 1) -> Generator[int, None, None]: + """Floor Line subtraction number generator. Can continue indefinitely.""" + while True: + yield from (-seed,) * (seed + 1) + seed += 1 + + +FLOOR_LINE_DATA: Final = tuple( + value + for _, value in zip( + range(FLOOR_LINE_COUNT), + floor_line_subtract_generator(), + strict=False, + ) +) + + +class Tile(IntEnum): + """All type types.""" + + blank = -6 + fake_cyan = -5 + fake_black = -4 + fake_red = -3 + fake_yellow = -2 + fake_blue = -1 + blue = 0 + yellow = auto() + red = auto() + black = auto() + cyan = auto() + one = auto() + + +REAL_TILES: Final = {Tile.blue, Tile.yellow, Tile.red, Tile.black, Tile.cyan} + + +class Phase(IntEnum): + """Game phases.""" + + factory_offer = 0 + wall_tiling = auto() + end = auto() + + +def generate_bag_contents() -> Counter[int]: + """Generate and return unrandomized bag.""" + tile_types = 5 + tile_count = 100 + count_each = tile_count // tile_types + return Counter(dict.fromkeys(range(tile_types), count_each)) + + +def bag_draw_tile(bag: Counter[int]) -> int: + """Return drawn tile from bag. Mutates bag.""" + # S311 Standard pseudo-random generators are not suitable for + # cryptographic purposes + tile = random.choice(tuple(bag.elements())) # noqa: S311 + bag[tile] -= 1 + return tile + + +def select_color(holder: Counter[int], color: int) -> int: + """Pop color tiles from bag. Returns count. Mutates holder. + + Raises KeyError if color not in holder. + """ + return holder.pop(color) + + +class PatternLine(NamedTuple): + """Player pattern line row.""" + + color: Tile + count_: int + + @classmethod + def blank(cls) -> Self: + """Return new blank pattern line.""" + return cls( + color=Tile.blank, + count_=0, + ) + + def place_tiles(self, color: Tile, place_count: int) -> Self: + """Return new pattern line after placing tiles of given color.""" + assert self.color == Tile.blank or self.color == color + assert place_count > 0 + return self._replace( + color=color, + count_=self.count_ + place_count, + ) + + +def remove_counter_zeros(counter: Counter[Any]) -> None: + """Remove any zero counts from given counter. Mutates counter.""" + for key, count in tuple(counter.items()): + if count == 0: + del counter[key] + + +def floor_fill_tile_excess( + floor: Counter[int], + tile: int, + count: int, +) -> Counter[int]: + """Fill floor with count of tile, return excess for box lid. Mutates floor.""" + excess: Counter[int] = Counter() + while floor.total() < FLOOR_LINE_COUNT and count > 0: + floor[tile] += 1 + count -= 1 + # If overflow and it's number one tile + if count and tile == Tile.one: + # Move non-one tiles from floor to excess + non_one = floor.total() - floor[Tile.one] + assert non_one > 0 + for _ in range(min(non_one, count)): + non_one_tiles = set(floor.elements()) - {Tile.one} + non_one_tile = sorted(non_one_tiles).pop() + # Move non-one tile from floor to box lid + floor[non_one_tile] -= 1 + excess[non_one_tile] += 1 + # Add one tile to floor + floor[tile] += 1 + count -= 1 + remove_counter_zeros(floor) + assert count >= 0 + if count: + # Add overflow tiles to box lid. + excess[tile] += count + + return excess + + +class UnplayableTileError(Exception): + """Unplayable Tile Exception.""" + + __slots__ = ("y",) + + def __init__(self, y: int) -> None: + """Remember Y position.""" + self.y = y + + +class PlayerData(NamedTuple): + """Player data.""" + + score: int + wall: NDArray[int8] + lines: tuple[PatternLine, ...] + floor: Counter[int] + + @classmethod + def new(cls, variant_play: bool = False) -> Self: + """Return new player data instance.""" + wall = full((5, 5), Tile.blank, int8) + + if not variant_play: + for y in range(5): + for x in range(5): + color = -((5 - y + x) % len(REAL_TILES) + 1) + wall[y, x] = color + + return cls( + score=0, + wall=wall, + lines=(PatternLine.blank(),) * 5, + floor=Counter(), + ) + + def copy(self) -> Self: + """Return copy of self.""" + return self._replace( + floor=self.floor.copy(), + ) + + def line_id_valid(self, line_id: int) -> bool: + """Return if given line id is valid.""" + return line_id >= 0 and line_id < len(self.lines) + + @staticmethod + def get_line_max_count(line_id: int) -> int: + """Return max count allowed in given line.""" + # Line id is keeping track of max count + return line_id + 1 + + def get_line_current_place_count(self, line_id: int) -> int: + """Return count of currently placed tiles for given line.""" + assert self.line_id_valid(line_id) + return self.lines[line_id].count_ + + def get_line_max_placable_count(self, line_id: int) -> int: + """Return max placable count for given line.""" + assert self.line_id_valid(line_id) + max_count = self.get_line_max_count(line_id) + return max_count - self.lines[line_id].count_ + + def get_row_colors_used(self, line_id: int) -> set[Tile]: + """Return set of tile colors used in wall for given row.""" + row = self.wall[line_id, :] + return {Tile(int(x)) for x in row[row >= 0]} + + def get_row_unused_colors(self, line_id: int) -> set[Tile]: + """Return set of tiles colors not currently used in wall for given row.""" + return REAL_TILES - self.get_row_colors_used(line_id) + + def yield_possible_placement_rows( + self, + color: int, + ) -> Generator[tuple[int, int], None, None]: + """Yield row line ids and number of placable for rows able to place color at.""" + for line_id, line in enumerate(self.lines): + # Color must match + if line.color != Tile.blank and int(line.color) != color: + # print("color mismatch") + continue + placable = self.get_line_max_placable_count(line_id) + # Must have placable spots + if not placable: + continue + # Must not already use color + if color in self.get_row_colors_used(line_id): + continue + yield (line_id, placable) + + def can_select_line( + self, + line_id: int, + color: int, + place_count: int, + ) -> bool: + """Return if can select given line with given color and place count.""" + if not self.line_id_valid(line_id): + # print("invalid line id") + return False + line = self.lines[line_id] + # Don't allow placing zero + if place_count <= 0: + # print("place count too smol") + return False + # Color must match + if line.color != Tile.blank and int(line.color) != color: + # print("color mismatch") + return False + # Must have space to place + if place_count > self.get_line_max_placable_count(line_id): + return False + # Can't place in row that uses that color already + return Tile(color) not in self.get_row_colors_used(line_id) + + @staticmethod + def replace_pattern_line( + lines: tuple[PatternLine, ...], + line_id: int, + new: PatternLine, + ) -> tuple[PatternLine, ...]: + """Return new pattern line data after replacing one of them.""" + left = lines[:line_id] + right = lines[line_id + 1 :] + return (*left, new, *right) + + def place_pattern_line_tiles( + self, + line_id: int, + color: int, + place_count: int, + ) -> Self: + """Return new player data after placing tiles in a pattern line.""" + assert self.can_select_line(line_id, color, place_count) + line = self.lines[line_id] + return self._replace( + lines=self.replace_pattern_line( + self.lines, + line_id, + line.place_tiles(Tile(color), place_count), + ), + ) + + def is_floor_line_full(self) -> bool: + """Return if floor line is full.""" + return self.floor.total() >= FLOOR_LINE_COUNT + + def place_floor_line_tiles( + self, + color: int, + place_count: int, + ) -> tuple[Self, Counter[int]]: + """Return new player and excess tiles for box lid.""" + floor = self.floor.copy() + for_box_lid = floor_fill_tile_excess(floor, color, place_count) + assert all(x > 0 for x in for_box_lid.values()), for_box_lid + return ( + self._replace(floor=floor), + for_box_lid, + ) + + def get_horizontal_linked_wall_count( + self, + x: int, + y: int, + wall: NDArray[int8], + ) -> int: + """Return horizontally-linked tile count.""" + count = 0 + for range_ in (range(x - 1, -1, -1), range(x + 1, 5)): + for cx in range_: + if wall[y, cx] < 0: + break + count += 1 + return count + + def get_vertically_linked_wall_count( + self, + x: int, + y: int, + wall: NDArray[int8], + ) -> int: + """Return vertically-linked tile count.""" + count = 0 + for range_ in (range(y - 1, -1, -1), range(y + 1, 5)): + for cy in range_: + if wall[cy, x] < 0: + break + count += 1 + return count + + def get_score_from_wall_placement( + self, + color: int, + x: int, + y: int, + wall: NDArray[int8], + ) -> int: + """Return score increment value from placing tile at given coordinates.""" + # Should be blank or fake at position + assert wall[y, x] < 0 + count = 1 + count += self.get_horizontal_linked_wall_count(x, y, wall) + count += self.get_vertically_linked_wall_count(x, y, wall) + return count + + def get_floor_line_scoring(self) -> int: + """Return score increment value from floor line.""" + total_count = self.floor.total() + assert total_count <= FLOOR_LINE_COUNT + score = 0 + for idx in range(total_count): + score += FLOOR_LINE_DATA[idx] + return score + + def perform_auto_wall_tiling(self) -> tuple[Self, Counter[int], bool]: + """Return new player data and tiles for box lid after performing automatic wall tiling.""" + for_box_lid: Counter[int] = Counter() + + score = self.score + new_lines = self.lines + new_wall = self.wall.copy() + for line_id, line in enumerate(self.lines): + if line.count_ != self.get_line_max_count(line_id): + continue + left = max(0, line.count_ - 1) + if left: + for_box_lid[line.color] += left + # placed tile is stuck in the wall now + x = tuple(map(int, new_wall[line_id, :])).index(-line.color - 1) + score += self.get_score_from_wall_placement( + line.color, + x, + line_id, + new_wall, + ) + new_wall[line_id, x] = line.color + new_lines = self.replace_pattern_line( + new_lines, + line_id, + PatternLine.blank(), + ) + + score += self.get_floor_line_scoring() + if score < 0: + score = 0 + + # Get one tile from floor line + floor = self.floor.copy() + has_one = False + if floor[Tile.one]: + floor[Tile.one] -= 1 + remove_counter_zeros(floor) + has_one = True + for_box_lid.update(floor) + + return ( + self._replace( + lines=new_lines, + wall=new_wall, + score=score, + floor=Counter(), + ), + for_box_lid, + has_one, + ) + + def has_horizontal_wall_line(self) -> bool: + """Return if full horizontal line is filled anywhere.""" + return any(all(self.wall[y, :] >= 0) for y in range(5)) + + def get_filled_horizontal_line_count(self) -> int: + """Return number of filled horizontal lines.""" + count = 0 + for y in range(5): + if all(self.wall[y, :] >= 0): + count += 1 + return count + + def get_end_of_game_score(self) -> int: + """Return end of game score for this player.""" + score = self.score + score += self.get_filled_horizontal_line_count() * 2 + for x in range(5): + if all(self.wall[:, x] >= 0): + score += 7 + counts = Counter(int(x) for x in self.wall[self.wall >= 0]) + for count in counts.values(): + if count == 5: + score += 10 + return score + + def perform_end_of_game_scoring(self) -> Self: + """Return new player data after performing end of game scoring.""" + return self._replace(score=self.get_end_of_game_score()) + + def get_manual_wall_tile_location(self) -> tuple[int, list[int]] | None: + """Return tuple of row and placable columns for wall tiling, or None if done. + + Raises UnplayableTileError if no valid placement locations. + """ + for y, line in enumerate(self.lines): + if line.color == Tile.blank: + continue + if line.count_ != self.get_line_max_count(y): + continue + + valid_x: list[int] = [] + for x, is_open in enumerate(self.wall[y, :] >= 0): + if not is_open: + continue + if line.color in {Tile(int(v)) for v in self.wall[:, x]}: + continue + valid_x.append(x) + if not valid_x: + raise UnplayableTileError(y) + return (y, valid_x) + return None + + def handle_unplayable_wall_tiling( + self, + y: int, + ) -> tuple[Self, Counter[int]]: + """Return new player data and tiles for floor line.""" + line = self.lines[y] + assert line.color != Tile.blank + + new_lines = self.replace_pattern_line( + self.lines, + y, + PatternLine.blank(), + ) + + return self._replace( + lines=new_lines, + ).place_floor_line_tiles(line.color, line.count_) + + def manual_wall_tiling_action( + self, + line_id: int, + x_pos: int, + ) -> tuple[Self, Counter[int]]: + """Wall tile given full line to given x position in that row. + + Return new player data and any tiles to return to box lid. + """ + for_box_lid: Counter[int] = Counter() + + score = self.score + new_lines = self.lines + new_wall = self.wall.copy() + + line = self.lines[line_id] + + assert line.count_ == self.get_line_max_count(line_id) + assert line.color != Tile.blank + assert new_wall[line_id, x_pos] == Tile.blank + + left = max(0, line.count_ - 1) + if left: + for_box_lid[line.color] += left + # placed tile is stuck in wall now + score += self.get_score_from_wall_placement( + line.color, + x_pos, + line_id, + new_wall, + ) + new_wall[line_id, x_pos] = line.color + new_lines = self.replace_pattern_line( + new_lines, + line_id, + PatternLine.blank(), + ) + + return ( + self._replace( + lines=new_lines, + wall=new_wall, + score=score, + ), + for_box_lid, + ) + + def finish_manual_wall_tiling(self) -> tuple[Self, Counter[int], bool]: + """Return new player data and tiles for box lid after performing manual wall tiling.""" + for_box_lid: Counter[int] = Counter() + + score = self.score + + score += self.get_floor_line_scoring() + if score < 0: + score = 0 + + # Get one tile from floor line + floor = self.floor.copy() + has_one = False + if floor[Tile.one]: + floor[Tile.one] -= 1 + remove_counter_zeros(floor) + has_one = True + for_box_lid.update(floor) + + return ( + self._replace( + score=score, + floor=Counter(), + ), + for_box_lid, + has_one, + ) + + +def factory_displays_deepcopy( + factory_displays: dict[int, Counter[int]], +) -> dict[int, Counter[int]]: + """Return deepcopy of factory displays.""" + return {k: v.copy() for k, v in factory_displays.items()} + + +def player_data_deepcopy( + player_data: dict[int, PlayerData], +) -> dict[int, PlayerData]: + """Return deepcopy of player data.""" + return {k: v.copy() for k, v in player_data.items()} + + +class SelectableSource(IntEnum): + """Selectable tile source.""" + + table_center = 0 + factory = auto() + + +class SelectableSourceTiles(NamedTuple): + """Selectable source tiles data.""" + + source: SelectableSource + tiles: Tile + # Factory ids + source_id: int | None = None + + +class SelectableDestination(IntEnum): + """Selectable tile destination.""" + + floor_line = 0 + pattern_line = auto() + + +class SelectableDestinationTiles(NamedTuple): + """Selectable destination tiles data.""" + + destination: SelectableDestination + place_count: int + # Pattern line ids + destination_id: int | None = None + + +class State(NamedTuple): + """Represents state of an azul game.""" + + variant_play: bool + current_phase: Phase + bag: Counter[int] + box_lid: Counter[int] + table_center: Counter[int] + factory_displays: dict[int, Counter[int]] + cursor_contents: Counter[int] + current_turn: int + player_data: dict[int, PlayerData] + + @classmethod + def blank(cls) -> Self: + """Return new blank state.""" + return cls( + variant_play=False, + current_phase=Phase.end, + bag=Counter(), + box_lid=Counter(), + table_center=Counter(), + factory_displays={}, + cursor_contents=Counter(), + current_turn=0, + player_data={}, + ) + + @classmethod + def new_game(cls, player_count: int, variant_play: bool = False) -> Self: + """Return state of a new game.""" + factory_count = player_count * 2 + 1 + bag = generate_bag_contents() + + factory_displays: dict[int, Counter[int]] = {} + for x in range(factory_count): + tiles: Counter[int] = Counter() + for _ in range(4): + tiles[bag_draw_tile(bag)] += 1 + factory_displays[x] = tiles + + return cls( + variant_play=variant_play, + current_phase=Phase.factory_offer, + bag=bag, + box_lid=Counter(), + table_center=Counter({Tile.one: 1}), + factory_displays=factory_displays, + cursor_contents=Counter(), + current_turn=0, + player_data={ + x: PlayerData.new(variant_play) for x in range(player_count) + }, + ) + + def is_cursor_empty(self) -> bool: + """Return if cursor is empty.""" + return self.cursor_contents.total() == 0 + + def can_cursor_select_factory(self, factory_id: int) -> bool: + """Return if cursor can select a specific factory.""" + assert self.current_phase == Phase.factory_offer + if not self.is_cursor_empty(): + return False + factory = self.factory_displays.get(factory_id, None) + if factory is None: + return False + return factory.total() > 0 + + def can_cursor_select_factory_color( + self, + factory_id: int, + color: int, + ) -> bool: + """Return if cursor can select color at factory.""" + if not self.can_cursor_select_factory(factory_id): + return False + factory = self.factory_displays[factory_id] + return factory[color] > 0 + + def cursor_selects_factory(self, factory_id: int, color: int) -> Self: + """Return new state after cursor selects factory.""" + assert self.can_cursor_select_factory_color(factory_id, color) + # Only mutate copies + factory_displays = factory_displays_deepcopy(self.factory_displays) + table_center = self.table_center.copy() + cursor_contents = self.cursor_contents.copy() + + factory = factory_displays[factory_id] + count = select_color(factory, color) + # Add to cursor + cursor_contents[color] += count + # Add all non-matching colored tiles to center of table + table_center.update(factory) + factory.clear() + + return self._replace( + table_center=table_center, + factory_displays=factory_displays, + cursor_contents=cursor_contents, + ) + + def can_cursor_select_center(self, color: int) -> bool: + """Return if cursor can select color from table center.""" + assert self.current_phase == Phase.factory_offer + if not self.is_cursor_empty(): + return False + return color != Tile.one and self.table_center[color] > 0 + + def cursor_selects_table_center(self, color: int) -> Self: + """Return new state after cursor selects from table center.""" + assert self.can_cursor_select_center(color) + table_center = self.table_center.copy() + cursor_contents = self.cursor_contents.copy() + + # Get all of color from table center and add to cursor + cursor_contents[color] += select_color(table_center, color) + # Handle number one tile + if table_center[Tile.one]: + cursor_contents[Tile.one] += select_color(table_center, Tile.one) + remove_counter_zeros(table_center) + + return self._replace( + table_center=table_center, + cursor_contents=cursor_contents, + ) + + def yield_table_center_selections( + self, + ) -> Generator[SelectableSourceTiles, None, None]: + """Yield SelectableSourceTiles objects from table center.""" + for color, count in self.table_center.items(): + if color == Tile.one or count <= 0: + continue + yield SelectableSourceTiles( + source=SelectableSource.table_center, + tiles=Tile(color), + ) + + def yield_selectable_tiles_factory_offer( + self, + ) -> Generator[SelectableSourceTiles, None, None]: + """Yield SelectableSourceTiles objects from all sources.""" + yield from self.yield_table_center_selections() + for factory_id, factory_display in self.factory_displays.items(): + for color in factory_display: + yield SelectableSourceTiles( + source=SelectableSource.factory, + tiles=Tile(color), + source_id=factory_id, + ) + + def apply_source_select_action_factory_offer( + self, + selection: SelectableSourceTiles, + ) -> Self: + """Return new state after applying selection action.""" + color = selection.tiles + if selection.source == SelectableSource.table_center: + return self.cursor_selects_table_center(color) + if selection.source == SelectableSource.factory: + assert selection.source_id is not None + return self.cursor_selects_factory(selection.source_id, color) + raise NotImplementedError(selection.source) + + def get_cursor_holding_color(self) -> int: + """Return color of tile cursor is holding.""" + cursor_colors = set(self.cursor_contents.elements()) + # Do not count number one tile + cursor_colors.discard(Tile.one) + assert len(cursor_colors) == 1, "Cursor should only exactly one color" + return cursor_colors.pop() + + def can_player_select_line( + self, + line_id: int, + color: int, + place_count: int, + ) -> bool: + """Return if player can select line.""" + player_data = self.player_data[self.current_turn] + + # Cannot place more than we have + # Can't be pulling tiles out of thin air now can we? + if place_count > self.cursor_contents[color]: + return False + + return player_data.can_select_line(line_id, color, place_count) + + def get_player_line_max_placable_count(self, line_id: int) -> int: + """Return max placable count for given line.""" + player_data = self.player_data[self.current_turn] + + return player_data.get_line_max_placable_count(line_id) + + def get_player_line_current_place_count(self, line_id: int) -> int: + """Return current place count for given line.""" + player_data = self.player_data[self.current_turn] + + return player_data.get_line_current_place_count(line_id) + + def all_pullable_empty(self) -> bool: + """Return if all pullable tile locations are empty, not counting cursor.""" + if self.table_center.total(): + return False + for factory_display in self.factory_displays.values(): + if factory_display.total(): + return False + return True + + def _factory_offer_maybe_next_turn(self) -> Self: + """Return either current state or new state if player's turn is over.""" + assert self.current_phase == Phase.factory_offer + # If cursor is still holding things, turn is not over. + if not self.is_cursor_empty(): + return self + # Turn is over + # Increment who's turn it is + current_turn = (self.current_turn + 1) % len(self.player_data) + + current_phase: Phase = self.current_phase + if self.all_pullable_empty(): + # Go to wall tiling phase + current_phase = Phase.wall_tiling + + ##if current_phase == Phase.wall_tiling and not self.variant_play: + ## return new_state.apply_auto_wall_tiling() + ##return new_state + return self._replace( + current_phase=current_phase, + current_turn=current_turn, + ) + + def player_select_floor_line(self, color: int, place_count: int) -> Self: + """Return new state after player adds tiles to floor line.""" + assert self.current_phase == Phase.factory_offer + cursor_contents = self.cursor_contents.copy() + assert place_count > 0 + assert place_count <= cursor_contents[color] + + box_lid = self.box_lid.copy() + current_player_data = self.player_data[self.current_turn] + + # Remove from cursor + cursor_contents[color] -= place_count + # Add to floor line + new_player_data, for_box_lid = ( + current_player_data.place_floor_line_tiles( + color, + place_count, + ) + ) + # Add overflow tiles to box lid + assert all(x > 0 for x in for_box_lid.values()), for_box_lid + box_lid.update(for_box_lid) + + # If has number one tile, add to floor line + if cursor_contents[Tile.one]: + # Add to floor line + new_player_data, for_box_lid = ( + new_player_data.place_floor_line_tiles( + Tile.one, + cursor_contents.pop(Tile.one), + ) + ) + # Add overflow tiles to box lid + assert all(x > 0 for x in for_box_lid.values()), for_box_lid + box_lid.update(for_box_lid) + + remove_counter_zeros(cursor_contents) + + # Update player data + player_data = player_data_deepcopy(self.player_data) + player_data[self.current_turn] = new_player_data + + return self._replace( + box_lid=box_lid, + cursor_contents=cursor_contents, + player_data=player_data, + )._factory_offer_maybe_next_turn() + + def player_selects_pattern_line( + self, + line_id: int, + place_count: int, + ) -> Self: + """Return new state after player selects line.""" + assert self.current_phase == Phase.factory_offer + assert not self.is_cursor_empty() + color = self.get_cursor_holding_color() + + assert self.can_player_select_line(line_id, color, place_count) + current_player_data = self.player_data[self.current_turn] + + new_player_data = current_player_data.place_pattern_line_tiles( + line_id, + color, + place_count, + ) + + cursor_contents = self.cursor_contents.copy() + cursor_contents[color] -= place_count + + # Might need to change box lid + box_lid = self.box_lid + + # If has number one tile, add to floor line + if cursor_contents[Tile.one]: + # Will be mutating box lid then + box_lid = self.box_lid.copy() + # Add to floor line + new_player_data, for_box_lid = ( + new_player_data.place_floor_line_tiles( + Tile.one, + cursor_contents.pop(Tile.one), + ) + ) + # Add overflow tiles to box lid + assert all(x > 0 for x in for_box_lid.values()), for_box_lid + box_lid.update(for_box_lid) + + remove_counter_zeros(cursor_contents) + + player_data = player_data_deepcopy(self.player_data) + player_data[self.current_turn] = new_player_data + + return self._replace( + box_lid=box_lid, + player_data=player_data, + cursor_contents=cursor_contents, + )._factory_offer_maybe_next_turn() + + def yield_selectable_tile_destinations_factory_offer( + self, + ) -> Generator[SelectableDestinationTiles, None, None]: + """Yield selectable tile destinations for factory offer phase.""" + assert self.current_phase == Phase.factory_offer + assert not self.is_cursor_empty() + + current_player_data = self.player_data[self.current_turn] + + color = self.get_cursor_holding_color() + count = self.cursor_contents[color] + + for ( + line_id, + placable, + ) in current_player_data.yield_possible_placement_rows(color): + ## for place_count in range(1, min(count, placable + 1)): + place_count = min(count, placable) + yield SelectableDestinationTiles( + destination=SelectableDestination.pattern_line, + place_count=place_count, + destination_id=line_id, + ) + # Can always place in floor line, even if full, + # because of box lid overflow + ## for place_count in range(1, count): + yield SelectableDestinationTiles( + destination=SelectableDestination.floor_line, + place_count=count, + ) + + def apply_destination_select_action_factory_offer( + self, + selection: SelectableDestinationTiles, + ) -> Self: + """Return new state after applying destination selection action.""" + assert self.current_phase == Phase.factory_offer + assert not self.is_cursor_empty() + + if selection.destination == SelectableDestination.floor_line: + color = self.get_cursor_holding_color() + return self.player_select_floor_line( + color, + selection.place_count, + ) + if selection.destination == SelectableDestination.pattern_line: + assert selection.destination_id is not None + return self.player_selects_pattern_line( + selection.destination_id, + selection.place_count, + ) + raise NotImplementedError(selection.destination) + + def apply_auto_wall_tiling(self) -> Self: + """Return new state after performing automatic wall tiling.""" + assert self.current_phase == Phase.wall_tiling + assert not self.variant_play + box_lid = self.box_lid.copy() + new_players = player_data_deepcopy(self.player_data) + + is_end = False + current_turn = self.current_turn + for player_id, player in self.player_data.items(): + new_player, for_box_lid, has_one = ( + player.perform_auto_wall_tiling() + ) + new_players[player_id] = new_player + box_lid.update(for_box_lid) + if not is_end: + is_end = new_player.has_horizontal_wall_line() + if has_one: + current_turn = player_id + + bag = self.bag.copy() + factory_displays: dict[int, Counter[int]] = {} + + if is_end: + for player_id in self.player_data: + new_players[player_id] = new_players[ + player_id + ].perform_end_of_game_scoring() + else: + out_of_tiles = False + for factory_id in self.factory_displays: + tiles: Counter[int] = Counter() + if out_of_tiles: + factory_displays[factory_id] = tiles + continue + for _ in range(4): + if bag.total() > 0: + tiles[bag_draw_tile(bag)] += 1 + else: + bag = box_lid + box_lid = Counter() + if bag.total() <= 0: + # "In the rare case that you run out of + # tiles again while there are one left in + # the lid, start the new round as usual even + # though not all Factory displays are + # properly filled." + out_of_tiles = True + break + factory_displays[factory_id] = tiles + + return self._replace( + current_phase=Phase.end if is_end else Phase.factory_offer, + current_turn=current_turn, + player_data=new_players, + bag=bag, + box_lid=box_lid, + factory_displays=factory_displays, + table_center=Counter({Tile.one: 1}), + ) + + def get_win_order(self) -> list[tuple[int, int]]: + """Return player ranking with (id, compare_score) entries.""" + counts: dict[int, int] = {} + # get_filled_horizontal_line_count can return at most 5 + for player_id, player in self.player_data.items(): + counts[player_id] = ( + player.score * 6 + player.get_filled_horizontal_line_count() + ) + return sorted(counts.items(), key=lambda x: x[1], reverse=True) + + def yield_all_factory_offer_destinations( + self, + ) -> Generator[tuple[SelectableDestinationTiles, ...]]: + """Yield all factory offer destinations.""" + if self.is_cursor_empty(): + yield () + else: + for ( + destination + ) in self.yield_selectable_tile_destinations_factory_offer(): + new = self.apply_destination_select_action_factory_offer( + destination, + ) + did_not_iter = True + for action in new.yield_all_factory_offer_destinations(): + did_not_iter = False + yield (destination, *action) + if did_not_iter: + yield (destination,) + + def yield_actions( + self, + ) -> Generator[ + tuple[SelectableDestinationTiles, ...] + | tuple[SelectableSourceTiles, tuple[SelectableDestinationTiles, ...]], + None, + None, + ]: + """Yield all possible actions able to be performed on this state.""" + if self.current_phase == Phase.factory_offer: + if not self.is_cursor_empty(): + yield from self.yield_all_factory_offer_destinations() + else: + for selection in self.yield_selectable_tiles_factory_offer(): + new = self.apply_source_select_action_factory_offer( + selection, + ) + for ( + action_chain + ) in new.yield_all_factory_offer_destinations(): + yield (selection, action_chain) + elif self.current_phase == Phase.end: + pass + else: + raise NotImplementedError(f"{self.current_phase = }") + + def preform_action( + self, + action: ( + tuple[SelectableDestinationTiles, ...] + | tuple[ + SelectableSourceTiles, + tuple[SelectableDestinationTiles, ...], + ] + ), + ) -> Self: + """Return new state after applying an action.""" + if self.current_phase == Phase.factory_offer: + if isinstance(action[0], SelectableDestinationTiles): + new = self + for destination in action: + assert isinstance(destination, SelectableDestinationTiles) + new = new.apply_destination_select_action_factory_offer( + destination, + ) + return new + selection, destinations = action + assert isinstance(selection, SelectableSourceTiles) + new = self.apply_source_select_action_factory_offer( + selection, + ) + for destination_ in destinations: + assert isinstance(destination_, SelectableDestinationTiles) + new = new.apply_destination_select_action_factory_offer( + destination_, + ) + return new + raise NotImplementedError() + + def _manual_wall_tiling_maybe_next_turn(self) -> Self: + # return self + raise NotImplementedError() + + def get_manual_wall_tiling_locations_for_player( + self, + player_id: int, + ) -> tuple[int, list[int]] | None | Self: + """Either return player wall tiling location data or new state. + + New state when player cannot wall tile their current row. + """ + current_player_data = self.player_data[player_id] + + try: + return current_player_data.get_manual_wall_tile_location() + except UnplayableTileError as unplayable_exc: + # kind of hacky, but it works + y_position = unplayable_exc.y + + new_player_data, for_box_lid = ( + current_player_data.handle_unplayable_wall_tiling(y_position) + ) + + box_lid = self.box_lid.copy() + + # Add overflow tiles to box lid + assert all(x > 0 for x in for_box_lid.values()), for_box_lid + box_lid.update(for_box_lid) + + # Update player data + player_data = player_data_deepcopy(self.player_data) + player_data[player_id] = new_player_data + + return self._replace( + box_lid=box_lid, + player_data=player_data, + )._manual_wall_tiling_maybe_next_turn() + + def manual_wall_tiling_action( + self, + player_id: int, + line_id: int, + x_pos: int, + ) -> Self: + """Perform manual wall tiling action.""" + current_player_data = self.player_data[player_id] + + new_player_data, for_box_lid = ( + current_player_data.manual_wall_tiling_action(line_id, x_pos) + ) + box_lid = self.box_lid.copy() + + # Add overflow tiles to box lid + assert all(x > 0 for x in for_box_lid.values()), for_box_lid + box_lid.update(for_box_lid) + + # Update player data + player_data = player_data_deepcopy(self.player_data) + player_data[player_id] = new_player_data + + new_state = self._replace( + box_lid=box_lid, + player_data=player_data, + ) + + result = new_state.get_manual_wall_tiling_locations_for_player( + player_id, + ) + if not isinstance(result, self.__class__): + return new_state._manual_wall_tiling_maybe_next_turn() + return result._manual_wall_tiling_maybe_next_turn() + + +def run() -> None: + """Run program.""" + from market_api import pretty_print_response as pprint + + random.seed(0) + state = State.new_game(2) + ticks = 0 + try: + ## last_turn = -1 + while state.current_phase == Phase.factory_offer: + ## assert last_turn != state.current_turn + ## last_turn = state.current_turn + actions = tuple(state.yield_actions()) + print(f"{len(actions) = }") + # S311 Standard pseudo-random generators are not suitable + # for cryptographic purposes + action = random.choice(actions) # noqa: S311 + ## pprint(action) + state = state.preform_action(action) + + ticks += 1 + print(f"{state.get_win_order() = }") + except BaseException: + print(f"{ticks = }") + ## print(f'{state = }') + pprint(state) + raise + ## print(f'{destination = }') + ## pprint(state) + pprint(state) + + +if __name__ == "__main__": + run() diff --git a/src/azul/statemachine.py b/src/azul/statemachine.py index 9c37448..6d24c0a 100644 --- a/src/azul/statemachine.py +++ b/src/azul/statemachine.py @@ -30,8 +30,7 @@ if TYPE_CHECKING: from collections.abc import Iterable - - from typing_extensions import Self + from typing import Self import trio __all__ = ["AsyncState", "AsyncStateMachine", "State", "StateMachine"] diff --git a/src/azul/tools.py b/src/azul/tools.py index 5c8629f..ccdc9e4 100644 --- a/src/azul/tools.py +++ b/src/azul/tools.py @@ -3,14 +3,7 @@ from __future__ import annotations # Programmed by CoolCat467 -import math -import random -from typing import TYPE_CHECKING, TypeVar - -if TYPE_CHECKING: - from collections.abc import Generator, Iterable - - from azul.game import Tile +from typing import TypeVar T = TypeVar("T") Numeric = TypeVar("Numeric", int, float) @@ -37,32 +30,6 @@ def saturate(value: Numeric, low: Numeric, high: Numeric) -> Numeric: return min(max(value, low), high) -def randomize(iterable: Iterable[T]) -> list[T]: - """Randomize all values of an iterable.""" - lst = list(iterable) - random.shuffle(lst) - return lst - - -def gen_random_proper_seq(length: int, **kwargs: float) -> list[str]: - """Generate a random sequence of letters given keyword arguments of =.""" - letters = [] - if sum(list(kwargs.values())) != 1: - raise ArithmeticError( - "Sum of percentages of " - + " ".join(list(kwargs.keys())) - + " are not equal to 100 percent!", - ) - for letter in kwargs: - letters += [letter] * math.ceil(length * kwargs[letter]) - return randomize(letters) - - -def sort_tiles(tile_object: Tile) -> int: - """Key function for sorting tiles.""" - return tile_object.color - - # def getCacheSignatureTile(tile, tilesize, greyshift, outlineSize): # """Return the string a tile and it's configuration information would use to identify itself in the tile cache.""" # safeFloat = lambda x: round(x*100) @@ -70,12 +37,3 @@ def sort_tiles(tile_object: Tile) -> int: # data = tile.color, safeFloat(tilesize), safeFloat(greyshift), safeFloat(outlineSize) # # types: ^ # return ''.join((str(i) for i in data)) - - -def floor_line_subtract_generator(seed: int = 1) -> Generator[int, None, None]: - """Floor Line subtraction number generator. Can continue indefinitely.""" - num = seed - while True: - nxt = [-num] * (num + 1) - yield from nxt - num += 1 diff --git a/src/azul/utils.py b/src/azul/utils.py deleted file mode 100644 index 22bf7ec..0000000 --- a/src/azul/utils.py +++ /dev/null @@ -1,49 +0,0 @@ -"""Two's Complement Utilities.""" - -# This is the base_io module from https://github.com/py-mine/mcproto v0.3.0, -# which is licensed under the GNU LESSER GENERAL PUBLIC LICENSE v3.0 - -from __future__ import annotations - -__author__ = "ItsDrike" -__license__ = "LGPL-3.0-only" - -__all__ = ["from_twos_complement", "to_twos_complement"] - - -def to_twos_complement(number: int, bits: int) -> int: - """Convert a given ``number`` into twos complement format of given amount of ``bits``. - - :raises ValueError: - Given ``number`` is out of range, and can't be converted into twos complement format, since - it wouldn't fit into the given amount of ``bits``. - """ - value_max = 1 << (bits - 1) - value_min = value_max * -1 - # With two's complement, we have one more negative number than positive - # this means we can't be exactly at value_max, but we can be at exactly value_min - if number >= value_max or number < value_min: - raise ValueError( - f"Can't convert number {number} into {bits}-bit twos complement format - out of range", - ) - - return number + (1 << bits) if number < 0 else number - - -def from_twos_complement(number: int, bits: int) -> int: - """Convert a given ``number`` from twos complement format of given amount of ``bits``. - - :raises ValueError: - Given ``number`` doesn't fit into given amount of ``bits``. This likely means that you're using - the wrong number, or that the number was converted into twos complement with higher amount of ``bits``. - """ - value_max = (1 << bits) - 1 - if number < 0 or number > value_max: - raise ValueError( - f"Can't convert number {number} from {bits}-bit twos complement format - out of range", - ) - - if number & (1 << (bits - 1)) != 0: - number -= 1 << bits - - return number diff --git a/src/azul/vector.py b/src/azul/vector.py index 5302e6d..d6c7e17 100644 --- a/src/azul/vector.py +++ b/src/azul/vector.py @@ -23,25 +23,25 @@ __title__ = "Vector Module" __author__ = "CoolCat467" __license__ = "GNU General Public License Version 3" -__version__ = "2.0.0" +__version__ = "2.0.1" import math import sys from typing import ( TYPE_CHECKING, + ClassVar, ) from azul.namedtuple_mod import NamedTupleMeta if TYPE_CHECKING: from collections.abc import Generator, Iterable, Iterator - - from typing_extensions import Self + from typing import Self # As a forward to the madness below, we are doing something incredibly sneeky. # We have BaseVector, which we want to have all of the shared functionality # of all Vector subclasses. We also want each Vector class to be a NamedTuple -# so we can let Python handle storing data in the most efficiant way and +# so we can let Python handle storing data in the most efficient way and # make Vectors immutable. # # Problem is, we can't have Vector classes be @@ -66,11 +66,21 @@ class BaseVector: __slots__ = () if TYPE_CHECKING: + # Because of type hacks later on, pretend we have + # the same things NamedTuple does + _field_defaults: ClassVar[dict[str, float]] + _fields: ClassVar[tuple[str, ...]] # D105 is 'Missing docstring in magic method', but this is to handle # typing issues def __iter__(self) -> Iterator[float]: ... # noqa: D105 def __getitem__(self, value: int) -> float: ... # noqa: D105 + def __len__(self) -> int: ... # noqa: D105 + def _asdict(self) -> dict[str, float]: ... + def _replace(self, /, **kwds: int | float) -> Self: ... + def __getnewargs__(self) -> tuple[float, ...]: ... # noqa: D105 + @classmethod + def _make(cls, iterable: Iterable[float]) -> Self: ... @classmethod def from_iter(cls: type[Self], iterable: Iterable[float]) -> Self: @@ -99,6 +109,10 @@ def normalized(self: Self) -> Self: """Return a normalized (unit) vector.""" return self / self.magnitude() + def __bool__(self: Self) -> bool: + """Return if any component is nonzero.""" + return any(self) + # rhs is Right Hand Side def __add__( self: Self, @@ -158,6 +172,12 @@ def __round__( """Return result of rounding self components to given number of digits.""" return self.rounded(ndigits) + def floored( + self: Self, + ) -> Self: + """Return result of rounding self components to given number of digits.""" + return self.from_iter(int(c) for c in self) + def __abs__( self: Self, ) -> Self: @@ -212,6 +232,7 @@ def clamp(self: Self, min_value: float, max_value: float) -> Self: if TYPE_CHECKING: VectorBase = BaseVector else: + # In reality, it's a NamedTuple metaclass VectorBase = type.__new__( NamedTupleMeta, "VectorBase", diff --git a/src/azul_computer_players/__init__.py b/src/azul_computer_players/__init__.py new file mode 100644 index 0000000..63513af --- /dev/null +++ b/src/azul_computer_players/__init__.py @@ -0,0 +1,17 @@ +"""Azul Computer Players.""" + +# Azul Computer Players +# Copyright (C) 2026 CoolCat467 +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . diff --git a/src/azul_computer_players/__main__.py b/src/azul_computer_players/__main__.py new file mode 100644 index 0000000..a551a72 --- /dev/null +++ b/src/azul_computer_players/__main__.py @@ -0,0 +1,31 @@ +"""Computer Players Module.""" + +# Programmed by CoolCat467 + +from __future__ import annotations + +# Computer Players Module +# Copyright (C) 2026 CoolCat467 +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +__title__ = "Computer Players Module" +__author__ = "CoolCat467" +__version__ = "0.0.0" +__license__ = "GNU General Public License Version 3" + +from azul_computer_players.minimax_ai import run + +if __name__ == "__main__": + run() diff --git a/src/azul_computer_players/machine_client.py b/src/azul_computer_players/machine_client.py new file mode 100644 index 0000000..9a9e115 --- /dev/null +++ b/src/azul_computer_players/machine_client.py @@ -0,0 +1,508 @@ +"""Machine Client - Checkers game client that can be controlled mechanically.""" + +from __future__ import annotations + +__title__ = "Machine Client" +__author__ = "CoolCat467" +__version__ = "0.0.0" + +import traceback +from abc import ABCMeta, abstractmethod +from contextlib import asynccontextmanager +from typing import TYPE_CHECKING, TypeAlias, cast + +import trio +from libcomponent.component import ( + Component, + ComponentManager, + Event, + ExternalRaiseManager, +) + +from azul.client import GameClient, read_advertisements +from azul.state import ( + PatternLine, + Phase, + SelectableDestination, + SelectableDestinationTiles, + SelectableSource, + SelectableSourceTiles, + State, + Tile, + factory_displays_deepcopy, + player_data_deepcopy, +) +from azul.vector import Vector2 + +if TYPE_CHECKING: + from collections import Counter + from collections.abc import AsyncGenerator + + from mypy_extensions import u8 + from numpy import int8 + from numpy.typing import NDArray + + +# Player: +# 0 = False = Person = MIN = 0, 2 +# 1 = True = AI (Us) = MAX = 1, 3 + +##Action: TypeAlias = tuple[SelectableSourceTiles, SelectableDestinationTiles] +Action: TypeAlias = ( + tuple[SelectableDestinationTiles, ...] + | tuple[SelectableSourceTiles, tuple[SelectableDestinationTiles, ...]] +) + + +class RemoteState(Component, metaclass=ABCMeta): + """Remote State. + + Keeps track of game state and call preform_action when it's this clients + turn. + """ + + __slots__ = ( + "can_made_play", + "has_initial", + "moves", + "playing_as", + "playing_lock", + "state", + ) + + def __init__(self, state_class: type[State] = State) -> None: + """Initialize remote state.""" + super().__init__("remote_state") + + ## print(f'[RemoteState] {state_class = }') + self.state = state_class.blank() + self.has_initial = False + + self.playing_as: u8 = 1 + self.moves = 0 + + self.playing_lock = trio.Lock() + self.can_made_play = True + + def bind_handlers(self) -> None: + """Register game event handlers.""" + self.register_handlers( + { + "game_winner": self.handle_game_over, + "game_initial_config": self.handle_initial_config, + "game_playing_as": self.handle_playing_as, + "game_board_data": self.handle_board_data, + "game_pattern_data": self.handle_pattern_data, + "game_factory_data": self.handle_factory_data, + "game_cursor_data": self.handle_cursor_data, + "game_table_data": self.handle_table_data, + # "game_cursor_set_movement_mode": + "game_pattern_current_turn_change": self.handle_pattern_current_turn_change, + # "game_cursor_set_destination": + "game_floor_data": self.handle_floor_data, + }, + ) + + async def apply_select_source( + self, + selection: SelectableSourceTiles, + ) -> None: + """Select source.""" + ## print(f"select {selection = }") + color = selection.tiles + if selection.source == SelectableSource.table_center: + await self.raise_event(Event("game_table_clicked", color)) + elif selection.source == SelectableSource.factory: + assert selection.source_id is not None + await self.raise_event( + Event("game_factory_clicked", (selection.source_id, color)), + ) + else: + raise NotImplementedError(selection.source) + + async def apply_select_destination( + self, + selection: SelectableDestinationTiles, + ) -> None: + """Select destination.""" + assert self.state.current_phase == Phase.factory_offer + ##assert not self.state.is_cursor_empty() + ## print(f'dest {selection = }') + + if selection.destination == SelectableDestination.floor_line: + await self.raise_event( + Event( + "game_floor_clicked", + (self.playing_as, selection.place_count), + ), + ) + elif selection.destination == SelectableDestination.pattern_line: + assert selection.destination_id is not None + line_id = selection.destination_id + currently_placed = self.state.get_player_line_current_place_count( + line_id, + ) + await self.raise_event( + Event( + "game_pattern_row_clicked", + ( + self.playing_as, + Vector2( + 5 - selection.place_count - currently_placed, + line_id, + ), + ), + ), + ) + else: + raise NotImplementedError(selection.destination) + + async def preform_action(self, action: Action) -> None: + """Raise events to perform game action.""" + await self.raise_event( + Event( + "game_cursor_location_transmit", + Vector2(0.5, 0.5), + ), + ) + source: SelectableSourceTiles | None = None + dest: tuple[SelectableDestinationTiles, ...] + if len(action) == 2: + raw_source, raw_dest = action + if isinstance(raw_source, SelectableSourceTiles): + source = raw_source + dest = cast("tuple[SelectableDestinationTiles, ...]", raw_dest) + else: + dest = cast("tuple[SelectableDestinationTiles, ...]", action) + else: + dest = action + + async with self.playing_lock: + self.can_made_play = False + if source is not None: + await self.apply_select_source(source) + for destination in dest: + ## print(f'{destination = }') + assert isinstance(destination, SelectableDestinationTiles) + await self.apply_select_destination(destination) + self.can_made_play = True + + ## raise NotImplementedError(f"{source = } {dest = }") + + @abstractmethod + async def preform_turn(self) -> Action: + """Perform turn, return action to perform.""" + + async def base_preform_turn(self) -> None: + """Perform turn.""" + ## async with self.playing_lock: + if not self.can_made_play: + print("Skipping making move because of flag.") + await trio.lowlevel.checkpoint() + return + self.can_made_play = False + self.moves += 1 + ## winner = self.state.check_for_win() + ## if winner is not None: + if self.state.current_phase == Phase.end: + print("Terminal state, not performing turn") + ##value = ("Lost", "Won")[winner == self.playing_as] + value = "" + print(f"{value} after {self.moves}") + await trio.lowlevel.checkpoint() + return + print(f"\nMove {self.moves}...") + action = await self.preform_turn() + await self.preform_action(action) + print("Action complete.") + + async def handle_playing_as(self, event: Event[u8]) -> None: + """Handle client playing as specified player event.""" + print("handle_playing_as") + self.playing_as = event.data + + if self.state.current_turn == self.playing_as: + await self.base_preform_turn() + return + await trio.lowlevel.checkpoint() + + async def handle_initial_config( + self, + event: Event[tuple[u8, u8, u8, u8, NDArray[int8]]], + ) -> None: + """Set up initial game state.""" + ## print("handle_initial_config") + ( + variant_play, + player_count, + _factory_count, + current_turn, + _floor_line_data, + ) = event.data + ## print(f'[RemoteState] {variant_play = }') + self.state = State.new_game(player_count, bool(variant_play)) + self.state = self.state._replace(current_turn=current_turn) + self.has_initial = True + ##if current_turn == self.playing_as: + ## await self.base_preform_turn() + + async def handle_game_over(self, event: Event[u8]) -> None: + """Raise network_stop event so we disconnect from server.""" + ## print("handle_game_over") + self.has_initial = False + await self.raise_event(Event("network_stop", None)) + + async def handle_board_data( + self, + event: Event[tuple[u8, NDArray[int8]]], + ) -> None: + """Handle player board data update.""" + ## print("handle_board_data") + player_id, board_data = event.data + + current_player_data = self.state.player_data[player_id] + + new_player_data = current_player_data._replace(wall=board_data) + + player_data = player_data_deepcopy(self.state.player_data) + player_data[player_id] = new_player_data + + self.state = self.state._replace( + player_data=player_data, + ) + await trio.lowlevel.checkpoint() + + async def handle_pattern_data( + self, + event: Event[tuple[u8, u8, tuple[u8, u8]]], + ) -> None: + """Handle player pattern line data update.""" + ## print("handle_pattern_data") + player_id, row_id, (tile_color, tile_count) = event.data + + current_player_data = self.state.player_data[player_id] + + new_player_data = current_player_data._replace( + lines=current_player_data.replace_pattern_line( + current_player_data.lines, + row_id, + PatternLine(Tile(tile_color), int(tile_count)), + ), + ) + + player_data = player_data_deepcopy(self.state.player_data) + player_data[player_id] = new_player_data + + self.state = self.state._replace( + player_data=player_data, + ) + await trio.lowlevel.checkpoint() + + async def handle_factory_data( + self, + event: Event[tuple[u8, Counter[u8]]], + ) -> None: + """Handle factory data update.""" + ## print("handle_factory_data") + factory_id, tiles = event.data + + factory_displays = factory_displays_deepcopy( + self.state.factory_displays, + ) + factory_displays[factory_id] = tiles + + self.state = self.state._replace( + factory_displays=factory_displays, + ) + + ##if self.state.current_turn == self.playing_as: + ## await self.base_preform_turn() + ## return + await trio.lowlevel.checkpoint() + + async def handle_cursor_data( + self, + event: Event[Counter[u8]], + ) -> None: + """Handle cursor data update.""" + ## print("handle_cursor_data") + cursor_contents = event.data + + self.state = self.state._replace( + cursor_contents=cursor_contents, + ) + + ## if self.state.current_turn == self.playing_as and not self.state.is_cursor_empty(): + ## await self.base_preform_turn() + ## return + await trio.lowlevel.checkpoint() + + async def handle_table_data(self, event: Event[Counter[u8]]) -> None: + """Handle table center tile data update.""" + ## print("handle_table_data") + table_center = event.data + + self.state = self.state._replace( + table_center=table_center, + ) + await trio.lowlevel.checkpoint() + + async def handle_pattern_current_turn_change( + self, + event: Event[u8], + ) -> None: + """Handle change of current turn.""" + print("handle_pattern_current_turn_change") + pattern_id = event.data + + self.state = self.state._replace( + current_turn=pattern_id, + ) + + if self.state.current_turn == self.playing_as: + await self.base_preform_turn() + return + await trio.lowlevel.checkpoint() + + async def handle_floor_data( + self, + event: Event[tuple[u8, Counter[u8]]], + ) -> None: + """Handle floor data event.""" + ## print("handle_floor_data") + floor_id, floor_line = event.data + + current_player_data = self.state.player_data[floor_id] + + new_player_data = current_player_data._replace(floor=floor_line) + + player_data = player_data_deepcopy(self.state.player_data) + player_data[floor_id] = new_player_data + + self.state = self.state._replace( + player_data=player_data, + ) + await trio.lowlevel.checkpoint() + + +class MachineClient(ComponentManager): + """Manager that runs until client_disconnected event fires.""" + + __slots__ = ("running",) + + def __init__(self, remote_state_class: type[RemoteState]) -> None: + """Initialize machine client.""" + super().__init__("machine_client") + + self.running = True + + self.add_component(remote_state_class()) + + @asynccontextmanager + async def client_with_block(self) -> AsyncGenerator[GameClient, None]: + """Add client temporarily with `with` block, ensuring closure.""" + async with GameClient("game_client") as client: + with self.temporary_component(client): + yield client + + def bind_handlers(self) -> None: + """Register client event handlers.""" + self.register_handlers( + { + "client_disconnected": self.handle_client_disconnected, + "client_connection_closed": self.handle_client_disconnected, + }, + ) + + ## async def raise_event(self, event: Event) -> None: + ## """Raise event but also log it if not tick.""" + ## if event.name not in {"tick"}: + ## print(f'{event = }') + ## return await super().raise_event(event) + + async def handle_client_disconnected(self, event: Event[None]) -> None: + """Set self.running to false on network disconnect.""" + self.running = False + + +async def run_client( + host: str, + port: int, + remote_state_class: type[RemoteState], + connected: set[tuple[str, int]], +) -> None: + """Run machine client and raise tick events.""" + async with trio.open_nursery() as main_nursery: + event_manager = ExternalRaiseManager( + "azul", + main_nursery, + "client", + ) + client = MachineClient(remote_state_class) + with event_manager.temporary_component(client): + async with client.client_with_block(): + await event_manager.raise_event( + Event("client_connect", (host, port)), + ) + print(f"Connected to server {host}:{port}") + try: + while client.running: # noqa: ASYNC110 + # Wait so backlog things happen + await trio.sleep(1) + except KeyboardInterrupt: + print("Shutting down client from keyboard interrupt.") + await event_manager.raise_event( + Event("network_stop", None), + ) + print(f"Disconnected from server {host}:{port}") + client.unbind_components() + connected.remove((host, port)) + + +def run_client_sync( + host: str, + port: int, + remote_state_class: type[RemoteState], +) -> None: + """Run client and connect to server at host:port.""" + trio.run(run_client, host, port, remote_state_class, set()) + + +async def run_clients_in_local_servers( + remote_state_class: type[RemoteState], +) -> None: + """Run clients in local servers.""" + connected: set[tuple[str, int]] = set() + print("Watching for advertisements...\n(CTRL + C to quit)") + try: + async with trio.open_nursery(strict_exception_groups=True) as nursery: + while True: + advertisements = set(await read_advertisements()) + servers = {server for _motd, server in advertisements} + servers -= connected + for server in servers: + connected.add(server) + nursery.start_soon( + run_client, + *server, + remote_state_class, + connected, + ) + await trio.sleep(1) + except BaseExceptionGroup as exc: + for ex in exc.exceptions: + if isinstance(ex, KeyboardInterrupt): + print("Shutting down from keyboard interrupt.") + break + else: + raise + + +def run_clients_in_local_servers_sync( + remote_state_class: type[RemoteState], +) -> None: + """Run clients in local servers.""" + try: + trio.run(run_clients_in_local_servers, remote_state_class) + except Exception as exc: + traceback.print_exception(exc) diff --git a/src/azul_computer_players/minimax.py b/src/azul_computer_players/minimax.py new file mode 100644 index 0000000..4c46356 --- /dev/null +++ b/src/azul_computer_players/minimax.py @@ -0,0 +1,235 @@ +"""Minimax - Boilerplate code for Minimax AIs.""" + +from __future__ import annotations + +# Programmed by CoolCat467 + +__title__ = "Minimax" +__author__ = "CoolCat467" +__version__ = "0.0.0" + +import operator +import random +from abc import ABC, abstractmethod +from enum import IntEnum, auto +from math import inf as infinity +from typing import TYPE_CHECKING, Generic, NamedTuple, TypeVar, cast + +if TYPE_CHECKING: + from collections.abc import Callable, Iterable + + +class Player(IntEnum): + """Enum for player status.""" + + __slots__ = () + MIN = auto() + MAX = auto() + CHANCE = auto() + + +State = TypeVar("State") +Action = TypeVar("Action") + + +class MinimaxResult(NamedTuple, Generic[Action]): + """Minimax Result.""" + + value: int | float + action: Action | None + + +class Minimax(ABC, Generic[State, Action]): + """Base class for Minimax AIs.""" + + __slots__ = () + + LOWEST = -1 + HIGHEST = 1 + + @classmethod + @abstractmethod + def value(cls, state: State) -> int | float: + """Return the value of a given game state. + + Should be in range [cls.LOWEST, cls.HIGHEST]. + """ + + @classmethod + @abstractmethod + def terminal(cls, state: State) -> bool: + """Return if given game state is terminal.""" + + @classmethod + @abstractmethod + def player(cls, state: State) -> Player: + """Return player status given the state of the game. + + Must return either Player.MIN or Player.MAX, or Player.CHANCE + if there is a random action. + """ + + @classmethod + @abstractmethod + def actions(cls, state: State) -> Iterable[Action]: + """Return a collection of all possible actions in a given game state.""" + + @classmethod + @abstractmethod + def result(cls, state: State, action: Action) -> State: + """Return new game state after performing action on given state.""" + + @classmethod + def probability(cls, action: Action) -> float: + """Return probability that given chance node action will happen. + + Should be in range [0.0, 1.0] for 0% and 100% chance respectively. + """ + raise NotImplementedError() + + @classmethod + def minimax( + cls, + state: State, + depth: int | None = 5, + ) -> MinimaxResult[Action]: + """Return minimax result best action for a given state for the current player.""" + if cls.terminal(state): + return MinimaxResult(cls.value(state), None) + if depth is not None and depth <= 0: + # Choose a random action + # No need for cryptographic secure random + return MinimaxResult( + cls.value(state), + random.choice(tuple(cls.actions(state))), # noqa: S311 + ) + next_down = None if depth is None else depth - 1 + + current_player = cls.player(state) + value: int | float + best: Callable[[float, float], float] + if current_player == Player.MAX: + value = -infinity + best = max + elif current_player == Player.MIN: + value = infinity + best = min + elif current_player == Player.CHANCE: + value = 0 + best = cast("Callable[[float, float], float]", sum) + else: + raise ValueError(f"Unexpected player type {current_player!r}") + + best_action: Action | None = None + for action in cls.actions(state): + result = cls.minimax(cls.result(state, action), next_down) + result_value = result.value + if current_player == Player.CHANCE: + # Probability[action] + result_value *= cls.probability(action) + new_value = best(value, result_value) + if new_value != value and current_player != Player.CHANCE: + best_action = action + value = new_value + return MinimaxResult(value, best_action) + + @classmethod + def alphabeta( + cls, + state: State, + depth: int | None = 5, + a: int | float = -infinity, + b: int | float = infinity, + ) -> MinimaxResult[Action]: + """Return minimax alphabeta pruning result best action for given current state.""" + # print(f'alphabeta {depth = } {a = } {b = }') + + if cls.terminal(state): + return MinimaxResult(cls.value(state), None) + if depth is not None and depth <= 0: + # Choose a random action + # No need for cryptographic secure random + return MinimaxResult( + cls.value(state), + random.choice(tuple(cls.actions(state))), # noqa: S311 + ) + next_down = None if depth is None else depth - 1 + + current_player = cls.player(state) + value: int | float + best: Callable[[float, float], float] + compare = operator.gt + set_idx = 0 + if current_player == Player.MAX: + value = -infinity + best = max + compare = operator.gt # greater than (>) + set_idx = 0 + elif current_player == Player.MIN: + value = infinity + best = min + compare = operator.lt # less than (<) + set_idx = 1 + elif current_player == Player.CHANCE: + value = 0 + best = cast("Callable[[float, float], float]", sum) + else: + raise ValueError(f"Unexpected player type {current_player!r}") + + actions = tuple(cls.actions(state)) + successors = len(actions) + expect_a = successors * (a - cls.HIGHEST) + cls.HIGHEST + expect_b = successors * (b - cls.LOWEST) + cls.LOWEST + + best_action: Action | None = None + for action in actions: + if current_player == Player.CHANCE: + # Limit child a, b to a valid range + ax = max(expect_a, cls.LOWEST) + bx = min(expect_b, cls.HIGHEST) + # Search the child with new cutoff values + result = cls.alphabeta( + cls.result(state, action), + next_down, + ax, + bx, + ) + score = result.value + # Check for a, b cutoff conditions + if score <= expect_a: + return MinimaxResult(a, None) + if score >= expect_b: + return MinimaxResult(b, None) + value += score + # Adjust a, b for the next child + expect_a += cls.HIGHEST - score + expect_b += cls.LOWEST - score + continue + + result = cls.alphabeta(cls.result(state, action), next_down, a, b) + new_value = best(value, result.value) + + if new_value != value: + best_action = action + value = new_value + + if compare(new_value, (a, b)[set_idx ^ 1]): + # print("cutoff") + break # cutoff + + alpha_beta_value = (a, b)[set_idx] + new_alpha_beta_value = best(alpha_beta_value, value) + + if new_alpha_beta_value != alpha_beta_value: + # Set new best + alpha_beta_list = [a, b] + alpha_beta_list[set_idx] = new_alpha_beta_value + a, b = alpha_beta_list + if current_player == Player.CHANCE: + # No cutoff occurred, return score + return MinimaxResult(value / successors, None) + return MinimaxResult(value, best_action) + + +if __name__ == "__main__": + print(f"{__title__}\nProgrammed by {__author__}.\n") diff --git a/src/azul_computer_players/minimax_ai.py b/src/azul_computer_players/minimax_ai.py new file mode 100755 index 0000000..f3171c9 --- /dev/null +++ b/src/azul_computer_players/minimax_ai.py @@ -0,0 +1,425 @@ +#!/usr/bin/env python3 +# AI that plays azul. + +"""Minimax Azul AI.""" + +from __future__ import annotations + +# Programmed by CoolCat467 + +__title__ = "Minimax AI" +__author__ = "CoolCat467" +__version__ = "0.0.0" + +import time +from collections.abc import Hashable, Iterable, Mapping +from enum import IntEnum, auto +from math import inf as infinity +from typing import Any, ClassVar, Self, TypeAlias, TypeVar + +from azul.state import ( + Phase, + SelectableDestinationTiles, + SelectableSourceTiles, + State, +) +from azul_computer_players.machine_client import ( + RemoteState, + run_clients_in_local_servers_sync, +) +from azul_computer_players.minimax import Minimax, MinimaxResult, Player + +T = TypeVar("T") +Action: TypeAlias = ( + tuple[SelectableDestinationTiles, ...] + | tuple[SelectableSourceTiles, tuple[SelectableDestinationTiles, ...]] +) + + +class TranspositionFlag(IntEnum): + """Flag enum for transposition table.""" + + LOWERBOUND = 0 + EXACT = auto() + UPPERBOUND = auto() + + +class AutoWallState(State): + """Azul State with automatic wall tiling in regular play mode.""" + + __slots__ = () + + def _factory_offer_maybe_next_turn(self) -> Self: + """Return either current state or new state if player's turn is over.""" + new_state = super()._factory_offer_maybe_next_turn() + + if ( + new_state.current_phase == Phase.wall_tiling + and not new_state.variant_play + ): + return new_state.apply_auto_wall_tiling() + return new_state + + +class MinimaxWithID(Minimax[State, Action]): + """Minimax with ID.""" + + __slots__ = () + + # Simple Transposition Table: + # key -> (stored_depth, value, action, flag) + # flag: TranspositionFlag: EXACT, LOWERBOUND, UPPERBOUND + TRANSPOSITION_TABLE: ClassVar[ + dict[int, tuple[int, MinimaxResult[Any], TranspositionFlag]] + ] = {} + + @classmethod + def _transposition_table_lookup( + cls, + state_hash: int, + depth: int, + alpha: float, + beta: float, + ) -> MinimaxResult[Action] | None: + """Lookup in transposition_table. Return (value, action) or None.""" + entry = cls.TRANSPOSITION_TABLE.get(state_hash) + if entry is None: + return None + + stored_depth, result, flag = entry + # only use if stored depth is deep enough + if stored_depth >= depth and ( + (flag == TranspositionFlag.EXACT) + or (flag == TranspositionFlag.LOWERBOUND and result.value > alpha) + or (flag == TranspositionFlag.UPPERBOUND and result.value < beta) + ): + return result + return None + + @classmethod + def _transposition_table_store( + cls, + state_hash: int, + depth: int, + result: MinimaxResult[Action], + alpha: float, + beta: float, + ) -> None: + """Store in transposition_table with proper flag.""" + if result.value <= alpha: + flag = TranspositionFlag.UPPERBOUND + elif result.value >= beta: + flag = TranspositionFlag.LOWERBOUND + else: + flag = TranspositionFlag.EXACT + cls.TRANSPOSITION_TABLE[state_hash] = (depth, result, flag) + + @classmethod + def hash_state(cls, state: State) -> int: + """Your state-to-hash function. Must be consistent.""" + # For small games you might do: return hash(state) + # For larger, use Zobrist or custom. + return hash(state) + + @classmethod + def alphabeta_transposition_table( + cls, + state: State, + depth: int = 5, + a: int | float = -infinity, + b: int | float = infinity, + ) -> MinimaxResult[Action]: + """AlphaBeta with transposition table.""" + if cls.terminal(state): + return MinimaxResult(cls.value(state), None) + if depth <= 0: + ## # Choose a random action + ## # No need for cryptographic secure random + return MinimaxResult( + cls.value(state), + next(iter(cls.actions(state))), + ) + next_down = depth - 1 + + state_h = cls.hash_state(state) + # 1) Try transposition_table lookup + transposition_table_hit = cls._transposition_table_lookup( + state_h, + depth, + a, + b, + ) + if transposition_table_hit is not None: + return transposition_table_hit + next_down = None if depth is None else depth - 1 + + current_player = cls.player(state) + value: int | float + + best_action: Action | None = None + + if current_player == Player.MAX: + value = -infinity + actions: list[tuple[Action, State]] = [ + (action, cls.result(state, action)) + for action in cls.actions(state) + ] + + actions.sort(key=lambda act: cls.value(act[1]), reverse=True) + for action, next_state in actions: + child = cls.alphabeta_transposition_table( + next_state, + next_down, + a, + b, + ) + if child.value > value: + value = child.value + best_action = action + a = max(a, value) + if a >= b: + break + + elif current_player == Player.MIN: + value = infinity + actions = [ + (action, cls.result(state, action)) + for action in cls.actions(state) + ] + + actions.sort(key=lambda act: cls.value(act[1])) + for action, next_state in actions: + child = cls.alphabeta_transposition_table( + next_state, + next_down, + a, + b, + ) + if child.value < value: + value = child.value + best_action = action + b = min(b, value) + if b <= a: + break + else: + raise NotImplementedError(f"{current_player = }") + + # 2) Store in transposition_table + result = MinimaxResult(value, best_action) + cls._transposition_table_store( + state_h, + depth, + result, # type: ignore[arg-type] + a, + b, + ) + return result # type: ignore[return-value] + + @classmethod + def iterative_deepening( + cls, + state: State, + start_depth: int = 5, + max_depth: int = 7, + time_limit_ns: int | float | None = None, + ) -> MinimaxResult[Action]: + """Run alpha-beta with increasing depth up to max_depth. + + If time_limit_ns is None, do all depths. Otherwise stop early. + """ + best_result: MinimaxResult[Action] = MinimaxResult(0, None) + start_t = time.perf_counter_ns() + + for depth in range(start_depth, max_depth + 1): + # clear or keep transposition_table between depths? often you keep it + # cls.TRANSPOSITION_TABLE.clear() + + result = cls.alphabeta_transposition_table( + state, + depth, + ) + best_result = result + + if abs(result.value) == cls.HIGHEST: + print(f"reached terminal state stop {depth=}") + break + + # optional time check + if ( + time_limit_ns + and (time.perf_counter_ns() - start_t) > time_limit_ns + ): + print( + f"break from time expired {depth=} ({(time.perf_counter_ns() - start_t) / 1e9} seconds elaped)", + ) + break + print( + f"{depth=} ({(time.perf_counter_ns() - start_t) / 1e9} seconds elaped)", + ) + + return best_result + + +MAX_PLAYER = 0 + + +def convert_hashable(obj: object) -> Hashable: + """Convert object to hashable object.""" + exc: TypeError | None = None + try: + hash(obj) + except TypeError as exc: # noqa: F841 + pass + else: + return obj + if isinstance(obj, Mapping): + return tuple(map(convert_hashable, obj.items())) + if isinstance(obj, Iterable): + return tuple(map(convert_hashable, obj)) + if exc is not None: + raise NotImplementedError(type(obj)) from exc + raise NotImplementedError(type(obj)) + + +# Minimax[tuple[State, u8], Action] +class AzulMinimax(MinimaxWithID): + """Minimax Algorithm for Azul.""" + + __slots__ = () + + @classmethod + def hash_state(cls, state: AutoWallState) -> int: # type: ignore[override] + """Return state hash value.""" + # For small games you might do: return hash(state) + # For larger, use Zobrist or custom. + ## return hash((state.size, tuple(state.pieces.items()), state.turn)) + return hash(convert_hashable(state)) + + @staticmethod + def value(state: State) -> int | float: + """Return value of given game state.""" + # Real + if AzulMinimax.terminal(state): + winner, _score = state.get_win_order()[0] + if winner == MAX_PLAYER: + return 10 + return -10 + # Heuristic + min_ = 0 + max_ = 0 + for player_id, player_data in state.player_data.items(): + score = player_data.get_end_of_game_score() + score += player_data.get_floor_line_scoring() + if player_id == MAX_PLAYER: + max_ += score + else: + min_ += score + # More max will make score higher, + # more min will make score lower + # Plus one in divisor makes so never / 0 + return (max_ - min_) / (abs(max_) + abs(min_) + 1) + + @staticmethod + def terminal(state: State) -> bool: + """Return if game state is terminal.""" + return state.current_phase == Phase.end + + @staticmethod + def player(state: State) -> Player: + """Return Player enum from current state's turn.""" + return Player.MAX if state.current_turn == MAX_PLAYER else Player.MIN + + @staticmethod + def actions(state: State) -> Iterable[Action]: + """Return all actions that are able to be performed for the current player in the given state.""" + return tuple(state.yield_actions()) + ## print(f'{len(actions) = }') + + @staticmethod + def result( + state: State, + action: Action, + ) -> State: + """Return new state after performing given action on given current state.""" + ## real_state, MAX_PLAYER = state + ## return (real_state.preform_action(action), MAX_PLAYER) + return state.preform_action(action) + + +## @classmethod +## def alphabeta( +## cls, +## state: tuple[State, u8], +## depth: int | None = 5, +## a: int | float = -infinity, +## b: int | float = infinity, +## ) -> MinimaxResult[ +## tuple[SelectableDestinationTiles, ...] +## | tuple[SelectableSourceTiles, tuple[SelectableDestinationTiles, ...]] +## ]: +## """Return minimax alphabeta pruning result best action for given current state.""" +## new_state, player = state +## if ( +## new_state.current_phase == Phase.wall_tiling +## and not new_state.variant_play +## ): +## new_state = new_state.apply_auto_wall_tiling() +## return super().alphabeta((new_state, player), depth, a, b) + + +class MinimaxPlayer(RemoteState): + """Minimax Player.""" + + __slots__ = () + + def __init__(self) -> None: + """Initialize remote minmax player state.""" + super().__init__(state_class=AutoWallState) + + async def preform_turn(self) -> Action: + """Perform turn.""" + print("preform_turn") + ##value, action = CheckersMinimax.adaptive_depth_minimax( + ## self.state, 4, 5 + ##) + ##value, action = CheckersMinimax.minimax(self.state, 4) + if not isinstance(self.state, AutoWallState): + self.state = AutoWallState._make(self.state) + assert isinstance(self.state, AutoWallState) + ## value, action = AzulMinimax.alphabeta((self.state, self.playing_as), 2) + ## value, action = AzulMinimax.alphabeta((self.state, self.playing_as), 4) + global MAX_PLAYER + MAX_PLAYER = self.playing_as + value, action = AzulMinimax.iterative_deepening( + self.state, + 2, + 20, + int(5 * 1e9), + ) + if action is None: + raise ValueError("action is None") + print(f"{value = }") + return action + + +def run() -> None: + """Run MinimaxPlayer clients in local server.""" + ## import random + ## + ## random.seed(0) + ## + ## state = (AutoWallState.new_game(2), 0) + ## + ## while not AzulMinimax.terminal(state): + ## action = AzulMinimax.adaptive_depth_minimax(state) + ## print(f"{action = }") + ## state = AzulMinimax.result(state, action.action) + ## print(f"{state = }") + ## print(state) + + run_clients_in_local_servers_sync(MinimaxPlayer) + + +if __name__ == "__main__": + print(f"{__title__} v{__version__}\nProgrammed by {__author__}.\n") + run() diff --git a/src/azul_computer_players/py.typed b/src/azul_computer_players/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/test-requirements.in b/test-requirements.in deleted file mode 100644 index 449c3e8..0000000 --- a/test-requirements.in +++ /dev/null @@ -1,28 +0,0 @@ -# For tests -pytest >= 5.0 # for faulthandler in core -coverage >= 7.2.5 -pytest-trio -pytest-cov - -# Tools -black; implementation_name == "cpython" -mypy >= 1.13.0 # Would use mypy[faster-cache], but orjson has build issues on pypy -orjson; implementation_name == "cpython" -ruff >= 0.6.6 -uv >= 0.2.24 -codespell - -# https://github.com/python-trio/trio/pull/654#issuecomment-420518745 -mypy-extensions -typing-extensions - -# Azul's own dependencies -# -cryptography>=43.0.0 -exceptiongroup; python_version < '3.11' -mypy_extensions>=1.0.0 -numpy~=2.1.3 -pygame~=2.6.0 -trio~=0.27.0 -typing_extensions>=4.12.2 -# diff --git a/test-requirements.txt b/test-requirements.txt deleted file mode 100644 index 1ef3a8a..0000000 --- a/test-requirements.txt +++ /dev/null @@ -1,108 +0,0 @@ -# This file was autogenerated by uv via the following command: -# uv pip compile --universal --python-version=3.10 test-requirements.in -o test-requirements.txt -attrs==25.4.0 - # via - # outcome - # trio -black==26.1.0 ; implementation_name == 'cpython' - # via -r test-requirements.in -cffi==2.0.0 ; (implementation_name != 'pypy' and os_name == 'nt') or platform_python_implementation != 'PyPy' - # via - # cryptography - # trio -click==8.3.1 ; implementation_name == 'cpython' - # via black -codespell==2.4.1 - # via -r test-requirements.in -colorama==0.4.6 ; sys_platform == 'win32' - # via - # click - # pytest -coverage==7.13.2 - # via - # -r test-requirements.in - # pytest-cov -cryptography==46.0.4 - # via -r test-requirements.in -exceptiongroup==1.3.1 ; python_full_version < '3.11' - # via - # -r test-requirements.in - # pytest - # trio -idna==3.11 - # via trio -iniconfig==2.3.0 - # via pytest -librt==0.7.8 ; platform_python_implementation != 'PyPy' - # via mypy -mypy==1.19.1 - # via -r test-requirements.in -mypy-extensions==1.1.0 - # via - # -r test-requirements.in - # black - # mypy -numpy==2.1.3 - # via -r test-requirements.in -orjson==3.11.6 ; implementation_name == 'cpython' - # via -r test-requirements.in -outcome==1.3.0.post0 - # via - # pytest-trio - # trio -packaging==26.0 - # via - # black - # pytest -pathspec==1.0.4 - # via - # black - # mypy -platformdirs==4.5.1 ; implementation_name == 'cpython' - # via black -pluggy==1.6.0 - # via - # pytest - # pytest-cov -pycparser==3.0 ; (implementation_name != 'PyPy' and implementation_name != 'pypy' and os_name == 'nt') or (implementation_name != 'PyPy' and platform_python_implementation != 'PyPy') - # via cffi -pygame==2.6.1 - # via -r test-requirements.in -pygments==2.19.2 - # via pytest -pytest==9.0.2 - # via - # -r test-requirements.in - # pytest-cov - # pytest-trio -pytest-cov==7.0.0 - # via -r test-requirements.in -pytest-trio==0.8.0 - # via -r test-requirements.in -pytokens==0.4.1 ; implementation_name == 'cpython' - # via black -ruff==0.14.14 - # via -r test-requirements.in -sniffio==1.3.1 - # via trio -sortedcontainers==2.4.0 - # via trio -tomli==2.4.0 ; python_full_version <= '3.11' - # via - # black - # coverage - # mypy - # pytest -trio==0.27.0 - # via - # -r test-requirements.in - # pytest-trio -typing-extensions==4.15.0 - # via - # -r test-requirements.in - # black - # cryptography - # exceptiongroup - # mypy -uv==0.9.28 - # via -r test-requirements.in diff --git a/tests/helpers.py b/tests/helpers.py deleted file mode 100644 index 0739739..0000000 --- a/tests/helpers.py +++ /dev/null @@ -1,175 +0,0 @@ -# This is the base_io module from https://github.com/py-mine/mcproto v0.5.0, -# which is licensed under the GNU LESSER GENERAL PUBLIC LICENSE v3.0 - -from __future__ import annotations - -__author__ = "ItsDrike" -__license__ = "LGPL-3.0-only" - -import inspect -import unittest.mock -from functools import partial -from typing import TYPE_CHECKING, Any, Generic, TypeVar - -import trio -from typing_extensions import ParamSpec - -if TYPE_CHECKING: - from collections.abc import Callable, Coroutine - -T = TypeVar("T") -P = ParamSpec("P") -T_Mock = TypeVar("T_Mock", bound=unittest.mock.Mock) - - -def synchronize(f: Callable[P, Coroutine[Any, Any, T]]) -> Callable[P, T]: - """Take an asynchronous function, and return a synchronous alternative. - - This is needed because we sometimes want to test asynchronous behavior in a synchronous test function, - where we can't simply await something. This function uses `trio.run` and generates a wrapper - around the original asynchronous function, that awaits the result in a blocking synchronous way, - returning the obtained value. - """ - - def wrapper(*args: P.args, **kwargs: P.kwargs) -> T: - return trio.run(partial(f, *args, **kwargs)) - - return wrapper - - -class SynchronizedMixin: - """Class acting as another wrapped object, with all async methods synchronized. - - This class needs :attr:`._WRAPPED_ATTRIBUTE` class variable to be set as the name of the internally - held attribute, holding the object we'll be wrapping around. - - Child classes of this mixin will have their lookup logic changed, to instead perform a lookup - on the wrapped attribute. Only if that lookup fails, we fallback to this class, meaning if both - the wrapped attribute and this class have some attribute defined, the attribute from the wrapped - object is returned. The only exceptions to this are lookup of the ``_WRAPPED_ATTRIBUTE`` variable, - and of the attribute name stored under the ``_WRAPPED_ATTRIBUTE`` (the wrapped object). - - If the attribute held by the wrapped object is an asynchronous function, instead of returning it - directly, the :func:`.synchronize` function will be called, returning a wrapped synchronous - alternative for the requested async function. - - This is useful when we need to quickly create a synchronous alternative to a class holding async methods. - However it isn't useful in production, since will cause typing issues (attributes will be accessible, but - type checkers won't know that they exist here, because of the dynamic nature of this implementation). - """ - - _WRAPPED_ATTRIBUTE: str - - def __getattribute__(self, name: str, /) -> Any: - """Return attributes of the wrapped object, if the attribute is a coroutine function, synchronize it. - - The only exception to this behavior is getting the :attr:`._WRAPPED_ATTRIBUTE` variable itself, or the - attribute named as the content of the ``_WRAPPED_ATTRIBUTE`` variable. All other attribute access will - be delegated to the wrapped attribute. If the wrapped object doesn't have given attribute, the lookup - will fallback to regular lookup for variables belonging to this class. - """ - if ( - name == "_WRAPPED_ATTRIBUTE" or name == self._WRAPPED_ATTRIBUTE - ): # Order is important - return super().__getattribute__(name) - - wrapped = getattr(self, self._WRAPPED_ATTRIBUTE) - - if hasattr(wrapped, name): - obj = getattr(wrapped, name) - if inspect.iscoroutinefunction(obj): - return synchronize(obj) - return obj - - return super().__getattribute__(name) - - def __setattr__(self, name: str, value: object, /) -> None: - """Allow for changing attributes of the wrapped object. - - * If wrapped object isn't yet set, fall back to :meth:`~object.__setattr__` of this class. - * If wrapped object doesn't already contain the attribute we want to set, also fallback to this class. - * Otherwise, run ``__setattr__`` on it to update it. - """ - try: - wrapped = getattr(self, self._WRAPPED_ATTRIBUTE) - except AttributeError: - return super().__setattr__(name, value) - else: - if hasattr(wrapped, name): - return setattr(wrapped, name, value) - - return super().__setattr__(name, value) - - -class UnpropagatingMockMixin(Generic[T_Mock]): - """Provides common functionality for our :class:`~unittest.mock.Mock` classes. - - By default, mock objects propagate themselves by returning a new instance of the same mock - class, with same initialization attributes. This is done whenever we're accessing new - attributes that mock class. - - This propagation makes sense for simple mocks without any additional restrictions, however when - dealing with limited mocks to some ``spec_set``, it doesn't usually make sense to propagate - those same ``spec_set`` restrictions, since we generally don't have attributes/methods of a - class be of/return the same class. - - This mixin class stops this propagation, and instead returns instances of specified mock class, - defined in :attr:`.child_mock_type` class variable, which is by default set to - :class:`~unittest.mock.MagicMock`, as it can safely represent most objects. - - .. note: - This propagation handling will only be done for the mock classes that inherited from this - mixin class. That means if the :attr:`.child_mock_type` is one of the regular mock classes, - and the mock is propagated, a regular mock class is returned as that new attribute. This - regular class then won't have the same overrides, and will therefore propagate itself, like - any other mock class would. - - If you wish to counteract this, you can set the :attr:`.child_mock_type` to a mock class - that also inherits from this mixin class, perhaps to your class itself, overriding any - propagation recursively. - """ - - child_mock_type: T_Mock = unittest.mock.MagicMock - - # Since this is a mixin class, we can access some attributes defined in mock classes safely. - # Define the types of these variables here, for proper static type analysis. - _mock_sealed: bool - _extract_mock_name: Callable[[], str] - - def _get_child_mock(self, **kwargs) -> T_Mock: - """Make :attr:`.child_mock_type`` instances instead of instances of the same class. - - By default, this method creates a new mock instance of the same original class, and passes - over the same initialization arguments. This overrides that behavior to instead create an - instance of :attr:`.child_mock_type` class. - """ - # Mocks can be sealed, in which case we wouldn't want to allow propagation of any kind - # and rather raise an AttributeError, informing that given attr isn't accessible - if self._mock_sealed: - mock_name = self._extract_mock_name() - obj_name = ( - f"{mock_name}.{kwargs['name']}" - if "name" in kwargs - else f"{mock_name}()" - ) - raise AttributeError(f"Can't access {obj_name}, mock is sealed.") - - # Propagate any other children as simple `unittest.mock.Mock` instances - # rather than `self.__class__` instances - return self.child_mock_type(**kwargs) - - -class CustomMockMixin(UnpropagatingMockMixin): - """Provides common functionality for our custom mock types. - - * Stops propagation of same ``spec_set`` restricted mock in child mocks - (see :class:`.UnpropagatingMockMixin` for more info) - * Allows using the ``spec_set`` attribute as class attribute - """ - - spec_set = None - - def __init__(self, **kwargs): - if "spec_set" in kwargs: - self.spec_set = kwargs.pop("spec_set") - super().__init__(spec_set=self.spec_set, **kwargs) # type: ignore # Mixin class, this __init__ is valid diff --git a/tests/protocol_helpers.py b/tests/protocol_helpers.py deleted file mode 100644 index 74a16f9..0000000 --- a/tests/protocol_helpers.py +++ /dev/null @@ -1,87 +0,0 @@ -# This is the base_io module from https://github.com/py-mine/mcproto v0.5.0, -# which is licensed under the GNU LESSER GENERAL PUBLIC LICENSE v3.0 - -from __future__ import annotations - -__author__ = "ItsDrike" -__license__ = "LGPL-3.0-only" - -from unittest.mock import AsyncMock, Mock - - -class WriteFunctionMock(Mock): - """Mock write function, storing the written data.""" - - def __init__(self, *a, **kw): - super().__init__(*a, **kw) - self.combined_data = bytearray() - - def __call__( - self, - data: bytes, - ) -> None: # pyright: ignore[reportIncompatibleMethodOverride] - """Override mock's ``__call__`` to extend our :attr:`.combined_data` bytearray. - - This allows us to keep track of exactly what data was written by the mocked write function - in total, rather than only having tools like :meth:`.assert_called_with`, which might let us - get the data from individual calls, but not the combined data, which is what we'll need. - """ - self.combined_data.extend(data) - return super().__call__(data) - - def assert_has_data( - self, - data: bytearray, - ensure_called: bool = True, - ) -> None: - """Ensure that the combined write data by the mocked function matches expected ``data``.""" - if ensure_called: - self.assert_called() - - if self.combined_data != data: - raise AssertionError( - f"Write function mock expected data {data!r}, but was {self.call_data!r}", - ) - - -class WriteFunctionAsyncMock(WriteFunctionMock, AsyncMock): - """Asynchronous mock write function, storing the written data.""" - - -class ReadFunctionMock(Mock): - """Mock read function, giving pre-defined data.""" - - def __init__(self, *a, combined_data: bytearray | None = None, **kw): - super().__init__(*a, **kw) - if combined_data is None: - combined_data = bytearray() - self.combined_data = combined_data - - def __call__( - self, - length: int, - ) -> bytearray: # pyright: ignore[reportIncompatibleMethodOverride] - """Override mock's __call__ to make it return part of our :attr:`.combined_data` bytearray. - - This allows us to make the return value always be the next requested part (length) of - the :attr:`.combined_data`. It would be difficult to replicate this with regular mocks, - because some functions can end up making multiple read calls, and each time the result - needs to be different (the next part). - """ - self.return_value = self.combined_data[:length] - del self.combined_data[:length] - return super().__call__(length) - - def assert_read_everything(self, ensure_called: bool = True) -> None: - """Ensure that the passed :attr:`.combined_data` was fully read and depleted.""" - if ensure_called: - self.assert_called() - - if len(self.combined_data) != 0: - raise AssertionError( - f"Read function didn't deplete all of it's data, remaining data: {self.combined_data!r}", - ) - - -class ReadFunctionAsyncMock(ReadFunctionMock, AsyncMock): - """Asynchronous mock read function, giving pre-defined data.""" diff --git a/tests/test_async_clock.py b/tests/test_async_clock.py deleted file mode 100644 index aac40bd..0000000 --- a/tests/test_async_clock.py +++ /dev/null @@ -1,29 +0,0 @@ -import pytest - -from azul import async_clock - - -@pytest.mark.trio -async def test_tick() -> None: - clock = async_clock.Clock() - - await clock.tick(60) - result = await clock.tick(60) - assert isinstance(result, int) - assert result >= 0 - assert repr(clock).startswith(" None: - clock = async_clock.Clock() - - for _ in range(20): - await clock.tick(60) - fps = clock.get_fps() - assert isinstance(fps, float) - assert fps >= 0 diff --git a/tests/test_base_io.py b/tests/test_base_io.py deleted file mode 100644 index 456aad8..0000000 --- a/tests/test_base_io.py +++ /dev/null @@ -1,746 +0,0 @@ -# This is the base_io module from https://github.com/py-mine/mcproto v0.5.0, -# which is licensed under the GNU LESSER GENERAL PUBLIC LICENSE v3.0 - -from __future__ import annotations - -__author__ = "ItsDrike" -__license__ = "LGPL-3.0-only" - -import platform -import struct -from abc import ABC, abstractmethod -from typing import Any -from unittest.mock import AsyncMock, Mock - -import pytest -from helpers import SynchronizedMixin -from protocol_helpers import ( - ReadFunctionAsyncMock, - ReadFunctionMock, - WriteFunctionAsyncMock, - WriteFunctionMock, -) - -from azul.base_io import ( - INT_FORMATS_TYPE, - BaseAsyncReader, - BaseAsyncWriter, - BaseSyncReader, - BaseSyncWriter, - StructFormat, -) -from azul.utils import to_twos_complement - -# region: Initializable concrete implementations of ABC classes. - - -class SyncWriter(BaseSyncWriter): - """Initializable concrete implementation of :class:`~mcproto.protocol.base_io.BaseSyncWriter` ABC.""" - - def write(self, data: bytes) -> None: - """Concrete implementation of abstract write method. - - Since :class:`abc.ABC` classes can't be initialized if they have any abstract methods - which weren't overridden with a concrete implementations, this is a fake implementation, - without any actual logic, purely to allow the initialization of this class. - - This method is expected to be mocked using :class:`~tests.mcproto.protocol.helpers.WriteFunctionMock` - if it's supposed to get called during testing. - - If this method gets called without being mocked, it will raise :exc:`NotImplementedError`. - """ - raise NotImplementedError( - "This concrete override of abstract write method isn't intended for actual use!\n" - " - If you're writing a new test, did you forget to mock it?\n" - " - If you're seeing this in an existing test, this method got called without the test expecting it," - " this probably means you changed something in the code leading to this call, but you haven't updated" - " the tests to mock this function.", - ) - - -class SyncReader(BaseSyncReader): - """Testable concrete implementation of :class:`~mcproto.protocol.base_io.BaseSyncReader` ABC.""" - - def read(self, length: int) -> bytearray: - """Concrete implementation of abstract read method. - - Since :class:`abc.ABC` classes can't be initialized if they have any abstract methods - which weren't overridden with a concrete implementations, this is a fake implementation, - without any actual logic, purely to allow the initialization of this class. - - This method is expected to be mocked using :class:`~tests.mcproto.protocol.helpers.ReadFunctionMock` - if it's supposed to get called during testing. - - If this method gets called without being mocked, it will raise :exc:`NotImplementedError`. - """ - raise NotImplementedError( - "This concrete override of abstract read method isn't intended for actual use!\n" - " - If you're writing a new test, did you forget to mock it?\n" - " - If you're seeing this in an existing test, this method got called without the test expecting it," - " this probably means you changed something in the code leading to this call, but you haven't updated" - " the tests to mock this function.", - ) - - -class AsyncWriter(BaseAsyncWriter): - """Initializable concrete implementation of :class:`~mcproto.protocol.base_io.BaseAsyncWriter` ABC.""" - - async def write(self, data: bytes) -> None: - """Concrete implementation of abstract write method. - - Since :class:`abc.ABC` classes can't be initialized if they have any abstract methods - which weren't overridden with a concrete implementations, this is a fake implementation, - without any actual logic, purely to allow the initialization of this class. - - This method is expected to be mocked using :class:`~tests.mcproto.protocol.helpers.WriteFunctionAsyncMock` - if it's supposed to get called during testing. - - If this method gets called without being mocked, it will raise :exc:`NotImplementedError`. - """ - raise NotImplementedError( - "This concrete override of abstract write method isn't intended for actual use!\n" - " - If you're writing a new test, did you forget to mock it?\n" - " - If you're seeing this in an existing test, this method got called without the test expecting it," - " this probably means you changed something in the code leading to this call, but you haven't updated" - " the tests to mock this function.", - ) - - -class AsyncReader(BaseAsyncReader): - """Testable concrete implementation of BaseAsyncReader ABC.""" - - async def read(self, length: int) -> bytearray: - """Concrete implementation of abstract read method. - - Since :class:`abc.ABC` classes can't be initialized if they have any abstract methods - which weren't overridden with a concrete implementations, this is a fake implementation, - without any actual logic, purely to allow the initialization of this class. - - This method is expected to be mocked using :class:`~tests.mcproto.protocol.helpers.ReadFunctionAsyncMock` - if it's supposed to get called during testing. - - If this method gets called without being mocked, it will raise :exc:`NotImplementedError`. - """ - raise NotImplementedError( - "This concrete override of abstract read method isn't intended for actual use!\n" - " - If you're writing a new test, did you forget to mock it?\n" - " - If you're seeing this in an existing test, this method got called without the test expecting it," - " this probably means you changed something in the code leading to this call, but you haven't updated" - " the tests to mock this function.", - ) - - -# endregion -# region: Synchronized classes - - -class WrappedAsyncReader(SynchronizedMixin): - """Wrapped synchronous implementation of asynchronous :class:`.AsyncReader` class. - - This essentially mimics :class:`~mcproto.protocol.base_io.BaseSyncReader`. - """ - - _WRAPPED_ATTRIBUTE = "_reader" - - def __init__(self): - self._reader = AsyncReader() - - -class WrappedAsyncWriter(SynchronizedMixin): - """Wrapped synchronous implementation of asynchronous :class:`.AsyncWriter` class. - - This essentially mimics :class:`~mcproto.protocol.base_io.BaseSyncWriter`. - """ - - _WRAPPED_ATTRIBUTE = "_writer" - - def __init__(self): - self._writer = AsyncWriter() - - -# endregion -# region: Abstract test classes - - -class WriterTests(ABC): - """Collection of tests for both sync and async versions of the writer.""" - - writer: BaseSyncWriter | BaseAsyncWriter - - @classmethod - @abstractmethod - def setup_class(cls): - """Initialize writer instance to be tested.""" - ... - - @pytest.fixture - def method_mock(self) -> Mock | AsyncMock: - """Obtain the appropriate type of mock, supporting both sync and async modes.""" - if isinstance(self.writer, BaseSyncWriter): - return Mock - return AsyncMock - - @pytest.fixture - def autopatch(self, monkeypatch: pytest.MonkeyPatch): - """Create a simple function, supporting patching both sync/async writer functions with appropriate mocks. - - This returned function takes in the name of the function to patch, and returns the mock object. - This mock object will either be Mock, or AsyncMock instance, depending on whether we're in async or sync mode. - """ - if isinstance(self.writer, SyncWriter): - patch_path = "mcproto.protocol.base_io.BaseSyncWriter" - mock_type = Mock - else: - patch_path = "mcproto.protocol.base_io.BaseAsyncWriter" - mock_type = AsyncMock - - def autopatch(function_name: str) -> Mock | AsyncMock: - mock_f = mock_type() - monkeypatch.setattr(f"{patch_path}.{function_name}", mock_f) - return mock_f - - return autopatch - - @pytest.fixture - def write_mock(self, monkeypatch: pytest.MonkeyPatch): - """Monkeypatch the write function with a mock which is returned.""" - mock_f = ( - WriteFunctionMock() - if isinstance(self.writer, BaseSyncWriter) - else WriteFunctionAsyncMock() - ) - monkeypatch.setattr(self.writer.__class__, "write", mock_f) - return mock_f - - @pytest.mark.parametrize( - ("fmt", "value", "expected_bytes"), - [ - (StructFormat.UBYTE, 0, [0]), - (StructFormat.UBYTE, 15, [15]), - (StructFormat.UBYTE, 255, [255]), - (StructFormat.BYTE, 0, [0]), - (StructFormat.BYTE, 15, [15]), - (StructFormat.BYTE, 127, [127]), - (StructFormat.BYTE, -20, [to_twos_complement(-20, bits=8)]), - (StructFormat.BYTE, -128, [to_twos_complement(-128, bits=8)]), - ], - ) - def test_write_value( - self, - fmt: INT_FORMATS_TYPE, - value: Any, - expected_bytes: list[int], - write_mock: WriteFunctionMock, - ): - """Test writing values sends expected bytes.""" - self.writer.write_value(fmt, value) - write_mock.assert_has_data(bytearray(expected_bytes)) - - @pytest.mark.parametrize( - ("fmt", "value"), - [ - (StructFormat.UBYTE, -1), - (StructFormat.UBYTE, 256), - (StructFormat.BYTE, -129), - (StructFormat.BYTE, 128), - ], - ) - def test_write_value_out_of_range( - self, - fmt: INT_FORMATS_TYPE, - value: Any, - ): - """Test writing out of range values for the given format raises :exc:`struct.error`.""" - with pytest.raises(struct.error): - self.writer.write_value(fmt, value) - - @pytest.mark.parametrize( - ("number", "expected_bytes"), - [ - (0, [0]), - (1, [1]), - (2, [2]), - (15, [15]), - (127, [127]), - (128, [128, 1]), - (129, [129, 1]), - (255, [255, 1]), - (1000000, [192, 132, 61]), - (2147483647, [255, 255, 255, 255, 7]), - ], - ) - def test_write_varuint( - self, - number: int, - expected_bytes: list[int], - write_mock: WriteFunctionMock, - ): - """Test writing varuints results in correct bytes.""" - self.writer._write_varuint(number) - write_mock.assert_has_data(bytearray(expected_bytes)) - - @pytest.mark.parametrize( - ("write_value", "max_bits"), - [ - (-1, 128), - (-1, 1), - (2**16, 16), - (2**32, 32), - ], - ) - def test_write_varuint_out_of_range(self, write_value: int, max_bits: int): - """Test writing out of range varuints raises :exc:`ValueError`.""" - with pytest.raises( - ValueError, - match="^Tried to write varint outside of the range of", - ): - self.writer._write_varuint(write_value, max_bits=max_bits) - - @pytest.mark.parametrize( - ("number", "expected_bytes"), - [ - (127, [127]), - (16384, [128, 128, 1]), - (-128, [128, 255, 255, 255, 15]), - (-16383, [129, 128, 255, 255, 15]), - ], - ) - def test_write_varint( - self, - number: int, - expected_bytes: list[int], - write_mock: WriteFunctionMock, - ): - """Test writing varints results in correct bytes.""" - self.writer.write_varint(number) - write_mock.assert_has_data(bytearray(expected_bytes)) - - @pytest.mark.parametrize( - ("number", "expected_bytes"), - [ - (127, [127]), - (16384, [128, 128, 1]), - (-128, [128, 255, 255, 255, 255, 255, 255, 255, 255, 1]), - (-16383, [129, 128, 255, 255, 255, 255, 255, 255, 255, 1]), - ], - ) - def test_write_varlong( - self, - number: int, - expected_bytes: list[int], - write_mock: WriteFunctionMock, - ): - """Test writing varlongs results in correct bytes.""" - self.writer.write_varlong(number) - write_mock.assert_has_data(bytearray(expected_bytes)) - - @pytest.mark.parametrize( - ("data", "expected_bytes"), - [ - (b"", [0]), - (b"\x01", [1, 1]), - ( - b"hello\0world", - [11, 104, 101, 108, 108, 111, 0, 119, 111, 114, 108, 100], - ), - (b"\x01\x02\x03four\x05", [8, 1, 2, 3, 102, 111, 117, 114, 5]), - ], - ) - def test_write_bytearray( - self, - data: bytes, - expected_bytes: list[int], - write_mock: WriteFunctionMock, - ): - """Test writing ASCII string results in correct bytes.""" - self.writer.write_bytearray(data) - write_mock.assert_has_data(bytearray(expected_bytes)) - - @pytest.mark.parametrize( - ("string", "expected_bytes"), - [ - ("test", [*list(map(ord, "test")), 0]), - ("a" * 100, [*list(map(ord, "a" * 100)), 0]), - ("", [0]), - ], - ) - def test_write_ascii( - self, - string: str, - expected_bytes: list[int], - write_mock: WriteFunctionMock, - ): - """Test writing ASCII string results in correct bytes.""" - self.writer.write_ascii(string) - write_mock.assert_has_data(bytearray(expected_bytes)) - - @pytest.mark.parametrize( - ("string", "expected_bytes"), - [ - ("test", [len("test"), *list(map(ord, "test"))]), - ("a" * 100, [len("a" * 100), *list(map(ord, "a" * 100))]), - ("", [0]), - ("नमस्ते", [18] + [int(x) for x in "नमस्ते".encode()]), - ], - ) - def test_write_utf( - self, - string: str, - expected_bytes: list[int], - write_mock: WriteFunctionMock, - ): - """Test writing UTF string results in correct bytes.""" - self.writer.write_utf(string) - write_mock.assert_has_data(bytearray(expected_bytes)) - - @pytest.mark.skipif( - platform.system() == "Windows", - reason="environment variable limit on Windows", - ) - def test_write_utf_limit(self, write_mock: WriteFunctionMock): - """Test writing a UTF string too big raises a :exc:`ValueError`.""" - with pytest.raises( - ValueError, - match="Maximum character limit for writing strings is 32767 characters.", - ): - self.writer.write_utf("a" * (32768)) - - def test_write_optional_true( - self, - method_mock: Mock | AsyncMock, - write_mock: WriteFunctionMock, - ): - """Test writing non-``None`` value writes ``True`` and runs the writer function.""" - mock_v = Mock() - mock_f = method_mock() - self.writer.write_optional(mock_v, mock_f) - mock_f.assert_called_once_with(mock_v) - write_mock.assert_has_data(bytearray([1])) - - def test_write_optional_false( - self, - method_mock: Mock | AsyncMock, - write_mock: WriteFunctionMock, - ): - """Test writing ``None`` value should write ``False`` and skip running the writer function.""" - mock_f = method_mock() - self.writer.write_optional(None, mock_f) - mock_f.assert_not_called() - write_mock.assert_has_data(bytearray([0])) - - -class ReaderTests(ABC): - """Collection of tests for both sync and async versions of the reader.""" - - reader: BaseSyncReader | BaseAsyncReader - - @classmethod - @abstractmethod - def setup_class(cls): - """Initialize reader instance to be tested.""" - ... - - @pytest.fixture - def method_mock(self) -> Mock | AsyncMock: - """Obtain the appropriate type of mock, supporting both sync and async modes.""" - if isinstance(self.reader, BaseSyncReader): - return Mock - return AsyncMock - - @pytest.fixture - def autopatch(self, monkeypatch: pytest.MonkeyPatch): - """Create a simple function, supporting patching both sync/async reader functions with appropriate mocks. - - This returned function takes in the name of the function to patch, and returns the mock object. - This mock object will either be Mock, or AsyncMock instance, depending on whether we're in async or sync mode. - """ - if isinstance(self.reader, SyncReader): - patch_path = "mcproto.protocol.base_io.BaseSyncReader" - mock_type = Mock - else: - patch_path = "mcproto.protocol.base_io.BaseAsyncReader" - mock_type = AsyncMock - - def autopatch(function_name: str) -> Mock | AsyncMock: - mock_f = mock_type() - monkeypatch.setattr(f"{patch_path}.{function_name}", mock_f) - return mock_f - - return autopatch - - @pytest.fixture - def read_mock(self, monkeypatch: pytest.MonkeyPatch): - """Monkeypatch the read function with a mock which is returned.""" - mock_f = ( - ReadFunctionMock() - if isinstance(self.reader, SyncReader) - else ReadFunctionAsyncMock() - ) - monkeypatch.setattr(self.reader.__class__, "read", mock_f) - yield mock_f - # Run this assertion after the test, to ensure that all specified data - # to be read, actually was read - mock_f.assert_read_everything() - - @pytest.mark.parametrize( - ("fmt", "read_bytes", "expected_value"), - [ - (StructFormat.UBYTE, [0], 0), - (StructFormat.UBYTE, [10], 10), - (StructFormat.UBYTE, [255], 255), - (StructFormat.BYTE, [0], 0), - (StructFormat.BYTE, [20], 20), - (StructFormat.BYTE, [127], 127), - (StructFormat.BYTE, [to_twos_complement(-20, bits=8)], -20), - (StructFormat.BYTE, [to_twos_complement(-128, bits=8)], -128), - ], - ) - def test_read_value( - self, - fmt: INT_FORMATS_TYPE, - read_bytes: list[int], - expected_value: Any, - read_mock: ReadFunctionMock, - ): - """Test reading bytes gets expected value.""" - read_mock.combined_data = bytearray(read_bytes) - assert self.reader.read_value(fmt) == expected_value - - @pytest.mark.parametrize( - ("read_bytes", "expected_value"), - [ - ([0], 0), - ([1], 1), - ([2], 2), - ([15], 15), - ([127], 127), - ([128, 1], 128), - ([129, 1], 129), - ([255, 1], 255), - ([192, 132, 61], 1000000), - ([255, 255, 255, 255, 7], 2147483647), - ], - ) - def test_read_varuint( - self, - read_bytes: list[int], - expected_value: int, - read_mock: ReadFunctionMock, - ): - """Test reading varuint bytes results in correct values.""" - read_mock.combined_data = bytearray(read_bytes) - assert self.reader._read_varuint() == expected_value - - @pytest.mark.parametrize( - ("read_bytes", "max_bits"), - [ - ([128, 128, 4], 16), - ([128, 128, 128, 128, 16], 32), - ], - ) - def test_read_varuint_out_of_range( - self, - read_bytes: list[int], - max_bits: int, - read_mock: ReadFunctionMock, - ): - """Test reading out-of-range varuints raises :exc:`IOError`.""" - read_mock.combined_data = bytearray(read_bytes) - with pytest.raises( - IOError, - match="^Received varint was outside the range of", - ): - self.reader._read_varuint(max_bits=max_bits) - - @pytest.mark.parametrize( - ("read_bytes", "expected_value"), - [ - ([127], 127), - ([128, 128, 1], 16384), - ([128, 255, 255, 255, 15], -128), - ([129, 128, 255, 255, 15], -16383), - ], - ) - def test_read_varint( - self, - read_bytes: list[int], - expected_value: int, - read_mock: ReadFunctionMock, - ): - """Test reading varuint bytes results in correct values.""" - read_mock.combined_data = bytearray(read_bytes) - assert self.reader.read_varint() == expected_value - - @pytest.mark.parametrize( - ("read_bytes", "expected_value"), - [ - ([127], 127), - ([128, 128, 1], 16384), - ([128, 255, 255, 255, 255, 255, 255, 255, 255, 1], -128), - ([129, 128, 255, 255, 255, 255, 255, 255, 255, 1], -16383), - ], - ) - def test_read_varlong( - self, - read_bytes: list[int], - expected_value: int, - read_mock: ReadFunctionMock, - ): - """Test reading varuint bytes results in correct values.""" - read_mock.combined_data = bytearray(read_bytes) - assert self.reader.read_varlong() == expected_value - - @pytest.mark.parametrize( - ("read_bytes", "expected_bytes"), - [ - ([0], b""), - ([1, 1], b"\x01"), - ( - [11, 104, 101, 108, 108, 111, 0, 119, 111, 114, 108, 100], - b"hello\0world", - ), - ([8, 1, 2, 3, 102, 111, 117, 114, 5], b"\x01\x02\x03four\x05"), - ], - ) - def test_read_bytearray( - self, - read_bytes: list[int], - expected_bytes: bytes, - read_mock: ReadFunctionMock, - ): - """Test reading ASCII string results in correct bytes.""" - read_mock.combined_data = bytearray(read_bytes) - assert self.reader.read_bytearray() == expected_bytes - - @pytest.mark.parametrize( - ("read_bytes", "expected_string"), - [ - ([*list(map(ord, "test")), 0], "test"), - ([*list(map(ord, "a" * 100)), 0], "a" * 100), - ([0], ""), - ], - ) - def test_read_ascii( - self, - read_bytes: list[int], - expected_string: str, - read_mock: ReadFunctionMock, - ): - """Test reading ASCII string results in correct bytes.""" - read_mock.combined_data = bytearray(read_bytes) - assert self.reader.read_ascii() == expected_string - - @pytest.mark.parametrize( - ("read_bytes", "expected_string"), - [ - ([len("test"), *list(map(ord, "test"))], "test"), - ([len("a" * 100), *list(map(ord, "a" * 100))], "a" * 100), - ([0], ""), - ([18] + [int(x) for x in "नमस्ते".encode()], "नमस्ते"), - ], - ) - def test_read_utf( - self, - read_bytes: list[int], - expected_string: str, - read_mock: ReadFunctionMock, - ): - """Test reading UTF string results in correct values.""" - read_mock.combined_data = bytearray(read_bytes) - assert self.reader.read_utf() == expected_string - - @pytest.mark.skipif( - platform.system() == "Windows", - reason="environment variable limit on Windows", - ) - @pytest.mark.parametrize( - ("read_bytes"), - [ - [253, 255, 7], - [128, 128, 2, *list(map(ord, "a" * 32768))], - ], - # Temporary workaround. - # https://github.com/pytest-dev/pytest/issues/6881#issuecomment-596381626 - ids=["a", "b"], - ) - def test_read_utf_limit( - self, - read_bytes: list[int], - read_mock: ReadFunctionMock, - ): - """Test reading a UTF string too big raises an IOError.""" - read_mock.combined_data = bytearray(read_bytes) - with pytest.raises( - IOError, - match="^Maximum read limit for utf strings is ", - ): - self.reader.read_utf() - - def test_read_optional_true( - self, - method_mock: Mock | AsyncMock, - read_mock: ReadFunctionMock, - ): - """Test reading optional runs reader function when first bool is ``True``.""" - mock_f = method_mock() - read_mock.combined_data = bytearray([1]) - self.reader.read_optional(mock_f) - mock_f.assert_called_once_with() - - def test_read_optional_false( - self, - method_mock: Mock | AsyncMock, - read_mock: ReadFunctionMock, - ): - """Test reading optional doesn't run reader function when first bool is ``False``.""" - mock_f = method_mock() - read_mock.combined_data = bytearray([0]) - self.reader.read_optional(mock_f) - mock_f.assert_not_called() - - -# endregion -# region: Concrete test classes - - -class TestBaseSyncWriter(WriterTests): - """Tests for individual write methods implemented in :class:`~mcproto.protocol.base_io.BaseSyncWriter`.""" - - @classmethod - def setup_class(cls): - """Initialize writer instance to be tested.""" - cls.writer = SyncWriter() - - -class TestBaseSyncReader(ReaderTests): - """Tests for individual write methods implemented in :class:`~mcproto.protocol.base_io.BaseSyncReader`.""" - - @classmethod - def setup_class(cls): - """Initialize reader instance to be tested.""" - cls.reader = SyncReader() - - -class TestBaseAsyncWriter(WriterTests): - """Tests for individual write methods implemented in :class:`~mcproto.protocol.base_io.BaseSyncReader`.""" - - writer: WrappedAsyncWriter - - @classmethod - def setup_class(cls): - """Initialize writer instance to be tested.""" - cls.writer = WrappedAsyncWriter() - - -class TestBaseAsyncReader(ReaderTests): - """Tests for individual write methods implemented in :class:`~mcproto.protocol.base_io.BaseSyncReader`.""" - - reader: WrappedAsyncReader - - @classmethod - def setup_class(cls): - """Initialize writer instance to be tested.""" - cls.reader = WrappedAsyncReader() - - -# endregion diff --git a/tests/test_buffer.py b/tests/test_buffer.py deleted file mode 100644 index ac30774..0000000 --- a/tests/test_buffer.py +++ /dev/null @@ -1,97 +0,0 @@ -# This is the base_io module from https://github.com/py-mine/mcproto v0.5.0, -# which is licensed under the GNU LESSER GENERAL PUBLIC LICENSE v3.0 - -from __future__ import annotations - -__author__ = "ItsDrike" -__license__ = "LGPL-3.0-only" - -import pytest - -from azul.buffer import Buffer - - -def test_write() -> None: - """Writing into the buffer should store data.""" - buf = Buffer() - buf.write(b"Hello") - assert buf, bytearray(b"Hello") - - -def test_read() -> None: - """Reading from buffer should return stored data.""" - buf = Buffer(b"Reading is cool") - data = buf.read(len(buf)) - assert data == b"Reading is cool" - - -def test_read_multiple() -> None: - """Multiple reads should deplete the data.""" - buf = Buffer(b"Something random") - data = buf.read(9) - assert data == b"Something" - data = buf.read(7) - assert data == b" random" - - -def test_no_data_read() -> None: - """Reading more data than available should raise IOError.""" - buf = Buffer(b"Blip") - with pytest.raises( - IOError, - match="^Requested to read more data than available.", - ): - buf.read(len(buf) + 1) - - -def test_reset() -> None: - """Resetting should treat already read data as new unread data.""" - buf = Buffer(b"Will it reset?") - data = buf.read(len(buf)) - buf.reset() - data2 = buf.read(len(buf)) - assert data == data2 - assert data == b"Will it reset?" - - -def test_clear() -> None: - """Clearing should remove all stored data from buffer.""" - buf = Buffer(b"Will it clear?") - buf.clear() - assert buf == bytearray() - - -def test_clear_resets_position() -> None: - """Clearing should reset reading position for new data to be read.""" - buf = Buffer(b"abcdef") - buf.read(3) - buf.clear() - buf.write(b"012345") - data = buf.read(3) - assert data == b"012" - - -def test_clear_read_only() -> None: - """Clearing should allow just removing the already read data.""" - buf = Buffer(b"0123456789") - buf.read(5) - buf.clear(only_already_read=True) - assert buf == bytearray(b"56789") - - -def test_flush() -> None: - """Flushing should read all available data and clear out the buffer.""" - buf = Buffer(b"Foobar") - data = buf.flush() - assert data == b"Foobar" - assert buf == bytearray() - - -def test_remainig() -> None: - """Buffer should report correct amount of remaining bytes to be read.""" - buf = Buffer(b"012345") # 6 bytes to be read - assert buf.remaining == 6 - buf.read(2) - assert buf.remaining == 4 - buf.clear() - assert buf.remaining == 0 diff --git a/tests/test_component.py b/tests/test_component.py deleted file mode 100644 index d2ce838..0000000 --- a/tests/test_component.py +++ /dev/null @@ -1,370 +0,0 @@ -from __future__ import annotations - -import gc - -import pytest -import trio - -from azul.component import ( - Component, - ComponentManager, - Event, - ExternalRaiseManager, -) - - -def test_event_init() -> None: - event = Event("event_name", {"fish": 27}, 3) - assert event.name == "event_name" - assert event.data == {"fish": 27} - assert event.level == 3 - - -def test_event_pop_level() -> None: - event = Event("event_name", None, 3) - assert event.pop_level() - assert event.level == 2 - assert event.pop_level() - assert event.level == 1 - assert event.pop_level() - assert event.level == 0 - - assert not event.pop_level() - assert event.level == 0 - - -def test_event_repr() -> None: - assert repr(Event("cat_moved", (3, 3))) == "Event('cat_moved', (3, 3), 0)" - - -def test_component_init() -> None: - component = Component("component_name") - assert component.name == "component_name" - - -def test_component_repr() -> None: - assert repr(Component("fish")) == "Component('fish')" - - -def test_component_manager_property_error() -> None: - component = Component("waffle") - assert not component.manager_exists - with pytest.raises( - AttributeError, - match="^No component manager bound for", - ): - component.manager # noqa: B018 - - -def test_componentmanager_add_has_manager_property() -> None: - manager = ComponentManager("manager") - sound_effect = Component("sound_effect") - with pytest.raises(AttributeError): - print(sound_effect.manager) - manager.add_component(sound_effect) - assert manager.component_exists("sound_effect") - assert sound_effect.manager_exists - assert sound_effect.manager is manager - assert sound_effect.component_exists("sound_effect") - assert sound_effect.components_exist(("sound_effect",)) - assert not sound_effect.components_exist(("sound_effect", "waffle")) - assert manager.list_components() == ("sound_effect",) - assert sound_effect.get_component("sound_effect") is sound_effect - assert sound_effect.get_components(("sound_effect",)) == [sound_effect] - - -def test_componentmanager_manager_property_weakref_failure() -> None: - # Have to override __del__, unbind_components called and unbinds - # components so weakref failure branch never hit in normal - # circumstances - class EvilNoUnbindManager(ComponentManager): - def __del__(self) -> None: - return - - manager = EvilNoUnbindManager("manager") - sound_effect = Component("sound_effect") - with pytest.raises(AttributeError): - print(sound_effect.manager) - manager.add_component(sound_effect) - assert sound_effect.manager is manager - del manager - # make sure gc collects manager - for _ in range(3): - gc.collect() - with pytest.raises(AttributeError): - print(sound_effect.manager) - - -def test_double_bind_error() -> None: - manager = ComponentManager("manager") - sound_effect = Component("sound_effect") - manager.add_component(sound_effect) - manager_two = ComponentManager("manager_two") - with pytest.raises(RuntimeError, match="component is already bound to"): - manager_two.add_component(sound_effect) - - -def test_self_component() -> None: - manager = ComponentManager("manager", "cat_event") - assert manager.component_exists("cat_event") - assert manager.get_component("cat_event") is manager - - cat_event = Component("cat_event") - with pytest.raises(ValueError, match="already exists"): - manager.add_component(cat_event) - - -def test_add_multiple() -> None: - manager = ComponentManager("manager") - manager.add_components( - ( - Component("fish"), - Component("waffle"), - ), - ) - assert manager.component_exists("fish") - assert manager.component_exists("waffle") - - manager.unbind_components() - assert not manager.get_all_components() - - -def test_component_not_exist_error() -> None: - manager = ComponentManager("manager") - with pytest.raises(ValueError, match="does not exist"): - manager.remove_component("darkness") - with pytest.raises(ValueError, match="does not exist"): - manager.get_component("darkness") - - -@pytest.mark.trio -async def test_self_component_handler() -> None: - event_called = False - - async def event_call(event: Event[None]) -> None: - nonlocal event_called - assert event.name == "fish_appears_event" - event_called = True - - manager = ComponentManager("manager", "cat") - manager.register_handler("fish_appears_event", event_call) - - assert manager.has_handler("fish_appears_event") - - await manager.raise_event(Event("fish_appears_event", None)) - assert event_called - - -@pytest.mark.trio -async def test_raise_event_register_handlers_double_call() -> None: - event_called_count = 0 - - async def event_call(event: Event[int]) -> None: - nonlocal event_called_count - assert event.data == 27 - event_called_count += 1 - - manager = ComponentManager("manager") - assert not manager.has_handler("event_name") - - manager.register_component_handler("event_name", event_call, manager.name) - assert manager.has_handler("event_name") - await manager.raise_event(Event("event_name", 27)) - assert event_called_count == 1 - - event_called_count = 0 - - with pytest.raises(ValueError, match="is not registered!"): - manager.register_component_handler( - "event_name", - event_call, - "2nd name", - ) - manager.add_component(Component("2nd name")) - manager.register_component_handler("event_name", event_call, "2nd name") - - await manager.raise_event(Event("event_name", 27)) - assert event_called_count == 2 - - -@pytest.mark.trio -async def test_raise_event_register_handlers() -> None: - event_called = False - - async def event_call(event: Event[int]) -> None: - nonlocal event_called - assert event.data == 27 - event_called = True - - manager = ComponentManager("manager") - sound_effect = Component("sound_effect") - manager.add_component(sound_effect) - sound_effect.register_handlers({"event_name": event_call}) - - assert sound_effect.has_handler("event_name") - - await sound_effect.raise_event(Event("event_name", 27)) - assert event_called - - event_called = False - await manager.raise_event(Event("event_name", 27)) - assert event_called - - event_called = False - manager.remove_component("sound_effect") - with pytest.raises(AttributeError, match="No component manager bound for"): - await sound_effect.raise_event(Event("event_name", 27)) - await manager.raise_event(Event("event_name", 27)) - assert not event_called - - -@pytest.mark.trio -async def test_raise_leveled_comes_back() -> None: - event_called = False - - async def event_call(event: Event[int]) -> None: - nonlocal event_called - assert event.level == 0 - event_called = True - - event_called_two = False - - async def event_call_two(event: Event[int]) -> None: - nonlocal event_called_two - assert event.level == 0 - event_called_two = True - - super_manager = ComponentManager("super_manager") - manager = ComponentManager("manager") - - super_manager.add_component(manager) - assert super_manager.component_exists("manager") - - super_manager.register_handler("leveled_event", event_call) - manager.register_handler("leveled_event", event_call_two) - - await manager.raise_event(Event("leveled_event", None, 1)) - assert event_called - assert event_called_two - - -@pytest.mark.trio -async def test_raise_event_register_handler() -> None: - event_called = False - - async def event_call(event: Event[int]) -> None: - nonlocal event_called - assert event.data == 27 - event_called = True - - manager = ComponentManager("manager") - sound_effect = Component("sound_effect") - manager.add_component(sound_effect) - sound_effect.register_handler("event_name", event_call) - - await sound_effect.raise_event(Event("event_name", 27)) - assert event_called - - -@pytest.mark.trio -async def test_raises_event_in_nursery() -> None: - nursery_called = False - event_called = False - - async def call_bean(event: Event[None]) -> None: - nonlocal event_called - assert event.name == "bean_event" - event_called = True - - async with trio.open_nursery() as nursery: - original = nursery.start_soon - - def replacement(*args: object, **kwargs: object) -> object: - nonlocal nursery_called - nursery_called = True - return original(*args, **kwargs) - - nursery.start_soon = replacement - - manager = ExternalRaiseManager("manager", nursery) - manager.register_handler("bean_event", call_bean) - await manager.raise_event(Event("bean_event", None)) - assert nursery_called - assert event_called - - -@pytest.mark.trio -async def test_internal_does_not_raise_event_in_nursery() -> None: - nursery_called = False - event_called = False - - async def call_bean(event: Event[None]) -> None: - nonlocal event_called - assert event.name == "bean_event" - event_called = True - - async with trio.open_nursery() as nursery: - original = nursery.start_soon - - def replacement(*args: object, **kwargs: object) -> object: - nonlocal nursery_called - nursery_called = True - return original(*args, **kwargs) - - nursery.start_soon = replacement - - manager = ExternalRaiseManager("manager", nursery) - manager.register_handler("bean_event", call_bean) - await manager.raise_event_internal(Event("bean_event", None)) - assert not nursery_called - assert event_called - - -@pytest.mark.trio -async def test_temporary_component() -> None: - event_called = False - - async def event_call(event: Event[int]) -> None: - nonlocal event_called - assert event.data == 27 - event_called = True - - manager = ComponentManager("manager") - with manager.temporary_component( - Component("sound_effect"), - ) as sound_effect: - assert manager.component_exists("sound_effect") - sound_effect.register_handler("event_name", event_call) - - await sound_effect.raise_event(Event("event_name", 27)) - assert event_called - assert not manager.component_exists("sound_effect") - with manager.temporary_component( - Component("sound_effect"), - ) as sound_effect: - manager.remove_component("sound_effect") - - -@pytest.mark.trio -async def test_remove_component() -> None: - event_called = False - - async def event_call(event: Event[int]) -> None: - nonlocal event_called - assert event.data == 27 - event_called = True - - manager = ComponentManager("manager") - sound_effect = Component("sound_effect") - manager.add_component(sound_effect) - assert manager.component_exists("sound_effect") - sound_effect.register_handler("event_name", event_call) - sound_effect.register_handler("waffle_name", event_call) - - await sound_effect.raise_event(Event("event_name", 27)) - assert event_called - manager.add_component(Component("jerald")) - manager.register_handler("event_name", event_call) - manager.remove_component("jerald") - manager.remove_component("sound_effect") - assert not manager.component_exists("sound_effect") diff --git a/tests/test_encrypted_event.py b/tests/test_encrypted_event.py deleted file mode 100644 index aaa837d..0000000 --- a/tests/test_encrypted_event.py +++ /dev/null @@ -1,79 +0,0 @@ -from __future__ import annotations - -import pytest -import trio -import trio.testing - -from azul.component import Event -from azul.encrypted_event import EncryptedNetworkEventComponent - - -@pytest.mark.trio -async def test_event_transmission() -> None: - one, two = trio.testing.memory_stream_pair() - client_one = EncryptedNetworkEventComponent.from_stream("one", stream=one) - client_two = EncryptedNetworkEventComponent.from_stream("two", stream=two) - - client_one.register_network_write_event("echo_event", 0) - client_two.register_read_network_event(0, "reposted_event") - - event = Event( - "echo_event", - bytearray("I will give my cat food to bob", "utf-8"), - 3, - ) - - await client_one.write_event(event) - read_event = await client_two.read_event() - assert read_event.name == "reposted_event" - assert read_event.data == event.data - - await client_one.close() - await client_two.close() - - -@pytest.mark.trio -async def test_event_encrypted_transmission() -> None: - verification_token = bytes.fromhex("da053623dd3dcd441e105ee5ce212ac8") - shared_secret = bytes.fromhex( - "95a883358f09cd5698b3cf8a414a8a659a35c4eb877e9b0228b7f64df85b0f26", - ) - - one, two = trio.testing.memory_stream_pair() - client_one = EncryptedNetworkEventComponent.from_stream("one", stream=one) - client_two = EncryptedNetworkEventComponent.from_stream("two", stream=two) - - client_one.register_network_write_event("echo_event", 0) - client_two.register_read_network_event(0, "reposted_event") - - event = Event( - "echo_event", - bytearray("I will give my cat food to bob", "utf-8"), - 3, - ) - - await client_one.write_event(event) - read_event = await client_two.read_event() - assert read_event.name == "reposted_event" - assert read_event.data == event.data - - await client_one.write_event(event) - assert ( - await two.receive_some() == b"\x00\x1eI will give my cat food to bob" - ) - - client_one.enable_encryption(shared_secret, verification_token) - client_two.enable_encryption(shared_secret, verification_token) - - await client_one.write_event(event) - read_event = await client_two.read_event() - assert read_event.name == "reposted_event" - assert read_event.data == event.data - - await client_one.write_event(event) - assert await two.receive_some() == bytearray.fromhex( - "2bb572309dfb71d22eb5f0442c5347f2d666ed16c97093190a8101c3e59f2beb", - ) - - await client_one.close() - await client_two.close() diff --git a/tests/test_encryption.py b/tests/test_encryption.py deleted file mode 100644 index 92538ad..0000000 --- a/tests/test_encryption.py +++ /dev/null @@ -1,110 +0,0 @@ -# This is the buffer module from https://github.com/py-mine/mcproto v0.5.0, -# which is licensed under the GNU LESSER GENERAL PUBLIC LICENSE v3.0 - -from __future__ import annotations - -__author__ = "ItsDrike" -__license__ = "LGPL-3.0-only" - -from typing import TYPE_CHECKING, cast - -from cryptography.hazmat.primitives.asymmetric.padding import MGF1, OAEP -from cryptography.hazmat.primitives.hashes import SHA256 -from cryptography.hazmat.primitives.serialization import load_pem_private_key - -from azul.encryption import ( - decrypt_token_and_secret, - deserialize_public_key, - encrypt_token_and_secret, - serialize_public_key, -) - -if TYPE_CHECKING: - from cryptography.hazmat.primitives.asymmetric.rsa import RSAPrivateKey - -_SERIALIZED_RSA_PRIVATE_KEY = b""" ------BEGIN PRIVATE KEY----- -MIICdgIBADANBgkqhkiG9w0BAQEFAASCAmAwggJcAgEAAoGBAMtRUQmRHqPkdA2K -F6fM2c8ibIPHYV5KVQXNEkVx7iEKS6JsfELhX1H8t/qQ3Ob4Pr4OFjgXx9n7GvfZ -gekNoswG6lnQH/n7t2sYA 6D+WvSix1FF2J6wPmpKriHS59TDk4opjaV14S4K4XjW -Gmm8DqCzgXkPGC2dunFb+1A8mdkrAgMBAAECgYAWj2dWkGu989OMzQ3i6LAic8dm -t/Dt7YGRqzejzQiHUgUieLcxFKDnEAu6GejpGBKeNCHzB3B9l4deiRwJKCIwHqMN -LKMKoayinA8mj/Y/ O/ELDofkEyeXOhFyM642sPpaxQJoNWc9QEsYbxpG2zeB3sPf -l3eIhkYTKVdxB+o8AQJBAPiddMjU8fuHyjKT6VCL2ZQbwnrRe1AaLLE6VLwEZuZC -wlbx5Lcszi77PkMRTvltQW39VN6MEjiYFSPtRJleA+sCQQDRW2e3BX6uiil2IZ08 -tPFMnltFJpa 8YvW50N6mySd8Zg1oQJpzP2fC0n0+K4j3EiA/Zli8jBt45cJ4dMGX -km/BAkEAtkYy5j+BvolbDGP3Ti+KcRU9K/DD+QGHvNRoZYTQsIdHlpk4t7eo3zci -+ecJwMOCkhKHE7cccNPHxBRkFBGiywJAJBt2pMsu0R2FDxm3C6xNXaCGL0P7hVwv -8y9B51 QUGlFjiJJz0OKjm6c/8IQDqFEY/LZDIamsZ0qBItNIPEMGQQJALZV0GD5Y -zmnkw1hek/JcfQBlVYo3gFmWBh6Hl1Lb7p3TKUViJCA1k2f0aGv7+d9aFS0fRq6u -/sETkem8Jc1s3g== ------END PRIVATE KEY----- -""" -RSA_PRIVATE_KEY = cast( - "RSAPrivateKey", - load_pem_private_key(_SERIALIZED_RSA_PRIVATE_KEY, password=None), -) -RSA_PUBLIC_KEY = RSA_PRIVATE_KEY.public_key() -SERIALIZED_RSA_PUBLIC_KEY = bytes.fromhex( - "30819f300d06092a864886f70d010101050003818d0030818902818100cb515109911ea3e4740d8a17a7ccd9cf226c83c7615e4a5505cd124571ee210a4ba26c7c42e15f51fcb7fa90dce6f83ebe0e163817c7d9fb1af7d981e90da2cc06ea59d01ff9fbb76b1803a0fe5af4a2c75145d89eb03e6a4aae21d2e7d4c3938a298da575e12e0ae178d61a69bc0ea0b381790f182d9dba715bfb503c99d92b0203010001", -) - - -def test_encrypt_token_and_secret() -> None: - """Test encryption returns properly encrypted (decryptable) values.""" - verification_token = bytes.fromhex("da053623dd3dcd441e105ee5ce212ac8") - shared_secret = bytes.fromhex( - "95a883358f09cd5698b3cf8a414a8a659a35c4eb877e9b0228b7f64df85b0f26", - ) - - encrypted_token, encrypted_secret = encrypt_token_and_secret( - RSA_PUBLIC_KEY, - verification_token, - shared_secret, - ) - - assert ( - RSA_PRIVATE_KEY.decrypt( - encrypted_token, - OAEP(MGF1(SHA256()), SHA256(), None), - ) - == verification_token - ) - assert ( - RSA_PRIVATE_KEY.decrypt( - encrypted_secret, - OAEP(MGF1(SHA256()), SHA256(), None), - ) - == shared_secret - ) - - -def test_decrypt_token_and_secret() -> None: - """Test decryption returns properly decrypted values.""" - encrypted_token = bytes.fromhex( - "5541c0c0fc99d8908ed428b20c260795bec7b4041a4f98d26fbed383e8dba077eb53fb5cf905e722e2ceb341843e875508134817bcd3a909ac279e77ed94fd98c428bbe00db630a5ad3df310380d9274ed369cc6a011e7edd45cbe44ae8ad2575ef793b23057e4b15f1b6e3e195ff0921e46370773218517922fbb8b96092d88", - ) - encrypted_secret = bytes.fromhex( - "1a43782ca17f71e87e6ef98f9be66050ecf5d185da81445d26ceb5941f95d69d61b726d27b5ca62aed4cbe27b40fd4bd6b16b5be154a7b6a24ae31c705bc47d9397589b448fb72b14572ea2a9d843c6a3c674b7454cef97e2d65be36e0d0a8cc9f1093a19a8d52a5633a5317d19779bb46146dfaea7a690a7f080fb77d59c7f9", - ) - - assert decrypt_token_and_secret( - RSA_PRIVATE_KEY, - encrypted_token, - encrypted_secret, - ) == ( - bytes.fromhex("da053623dd3dcd441e105ee5ce212ac8"), - bytes.fromhex( - "95a883358f09cd5698b3cf8a414a8a659a35c4eb877e9b0228b7f64df85b0f26", - ), - ) - - -def test_serialize_public_key() -> None: - """Test serialize_public_key.""" - assert serialize_public_key(RSA_PUBLIC_KEY) == SERIALIZED_RSA_PUBLIC_KEY - - -def test_deserialize_public_key() -> None: - """Test deserialize_public_key.""" - assert deserialize_public_key(SERIALIZED_RSA_PUBLIC_KEY) == RSA_PUBLIC_KEY diff --git a/tests/test_network.py b/tests/test_network.py deleted file mode 100644 index 0273bff..0000000 --- a/tests/test_network.py +++ /dev/null @@ -1,178 +0,0 @@ -from __future__ import annotations - -from typing import TYPE_CHECKING - -import pytest -import trio -import trio.testing - -from azul.component import ComponentManager, Event -from azul.network import ( - NetworkComponent, - NetworkEventComponent, - NetworkStreamNotConnectedError, - NetworkTimeoutError, - Server, -) - -if TYPE_CHECKING: - from collections.abc import Callable - - -@pytest.mark.trio -async def client_connect(port: int, stop_server: Callable[[], None]) -> None: - await trio.sleep(0.05) - # manager = ComponentManager("manager") - - client = NetworkEventComponent("client") - # manager.add_component(client) - - await client.connect("127.0.0.1", port) - - client.register_network_write_event("echo_event", 0) - client.register_read_network_event(1, "reposted_event") - - event = Event( - "echo_event", - bytearray("I will give my cat food to bob", "utf-8"), - 3, - ) - - # await client.raise_event(event) - await client.write_event(event) - print(f"{await client.read_event() = }") - - await client.close() - stop_server() - - -@pytest.mark.trio -async def run_async() -> None: - class TestServer(Server): - async def handler(self, stream: trio.SocketStream) -> None: - client = NetworkEventComponent.from_stream("client", stream=stream) - - client.register_read_network_event(0, "repost_event") - client.register_network_write_event("repost_event", 1) - - await client.write_event(await client.read_event()) - await stream.aclose() - - server = TestServer("server") - port = 3004 - async with trio.open_nursery() as nursery: - nursery.start_soon(server.serve, port) - nursery.start_soon(client_connect, port, server.stop_serving) - nursery.start_soon(client_connect, port, server.stop_serving) - - -def test_not_connected() -> None: - client = NetworkComponent("name") - assert client.not_connected - with pytest.raises(NetworkStreamNotConnectedError): - print(client.stream) - - -@pytest.mark.trio -async def test_from_stream() -> None: - stream = trio.testing.MemorySendStream() - - named = NetworkComponent.from_stream( - kwargs={"name": "name"}, - stream=stream, - ) - with pytest.raises(RuntimeError, match="Already connected!"): - await named.connect("example.com", 80) - await named.close() - - -@pytest.mark.trio -async def test_register_network_write_event() -> None: - client = NetworkEventComponent("client") - client.register_network_write_event("echo_event", 0) - with pytest.raises(ValueError, match="event already registered"): - client.register_network_write_event("echo_event", 0) - client.register_read_network_event(0, "reposted_event") - with pytest.raises(ValueError, match="events are also being received"): - client.register_network_write_events({"reposted_event": 0}) - with pytest.raises(RuntimeError, match="Unhandled network event name"): - await client.write_event(Event("jerald event", bytearray())) - client.register_network_write_events({}) - - -@pytest.mark.trio -async def test_register_network_read_event() -> None: - one, two = trio.testing.memory_stream_pair() - client_one = NetworkEventComponent.from_stream("one", stream=one) - client_two = NetworkEventComponent.from_stream("two", stream=two) - client_one.register_network_write_event("echo_event", 0) - await client_one.write_event( - Event( - "echo_event", - bytearray("I will give my cat food to bob", "utf-8"), - ), - ) - with pytest.raises(RuntimeError, match="Unhandled packet ID 0"): - await client_two.read_event() - with pytest.raises(ValueError, match="Packet id 0 packets are also"): - client_one.register_read_network_event(0, "echo_event") - client_two.register_read_network_event(0, "reposted_event") - with pytest.raises(ValueError, match="Packet ID 0 already registered!"): - client_two.register_read_network_events({0: "type_two"}) - client_two.register_read_network_events({}) - - -@pytest.mark.trio -async def test_event_transmission() -> None: - one, two = trio.testing.memory_stream_pair() - client_one = NetworkEventComponent.from_stream("one", stream=one) - manager = ComponentManager("manager") - async with NetworkEventComponent.from_stream( - "two", - stream=two, - ) as client_two: - manager.add_component(client_one) - - assert not client_one.not_connected - - client_one.register_network_write_event("echo_event", 0) - client_two.register_read_network_event(0, "reposted_event") - - event = Event( - "echo_event", - bytearray("I will give my cat food to bob", "utf-8"), - 3, - ) - - await client_one.write_event(event) - read_event = await client_two.read_event() - assert read_event.name == "reposted_event" - assert read_event.data == event.data - - await client_one.write_event(event) - assert ( - await two.receive_some() - == b"\x00\x1eI will give my cat food to bob" - ) - - await client_one.wait_write_might_not_block() - await one.send_all(b"") - client_two.timeout = 0.05 - with pytest.raises(NetworkTimeoutError): - await client_two.read_event() - await one.send_all(b"cat") - with pytest.raises(OSError, match="Server stopped responding"): - await client_two.read(4) - - await client_one.send_eof() - await client_one.send_eof() - - await client_one.close() - await client_one.close() - - -def test_server() -> None: - server = Server("name") - server.stop_serving() - server.serve_cancel_scope = trio.CancelScope() - server.stop_serving() diff --git a/tests/test_objects.py b/tests/test_objects.py new file mode 100644 index 0000000..66450f6 --- /dev/null +++ b/tests/test_objects.py @@ -0,0 +1,86 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +import pytest +from pygame.surface import Surface + +from azul.objects import Button, OutlinedText, Text + +if TYPE_CHECKING: + from pygame.font import Font + + +class MockSurface(Surface): + """Mocking a pygame surface for testing.""" + + __slots__ = ("text_data",) + + def __init__(self, text_data: str = "") -> None: + super().__init__((0, 0)) + self.text_data = text_data + + +class MockFont: + """Mocking a pygame font for testing.""" + + __slots__ = () + + def render( + self, + text: str, + antialias: bool, + color: tuple[int, int, int], + ) -> MockSurface: + """Fake render method.""" + return MockSurface(text) + + +@pytest.fixture +def font() -> MockFont: + return MockFont() + + +def test_text_initialization(font: Font) -> None: + text = Text("TestText", font) + assert text.text == "None" + assert text.color == (255, 255, 255) + assert text.font == font + + +def test_text_rendering(font: Font) -> None: + text = Text("TestText", font) + assert text.image is None + + +def test_text_rendering_blank(font: Font) -> None: + text = Text("TestText", font) + text.text = "" + text.text = "" + assert text.image.text_data == "" + + +def test_outlined_text_initialization(font: Font) -> None: + outlined_text = OutlinedText("TestOutlinedText", font) + assert outlined_text.outline == (0, 0, 0) + assert outlined_text.inside == (255, 255, 255) + + +def test_outlined_text_rendering(font: Font) -> None: + outlined_text = OutlinedText("TestOutlinedText", font) + outlined_text.text = "Outlined Text" + assert outlined_text.text == "Outlined Text" + + +def test_outlined_text_rendering_zero_border(font: Font) -> None: + outlined_text = OutlinedText("TestOutlinedText", font) + outlined_text.border_width = 0 + outlined_text.text = "Outlined Text" + assert isinstance(outlined_text.image, Surface) + + +def test_button_initialization(font: Font) -> None: + button = Button("TestButton", font) + assert button.text == "None" + assert button.color == (0, 0, 0, 255) # type: ignore[comparison-overlap] + assert button.border_width == 3 diff --git a/tests/test_sprite.py b/tests/test_sprite.py new file mode 100644 index 0000000..14585db --- /dev/null +++ b/tests/test_sprite.py @@ -0,0 +1,331 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, cast + +import pytest +import trio +from libcomponent.component import Event +from pygame.mask import Mask +from pygame.rect import Rect +from pygame.surface import Surface + +from azul.sprite import ( + AnimationComponent, + DragClickEventComponent, + GroupProcessor, + ImageComponent, + MovementComponent, + OutlineComponent, + Sprite, + TargetingComponent, + TickEventData, +) +from azul.vector import Vector2 + + +@pytest.fixture +def sprite() -> Sprite: + return Sprite("test_sprite") + + +@pytest.fixture +def image_component(sprite: Sprite) -> ImageComponent: + sprite.add_component(ImageComponent()) + return cast("ImageComponent", sprite.get_component("image")) + + +@pytest.fixture +def animation_component(image_component: ImageComponent) -> AnimationComponent: + return cast( + "AnimationComponent", + image_component.get_component("animation"), + ) + + +@pytest.fixture +def outline_component(image_component: ImageComponent) -> OutlineComponent: + return cast("OutlineComponent", image_component.get_component("outline")) + + +@pytest.fixture +def movement_component(sprite: Sprite) -> MovementComponent: + sprite.add_component(MovementComponent()) + return cast("MovementComponent", sprite.get_component("movement")) + + +@pytest.fixture +def targeting_component( + movement_component: MovementComponent, +) -> TargetingComponent: + sprite = movement_component.manager + sprite.add_component(TargetingComponent()) + return cast("TargetingComponent", sprite.get_component("targeting")) + + +@pytest.fixture +def drag_click_event_component() -> DragClickEventComponent: + return DragClickEventComponent() + + +@pytest.fixture +def group_processor() -> GroupProcessor: + return GroupProcessor() + + +def test_sprite_init(sprite: Sprite) -> None: + assert sprite.name == "test_sprite" + assert not sprite.visible + assert sprite.rect == Rect(0, 0, 0, 0) + + +def test_sprite_location(sprite: Sprite) -> None: + sprite.location = (10, 20) + assert sprite.rect.center == (10, 20) + + +def test_sprite_repr(sprite: Sprite) -> None: + assert repr(sprite) == "" + + +def test_sprite_image(sprite: Sprite) -> None: + sprite.dirty = 0 + assert sprite.image is None + assert not sprite.dirty + sprite.image = Surface((10, 10)) + assert isinstance(sprite.image, Surface) + assert sprite.dirty + assert sprite.rect.size == (10, 10) # type: ignore[unreachable] + + +def test_sprite_image_set_none(sprite: Sprite) -> None: + sprite.dirty = 0 + assert sprite.image is None + assert not sprite.dirty + sprite.image = None + assert sprite.dirty + + +def test_sprite_image_no_set_location_change(sprite: Sprite) -> None: + sprite.update_location_on_resize = False + sprite.location = (100, 100) + sprite.image = Surface((50, 25)) + assert sprite.location == (125, 112) + + +def test_sprite_image_set_location_change(sprite: Sprite) -> None: + sprite.update_location_on_resize = True + sprite.location = (100, 100) + sprite.image = Surface((50, 25)) + assert sprite.location == (100, 100) + + +def test_sprite_selected_invisible(sprite: Sprite) -> None: + assert not sprite.visible + sprite.rect.size = (100, 100) + assert not sprite.is_selected((20, 20)) + + +def test_sprite_selected(sprite: Sprite) -> None: + sprite.visible = True + sprite.rect.size = (100, 100) + assert sprite.is_selected((20, 20)) + + +def test_image_component_init(image_component: ImageComponent) -> None: + assert image_component.mask_threshold == 127 + + +def test_image_component_add_image(image_component: ImageComponent) -> None: + image = Surface((10, 10)) + image_component.add_image("test_image", image) + assert "test_image" in image_component.list_images() + + +def test_image_component_add_images(image_component: ImageComponent) -> None: + image = Surface((10, 10)) + image_component.add_images({"test_image": image}) + assert "test_image" in image_component.list_images() + + +def test_image_component_get_image_fail( + image_component: ImageComponent, +) -> None: + with pytest.raises( + ValueError, + match=r'^No image saved for identifier "test_image"$', + ): + image_component.get_image("test_image") + + +def test_image_component_get_mask_fail( + image_component: ImageComponent, +) -> None: + with pytest.raises( + ValueError, + match=r'^No mask saved for identifier "test_mask"$', + ): + image_component.get_mask("test_mask") + + +def test_image_component_get_mask_success( + image_component: ImageComponent, +) -> None: + image = Surface((10, 10)) + image_component.add_image("test_image", image) + assert isinstance(image_component.get_mask("test_image"), Mask) + + +def test_image_component_add_image_and_mask_invalid_image( + image_component: ImageComponent, +) -> None: + with pytest.raises( + ValueError, + match=r"^Expected surface to be a valid identifier$", + ): + image_component.add_image_and_mask("test_image", None, None) # type: ignore[arg-type] + with pytest.raises( + ValueError, + match=r"^Expected surface to be a valid identifier$", + ): + image_component.add_image_and_mask("test_image", "copy_from", None) # type: ignore[arg-type] + + +def test_image_component_add_image_and_mask_invalid_mask( + image_component: ImageComponent, +) -> None: + image = Surface((1, 1)) + with pytest.raises( + ValueError, + match=r"^Expected mask to be a valid identifier$", + ): + image_component.add_image_and_mask("test_image", image, None) # type: ignore[arg-type] + with pytest.raises( + ValueError, + match=r"^Expected mask to be a valid identifier$", + ): + image_component.add_image_and_mask("test_image", image, "copy_from") + + +def test_image_component_get_image(image_component: ImageComponent) -> None: + image = Surface((1, 1)) + image_component.add_image("test_image", image) + assert image_component.get_image("test_image") is image + + +def test_image_component_add_image_duplication( + image_component: ImageComponent, +) -> None: + image = Surface((1, 1)) + image_component.add_image("test_image", image) + image_component.add_image("duplicate", "test_image") + assert image_component.get_image("duplicate") is image + + +def test_image_component_get_duplicate_mask( + image_component: ImageComponent, +) -> None: + image = Surface((1, 1)) + image_component.add_image("test_image", image) + image_component.add_image("duplicate", "test_image") + assert isinstance(image_component.get_mask("duplicate"), Mask) + + +def test_image_component_set_image_affects_sprite( + image_component: ImageComponent, +) -> None: + image = Surface((1, 1)) + sprite = cast("Sprite", image_component.manager.get_component("sprite")) + image_component.add_image("test_image", image) + assert sprite.image is None + image_component.set_image("test_image") + if TYPE_CHECKING: + sprite.image = image + assert sprite.image is image + image_component.set_image("test_image") + assert sprite.image is image + + +def test_movement_component_init( + movement_component: MovementComponent, +) -> None: + assert movement_component.heading == Vector2(0, 0) + assert movement_component.speed == 0 + + +def test_movement_component_point_toward( + movement_component: MovementComponent, +) -> None: + movement_component.point_toward((10, 20)) + assert ( + movement_component.heading + == Vector2.from_points((0, 0), (10, 20)).normalized() + ) + + +def test_movement_component_move_heading_time( + movement_component: MovementComponent, +) -> None: + movement_component.speed = 5 + movement_component.move_heading_time(1) + assert movement_component.heading * 5 == movement_component.heading + + +def test_targeting_component_init( + targeting_component: TargetingComponent, +) -> None: + assert targeting_component.destination == Vector2(0, 0) + assert targeting_component.event_raise_name == "reached_destination" + + +def test_targeting_component_update_heading( + targeting_component: TargetingComponent, +) -> None: + targeting_component.destination = Vector2(10, 20) + targeting_component.update_heading() + assert targeting_component.to_destination() == Vector2.from_points( + (0, 0), + (10, 20), + ) + + +@pytest.mark.trio +async def test_targeting_component_move_destination_time( + targeting_component: TargetingComponent, +) -> None: + movement_component = targeting_component.get_component("movement") + movement_component.speed = 1 + targeting_component.destination = Vector2(10, 20) + current_distance = targeting_component.to_destination().magnitude() + await targeting_component.move_destination_time(1) + assert targeting_component.to_destination().magnitude() < current_distance + + +def test_drag_click_event_component_init( + drag_click_event_component: DragClickEventComponent, +) -> None: + assert drag_click_event_component.pressed == {} + + +def test_group_processor_init(group_processor: GroupProcessor) -> None: + assert group_processor.groups == {} + assert group_processor.group_names == {} + assert group_processor.new_gid == 0 + + +def test_group_processor_new_group(group_processor: GroupProcessor) -> None: + gid = group_processor.new_group("test_group") + assert gid in group_processor.groups + assert "test_group" in group_processor.group_names + + +@pytest.mark.trio +async def test_animation_component_tick( + animation_component: AnimationComponent, +) -> None: + async with trio.open_nursery() as nursery: + nursery.start_soon( + animation_component.tick, + Event("tick", TickEventData(time_passed=1, fps=60)), + ) + await trio.lowlevel.checkpoint() + # Assert that the animation component has updated correctly diff --git a/tests/test_statemachine.py b/tests/test_statemachine.py index f3cfafe..6b0cf59 100644 --- a/tests/test_statemachine.py +++ b/tests/test_statemachine.py @@ -11,30 +11,30 @@ def test_state() -> None: - state = State("waffle_time") + state = State[StateMachine]("waffle_time") assert state.name == "waffle_time" with pytest.raises( RuntimeError, - match="^State has no statemachine bound$", + match=r"^State has no statemachine bound$", ): print(state.machine) def test_state_repr() -> None: - state = State("waffle_time") + state = State[StateMachine]("waffle_time") assert repr(state) == "State('waffle_time')" def test_async_state() -> None: - state = AsyncState("waffle_time") + state = AsyncState[AsyncStateMachine]("waffle_time") assert state.name == "waffle_time" with pytest.raises( RuntimeError, - match="^State has no statemachine bound$", + match=r"^State has no statemachine bound$", ): print(state.machine) @@ -43,18 +43,18 @@ def test_state_machine_add() -> None: machine = StateMachine() add_actions_run = False - class TestState(State): + class TestState(State[StateMachine]): def add_actions(self) -> None: nonlocal add_actions_run add_actions_run = True machine.add_state(TestState("test")) - bob = State("bob") + bob = State[StateMachine]("bob") with pytest.raises(RuntimeError, match="State has no statemachine bound"): - assert bob.machine + assert bob.machine is not None with pytest.raises(TypeError, match="is not an instance of State!"): - machine.add_state(AsyncState("test")) - with pytest.raises(ValueError, match="is not a registered State."): + machine.add_state(AsyncState("test")) # type: ignore[arg-type] + with pytest.raises(ValueError, match=r"is not a registered State\."): machine.remove_state("waffle") machine.add_state(bob) assert add_actions_run @@ -67,7 +67,7 @@ def add_actions(self) -> None: gc.collect() with pytest.raises(RuntimeError, match="State has no statemachine bound"): - assert bob.machine + assert bob.machine is not None def test_state_machine_think() -> None: @@ -89,7 +89,7 @@ def test_state_machine_think() -> None: machine.set_state("bob") machine.add_state(State("bob")) - class ToBob(State): + class ToBob(State[StateMachine]): __slots__ = () def check_conditions(self) -> str: @@ -105,12 +105,12 @@ async def test_async_state_machine_add() -> None: machine = AsyncStateMachine() machine.add_state(AsyncState("test")) - bob = AsyncState("bob") + bob = AsyncState[AsyncStateMachine]("bob") with pytest.raises(RuntimeError, match="State has no statemachine bound"): - assert bob.machine + assert bob.machine is not None with pytest.raises(TypeError, match="is not an instance of AsyncState!"): - machine.add_state(State("test")) - with pytest.raises(ValueError, match="is not a registered AsyncState."): + machine.add_state(State("test")) # type: ignore[arg-type] + with pytest.raises(ValueError, match=r"is not a registered AsyncState\."): machine.remove_state("waffle") machine.add_state(bob) @@ -122,7 +122,7 @@ async def test_async_state_machine_add() -> None: gc.collect() with pytest.raises(RuntimeError, match="State has no statemachine bound"): - assert bob.machine + assert bob.machine is not None @pytest.mark.trio @@ -130,7 +130,7 @@ async def test_async_state_machine_think() -> None: machine = AsyncStateMachine() await machine.think() machine.add_states(()) - jerald = AsyncState("jerald") + jerald = AsyncState[AsyncStateMachine]("jerald") machine.add_states((jerald,)) machine.add_state(AsyncState("bob")) await machine.set_state("jerald") @@ -145,7 +145,7 @@ async def test_async_state_machine_think() -> None: await machine.set_state("bob") machine.add_state(AsyncState("bob")) - class ToBob(AsyncState): + class ToBob(AsyncState[AsyncStateMachine]): __slots__ = () async def check_conditions(self) -> str: diff --git a/tests/test_utils.py b/tests/test_utils.py deleted file mode 100644 index 37b40b4..0000000 --- a/tests/test_utils.py +++ /dev/null @@ -1,121 +0,0 @@ -# This is the base_io module from https://github.com/py-mine/mcproto v0.5.0, -# which is licensed under the GNU LESSER GENERAL PUBLIC LICENSE v3.0 - -from __future__ import annotations - -__author__ = "ItsDrike" -__license__ = "LGPL-3.0-only" - -import pytest - -from azul.utils import from_twos_complement, to_twos_complement - -# TODO: Consider adding tests for enforce_range - - -@pytest.mark.parametrize( - ("number", "bits", "expected_out"), - [ - (0, 8, 0), - (1, 8, 1), - (10, 8, 10), - (127, 8, 127), - ], -) -def test_to_twos_complement_positive( - number: int, - bits: int, - expected_out: int, -): - """Test conversion to two's complement format from positive numbers gives expected result.""" - assert to_twos_complement(number, bits) == expected_out - - -@pytest.mark.parametrize( - ("number", "bits", "expected_out"), - [ - (-1, 8, 255), - (-10, 8, 246), - (-128, 8, 128), - ], -) -def test_to_twos_complement_negative( - number: int, - bits: int, - expected_out: int, -): - """Test conversion to two's complement format of negative numbers gives expected result.""" - assert to_twos_complement(number, bits) == expected_out - - -@pytest.mark.parametrize( - ("number", "bits"), - [ - (128, 8), - (-129, 8), - (32768, 16), - (-32769, 16), - (2147483648, 32), - (-2147483649, 32), - (9223372036854775808, 64), - (-9223372036854775809, 64), - ], -) -def test_to_twos_complement_range(number: int, bits: int): - """Test conversion to two's complement format for out of range numbers raises :exc:`ValueError`.""" - with pytest.raises(ValueError, match="out of range"): - to_twos_complement(number, bits) - - -@pytest.mark.parametrize( - ("number", "bits", "expected_out"), - [ - (0, 8, 0), - (1, 8, 1), - (10, 8, 10), - (127, 8, 127), - ], -) -def test_from_twos_complement_positive( - number: int, - bits: int, - expected_out: int, -): - """Test conversion from two's complement format of positive numbers give expected result.""" - assert from_twos_complement(number, bits) == expected_out - - -@pytest.mark.parametrize( - ("number", "bits", "expected_out"), - [ - (255, 8, -1), - (246, 8, -10), - (128, 8, -128), - ], -) -def test_from_twos_complement_negative( - number: int, - bits: int, - expected_out: int, -): - """Test conversion from two's complement format of negative numbers give expected result.""" - assert from_twos_complement(number, bits) == expected_out - - -@pytest.mark.parametrize( - ("number", "bits"), - [ - (256, 8), - (-1, 8), - (65536, 16), - (-1, 16), - (4294967296, 32), - (-1, 32), - (18446744073709551616, 64), - (-1, 64), - ], -) -def test_from_twos_complement_range(number: int, bits: int): - """Test conversion from two's complement format for out of range numbers raises :exc:`ValueError`.""" - with pytest.raises(ValueError, match="out of range"): - from_twos_complement(number, bits) diff --git a/tests/test_vector.py b/tests/test_vector.py index c835d63..230cbf7 100644 --- a/tests/test_vector.py +++ b/tests/test_vector.py @@ -28,7 +28,7 @@ def test_eq_vec() -> None: def test_eq_tuple() -> None: - assert Vector2(3, 6) == (3, 6) + assert Vector2(3, 6) == (3, 6) # type: ignore[comparison-overlap] def test_from_points() -> None: @@ -67,6 +67,10 @@ def test_mul() -> None: assert Vector2(5, 10) * 3 == Vector2(15, 30) +def test_rmul() -> None: + assert 3 * Vector2(5, 10) == Vector2(15, 30) + + def test_truediv() -> None: assert Vector2(10, 5) / 2 == Vector2(5, 2.5) diff --git a/tools/project_requirements.py b/tools/project_requirements.py deleted file mode 100755 index edcfbad..0000000 --- a/tools/project_requirements.py +++ /dev/null @@ -1,106 +0,0 @@ -#!/usr/bin/env python3 - -"""Project Requirements - Write test-requirements.in based on pyproject.toml.""" - -# Programmed by CoolCat467 - -from __future__ import annotations - -# Project Requirements - Write test-requirements.in based on pyproject.toml. -# Copyright (C) 2024 CoolCat467 -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program. If not, see . - -__title__ = "Project Requirements" -__author__ = "CoolCat467" -__version__ = "0.0.0" -__license__ = "GNU General Public License Version 3" - -import sys -from pathlib import Path -from typing import Final - -import tomllib - -# Key to start replacing inside of contents -KEY: Final = "TOML_DEPENDENCIES" - - -def run() -> None: - """Run program.""" - # Find root folder - this = Path(__file__).absolute() - tools = this.parent - root = tools.parent - # Make sure it's right - assert (root / "LICENSE").exists(), "Not in correct directory!" - - # Read pyproject.toml - pyproject = root / "pyproject.toml" - with pyproject.open("rb") as fp: - data = tomllib.load(fp) - - # Get dependencies list - assert isinstance(data, dict) - project = data["project"] - assert isinstance(project, dict) - dependencies = project["dependencies"] - assert isinstance(dependencies, list) - - # Read requirements file - requirements_list = root / "test-requirements.in" - assert requirements_list.exists(), f"{requirements_list} does not exist!" - requirements_data = requirements_list.read_text("utf-8") - - # Find out what start and end should be based on key. - key_start = f"<{KEY}>" - key_end = f"" - - # Try to find start and end triggers in requirements data - start_char = requirements_data.find(key_start) - end_char = requirements_data.find(key_end) - if -1 in {start_char, end_char}: - raise ValueError( - f"{key_start!r} or {key_end!r} not found in {requirements_list}", - ) - - # Create overwrite text - dependencies_text = "\n".join(sorted(dependencies)) - overwrite_text = "\n".join( - ( - key_start, - dependencies_text, - f"#{key_end}", - ), - ) - # Create new file contents - end = end_char + len(key_end) - new_text = ( - requirements_data[:start_char] - + overwrite_text - + requirements_data[end:] - ) - - # If new text differs, overwrite and alert - if new_text != requirements_data: - print("Requirements file is outdated...") - requirements_list.write_text(new_text, "utf-8") - print("Requirements file updated successfully.") - return 1 - print("Requirements file is up to date.") - return 0 - - -if __name__ == "__main__": - sys.exit(run()) diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..2a61f1c --- /dev/null +++ b/uv.lock @@ -0,0 +1,1007 @@ +version = 1 +revision = 3 +requires-python = ">=3.11" + +[[package]] +name = "attrs" +version = "25.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6b/5c/685e6633917e101e5dcb62b9dd76946cbb57c26e133bae9e0cd36033c0a9/attrs-25.4.0.tar.gz", hash = "sha256:16d5969b87f0859ef33a48b35d55ac1be6e42ae49d5e853b597db70c35c57e11", size = 934251, upload-time = "2025-10-06T13:54:44.725Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3a/2a/7cc015f5b9f5db42b7d48157e23356022889fc354a2813c15934b7cb5c0e/attrs-25.4.0-py3-none-any.whl", hash = "sha256:adcf7e2a1fb3b36ac48d97835bb6d8ade15b8dcce26aba8bf1d14847b57a3373", size = 67615, upload-time = "2025-10-06T13:54:43.17Z" }, +] + +[[package]] +name = "azul" +source = { editable = "." } +dependencies = [ + { name = "libcomponent" }, + { name = "mypy-extensions" }, + { name = "numpy" }, + { name = "orjson" }, + { name = "pygame" }, + { name = "trio" }, +] + +[package.optional-dependencies] +tests = [ + { name = "coverage" }, + { name = "pytest" }, + { name = "pytest-cov" }, + { name = "pytest-trio" }, +] +tools = [ + { name = "codespell" }, + { name = "mypy" }, + { name = "pre-commit" }, + { name = "ruff" }, + { name = "uv" }, +] + +[package.metadata] +requires-dist = [ + { name = "codespell", marker = "extra == 'tools'", specifier = ">=2.3.0" }, + { name = "coverage", marker = "extra == 'tests'", specifier = ">=7.2.5" }, + { name = "exceptiongroup", marker = "python_full_version < '3.11'", specifier = ">=1.2.2" }, + { name = "libcomponent", specifier = "~=0.0.5" }, + { name = "mypy", marker = "extra == 'tools'", specifier = ">=1.14.1" }, + { name = "mypy-extensions", specifier = ">=1.0.0" }, + { name = "numpy", specifier = "~=2.4.2" }, + { name = "orjson", specifier = ">=3.10,<4" }, + { name = "pre-commit", marker = "extra == 'tools'", specifier = ">=4.2.0" }, + { name = "pygame", specifier = "~=2.6.0" }, + { name = "pytest", marker = "extra == 'tests'", specifier = ">=5.0" }, + { name = "pytest-cov", marker = "extra == 'tests'", specifier = ">=6.0.0" }, + { name = "pytest-trio", marker = "extra == 'tests'", specifier = ">=0.8.0" }, + { name = "ruff", marker = "extra == 'tools'", specifier = ">=0.9.2" }, + { name = "trio", specifier = "~=0.32.0" }, + { name = "uv", marker = "extra == 'tools'", specifier = ">=0.10.0" }, +] +provides-extras = ["tests", "tools"] + +[[package]] +name = "cffi" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/4a/3dfd5f7850cbf0d06dc84ba9aa00db766b52ca38d8b86e3a38314d52498c/cffi-2.0.0-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:b4c854ef3adc177950a8dfc81a86f5115d2abd545751a304c5bcf2c2c7283cfe", size = 184344, upload-time = "2025-09-08T23:22:26.456Z" }, + { url = "https://files.pythonhosted.org/packages/4f/8b/f0e4c441227ba756aafbe78f117485b25bb26b1c059d01f137fa6d14896b/cffi-2.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2de9a304e27f7596cd03d16f1b7c72219bd944e99cc52b84d0145aefb07cbd3c", size = 180560, upload-time = "2025-09-08T23:22:28.197Z" }, + { url = "https://files.pythonhosted.org/packages/b1/b7/1200d354378ef52ec227395d95c2576330fd22a869f7a70e88e1447eb234/cffi-2.0.0-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:baf5215e0ab74c16e2dd324e8ec067ef59e41125d3eade2b863d294fd5035c92", size = 209613, upload-time = "2025-09-08T23:22:29.475Z" }, + { url = "https://files.pythonhosted.org/packages/b8/56/6033f5e86e8cc9bb629f0077ba71679508bdf54a9a5e112a3c0b91870332/cffi-2.0.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:730cacb21e1bdff3ce90babf007d0a0917cc3e6492f336c2f0134101e0944f93", size = 216476, upload-time = "2025-09-08T23:22:31.063Z" }, + { url = "https://files.pythonhosted.org/packages/dc/7f/55fecd70f7ece178db2f26128ec41430d8720f2d12ca97bf8f0a628207d5/cffi-2.0.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6824f87845e3396029f3820c206e459ccc91760e8fa24422f8b0c3d1731cbec5", size = 203374, upload-time = "2025-09-08T23:22:32.507Z" }, + { url = "https://files.pythonhosted.org/packages/84/ef/a7b77c8bdc0f77adc3b46888f1ad54be8f3b7821697a7b89126e829e676a/cffi-2.0.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9de40a7b0323d889cf8d23d1ef214f565ab154443c42737dfe52ff82cf857664", size = 202597, upload-time = "2025-09-08T23:22:34.132Z" }, + { url = "https://files.pythonhosted.org/packages/d7/91/500d892b2bf36529a75b77958edfcd5ad8e2ce4064ce2ecfeab2125d72d1/cffi-2.0.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8941aaadaf67246224cee8c3803777eed332a19d909b47e29c9842ef1e79ac26", size = 215574, upload-time = "2025-09-08T23:22:35.443Z" }, + { url = "https://files.pythonhosted.org/packages/44/64/58f6255b62b101093d5df22dcb752596066c7e89dd725e0afaed242a61be/cffi-2.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a05d0c237b3349096d3981b727493e22147f934b20f6f125a3eba8f994bec4a9", size = 218971, upload-time = "2025-09-08T23:22:36.805Z" }, + { url = "https://files.pythonhosted.org/packages/ab/49/fa72cebe2fd8a55fbe14956f9970fe8eb1ac59e5df042f603ef7c8ba0adc/cffi-2.0.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:94698a9c5f91f9d138526b48fe26a199609544591f859c870d477351dc7b2414", size = 211972, upload-time = "2025-09-08T23:22:38.436Z" }, + { url = "https://files.pythonhosted.org/packages/0b/28/dd0967a76aab36731b6ebfe64dec4e981aff7e0608f60c2d46b46982607d/cffi-2.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5fed36fccc0612a53f1d4d9a816b50a36702c28a2aa880cb8a122b3466638743", size = 217078, upload-time = "2025-09-08T23:22:39.776Z" }, + { url = "https://files.pythonhosted.org/packages/2b/c0/015b25184413d7ab0a410775fdb4a50fca20f5589b5dab1dbbfa3baad8ce/cffi-2.0.0-cp311-cp311-win32.whl", hash = "sha256:c649e3a33450ec82378822b3dad03cc228b8f5963c0c12fc3b1e0ab940f768a5", size = 172076, upload-time = "2025-09-08T23:22:40.95Z" }, + { url = "https://files.pythonhosted.org/packages/ae/8f/dc5531155e7070361eb1b7e4c1a9d896d0cb21c49f807a6c03fd63fc877e/cffi-2.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:66f011380d0e49ed280c789fbd08ff0d40968ee7b665575489afa95c98196ab5", size = 182820, upload-time = "2025-09-08T23:22:42.463Z" }, + { url = "https://files.pythonhosted.org/packages/95/5c/1b493356429f9aecfd56bc171285a4c4ac8697f76e9bbbbb105e537853a1/cffi-2.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:c6638687455baf640e37344fe26d37c404db8b80d037c3d29f58fe8d1c3b194d", size = 177635, upload-time = "2025-09-08T23:22:43.623Z" }, + { url = "https://files.pythonhosted.org/packages/ea/47/4f61023ea636104d4f16ab488e268b93008c3d0bb76893b1b31db1f96802/cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d", size = 185271, upload-time = "2025-09-08T23:22:44.795Z" }, + { url = "https://files.pythonhosted.org/packages/df/a2/781b623f57358e360d62cdd7a8c681f074a71d445418a776eef0aadb4ab4/cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c", size = 181048, upload-time = "2025-09-08T23:22:45.938Z" }, + { url = "https://files.pythonhosted.org/packages/ff/df/a4f0fbd47331ceeba3d37c2e51e9dfc9722498becbeec2bd8bc856c9538a/cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe", size = 212529, upload-time = "2025-09-08T23:22:47.349Z" }, + { url = "https://files.pythonhosted.org/packages/d5/72/12b5f8d3865bf0f87cf1404d8c374e7487dcf097a1c91c436e72e6badd83/cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062", size = 220097, upload-time = "2025-09-08T23:22:48.677Z" }, + { url = "https://files.pythonhosted.org/packages/c2/95/7a135d52a50dfa7c882ab0ac17e8dc11cec9d55d2c18dda414c051c5e69e/cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e", size = 207983, upload-time = "2025-09-08T23:22:50.06Z" }, + { url = "https://files.pythonhosted.org/packages/3a/c8/15cb9ada8895957ea171c62dc78ff3e99159ee7adb13c0123c001a2546c1/cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037", size = 206519, upload-time = "2025-09-08T23:22:51.364Z" }, + { url = "https://files.pythonhosted.org/packages/78/2d/7fa73dfa841b5ac06c7b8855cfc18622132e365f5b81d02230333ff26e9e/cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba", size = 219572, upload-time = "2025-09-08T23:22:52.902Z" }, + { url = "https://files.pythonhosted.org/packages/07/e0/267e57e387b4ca276b90f0434ff88b2c2241ad72b16d31836adddfd6031b/cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94", size = 222963, upload-time = "2025-09-08T23:22:54.518Z" }, + { url = "https://files.pythonhosted.org/packages/b6/75/1f2747525e06f53efbd878f4d03bac5b859cbc11c633d0fb81432d98a795/cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187", size = 221361, upload-time = "2025-09-08T23:22:55.867Z" }, + { url = "https://files.pythonhosted.org/packages/7b/2b/2b6435f76bfeb6bbf055596976da087377ede68df465419d192acf00c437/cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18", size = 172932, upload-time = "2025-09-08T23:22:57.188Z" }, + { url = "https://files.pythonhosted.org/packages/f8/ed/13bd4418627013bec4ed6e54283b1959cf6db888048c7cf4b4c3b5b36002/cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5", size = 183557, upload-time = "2025-09-08T23:22:58.351Z" }, + { url = "https://files.pythonhosted.org/packages/95/31/9f7f93ad2f8eff1dbc1c3656d7ca5bfd8fb52c9d786b4dcf19b2d02217fa/cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6", size = 177762, upload-time = "2025-09-08T23:22:59.668Z" }, + { url = "https://files.pythonhosted.org/packages/4b/8d/a0a47a0c9e413a658623d014e91e74a50cdd2c423f7ccfd44086ef767f90/cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb", size = 185230, upload-time = "2025-09-08T23:23:00.879Z" }, + { url = "https://files.pythonhosted.org/packages/4a/d2/a6c0296814556c68ee32009d9c2ad4f85f2707cdecfd7727951ec228005d/cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca", size = 181043, upload-time = "2025-09-08T23:23:02.231Z" }, + { url = "https://files.pythonhosted.org/packages/b0/1e/d22cc63332bd59b06481ceaac49d6c507598642e2230f201649058a7e704/cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b", size = 212446, upload-time = "2025-09-08T23:23:03.472Z" }, + { url = "https://files.pythonhosted.org/packages/a9/f5/a2c23eb03b61a0b8747f211eb716446c826ad66818ddc7810cc2cc19b3f2/cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b", size = 220101, upload-time = "2025-09-08T23:23:04.792Z" }, + { url = "https://files.pythonhosted.org/packages/f2/7f/e6647792fc5850d634695bc0e6ab4111ae88e89981d35ac269956605feba/cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2", size = 207948, upload-time = "2025-09-08T23:23:06.127Z" }, + { url = "https://files.pythonhosted.org/packages/cb/1e/a5a1bd6f1fb30f22573f76533de12a00bf274abcdc55c8edab639078abb6/cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3", size = 206422, upload-time = "2025-09-08T23:23:07.753Z" }, + { url = "https://files.pythonhosted.org/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26", size = 219499, upload-time = "2025-09-08T23:23:09.648Z" }, + { url = "https://files.pythonhosted.org/packages/50/e1/a969e687fcf9ea58e6e2a928ad5e2dd88cc12f6f0ab477e9971f2309b57c/cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c", size = 222928, upload-time = "2025-09-08T23:23:10.928Z" }, + { url = "https://files.pythonhosted.org/packages/36/54/0362578dd2c9e557a28ac77698ed67323ed5b9775ca9d3fe73fe191bb5d8/cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b", size = 221302, upload-time = "2025-09-08T23:23:12.42Z" }, + { url = "https://files.pythonhosted.org/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", size = 172909, upload-time = "2025-09-08T23:23:14.32Z" }, + { url = "https://files.pythonhosted.org/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", size = 183402, upload-time = "2025-09-08T23:23:15.535Z" }, + { url = "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780, upload-time = "2025-09-08T23:23:16.761Z" }, + { url = "https://files.pythonhosted.org/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5", size = 185320, upload-time = "2025-09-08T23:23:18.087Z" }, + { url = "https://files.pythonhosted.org/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13", size = 181487, upload-time = "2025-09-08T23:23:19.622Z" }, + { url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049, upload-time = "2025-09-08T23:23:20.853Z" }, + { url = "https://files.pythonhosted.org/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", size = 207793, upload-time = "2025-09-08T23:23:22.08Z" }, + { url = "https://files.pythonhosted.org/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", size = 206300, upload-time = "2025-09-08T23:23:23.314Z" }, + { url = "https://files.pythonhosted.org/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", size = 219244, upload-time = "2025-09-08T23:23:24.541Z" }, + { url = "https://files.pythonhosted.org/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", size = 222828, upload-time = "2025-09-08T23:23:26.143Z" }, + { url = "https://files.pythonhosted.org/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", size = 220926, upload-time = "2025-09-08T23:23:27.873Z" }, + { url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328, upload-time = "2025-09-08T23:23:44.61Z" }, + { url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650, upload-time = "2025-09-08T23:23:45.848Z" }, + { url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687, upload-time = "2025-09-08T23:23:47.105Z" }, + { url = "https://files.pythonhosted.org/packages/3e/61/c768e4d548bfa607abcda77423448df8c471f25dbe64fb2ef6d555eae006/cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9", size = 188773, upload-time = "2025-09-08T23:23:29.347Z" }, + { url = "https://files.pythonhosted.org/packages/2c/ea/5f76bce7cf6fcd0ab1a1058b5af899bfbef198bea4d5686da88471ea0336/cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d", size = 185013, upload-time = "2025-09-08T23:23:30.63Z" }, + { url = "https://files.pythonhosted.org/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", size = 221593, upload-time = "2025-09-08T23:23:31.91Z" }, + { url = "https://files.pythonhosted.org/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", size = 209354, upload-time = "2025-09-08T23:23:33.214Z" }, + { url = "https://files.pythonhosted.org/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", size = 208480, upload-time = "2025-09-08T23:23:34.495Z" }, + { url = "https://files.pythonhosted.org/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", size = 221584, upload-time = "2025-09-08T23:23:36.096Z" }, + { url = "https://files.pythonhosted.org/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", size = 224443, upload-time = "2025-09-08T23:23:37.328Z" }, + { url = "https://files.pythonhosted.org/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", size = 223437, upload-time = "2025-09-08T23:23:38.945Z" }, + { url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487, upload-time = "2025-09-08T23:23:40.423Z" }, + { url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726, upload-time = "2025-09-08T23:23:41.742Z" }, + { url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" }, +] + +[[package]] +name = "cfgv" +version = "3.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4e/b5/721b8799b04bf9afe054a3899c6cf4e880fcf8563cc71c15610242490a0c/cfgv-3.5.0.tar.gz", hash = "sha256:d5b1034354820651caa73ede66a6294d6e95c1b00acc5e9b098e917404669132", size = 7334, upload-time = "2025-11-19T20:55:51.612Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/db/3c/33bac158f8ab7f89b2e59426d5fe2e4f63f7ed25df84c036890172b412b5/cfgv-3.5.0-py2.py3-none-any.whl", hash = "sha256:a8dc6b26ad22ff227d2634a65cb388215ce6cc96bbcc5cfde7641ae87e8dacc0", size = 7445, upload-time = "2025-11-19T20:55:50.744Z" }, +] + +[[package]] +name = "codespell" +version = "2.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/15/e0/709453393c0ea77d007d907dd436b3ee262e28b30995ea1aa36c6ffbccaf/codespell-2.4.1.tar.gz", hash = "sha256:299fcdcb09d23e81e35a671bbe746d5ad7e8385972e65dbb833a2eaac33c01e5", size = 344740, upload-time = "2025-01-28T18:52:39.411Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/20/01/b394922252051e97aab231d416c86da3d8a6d781eeadcdca1082867de64e/codespell-2.4.1-py3-none-any.whl", hash = "sha256:3dadafa67df7e4a3dbf51e0d7315061b80d265f9552ebd699b3dd6834b47e425", size = 344501, upload-time = "2025-01-28T18:52:37.057Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "coverage" +version = "7.13.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/11/43/3e4ac666cc35f231fa70c94e9f38459299de1a152813f9d2f60fc5f3ecaf/coverage-7.13.3.tar.gz", hash = "sha256:f7f6182d3dfb8802c1747eacbfe611b669455b69b7c037484bb1efbbb56711ac", size = 826832, upload-time = "2026-02-03T14:02:30.944Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/09/1ac74e37cf45f17eb41e11a21854f7f92a4c2d6c6098ef4a1becb0c6d8d3/coverage-7.13.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:5907605ee20e126eeee2abe14aae137043c2c8af2fa9b38d2ab3b7a6b8137f73", size = 219276, upload-time = "2026-02-03T14:00:00.296Z" }, + { url = "https://files.pythonhosted.org/packages/2e/cb/71908b08b21beb2c437d0d5870c4ec129c570ca1b386a8427fcdb11cf89c/coverage-7.13.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a88705500988c8acad8b8fd86c2a933d3aa96bec1ddc4bc5cb256360db7bbd00", size = 219776, upload-time = "2026-02-03T14:00:02.414Z" }, + { url = "https://files.pythonhosted.org/packages/09/85/c4f3dd69232887666a2c0394d4be21c60ea934d404db068e6c96aa59cd87/coverage-7.13.3-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:7bbb5aa9016c4c29e3432e087aa29ebee3f8fda089cfbfb4e6d64bd292dcd1c2", size = 250196, upload-time = "2026-02-03T14:00:04.197Z" }, + { url = "https://files.pythonhosted.org/packages/9c/cc/560ad6f12010344d0778e268df5ba9aa990aacccc310d478bf82bf3d302c/coverage-7.13.3-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0c2be202a83dde768937a61cdc5d06bf9fb204048ca199d93479488e6247656c", size = 252111, upload-time = "2026-02-03T14:00:05.639Z" }, + { url = "https://files.pythonhosted.org/packages/f0/66/3193985fb2c58e91f94cfbe9e21a6fdf941e9301fe2be9e92c072e9c8f8c/coverage-7.13.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0f45e32ef383ce56e0ca099b2e02fcdf7950be4b1b56afaab27b4ad790befe5b", size = 254217, upload-time = "2026-02-03T14:00:07.738Z" }, + { url = "https://files.pythonhosted.org/packages/c5/78/f0f91556bf1faa416792e537c523c5ef9db9b1d32a50572c102b3d7c45b3/coverage-7.13.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6ed2e787249b922a93cd95c671cc9f4c9797a106e81b455c83a9ddb9d34590c0", size = 250318, upload-time = "2026-02-03T14:00:09.224Z" }, + { url = "https://files.pythonhosted.org/packages/6f/aa/fc654e45e837d137b2c1f3a2cc09b4aea1e8b015acd2f774fa0f3d2ddeba/coverage-7.13.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:05dd25b21afffe545e808265897c35f32d3e4437663923e0d256d9ab5031fb14", size = 251909, upload-time = "2026-02-03T14:00:10.712Z" }, + { url = "https://files.pythonhosted.org/packages/73/4d/ab53063992add8a9ca0463c9d92cce5994a29e17affd1c2daa091b922a93/coverage-7.13.3-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:46d29926349b5c4f1ea4fca95e8c892835515f3600995a383fa9a923b5739ea4", size = 249971, upload-time = "2026-02-03T14:00:12.402Z" }, + { url = "https://files.pythonhosted.org/packages/29/25/83694b81e46fcff9899694a1b6f57573429cdd82b57932f09a698f03eea5/coverage-7.13.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:fae6a21537519c2af00245e834e5bf2884699cc7c1055738fd0f9dc37a3644ad", size = 249692, upload-time = "2026-02-03T14:00:13.868Z" }, + { url = "https://files.pythonhosted.org/packages/d4/ef/d68fc304301f4cb4bf6aefa0045310520789ca38dabdfba9dbecd3f37919/coverage-7.13.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:c672d4e2f0575a4ca2bf2aa0c5ced5188220ab806c1bb6d7179f70a11a017222", size = 250597, upload-time = "2026-02-03T14:00:15.461Z" }, + { url = "https://files.pythonhosted.org/packages/8d/85/240ad396f914df361d0f71e912ddcedb48130c71b88dc4193fe3c0306f00/coverage-7.13.3-cp311-cp311-win32.whl", hash = "sha256:fcda51c918c7a13ad93b5f89a58d56e3a072c9e0ba5c231b0ed81404bf2648fb", size = 221773, upload-time = "2026-02-03T14:00:17.462Z" }, + { url = "https://files.pythonhosted.org/packages/2f/71/165b3a6d3d052704a9ab52d11ea64ef3426745de517dda44d872716213a7/coverage-7.13.3-cp311-cp311-win_amd64.whl", hash = "sha256:d1a049b5c51b3b679928dd35e47c4a2235e0b6128b479a7596d0ef5b42fa6301", size = 222711, upload-time = "2026-02-03T14:00:19.449Z" }, + { url = "https://files.pythonhosted.org/packages/51/d0/0ddc9c5934cdd52639c5df1f1eb0fdab51bb52348f3a8d1c7db9c600d93a/coverage-7.13.3-cp311-cp311-win_arm64.whl", hash = "sha256:79f2670c7e772f4917895c3d89aad59e01f3dbe68a4ed2d0373b431fad1dcfba", size = 221377, upload-time = "2026-02-03T14:00:20.968Z" }, + { url = "https://files.pythonhosted.org/packages/94/44/330f8e83b143f6668778ed61d17ece9dc48459e9e74669177de02f45fec5/coverage-7.13.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ed48b4170caa2c4420e0cd27dc977caaffc7eecc317355751df8373dddcef595", size = 219441, upload-time = "2026-02-03T14:00:22.585Z" }, + { url = "https://files.pythonhosted.org/packages/08/e7/29db05693562c2e65bdf6910c0af2fd6f9325b8f43caf7a258413f369e30/coverage-7.13.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8f2adf4bcffbbec41f366f2e6dffb9d24e8172d16e91da5799c9b7ed6b5716e6", size = 219801, upload-time = "2026-02-03T14:00:24.186Z" }, + { url = "https://files.pythonhosted.org/packages/90/ae/7f8a78249b02b0818db46220795f8ac8312ea4abd1d37d79ea81db5cae81/coverage-7.13.3-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:01119735c690786b6966a1e9f098da4cd7ca9174c4cfe076d04e653105488395", size = 251306, upload-time = "2026-02-03T14:00:25.798Z" }, + { url = "https://files.pythonhosted.org/packages/62/71/a18a53d1808e09b2e9ebd6b47dad5e92daf4c38b0686b4c4d1b2f3e42b7f/coverage-7.13.3-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8bb09e83c603f152d855f666d70a71765ca8e67332e5829e62cb9466c176af23", size = 254051, upload-time = "2026-02-03T14:00:27.474Z" }, + { url = "https://files.pythonhosted.org/packages/4a/0a/eb30f6455d04c5a3396d0696cad2df0269ae7444bb322f86ffe3376f7bf9/coverage-7.13.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b607a40cba795cfac6d130220d25962931ce101f2f478a29822b19755377fb34", size = 255160, upload-time = "2026-02-03T14:00:29.024Z" }, + { url = "https://files.pythonhosted.org/packages/7b/7e/a45baac86274ce3ed842dbb84f14560c673ad30535f397d89164ec56c5df/coverage-7.13.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:44f14a62f5da2e9aedf9080e01d2cda61df39197d48e323538ec037336d68da8", size = 251709, upload-time = "2026-02-03T14:00:30.641Z" }, + { url = "https://files.pythonhosted.org/packages/c0/df/dd0dc12f30da11349993f3e218901fdf82f45ee44773596050c8f5a1fb25/coverage-7.13.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:debf29e0b157769843dff0981cc76f79e0ed04e36bb773c6cac5f6029054bd8a", size = 253083, upload-time = "2026-02-03T14:00:32.14Z" }, + { url = "https://files.pythonhosted.org/packages/ab/32/fc764c8389a8ce95cb90eb97af4c32f392ab0ac23ec57cadeefb887188d3/coverage-7.13.3-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:824bb95cd71604031ae9a48edb91fd6effde669522f960375668ed21b36e3ec4", size = 251227, upload-time = "2026-02-03T14:00:34.721Z" }, + { url = "https://files.pythonhosted.org/packages/dd/ca/d025e9da8f06f24c34d2da9873957cfc5f7e0d67802c3e34d0caa8452130/coverage-7.13.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:8f1010029a5b52dc427c8e2a8dbddb2303ddd180b806687d1acd1bb1d06649e7", size = 250794, upload-time = "2026-02-03T14:00:36.278Z" }, + { url = "https://files.pythonhosted.org/packages/45/c7/76bf35d5d488ec8f68682eb8e7671acc50a6d2d1c1182de1d2b6d4ffad3b/coverage-7.13.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:cd5dee4fd7659d8306ffa79eeaaafd91fa30a302dac3af723b9b469e549247e0", size = 252671, upload-time = "2026-02-03T14:00:38.368Z" }, + { url = "https://files.pythonhosted.org/packages/bf/10/1921f1a03a7c209e1cb374f81a6b9b68b03cdb3ecc3433c189bc90e2a3d5/coverage-7.13.3-cp312-cp312-win32.whl", hash = "sha256:f7f153d0184d45f3873b3ad3ad22694fd73aadcb8cdbc4337ab4b41ea6b4dff1", size = 221986, upload-time = "2026-02-03T14:00:40.442Z" }, + { url = "https://files.pythonhosted.org/packages/3c/7c/f5d93297f8e125a80c15545edc754d93e0ed8ba255b65e609b185296af01/coverage-7.13.3-cp312-cp312-win_amd64.whl", hash = "sha256:03a6e5e1e50819d6d7436f5bc40c92ded7e484e400716886ac921e35c133149d", size = 222793, upload-time = "2026-02-03T14:00:42.106Z" }, + { url = "https://files.pythonhosted.org/packages/43/59/c86b84170015b4555ebabca8649bdf9f4a1f737a73168088385ed0f947c4/coverage-7.13.3-cp312-cp312-win_arm64.whl", hash = "sha256:51c4c42c0e7d09a822b08b6cf79b3c4db8333fffde7450da946719ba0d45730f", size = 221410, upload-time = "2026-02-03T14:00:43.726Z" }, + { url = "https://files.pythonhosted.org/packages/81/f3/4c333da7b373e8c8bfb62517e8174a01dcc373d7a9083698e3b39d50d59c/coverage-7.13.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:853c3d3c79ff0db65797aad79dee6be020efd218ac4510f15a205f1e8d13ce25", size = 219468, upload-time = "2026-02-03T14:00:45.829Z" }, + { url = "https://files.pythonhosted.org/packages/d6/31/0714337b7d23630c8de2f4d56acf43c65f8728a45ed529b34410683f7217/coverage-7.13.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f75695e157c83d374f88dcc646a60cb94173304a9258b2e74ba5a66b7614a51a", size = 219839, upload-time = "2026-02-03T14:00:47.407Z" }, + { url = "https://files.pythonhosted.org/packages/12/99/bd6f2a2738144c98945666f90cae446ed870cecf0421c767475fcf42cdbe/coverage-7.13.3-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:2d098709621d0819039f3f1e471ee554f55a0b2ac0d816883c765b14129b5627", size = 250828, upload-time = "2026-02-03T14:00:49.029Z" }, + { url = "https://files.pythonhosted.org/packages/6f/99/97b600225fbf631e6f5bfd3ad5bcaf87fbb9e34ff87492e5a572ff01bbe2/coverage-7.13.3-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:16d23d6579cf80a474ad160ca14d8b319abaa6db62759d6eef53b2fc979b58c8", size = 253432, upload-time = "2026-02-03T14:00:50.655Z" }, + { url = "https://files.pythonhosted.org/packages/5f/5c/abe2b3490bda26bd4f5e3e799be0bdf00bd81edebedc2c9da8d3ef288fa8/coverage-7.13.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:00d34b29a59d2076e6f318b30a00a69bf63687e30cd882984ed444e753990cc1", size = 254672, upload-time = "2026-02-03T14:00:52.757Z" }, + { url = "https://files.pythonhosted.org/packages/31/ba/5d1957c76b40daff53971fe0adb84d9c2162b614280031d1d0653dd010c1/coverage-7.13.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ab6d72bffac9deb6e6cb0f61042e748de3f9f8e98afb0375a8e64b0b6e11746b", size = 251050, upload-time = "2026-02-03T14:00:54.332Z" }, + { url = "https://files.pythonhosted.org/packages/69/dc/dffdf3bfe9d32090f047d3c3085378558cb4eb6778cda7de414ad74581ed/coverage-7.13.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e129328ad1258e49cae0123a3b5fcb93d6c2fa90d540f0b4c7cdcdc019aaa3dc", size = 252801, upload-time = "2026-02-03T14:00:56.121Z" }, + { url = "https://files.pythonhosted.org/packages/87/51/cdf6198b0f2746e04511a30dc9185d7b8cdd895276c07bdb538e37f1cd50/coverage-7.13.3-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:2213a8d88ed35459bda71597599d4eec7c2ebad201c88f0bfc2c26fd9b0dd2ea", size = 250763, upload-time = "2026-02-03T14:00:58.719Z" }, + { url = "https://files.pythonhosted.org/packages/d7/1a/596b7d62218c1d69f2475b69cc6b211e33c83c902f38ee6ae9766dd422da/coverage-7.13.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:00dd3f02de6d5f5c9c3d95e3e036c3c2e2a669f8bf2d3ceb92505c4ce7838f67", size = 250587, upload-time = "2026-02-03T14:01:01.197Z" }, + { url = "https://files.pythonhosted.org/packages/f7/46/52330d5841ff660f22c130b75f5e1dd3e352c8e7baef5e5fef6b14e3e991/coverage-7.13.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f9bada7bc660d20b23d7d312ebe29e927b655cf414dadcdb6335a2075695bd86", size = 252358, upload-time = "2026-02-03T14:01:02.824Z" }, + { url = "https://files.pythonhosted.org/packages/36/8a/e69a5be51923097ba7d5cff9724466e74fe486e9232020ba97c809a8b42b/coverage-7.13.3-cp313-cp313-win32.whl", hash = "sha256:75b3c0300f3fa15809bd62d9ca8b170eb21fcf0100eb4b4154d6dc8b3a5bbd43", size = 222007, upload-time = "2026-02-03T14:01:04.876Z" }, + { url = "https://files.pythonhosted.org/packages/0a/09/a5a069bcee0d613bdd48ee7637fa73bc09e7ed4342b26890f2df97cc9682/coverage-7.13.3-cp313-cp313-win_amd64.whl", hash = "sha256:a2f7589c6132c44c53f6e705e1a6677e2b7821378c22f7703b2cf5388d0d4587", size = 222812, upload-time = "2026-02-03T14:01:07.296Z" }, + { url = "https://files.pythonhosted.org/packages/3d/4f/d62ad7dfe32f9e3d4a10c178bb6f98b10b083d6e0530ca202b399371f6c1/coverage-7.13.3-cp313-cp313-win_arm64.whl", hash = "sha256:123ceaf2b9d8c614f01110f908a341e05b1b305d6b2ada98763b9a5a59756051", size = 221433, upload-time = "2026-02-03T14:01:09.156Z" }, + { url = "https://files.pythonhosted.org/packages/04/b2/4876c46d723d80b9c5b695f1a11bf5f7c3dabf540ec00d6edc076ff025e6/coverage-7.13.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:cc7fd0f726795420f3678ac82ff882c7fc33770bd0074463b5aef7293285ace9", size = 220162, upload-time = "2026-02-03T14:01:11.409Z" }, + { url = "https://files.pythonhosted.org/packages/fc/04/9942b64a0e0bdda2c109f56bda42b2a59d9d3df4c94b85a323c1cae9fc77/coverage-7.13.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:d358dc408edc28730aed5477a69338e444e62fba0b7e9e4a131c505fadad691e", size = 220510, upload-time = "2026-02-03T14:01:13.038Z" }, + { url = "https://files.pythonhosted.org/packages/5a/82/5cfe1e81eae525b74669f9795f37eb3edd4679b873d79d1e6c1c14ee6c1c/coverage-7.13.3-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5d67b9ed6f7b5527b209b24b3df9f2e5bf0198c1bbf99c6971b0e2dcb7e2a107", size = 261801, upload-time = "2026-02-03T14:01:14.674Z" }, + { url = "https://files.pythonhosted.org/packages/0b/ec/a553d7f742fd2cd12e36a16a7b4b3582d5934b496ef2b5ea8abeb10903d4/coverage-7.13.3-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:59224bfb2e9b37c1335ae35d00daa3a5b4e0b1a20f530be208fff1ecfa436f43", size = 263882, upload-time = "2026-02-03T14:01:16.343Z" }, + { url = "https://files.pythonhosted.org/packages/e1/58/8f54a2a93e3d675635bc406de1c9ac8d551312142ff52c9d71b5e533ad45/coverage-7.13.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ae9306b5299e31e31e0d3b908c66bcb6e7e3ddca143dea0266e9ce6c667346d3", size = 266306, upload-time = "2026-02-03T14:01:18.02Z" }, + { url = "https://files.pythonhosted.org/packages/1a/be/e593399fd6ea1f00aee79ebd7cc401021f218d34e96682a92e1bae092ff6/coverage-7.13.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:343aaeb5f8bb7bcd38620fd7bc56e6ee8207847d8c6103a1e7b72322d381ba4a", size = 261051, upload-time = "2026-02-03T14:01:19.757Z" }, + { url = "https://files.pythonhosted.org/packages/5c/e5/e9e0f6138b21bcdebccac36fbfde9cf15eb1bbcea9f5b1f35cd1f465fb91/coverage-7.13.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:b2182129f4c101272ff5f2f18038d7b698db1bf8e7aa9e615cb48440899ad32e", size = 263868, upload-time = "2026-02-03T14:01:21.487Z" }, + { url = "https://files.pythonhosted.org/packages/9a/bf/de72cfebb69756f2d4a2dde35efcc33c47d85cd3ebdf844b3914aac2ef28/coverage-7.13.3-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:94d2ac94bd0cc57c5626f52f8c2fffed1444b5ae8c9fc68320306cc2b255e155", size = 261498, upload-time = "2026-02-03T14:01:23.097Z" }, + { url = "https://files.pythonhosted.org/packages/f2/91/4a2d313a70fc2e98ca53afd1c8ce67a89b1944cd996589a5b1fe7fbb3e5c/coverage-7.13.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:65436cde5ecabe26fb2f0bf598962f0a054d3f23ad529361326ac002c61a2a1e", size = 260394, upload-time = "2026-02-03T14:01:24.949Z" }, + { url = "https://files.pythonhosted.org/packages/40/83/25113af7cf6941e779eb7ed8de2a677865b859a07ccee9146d4cc06a03e3/coverage-7.13.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:db83b77f97129813dbd463a67e5335adc6a6a91db652cc085d60c2d512746f96", size = 262579, upload-time = "2026-02-03T14:01:26.703Z" }, + { url = "https://files.pythonhosted.org/packages/1e/19/a5f2b96262977e82fb9aabbe19b4d83561f5d063f18dde3e72f34ffc3b2f/coverage-7.13.3-cp313-cp313t-win32.whl", hash = "sha256:dfb428e41377e6b9ba1b0a32df6db5409cb089a0ed1d0a672dc4953ec110d84f", size = 222679, upload-time = "2026-02-03T14:01:28.553Z" }, + { url = "https://files.pythonhosted.org/packages/81/82/ef1747b88c87a5c7d7edc3704799ebd650189a9158e680a063308b6125ef/coverage-7.13.3-cp313-cp313t-win_amd64.whl", hash = "sha256:5badd7e596e6b0c89aa8ec6d37f4473e4357f982ce57f9a2942b0221cd9cf60c", size = 223740, upload-time = "2026-02-03T14:01:30.776Z" }, + { url = "https://files.pythonhosted.org/packages/1c/4c/a67c7bb5b560241c22736a9cb2f14c5034149ffae18630323fde787339e4/coverage-7.13.3-cp313-cp313t-win_arm64.whl", hash = "sha256:989aa158c0eb19d83c76c26f4ba00dbb272485c56e452010a3450bdbc9daafd9", size = 221996, upload-time = "2026-02-03T14:01:32.495Z" }, + { url = "https://files.pythonhosted.org/packages/5e/b3/677bb43427fed9298905106f39c6520ac75f746f81b8f01104526a8026e4/coverage-7.13.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:c6f6169bbdbdb85aab8ac0392d776948907267fcc91deeacf6f9d55f7a83ae3b", size = 219513, upload-time = "2026-02-03T14:01:34.29Z" }, + { url = "https://files.pythonhosted.org/packages/42/53/290046e3bbf8986cdb7366a42dab3440b9983711eaff044a51b11006c67b/coverage-7.13.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:2f5e731627a3d5ef11a2a35aa0c6f7c435867c7ccbc391268eb4f2ca5dbdcc10", size = 219850, upload-time = "2026-02-03T14:01:35.984Z" }, + { url = "https://files.pythonhosted.org/packages/ea/2b/ab41f10345ba2e49d5e299be8663be2b7db33e77ac1b85cd0af985ea6406/coverage-7.13.3-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:9db3a3285d91c0b70fab9f39f0a4aa37d375873677efe4e71e58d8321e8c5d39", size = 250886, upload-time = "2026-02-03T14:01:38.287Z" }, + { url = "https://files.pythonhosted.org/packages/72/2d/b3f6913ee5a1d5cdd04106f257e5fac5d048992ffc2d9995d07b0f17739f/coverage-7.13.3-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:06e49c5897cb12e3f7ecdc111d44e97c4f6d0557b81a7a0204ed70a8b038f86f", size = 253393, upload-time = "2026-02-03T14:01:40.118Z" }, + { url = "https://files.pythonhosted.org/packages/f0/f6/b1f48810ffc6accf49a35b9943636560768f0812330f7456aa87dc39aff5/coverage-7.13.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fb25061a66802df9fc13a9ba1967d25faa4dae0418db469264fd9860a921dde4", size = 254740, upload-time = "2026-02-03T14:01:42.413Z" }, + { url = "https://files.pythonhosted.org/packages/57/d0/e59c54f9be0b61808f6bc4c8c4346bd79f02dd6bbc3f476ef26124661f20/coverage-7.13.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:99fee45adbb1caeb914da16f70e557fb7ff6ddc9e4b14de665bd41af631367ef", size = 250905, upload-time = "2026-02-03T14:01:44.163Z" }, + { url = "https://files.pythonhosted.org/packages/d5/f7/5291bcdf498bafbee3796bb32ef6966e9915aebd4d0954123c8eae921c32/coverage-7.13.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:318002f1fd819bdc1651c619268aa5bc853c35fa5cc6d1e8c96bd9cd6c828b75", size = 252753, upload-time = "2026-02-03T14:01:45.974Z" }, + { url = "https://files.pythonhosted.org/packages/a0/a9/1dcafa918c281554dae6e10ece88c1add82db685be123e1b05c2056ff3fb/coverage-7.13.3-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:71295f2d1d170b9977dc386d46a7a1b7cbb30e5405492529b4c930113a33f895", size = 250716, upload-time = "2026-02-03T14:01:48.844Z" }, + { url = "https://files.pythonhosted.org/packages/44/bb/4ea4eabcce8c4f6235df6e059fbc5db49107b24c4bdffc44aee81aeca5a8/coverage-7.13.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:5b1ad2e0dc672625c44bc4fe34514602a9fd8b10d52ddc414dc585f74453516c", size = 250530, upload-time = "2026-02-03T14:01:50.793Z" }, + { url = "https://files.pythonhosted.org/packages/6d/31/4a6c9e6a71367e6f923b27b528448c37f4e959b7e4029330523014691007/coverage-7.13.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b2beb64c145593a50d90db5c7178f55daeae129123b0d265bdb3cbec83e5194a", size = 252186, upload-time = "2026-02-03T14:01:52.607Z" }, + { url = "https://files.pythonhosted.org/packages/27/92/e1451ef6390a4f655dc42da35d9971212f7abbbcad0bdb7af4407897eb76/coverage-7.13.3-cp314-cp314-win32.whl", hash = "sha256:3d1aed4f4e837a832df2f3b4f68a690eede0de4560a2dbc214ea0bc55aabcdb4", size = 222253, upload-time = "2026-02-03T14:01:55.071Z" }, + { url = "https://files.pythonhosted.org/packages/8a/98/78885a861a88de020c32a2693487c37d15a9873372953f0c3c159d575a43/coverage-7.13.3-cp314-cp314-win_amd64.whl", hash = "sha256:9f9efbbaf79f935d5fbe3ad814825cbce4f6cdb3054384cb49f0c0f496125fa0", size = 223069, upload-time = "2026-02-03T14:01:56.95Z" }, + { url = "https://files.pythonhosted.org/packages/eb/fb/3784753a48da58a5337972abf7ca58b1fb0f1bda21bc7b4fae992fd28e47/coverage-7.13.3-cp314-cp314-win_arm64.whl", hash = "sha256:31b6e889c53d4e6687ca63706148049494aace140cffece1c4dc6acadb70a7b3", size = 221633, upload-time = "2026-02-03T14:01:58.758Z" }, + { url = "https://files.pythonhosted.org/packages/40/f9/75b732d9674d32cdbffe801ed5f770786dd1c97eecedef2125b0d25102dc/coverage-7.13.3-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:c5e9787cec750793a19a28df7edd85ac4e49d3fb91721afcdc3b86f6c08d9aa8", size = 220243, upload-time = "2026-02-03T14:02:01.109Z" }, + { url = "https://files.pythonhosted.org/packages/cf/7e/2868ec95de5a65703e6f0c87407ea822d1feb3619600fbc3c1c4fa986090/coverage-7.13.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:e5b86db331c682fd0e4be7098e6acee5e8a293f824d41487c667a93705d415ca", size = 220515, upload-time = "2026-02-03T14:02:02.862Z" }, + { url = "https://files.pythonhosted.org/packages/7d/eb/9f0d349652fced20bcaea0f67fc5777bd097c92369f267975732f3dc5f45/coverage-7.13.3-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:edc7754932682d52cf6e7a71806e529ecd5ce660e630e8bd1d37109a2e5f63ba", size = 261874, upload-time = "2026-02-03T14:02:04.727Z" }, + { url = "https://files.pythonhosted.org/packages/ee/a5/6619bc4a6c7b139b16818149a3e74ab2e21599ff9a7b6811b6afde99f8ec/coverage-7.13.3-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:d3a16d6398666510a6886f67f43d9537bfd0e13aca299688a19daa84f543122f", size = 264004, upload-time = "2026-02-03T14:02:06.634Z" }, + { url = "https://files.pythonhosted.org/packages/29/b7/90aa3fc645a50c6f07881fca4fd0ba21e3bfb6ce3a7078424ea3a35c74c9/coverage-7.13.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:303d38b19626c1981e1bb067a9928236d88eb0e4479b18a74812f05a82071508", size = 266408, upload-time = "2026-02-03T14:02:09.037Z" }, + { url = "https://files.pythonhosted.org/packages/62/55/08bb2a1e4dcbae384e638f0effef486ba5987b06700e481691891427d879/coverage-7.13.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:284e06eadfe15ddfee2f4ee56631f164ef897a7d7d5a15bca5f0bb88889fc5ba", size = 260977, upload-time = "2026-02-03T14:02:11.755Z" }, + { url = "https://files.pythonhosted.org/packages/9b/76/8bd4ae055a42d8fb5dd2230e5cf36ff2e05f85f2427e91b11a27fea52ed7/coverage-7.13.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:d401f0864a1d3198422816878e4e84ca89ec1c1bf166ecc0ae01380a39b888cd", size = 263868, upload-time = "2026-02-03T14:02:13.565Z" }, + { url = "https://files.pythonhosted.org/packages/e3/f9/ba000560f11e9e32ec03df5aa8477242c2d95b379c99ac9a7b2e7fbacb1a/coverage-7.13.3-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:3f379b02c18a64de78c4ccdddf1c81c2c5ae1956c72dacb9133d7dd7809794ab", size = 261474, upload-time = "2026-02-03T14:02:16.069Z" }, + { url = "https://files.pythonhosted.org/packages/90/4b/4de4de8f9ca7af4733bfcf4baa440121b7dbb3856daf8428ce91481ff63b/coverage-7.13.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:7a482f2da9086971efb12daca1d6547007ede3674ea06e16d7663414445c683e", size = 260317, upload-time = "2026-02-03T14:02:17.996Z" }, + { url = "https://files.pythonhosted.org/packages/05/71/5cd8436e2c21410ff70be81f738c0dddea91bcc3189b1517d26e0102ccb3/coverage-7.13.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:562136b0d401992118d9b49fbee5454e16f95f85b120a4226a04d816e33fe024", size = 262635, upload-time = "2026-02-03T14:02:20.405Z" }, + { url = "https://files.pythonhosted.org/packages/e7/f8/2834bb45bdd70b55a33ec354b8b5f6062fc90e5bb787e14385903a979503/coverage-7.13.3-cp314-cp314t-win32.whl", hash = "sha256:ca46e5c3be3b195098dd88711890b8011a9fa4feca942292bb84714ce5eab5d3", size = 223035, upload-time = "2026-02-03T14:02:22.323Z" }, + { url = "https://files.pythonhosted.org/packages/26/75/f8290f0073c00d9ae14056d2b84ab92dff21d5370e464cb6cb06f52bf580/coverage-7.13.3-cp314-cp314t-win_amd64.whl", hash = "sha256:06d316dbb3d9fd44cca05b2dbcfbef22948493d63a1f28e828d43e6cc505fed8", size = 224142, upload-time = "2026-02-03T14:02:24.143Z" }, + { url = "https://files.pythonhosted.org/packages/03/01/43ac78dfea8946c4a9161bbc034b5549115cb2b56781a4b574927f0d141a/coverage-7.13.3-cp314-cp314t-win_arm64.whl", hash = "sha256:299d66e9218193f9dc6e4880629ed7c4cd23486005166247c283fb98531656c3", size = 222166, upload-time = "2026-02-03T14:02:26.005Z" }, + { url = "https://files.pythonhosted.org/packages/7d/fb/70af542d2d938c778c9373ce253aa4116dbe7c0a5672f78b2b2ae0e1b94b/coverage-7.13.3-py3-none-any.whl", hash = "sha256:90a8af9dba6429b2573199622d72e0ebf024d6276f16abce394ad4d181bb0910", size = 211237, upload-time = "2026-02-03T14:02:27.986Z" }, +] + +[package.optional-dependencies] +toml = [ + { name = "tomli", marker = "python_full_version <= '3.11'" }, +] + +[[package]] +name = "cryptography" +version = "46.0.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/78/19/f748958276519adf6a0c1e79e7b8860b4830dda55ccdf29f2719b5fc499c/cryptography-46.0.4.tar.gz", hash = "sha256:bfd019f60f8abc2ed1b9be4ddc21cfef059c841d86d710bb69909a688cbb8f59", size = 749301, upload-time = "2026-01-28T00:24:37.379Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8d/99/157aae7949a5f30d51fcb1a9851e8ebd5c74bf99b5285d8bb4b8b9ee641e/cryptography-46.0.4-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:281526e865ed4166009e235afadf3a4c4cba6056f99336a99efba65336fd5485", size = 7173686, upload-time = "2026-01-28T00:23:07.515Z" }, + { url = "https://files.pythonhosted.org/packages/87/91/874b8910903159043b5c6a123b7e79c4559ddd1896e38967567942635778/cryptography-46.0.4-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5f14fba5bf6f4390d7ff8f086c566454bff0411f6d8aa7af79c88b6f9267aecc", size = 4275871, upload-time = "2026-01-28T00:23:09.439Z" }, + { url = "https://files.pythonhosted.org/packages/c0/35/690e809be77896111f5b195ede56e4b4ed0435b428c2f2b6d35046fbb5e8/cryptography-46.0.4-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:47bcd19517e6389132f76e2d5303ded6cf3f78903da2158a671be8de024f4cd0", size = 4423124, upload-time = "2026-01-28T00:23:11.529Z" }, + { url = "https://files.pythonhosted.org/packages/1a/5b/a26407d4f79d61ca4bebaa9213feafdd8806dc69d3d290ce24996d3cfe43/cryptography-46.0.4-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:01df4f50f314fbe7009f54046e908d1754f19d0c6d3070df1e6268c5a4af09fa", size = 4277090, upload-time = "2026-01-28T00:23:13.123Z" }, + { url = "https://files.pythonhosted.org/packages/0c/d8/4bb7aec442a9049827aa34cee1aa83803e528fa55da9a9d45d01d1bb933e/cryptography-46.0.4-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:5aa3e463596b0087b3da0dbe2b2487e9fc261d25da85754e30e3b40637d61f81", size = 4947652, upload-time = "2026-01-28T00:23:14.554Z" }, + { url = "https://files.pythonhosted.org/packages/2b/08/f83e2e0814248b844265802d081f2fac2f1cbe6cd258e72ba14ff006823a/cryptography-46.0.4-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:0a9ad24359fee86f131836a9ac3bffc9329e956624a2d379b613f8f8abaf5255", size = 4455157, upload-time = "2026-01-28T00:23:16.443Z" }, + { url = "https://files.pythonhosted.org/packages/0a/05/19d849cf4096448779d2dcc9bb27d097457dac36f7273ffa875a93b5884c/cryptography-46.0.4-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:dc1272e25ef673efe72f2096e92ae39dea1a1a450dd44918b15351f72c5a168e", size = 3981078, upload-time = "2026-01-28T00:23:17.838Z" }, + { url = "https://files.pythonhosted.org/packages/e6/89/f7bac81d66ba7cde867a743ea5b37537b32b5c633c473002b26a226f703f/cryptography-46.0.4-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:de0f5f4ec8711ebc555f54735d4c673fc34b65c44283895f1a08c2b49d2fd99c", size = 4276213, upload-time = "2026-01-28T00:23:19.257Z" }, + { url = "https://files.pythonhosted.org/packages/da/9f/7133e41f24edd827020ad21b068736e792bc68eecf66d93c924ad4719fb3/cryptography-46.0.4-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:eeeb2e33d8dbcccc34d64651f00a98cb41b2dc69cef866771a5717e6734dfa32", size = 4912190, upload-time = "2026-01-28T00:23:21.244Z" }, + { url = "https://files.pythonhosted.org/packages/a6/f7/6d43cbaddf6f65b24816e4af187d211f0bc536a29961f69faedc48501d8e/cryptography-46.0.4-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:3d425eacbc9aceafd2cb429e42f4e5d5633c6f873f5e567077043ef1b9bbf616", size = 4454641, upload-time = "2026-01-28T00:23:22.866Z" }, + { url = "https://files.pythonhosted.org/packages/9e/4f/ebd0473ad656a0ac912a16bd07db0f5d85184924e14fc88feecae2492834/cryptography-46.0.4-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:91627ebf691d1ea3976a031b61fb7bac1ccd745afa03602275dda443e11c8de0", size = 4405159, upload-time = "2026-01-28T00:23:25.278Z" }, + { url = "https://files.pythonhosted.org/packages/d1/f7/7923886f32dc47e27adeff8246e976d77258fd2aa3efdd1754e4e323bf49/cryptography-46.0.4-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:2d08bc22efd73e8854b0b7caff402d735b354862f1145d7be3b9c0f740fef6a0", size = 4666059, upload-time = "2026-01-28T00:23:26.766Z" }, + { url = "https://files.pythonhosted.org/packages/eb/a7/0fca0fd3591dffc297278a61813d7f661a14243dd60f499a7a5b48acb52a/cryptography-46.0.4-cp311-abi3-win32.whl", hash = "sha256:82a62483daf20b8134f6e92898da70d04d0ef9a75829d732ea1018678185f4f5", size = 3026378, upload-time = "2026-01-28T00:23:28.317Z" }, + { url = "https://files.pythonhosted.org/packages/2d/12/652c84b6f9873f0909374864a57b003686c642ea48c84d6c7e2c515e6da5/cryptography-46.0.4-cp311-abi3-win_amd64.whl", hash = "sha256:6225d3ebe26a55dbc8ead5ad1265c0403552a63336499564675b29eb3184c09b", size = 3478614, upload-time = "2026-01-28T00:23:30.275Z" }, + { url = "https://files.pythonhosted.org/packages/b9/27/542b029f293a5cce59349d799d4d8484b3b1654a7b9a0585c266e974a488/cryptography-46.0.4-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:485e2b65d25ec0d901bca7bcae0f53b00133bf3173916d8e421f6fddde103908", size = 7116417, upload-time = "2026-01-28T00:23:31.958Z" }, + { url = "https://files.pythonhosted.org/packages/f8/f5/559c25b77f40b6bf828eabaf988efb8b0e17b573545edb503368ca0a2a03/cryptography-46.0.4-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:078e5f06bd2fa5aea5a324f2a09f914b1484f1d0c2a4d6a8a28c74e72f65f2da", size = 4264508, upload-time = "2026-01-28T00:23:34.264Z" }, + { url = "https://files.pythonhosted.org/packages/49/a1/551fa162d33074b660dc35c9bc3616fefa21a0e8c1edd27b92559902e408/cryptography-46.0.4-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:dce1e4f068f03008da7fa51cc7abc6ddc5e5de3e3d1550334eaf8393982a5829", size = 4409080, upload-time = "2026-01-28T00:23:35.793Z" }, + { url = "https://files.pythonhosted.org/packages/b0/6a/4d8d129a755f5d6df1bbee69ea2f35ebfa954fa1847690d1db2e8bca46a5/cryptography-46.0.4-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:2067461c80271f422ee7bdbe79b9b4be54a5162e90345f86a23445a0cf3fd8a2", size = 4270039, upload-time = "2026-01-28T00:23:37.263Z" }, + { url = "https://files.pythonhosted.org/packages/4c/f5/ed3fcddd0a5e39321e595e144615399e47e7c153a1fb8c4862aec3151ff9/cryptography-46.0.4-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:c92010b58a51196a5f41c3795190203ac52edfd5dc3ff99149b4659eba9d2085", size = 4926748, upload-time = "2026-01-28T00:23:38.884Z" }, + { url = "https://files.pythonhosted.org/packages/43/ae/9f03d5f0c0c00e85ecb34f06d3b79599f20630e4db91b8a6e56e8f83d410/cryptography-46.0.4-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:829c2b12bbc5428ab02d6b7f7e9bbfd53e33efd6672d21341f2177470171ad8b", size = 4442307, upload-time = "2026-01-28T00:23:40.56Z" }, + { url = "https://files.pythonhosted.org/packages/8b/22/e0f9f2dae8040695103369cf2283ef9ac8abe4d51f68710bec2afd232609/cryptography-46.0.4-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:62217ba44bf81b30abaeda1488686a04a702a261e26f87db51ff61d9d3510abd", size = 3959253, upload-time = "2026-01-28T00:23:42.827Z" }, + { url = "https://files.pythonhosted.org/packages/01/5b/6a43fcccc51dae4d101ac7d378a8724d1ba3de628a24e11bf2f4f43cba4d/cryptography-46.0.4-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:9c2da296c8d3415b93e6053f5a728649a87a48ce084a9aaf51d6e46c87c7f2d2", size = 4269372, upload-time = "2026-01-28T00:23:44.655Z" }, + { url = "https://files.pythonhosted.org/packages/17/b7/0f6b8c1dd0779df2b526e78978ff00462355e31c0a6f6cff8a3e99889c90/cryptography-46.0.4-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:9b34d8ba84454641a6bf4d6762d15847ecbd85c1316c0a7984e6e4e9f748ec2e", size = 4891908, upload-time = "2026-01-28T00:23:46.48Z" }, + { url = "https://files.pythonhosted.org/packages/83/17/259409b8349aa10535358807a472c6a695cf84f106022268d31cea2b6c97/cryptography-46.0.4-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:df4a817fa7138dd0c96c8c8c20f04b8aaa1fac3bbf610913dcad8ea82e1bfd3f", size = 4441254, upload-time = "2026-01-28T00:23:48.403Z" }, + { url = "https://files.pythonhosted.org/packages/9c/fe/e4a1b0c989b00cee5ffa0764401767e2d1cf59f45530963b894129fd5dce/cryptography-46.0.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:b1de0ebf7587f28f9190b9cb526e901bf448c9e6a99655d2b07fff60e8212a82", size = 4396520, upload-time = "2026-01-28T00:23:50.26Z" }, + { url = "https://files.pythonhosted.org/packages/b3/81/ba8fd9657d27076eb40d6a2f941b23429a3c3d2f56f5a921d6b936a27bc9/cryptography-46.0.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9b4d17bc7bd7cdd98e3af40b441feaea4c68225e2eb2341026c84511ad246c0c", size = 4651479, upload-time = "2026-01-28T00:23:51.674Z" }, + { url = "https://files.pythonhosted.org/packages/00/03/0de4ed43c71c31e4fe954edd50b9d28d658fef56555eba7641696370a8e2/cryptography-46.0.4-cp314-cp314t-win32.whl", hash = "sha256:c411f16275b0dea722d76544a61d6421e2cc829ad76eec79280dbdc9ddf50061", size = 3001986, upload-time = "2026-01-28T00:23:53.485Z" }, + { url = "https://files.pythonhosted.org/packages/5c/70/81830b59df7682917d7a10f833c4dab2a5574cd664e86d18139f2b421329/cryptography-46.0.4-cp314-cp314t-win_amd64.whl", hash = "sha256:728fedc529efc1439eb6107b677f7f7558adab4553ef8669f0d02d42d7b959a7", size = 3468288, upload-time = "2026-01-28T00:23:55.09Z" }, + { url = "https://files.pythonhosted.org/packages/56/f7/f648fdbb61d0d45902d3f374217451385edc7e7768d1b03ff1d0e5ffc17b/cryptography-46.0.4-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:a9556ba711f7c23f77b151d5798f3ac44a13455cc68db7697a1096e6d0563cab", size = 7169583, upload-time = "2026-01-28T00:23:56.558Z" }, + { url = "https://files.pythonhosted.org/packages/d8/cc/8f3224cbb2a928de7298d6ed4790f5ebc48114e02bdc9559196bfb12435d/cryptography-46.0.4-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8bf75b0259e87fa70bddc0b8b4078b76e7fd512fd9afae6c1193bcf440a4dbef", size = 4275419, upload-time = "2026-01-28T00:23:58.364Z" }, + { url = "https://files.pythonhosted.org/packages/17/43/4a18faa7a872d00e4264855134ba82d23546c850a70ff209e04ee200e76f/cryptography-46.0.4-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3c268a3490df22270955966ba236d6bc4a8f9b6e4ffddb78aac535f1a5ea471d", size = 4419058, upload-time = "2026-01-28T00:23:59.867Z" }, + { url = "https://files.pythonhosted.org/packages/ee/64/6651969409821d791ba12346a124f55e1b76f66a819254ae840a965d4b9c/cryptography-46.0.4-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:812815182f6a0c1d49a37893a303b44eaac827d7f0d582cecfc81b6427f22973", size = 4278151, upload-time = "2026-01-28T00:24:01.731Z" }, + { url = "https://files.pythonhosted.org/packages/20/0b/a7fce65ee08c3c02f7a8310cc090a732344066b990ac63a9dfd0a655d321/cryptography-46.0.4-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:a90e43e3ef65e6dcf969dfe3bb40cbf5aef0d523dff95bfa24256be172a845f4", size = 4939441, upload-time = "2026-01-28T00:24:03.175Z" }, + { url = "https://files.pythonhosted.org/packages/db/a7/20c5701e2cd3e1dfd7a19d2290c522a5f435dd30957d431dcb531d0f1413/cryptography-46.0.4-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:a05177ff6296644ef2876fce50518dffb5bcdf903c85250974fc8bc85d54c0af", size = 4451617, upload-time = "2026-01-28T00:24:05.403Z" }, + { url = "https://files.pythonhosted.org/packages/00/dc/3e16030ea9aa47b63af6524c354933b4fb0e352257c792c4deeb0edae367/cryptography-46.0.4-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:daa392191f626d50f1b136c9b4cf08af69ca8279d110ea24f5c2700054d2e263", size = 3977774, upload-time = "2026-01-28T00:24:06.851Z" }, + { url = "https://files.pythonhosted.org/packages/42/c8/ad93f14118252717b465880368721c963975ac4b941b7ef88f3c56bf2897/cryptography-46.0.4-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:e07ea39c5b048e085f15923511d8121e4a9dc45cee4e3b970ca4f0d338f23095", size = 4277008, upload-time = "2026-01-28T00:24:08.926Z" }, + { url = "https://files.pythonhosted.org/packages/00/cf/89c99698151c00a4631fbfcfcf459d308213ac29e321b0ff44ceeeac82f1/cryptography-46.0.4-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:d5a45ddc256f492ce42a4e35879c5e5528c09cd9ad12420828c972951d8e016b", size = 4903339, upload-time = "2026-01-28T00:24:12.009Z" }, + { url = "https://files.pythonhosted.org/packages/03/c3/c90a2cb358de4ac9309b26acf49b2a100957e1ff5cc1e98e6c4996576710/cryptography-46.0.4-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:6bb5157bf6a350e5b28aee23beb2d84ae6f5be390b2f8ee7ea179cda077e1019", size = 4451216, upload-time = "2026-01-28T00:24:13.975Z" }, + { url = "https://files.pythonhosted.org/packages/96/2c/8d7f4171388a10208671e181ca43cdc0e596d8259ebacbbcfbd16de593da/cryptography-46.0.4-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:dd5aba870a2c40f87a3af043e0dee7d9eb02d4aff88a797b48f2b43eff8c3ab4", size = 4404299, upload-time = "2026-01-28T00:24:16.169Z" }, + { url = "https://files.pythonhosted.org/packages/e9/23/cbb2036e450980f65c6e0a173b73a56ff3bccd8998965dea5cc9ddd424a5/cryptography-46.0.4-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:93d8291da8d71024379ab2cb0b5c57915300155ad42e07f76bea6ad838d7e59b", size = 4664837, upload-time = "2026-01-28T00:24:17.629Z" }, + { url = "https://files.pythonhosted.org/packages/0a/21/f7433d18fe6d5845329cbdc597e30caf983229c7a245bcf54afecc555938/cryptography-46.0.4-cp38-abi3-win32.whl", hash = "sha256:0563655cb3c6d05fb2afe693340bc050c30f9f34e15763361cf08e94749401fc", size = 3009779, upload-time = "2026-01-28T00:24:20.198Z" }, + { url = "https://files.pythonhosted.org/packages/3a/6a/bd2e7caa2facffedf172a45c1a02e551e6d7d4828658c9a245516a598d94/cryptography-46.0.4-cp38-abi3-win_amd64.whl", hash = "sha256:fa0900b9ef9c49728887d1576fd8d9e7e3ea872fa9b25ef9b64888adc434e976", size = 3466633, upload-time = "2026-01-28T00:24:21.851Z" }, + { url = "https://files.pythonhosted.org/packages/59/e0/f9c6c53e1f2a1c2507f00f2faba00f01d2f334b35b0fbfe5286715da2184/cryptography-46.0.4-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:766330cce7416c92b5e90c3bb71b1b79521760cdcfc3a6a1a182d4c9fab23d2b", size = 3476316, upload-time = "2026-01-28T00:24:24.144Z" }, + { url = "https://files.pythonhosted.org/packages/27/7a/f8d2d13227a9a1a9fe9c7442b057efecffa41f1e3c51d8622f26b9edbe8f/cryptography-46.0.4-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:c236a44acfb610e70f6b3e1c3ca20ff24459659231ef2f8c48e879e2d32b73da", size = 4216693, upload-time = "2026-01-28T00:24:25.758Z" }, + { url = "https://files.pythonhosted.org/packages/c5/de/3787054e8f7972658370198753835d9d680f6cd4a39df9f877b57f0dd69c/cryptography-46.0.4-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:8a15fb869670efa8f83cbffbc8753c1abf236883225aed74cd179b720ac9ec80", size = 4382765, upload-time = "2026-01-28T00:24:27.577Z" }, + { url = "https://files.pythonhosted.org/packages/8a/5f/60e0afb019973ba6a0b322e86b3d61edf487a4f5597618a430a2a15f2d22/cryptography-46.0.4-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:fdc3daab53b212472f1524d070735b2f0c214239df131903bae1d598016fa822", size = 4216066, upload-time = "2026-01-28T00:24:29.056Z" }, + { url = "https://files.pythonhosted.org/packages/81/8e/bf4a0de294f147fee66f879d9bae6f8e8d61515558e3d12785dd90eca0be/cryptography-46.0.4-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:44cc0675b27cadb71bdbb96099cca1fa051cd11d2ade09e5cd3a2edb929ed947", size = 4382025, upload-time = "2026-01-28T00:24:30.681Z" }, + { url = "https://files.pythonhosted.org/packages/79/f4/9ceb90cfd6a3847069b0b0b353fd3075dc69b49defc70182d8af0c4ca390/cryptography-46.0.4-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:be8c01a7d5a55f9a47d1888162b76c8f49d62b234d88f0ff91a9fbebe32ffbc3", size = 3406043, upload-time = "2026-01-28T00:24:32.236Z" }, +] + +[[package]] +name = "distlib" +version = "0.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/96/8e/709914eb2b5749865801041647dc7f4e6d00b549cfe88b65ca192995f07c/distlib-0.4.0.tar.gz", hash = "sha256:feec40075be03a04501a973d81f633735b4b69f98b05450592310c0f401a4e0d", size = 614605, upload-time = "2025-07-17T16:52:00.465Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/33/6b/e0547afaf41bf2c42e52430072fa5658766e3d65bd4b03a563d1b6336f57/distlib-0.4.0-py2.py3-none-any.whl", hash = "sha256:9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16", size = 469047, upload-time = "2025-07-17T16:51:58.613Z" }, +] + +[[package]] +name = "filelock" +version = "3.20.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1d/65/ce7f1b70157833bf3cb851b556a37d4547ceafc158aa9b34b36782f23696/filelock-3.20.3.tar.gz", hash = "sha256:18c57ee915c7ec61cff0ecf7f0f869936c7c30191bb0cf406f1341778d0834e1", size = 19485, upload-time = "2026-01-09T17:55:05.421Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b5/36/7fb70f04bf00bc646cd5bb45aa9eddb15e19437a28b8fb2b4a5249fac770/filelock-3.20.3-py3-none-any.whl", hash = "sha256:4b0dda527ee31078689fc205ec4f1c1bf7d56cf88b6dc9426c4f230e46c2dce1", size = 16701, upload-time = "2026-01-09T17:55:04.334Z" }, +] + +[[package]] +name = "identify" +version = "2.6.16" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5b/8d/e8b97e6bd3fb6fb271346f7981362f1e04d6a7463abd0de79e1fda17c067/identify-2.6.16.tar.gz", hash = "sha256:846857203b5511bbe94d5a352a48ef2359532bc8f6727b5544077a0dcfb24980", size = 99360, upload-time = "2026-01-12T18:58:58.201Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b8/58/40fbbcefeda82364720eba5cf2270f98496bdfa19ea75b4cccae79c698e6/identify-2.6.16-py2.py3-none-any.whl", hash = "sha256:391ee4d77741d994189522896270b787aed8670389bfd60f326d677d64a6dfb0", size = 99202, upload-time = "2026-01-12T18:58:56.627Z" }, +] + +[[package]] +name = "idna" +version = "3.11" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "libcomponent" +version = "0.0.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, + { name = "mypy-extensions" }, + { name = "trio" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ec/4a/e041aa4a2019af6d10aefc0f861041579980b4629aae4bc2bc72d7ea89af/libcomponent-0.0.5.tar.gz", hash = "sha256:679602a93e6b3fd58811d6e7866f6ab096611ecf615ef3a573bec6455c93e809", size = 41202, upload-time = "2025-09-20T05:20:00.252Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/97/22/f3c6338ea0facf7740a2f67bf331b083a86532c163f80a4bf3fbbf6fb1e8/libcomponent-0.0.5-py3-none-any.whl", hash = "sha256:bd3f05e3085619ddf14f1727eff81d1160fdd2ee63bf5db4372da5c22d0c8687", size = 37244, upload-time = "2025-09-20T05:19:58.718Z" }, +] + +[[package]] +name = "librt" +version = "0.7.8" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/24/5f3646ff414285e0f7708fa4e946b9bf538345a41d1c375c439467721a5e/librt-0.7.8.tar.gz", hash = "sha256:1a4ede613941d9c3470b0368be851df6bb78ab218635512d0370b27a277a0862", size = 148323, upload-time = "2026-01-14T12:56:16.876Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1b/a3/87ea9c1049f2c781177496ebee29430e4631f439b8553a4969c88747d5d8/librt-0.7.8-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ff3e9c11aa260c31493d4b3197d1e28dd07768594a4f92bec4506849d736248f", size = 56507, upload-time = "2026-01-14T12:54:54.156Z" }, + { url = "https://files.pythonhosted.org/packages/5e/4a/23bcef149f37f771ad30203d561fcfd45b02bc54947b91f7a9ac34815747/librt-0.7.8-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:ddb52499d0b3ed4aa88746aaf6f36a08314677d5c346234c3987ddc506404eac", size = 58455, upload-time = "2026-01-14T12:54:55.978Z" }, + { url = "https://files.pythonhosted.org/packages/22/6e/46eb9b85c1b9761e0f42b6e6311e1cc544843ac897457062b9d5d0b21df4/librt-0.7.8-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:e9c0afebbe6ce177ae8edba0c7c4d626f2a0fc12c33bb993d163817c41a7a05c", size = 164956, upload-time = "2026-01-14T12:54:57.311Z" }, + { url = "https://files.pythonhosted.org/packages/7a/3f/aa7c7f6829fb83989feb7ba9aa11c662b34b4bd4bd5b262f2876ba3db58d/librt-0.7.8-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:631599598e2c76ded400c0a8722dec09217c89ff64dc54b060f598ed68e7d2a8", size = 174364, upload-time = "2026-01-14T12:54:59.089Z" }, + { url = "https://files.pythonhosted.org/packages/3f/2d/d57d154b40b11f2cb851c4df0d4c4456bacd9b1ccc4ecb593ddec56c1a8b/librt-0.7.8-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c1ba843ae20db09b9d5c80475376168feb2640ce91cd9906414f23cc267a1ff", size = 188034, upload-time = "2026-01-14T12:55:00.141Z" }, + { url = "https://files.pythonhosted.org/packages/59/f9/36c4dad00925c16cd69d744b87f7001792691857d3b79187e7a673e812fb/librt-0.7.8-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:b5b007bb22ea4b255d3ee39dfd06d12534de2fcc3438567d9f48cdaf67ae1ae3", size = 186295, upload-time = "2026-01-14T12:55:01.303Z" }, + { url = "https://files.pythonhosted.org/packages/23/9b/8a9889d3df5efb67695a67785028ccd58e661c3018237b73ad081691d0cb/librt-0.7.8-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:dbd79caaf77a3f590cbe32dc2447f718772d6eea59656a7dcb9311161b10fa75", size = 181470, upload-time = "2026-01-14T12:55:02.492Z" }, + { url = "https://files.pythonhosted.org/packages/43/64/54d6ef11afca01fef8af78c230726a9394759f2addfbf7afc5e3cc032a45/librt-0.7.8-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:87808a8d1e0bd62a01cafc41f0fd6818b5a5d0ca0d8a55326a81643cdda8f873", size = 201713, upload-time = "2026-01-14T12:55:03.919Z" }, + { url = "https://files.pythonhosted.org/packages/2d/29/73e7ed2991330b28919387656f54109139b49e19cd72902f466bd44415fd/librt-0.7.8-cp311-cp311-win32.whl", hash = "sha256:31724b93baa91512bd0a376e7cf0b59d8b631ee17923b1218a65456fa9bda2e7", size = 43803, upload-time = "2026-01-14T12:55:04.996Z" }, + { url = "https://files.pythonhosted.org/packages/3f/de/66766ff48ed02b4d78deea30392ae200bcbd99ae61ba2418b49fd50a4831/librt-0.7.8-cp311-cp311-win_amd64.whl", hash = "sha256:978e8b5f13e52cf23a9e80f3286d7546baa70bc4ef35b51d97a709d0b28e537c", size = 50080, upload-time = "2026-01-14T12:55:06.489Z" }, + { url = "https://files.pythonhosted.org/packages/6f/e3/33450438ff3a8c581d4ed7f798a70b07c3206d298cf0b87d3806e72e3ed8/librt-0.7.8-cp311-cp311-win_arm64.whl", hash = "sha256:20e3946863d872f7cabf7f77c6c9d370b8b3d74333d3a32471c50d3a86c0a232", size = 43383, upload-time = "2026-01-14T12:55:07.49Z" }, + { url = "https://files.pythonhosted.org/packages/56/04/79d8fcb43cae376c7adbab7b2b9f65e48432c9eced62ac96703bcc16e09b/librt-0.7.8-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:9b6943885b2d49c48d0cff23b16be830ba46b0152d98f62de49e735c6e655a63", size = 57472, upload-time = "2026-01-14T12:55:08.528Z" }, + { url = "https://files.pythonhosted.org/packages/b4/ba/60b96e93043d3d659da91752689023a73981336446ae82078cddf706249e/librt-0.7.8-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:46ef1f4b9b6cc364b11eea0ecc0897314447a66029ee1e55859acb3dd8757c93", size = 58986, upload-time = "2026-01-14T12:55:09.466Z" }, + { url = "https://files.pythonhosted.org/packages/7c/26/5215e4cdcc26e7be7eee21955a7e13cbf1f6d7d7311461a6014544596fac/librt-0.7.8-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:907ad09cfab21e3c86e8f1f87858f7049d1097f77196959c033612f532b4e592", size = 168422, upload-time = "2026-01-14T12:55:10.499Z" }, + { url = "https://files.pythonhosted.org/packages/0f/84/e8d1bc86fa0159bfc24f3d798d92cafd3897e84c7fea7fe61b3220915d76/librt-0.7.8-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2991b6c3775383752b3ca0204842743256f3ad3deeb1d0adc227d56b78a9a850", size = 177478, upload-time = "2026-01-14T12:55:11.577Z" }, + { url = "https://files.pythonhosted.org/packages/57/11/d0268c4b94717a18aa91df1100e767b010f87b7ae444dafaa5a2d80f33a6/librt-0.7.8-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:03679b9856932b8c8f674e87aa3c55ea11c9274301f76ae8dc4d281bda55cf62", size = 192439, upload-time = "2026-01-14T12:55:12.7Z" }, + { url = "https://files.pythonhosted.org/packages/8d/56/1e8e833b95fe684f80f8894ae4d8b7d36acc9203e60478fcae599120a975/librt-0.7.8-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3968762fec1b2ad34ce57458b6de25dbb4142713e9ca6279a0d352fa4e9f452b", size = 191483, upload-time = "2026-01-14T12:55:13.838Z" }, + { url = "https://files.pythonhosted.org/packages/17/48/f11cf28a2cb6c31f282009e2208312aa84a5ee2732859f7856ee306176d5/librt-0.7.8-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:bb7a7807523a31f03061288cc4ffc065d684c39db7644c676b47d89553c0d714", size = 185376, upload-time = "2026-01-14T12:55:15.017Z" }, + { url = "https://files.pythonhosted.org/packages/b8/6a/d7c116c6da561b9155b184354a60a3d5cdbf08fc7f3678d09c95679d13d9/librt-0.7.8-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ad64a14b1e56e702e19b24aae108f18ad1bf7777f3af5fcd39f87d0c5a814449", size = 206234, upload-time = "2026-01-14T12:55:16.571Z" }, + { url = "https://files.pythonhosted.org/packages/61/de/1975200bb0285fc921c5981d9978ce6ce11ae6d797df815add94a5a848a3/librt-0.7.8-cp312-cp312-win32.whl", hash = "sha256:0241a6ed65e6666236ea78203a73d800dbed896cf12ae25d026d75dc1fcd1dac", size = 44057, upload-time = "2026-01-14T12:55:18.077Z" }, + { url = "https://files.pythonhosted.org/packages/8e/cd/724f2d0b3461426730d4877754b65d39f06a41ac9d0a92d5c6840f72b9ae/librt-0.7.8-cp312-cp312-win_amd64.whl", hash = "sha256:6db5faf064b5bab9675c32a873436b31e01d66ca6984c6f7f92621656033a708", size = 50293, upload-time = "2026-01-14T12:55:19.179Z" }, + { url = "https://files.pythonhosted.org/packages/bd/cf/7e899acd9ee5727ad8160fdcc9994954e79fab371c66535c60e13b968ffc/librt-0.7.8-cp312-cp312-win_arm64.whl", hash = "sha256:57175aa93f804d2c08d2edb7213e09276bd49097611aefc37e3fa38d1fb99ad0", size = 43574, upload-time = "2026-01-14T12:55:20.185Z" }, + { url = "https://files.pythonhosted.org/packages/a1/fe/b1f9de2829cf7fc7649c1dcd202cfd873837c5cc2fc9e526b0e7f716c3d2/librt-0.7.8-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4c3995abbbb60b3c129490fa985dfe6cac11d88fc3c36eeb4fb1449efbbb04fc", size = 57500, upload-time = "2026-01-14T12:55:21.219Z" }, + { url = "https://files.pythonhosted.org/packages/eb/d4/4a60fbe2e53b825f5d9a77325071d61cd8af8506255067bf0c8527530745/librt-0.7.8-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:44e0c2cbc9bebd074cf2cdbe472ca185e824be4e74b1c63a8e934cea674bebf2", size = 59019, upload-time = "2026-01-14T12:55:22.256Z" }, + { url = "https://files.pythonhosted.org/packages/6a/37/61ff80341ba5159afa524445f2d984c30e2821f31f7c73cf166dcafa5564/librt-0.7.8-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:4d2f1e492cae964b3463a03dc77a7fe8742f7855d7258c7643f0ee32b6651dd3", size = 169015, upload-time = "2026-01-14T12:55:23.24Z" }, + { url = "https://files.pythonhosted.org/packages/1c/86/13d4f2d6a93f181ebf2fc953868826653ede494559da8268023fe567fca3/librt-0.7.8-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:451e7ffcef8f785831fdb791bd69211f47e95dc4c6ddff68e589058806f044c6", size = 178161, upload-time = "2026-01-14T12:55:24.826Z" }, + { url = "https://files.pythonhosted.org/packages/88/26/e24ef01305954fc4d771f1f09f3dd682f9eb610e1bec188ffb719374d26e/librt-0.7.8-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3469e1af9f1380e093ae06bedcbdd11e407ac0b303a56bbe9afb1d6824d4982d", size = 193015, upload-time = "2026-01-14T12:55:26.04Z" }, + { url = "https://files.pythonhosted.org/packages/88/a0/92b6bd060e720d7a31ed474d046a69bd55334ec05e9c446d228c4b806ae3/librt-0.7.8-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f11b300027ce19a34f6d24ebb0a25fd0e24a9d53353225a5c1e6cadbf2916b2e", size = 192038, upload-time = "2026-01-14T12:55:27.208Z" }, + { url = "https://files.pythonhosted.org/packages/06/bb/6f4c650253704279c3a214dad188101d1b5ea23be0606628bc6739456624/librt-0.7.8-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:4adc73614f0d3c97874f02f2c7fd2a27854e7e24ad532ea6b965459c5b757eca", size = 186006, upload-time = "2026-01-14T12:55:28.594Z" }, + { url = "https://files.pythonhosted.org/packages/dc/00/1c409618248d43240cadf45f3efb866837fa77e9a12a71481912135eb481/librt-0.7.8-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:60c299e555f87e4c01b2eca085dfccda1dde87f5a604bb45c2906b8305819a93", size = 206888, upload-time = "2026-01-14T12:55:30.214Z" }, + { url = "https://files.pythonhosted.org/packages/d9/83/b2cfe8e76ff5c1c77f8a53da3d5de62d04b5ebf7cf913e37f8bca43b5d07/librt-0.7.8-cp313-cp313-win32.whl", hash = "sha256:b09c52ed43a461994716082ee7d87618096851319bf695d57ec123f2ab708951", size = 44126, upload-time = "2026-01-14T12:55:31.44Z" }, + { url = "https://files.pythonhosted.org/packages/a9/0b/c59d45de56a51bd2d3a401fc63449c0ac163e4ef7f523ea8b0c0dee86ec5/librt-0.7.8-cp313-cp313-win_amd64.whl", hash = "sha256:f8f4a901a3fa28969d6e4519deceab56c55a09d691ea7b12ca830e2fa3461e34", size = 50262, upload-time = "2026-01-14T12:55:33.01Z" }, + { url = "https://files.pythonhosted.org/packages/fc/b9/973455cec0a1ec592395250c474164c4a58ebf3e0651ee920fef1a2623f1/librt-0.7.8-cp313-cp313-win_arm64.whl", hash = "sha256:43d4e71b50763fcdcf64725ac680d8cfa1706c928b844794a7aa0fa9ac8e5f09", size = 43600, upload-time = "2026-01-14T12:55:34.054Z" }, + { url = "https://files.pythonhosted.org/packages/1a/73/fa8814c6ce2d49c3827829cadaa1589b0bf4391660bd4510899393a23ebc/librt-0.7.8-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:be927c3c94c74b05128089a955fba86501c3b544d1d300282cc1b4bd370cb418", size = 57049, upload-time = "2026-01-14T12:55:35.056Z" }, + { url = "https://files.pythonhosted.org/packages/53/fe/f6c70956da23ea235fd2e3cc16f4f0b4ebdfd72252b02d1164dd58b4e6c3/librt-0.7.8-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:7b0803e9008c62a7ef79058233db7ff6f37a9933b8f2573c05b07ddafa226611", size = 58689, upload-time = "2026-01-14T12:55:36.078Z" }, + { url = "https://files.pythonhosted.org/packages/1f/4d/7a2481444ac5fba63050d9abe823e6bc16896f575bfc9c1e5068d516cdce/librt-0.7.8-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:79feb4d00b2a4e0e05c9c56df707934f41fcb5fe53fd9efb7549068d0495b758", size = 166808, upload-time = "2026-01-14T12:55:37.595Z" }, + { url = "https://files.pythonhosted.org/packages/ac/3c/10901d9e18639f8953f57c8986796cfbf4c1c514844a41c9197cf87cb707/librt-0.7.8-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b9122094e3f24aa759c38f46bd8863433820654927370250f460ae75488b66ea", size = 175614, upload-time = "2026-01-14T12:55:38.756Z" }, + { url = "https://files.pythonhosted.org/packages/db/01/5cbdde0951a5090a80e5ba44e6357d375048123c572a23eecfb9326993a7/librt-0.7.8-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7e03bea66af33c95ce3addf87a9bf1fcad8d33e757bc479957ddbc0e4f7207ac", size = 189955, upload-time = "2026-01-14T12:55:39.939Z" }, + { url = "https://files.pythonhosted.org/packages/6a/b4/e80528d2f4b7eaf1d437fcbd6fc6ba4cbeb3e2a0cb9ed5a79f47c7318706/librt-0.7.8-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f1ade7f31675db00b514b98f9ab9a7698c7282dad4be7492589109471852d398", size = 189370, upload-time = "2026-01-14T12:55:41.057Z" }, + { url = "https://files.pythonhosted.org/packages/c1/ab/938368f8ce31a9787ecd4becb1e795954782e4312095daf8fd22420227c8/librt-0.7.8-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:a14229ac62adcf1b90a15992f1ab9c69ae8b99ffb23cb64a90878a6e8a2f5b81", size = 183224, upload-time = "2026-01-14T12:55:42.328Z" }, + { url = "https://files.pythonhosted.org/packages/3c/10/559c310e7a6e4014ac44867d359ef8238465fb499e7eb31b6bfe3e3f86f5/librt-0.7.8-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5bcaaf624fd24e6a0cb14beac37677f90793a96864c67c064a91458611446e83", size = 203541, upload-time = "2026-01-14T12:55:43.501Z" }, + { url = "https://files.pythonhosted.org/packages/f8/db/a0db7acdb6290c215f343835c6efda5b491bb05c3ddc675af558f50fdba3/librt-0.7.8-cp314-cp314-win32.whl", hash = "sha256:7aa7d5457b6c542ecaed79cec4ad98534373c9757383973e638ccced0f11f46d", size = 40657, upload-time = "2026-01-14T12:55:44.668Z" }, + { url = "https://files.pythonhosted.org/packages/72/e0/4f9bdc2a98a798511e81edcd6b54fe82767a715e05d1921115ac70717f6f/librt-0.7.8-cp314-cp314-win_amd64.whl", hash = "sha256:3d1322800771bee4a91f3b4bd4e49abc7d35e65166821086e5afd1e6c0d9be44", size = 46835, upload-time = "2026-01-14T12:55:45.655Z" }, + { url = "https://files.pythonhosted.org/packages/f9/3d/59c6402e3dec2719655a41ad027a7371f8e2334aa794ed11533ad5f34969/librt-0.7.8-cp314-cp314-win_arm64.whl", hash = "sha256:5363427bc6a8c3b1719f8f3845ea53553d301382928a86e8fab7984426949bce", size = 39885, upload-time = "2026-01-14T12:55:47.138Z" }, + { url = "https://files.pythonhosted.org/packages/4e/9c/2481d80950b83085fb14ba3c595db56330d21bbc7d88a19f20165f3538db/librt-0.7.8-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:ca916919793a77e4a98d4a1701e345d337ce53be4a16620f063191f7322ac80f", size = 59161, upload-time = "2026-01-14T12:55:48.45Z" }, + { url = "https://files.pythonhosted.org/packages/96/79/108df2cfc4e672336765d54e3ff887294c1cc36ea4335c73588875775527/librt-0.7.8-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:54feb7b4f2f6706bb82325e836a01be805770443e2400f706e824e91f6441dde", size = 61008, upload-time = "2026-01-14T12:55:49.527Z" }, + { url = "https://files.pythonhosted.org/packages/46/f2/30179898f9994a5637459d6e169b6abdc982012c0a4b2d4c26f50c06f911/librt-0.7.8-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:39a4c76fee41007070f872b648cc2f711f9abf9a13d0c7162478043377b52c8e", size = 187199, upload-time = "2026-01-14T12:55:50.587Z" }, + { url = "https://files.pythonhosted.org/packages/b4/da/f7563db55cebdc884f518ba3791ad033becc25ff68eb70902b1747dc0d70/librt-0.7.8-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ac9c8a458245c7de80bc1b9765b177055efff5803f08e548dd4bb9ab9a8d789b", size = 198317, upload-time = "2026-01-14T12:55:51.991Z" }, + { url = "https://files.pythonhosted.org/packages/b3/6c/4289acf076ad371471fa86718c30ae353e690d3de6167f7db36f429272f1/librt-0.7.8-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:95b67aa7eff150f075fda09d11f6bfb26edffd300f6ab1666759547581e8f666", size = 210334, upload-time = "2026-01-14T12:55:53.682Z" }, + { url = "https://files.pythonhosted.org/packages/4a/7f/377521ac25b78ac0a5ff44127a0360ee6d5ddd3ce7327949876a30533daa/librt-0.7.8-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:535929b6eff670c593c34ff435d5440c3096f20fa72d63444608a5aef64dd581", size = 211031, upload-time = "2026-01-14T12:55:54.827Z" }, + { url = "https://files.pythonhosted.org/packages/c5/b1/e1e96c3e20b23d00cf90f4aad48f0deb4cdfec2f0ed8380d0d85acf98bbf/librt-0.7.8-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:63937bd0f4d1cb56653dc7ae900d6c52c41f0015e25aaf9902481ee79943b33a", size = 204581, upload-time = "2026-01-14T12:55:56.811Z" }, + { url = "https://files.pythonhosted.org/packages/43/71/0f5d010e92ed9747e14bef35e91b6580533510f1e36a8a09eb79ee70b2f0/librt-0.7.8-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cf243da9e42d914036fd362ac3fa77d80a41cadcd11ad789b1b5eec4daaf67ca", size = 224731, upload-time = "2026-01-14T12:55:58.175Z" }, + { url = "https://files.pythonhosted.org/packages/22/f0/07fb6ab5c39a4ca9af3e37554f9d42f25c464829254d72e4ebbd81da351c/librt-0.7.8-cp314-cp314t-win32.whl", hash = "sha256:171ca3a0a06c643bd0a2f62a8944e1902c94aa8e5da4db1ea9a8daf872685365", size = 41173, upload-time = "2026-01-14T12:55:59.315Z" }, + { url = "https://files.pythonhosted.org/packages/24/d4/7e4be20993dc6a782639625bd2f97f3c66125c7aa80c82426956811cfccf/librt-0.7.8-cp314-cp314t-win_amd64.whl", hash = "sha256:445b7304145e24c60288a2f172b5ce2ca35c0f81605f5299f3fa567e189d2e32", size = 47668, upload-time = "2026-01-14T12:56:00.261Z" }, + { url = "https://files.pythonhosted.org/packages/fc/85/69f92b2a7b3c0f88ffe107c86b952b397004b5b8ea5a81da3d9c04c04422/librt-0.7.8-cp314-cp314t-win_arm64.whl", hash = "sha256:8766ece9de08527deabcd7cb1b4f1a967a385d26e33e536d6d8913db6ef74f06", size = 40550, upload-time = "2026-01-14T12:56:01.542Z" }, +] + +[[package]] +name = "mypy" +version = "1.19.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "librt", marker = "platform_python_implementation != 'PyPy'" }, + { name = "mypy-extensions" }, + { name = "pathspec" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f5/db/4efed9504bc01309ab9c2da7e352cc223569f05478012b5d9ece38fd44d2/mypy-1.19.1.tar.gz", hash = "sha256:19d88bb05303fe63f71dd2c6270daca27cb9401c4ca8255fe50d1d920e0eb9ba", size = 3582404, upload-time = "2025-12-15T05:03:48.42Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/47/6b3ebabd5474d9cdc170d1342fbf9dddc1b0ec13ec90bf9004ee6f391c31/mypy-1.19.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:d8dfc6ab58ca7dda47d9237349157500468e404b17213d44fc1cb77bce532288", size = 13028539, upload-time = "2025-12-15T05:03:44.129Z" }, + { url = "https://files.pythonhosted.org/packages/5c/a6/ac7c7a88a3c9c54334f53a941b765e6ec6c4ebd65d3fe8cdcfbe0d0fd7db/mypy-1.19.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e3f276d8493c3c97930e354b2595a44a21348b320d859fb4a2b9f66da9ed27ab", size = 12083163, upload-time = "2025-12-15T05:03:37.679Z" }, + { url = "https://files.pythonhosted.org/packages/67/af/3afa9cf880aa4a2c803798ac24f1d11ef72a0c8079689fac5cfd815e2830/mypy-1.19.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2abb24cf3f17864770d18d673c85235ba52456b36a06b6afc1e07c1fdcd3d0e6", size = 12687629, upload-time = "2025-12-15T05:02:31.526Z" }, + { url = "https://files.pythonhosted.org/packages/2d/46/20f8a7114a56484ab268b0ab372461cb3a8f7deed31ea96b83a4e4cfcfca/mypy-1.19.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a009ffa5a621762d0c926a078c2d639104becab69e79538a494bcccb62cc0331", size = 13436933, upload-time = "2025-12-15T05:03:15.606Z" }, + { url = "https://files.pythonhosted.org/packages/5b/f8/33b291ea85050a21f15da910002460f1f445f8007adb29230f0adea279cb/mypy-1.19.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f7cee03c9a2e2ee26ec07479f38ea9c884e301d42c6d43a19d20fb014e3ba925", size = 13661754, upload-time = "2025-12-15T05:02:26.731Z" }, + { url = "https://files.pythonhosted.org/packages/fd/a3/47cbd4e85bec4335a9cd80cf67dbc02be21b5d4c9c23ad6b95d6c5196bac/mypy-1.19.1-cp311-cp311-win_amd64.whl", hash = "sha256:4b84a7a18f41e167f7995200a1d07a4a6810e89d29859df936f1c3923d263042", size = 10055772, upload-time = "2025-12-15T05:03:26.179Z" }, + { url = "https://files.pythonhosted.org/packages/06/8a/19bfae96f6615aa8a0604915512e0289b1fad33d5909bf7244f02935d33a/mypy-1.19.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a8174a03289288c1f6c46d55cef02379b478bfbc8e358e02047487cad44c6ca1", size = 13206053, upload-time = "2025-12-15T05:03:46.622Z" }, + { url = "https://files.pythonhosted.org/packages/a5/34/3e63879ab041602154ba2a9f99817bb0c85c4df19a23a1443c8986e4d565/mypy-1.19.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ffcebe56eb09ff0c0885e750036a095e23793ba6c2e894e7e63f6d89ad51f22e", size = 12219134, upload-time = "2025-12-15T05:03:24.367Z" }, + { url = "https://files.pythonhosted.org/packages/89/cc/2db6f0e95366b630364e09845672dbee0cbf0bbe753a204b29a944967cd9/mypy-1.19.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b64d987153888790bcdb03a6473d321820597ab8dd9243b27a92153c4fa50fd2", size = 12731616, upload-time = "2025-12-15T05:02:44.725Z" }, + { url = "https://files.pythonhosted.org/packages/00/be/dd56c1fd4807bc1eba1cf18b2a850d0de7bacb55e158755eb79f77c41f8e/mypy-1.19.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c35d298c2c4bba75feb2195655dfea8124d855dfd7343bf8b8c055421eaf0cf8", size = 13620847, upload-time = "2025-12-15T05:03:39.633Z" }, + { url = "https://files.pythonhosted.org/packages/6d/42/332951aae42b79329f743bf1da088cd75d8d4d9acc18fbcbd84f26c1af4e/mypy-1.19.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:34c81968774648ab5ac09c29a375fdede03ba253f8f8287847bd480782f73a6a", size = 13834976, upload-time = "2025-12-15T05:03:08.786Z" }, + { url = "https://files.pythonhosted.org/packages/6f/63/e7493e5f90e1e085c562bb06e2eb32cae27c5057b9653348d38b47daaecc/mypy-1.19.1-cp312-cp312-win_amd64.whl", hash = "sha256:b10e7c2cd7870ba4ad9b2d8a6102eb5ffc1f16ca35e3de6bfa390c1113029d13", size = 10118104, upload-time = "2025-12-15T05:03:10.834Z" }, + { url = "https://files.pythonhosted.org/packages/de/9f/a6abae693f7a0c697dbb435aac52e958dc8da44e92e08ba88d2e42326176/mypy-1.19.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e3157c7594ff2ef1634ee058aafc56a82db665c9438fd41b390f3bde1ab12250", size = 13201927, upload-time = "2025-12-15T05:02:29.138Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a4/45c35ccf6e1c65afc23a069f50e2c66f46bd3798cbe0d680c12d12935caa/mypy-1.19.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bdb12f69bcc02700c2b47e070238f42cb87f18c0bc1fc4cdb4fb2bc5fd7a3b8b", size = 12206730, upload-time = "2025-12-15T05:03:01.325Z" }, + { url = "https://files.pythonhosted.org/packages/05/bb/cdcf89678e26b187650512620eec8368fded4cfd99cfcb431e4cdfd19dec/mypy-1.19.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f859fb09d9583a985be9a493d5cfc5515b56b08f7447759a0c5deaf68d80506e", size = 12724581, upload-time = "2025-12-15T05:03:20.087Z" }, + { url = "https://files.pythonhosted.org/packages/d1/32/dd260d52babf67bad8e6770f8e1102021877ce0edea106e72df5626bb0ec/mypy-1.19.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c9a6538e0415310aad77cb94004ca6482330fece18036b5f360b62c45814c4ef", size = 13616252, upload-time = "2025-12-15T05:02:49.036Z" }, + { url = "https://files.pythonhosted.org/packages/71/d0/5e60a9d2e3bd48432ae2b454b7ef2b62a960ab51292b1eda2a95edd78198/mypy-1.19.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:da4869fc5e7f62a88f3fe0b5c919d1d9f7ea3cef92d3689de2823fd27e40aa75", size = 13840848, upload-time = "2025-12-15T05:02:55.95Z" }, + { url = "https://files.pythonhosted.org/packages/98/76/d32051fa65ecf6cc8c6610956473abdc9b4c43301107476ac03559507843/mypy-1.19.1-cp313-cp313-win_amd64.whl", hash = "sha256:016f2246209095e8eda7538944daa1d60e1e8134d98983b9fc1e92c1fc0cb8dd", size = 10135510, upload-time = "2025-12-15T05:02:58.438Z" }, + { url = "https://files.pythonhosted.org/packages/de/eb/b83e75f4c820c4247a58580ef86fcd35165028f191e7e1ba57128c52782d/mypy-1.19.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:06e6170bd5836770e8104c8fdd58e5e725cfeb309f0a6c681a811f557e97eac1", size = 13199744, upload-time = "2025-12-15T05:03:30.823Z" }, + { url = "https://files.pythonhosted.org/packages/94/28/52785ab7bfa165f87fcbb61547a93f98bb20e7f82f90f165a1f69bce7b3d/mypy-1.19.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:804bd67b8054a85447c8954215a906d6eff9cabeabe493fb6334b24f4bfff718", size = 12215815, upload-time = "2025-12-15T05:02:42.323Z" }, + { url = "https://files.pythonhosted.org/packages/0a/c6/bdd60774a0dbfb05122e3e925f2e9e846c009e479dcec4821dad881f5b52/mypy-1.19.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:21761006a7f497cb0d4de3d8ef4ca70532256688b0523eee02baf9eec895e27b", size = 12740047, upload-time = "2025-12-15T05:03:33.168Z" }, + { url = "https://files.pythonhosted.org/packages/32/2a/66ba933fe6c76bd40d1fe916a83f04fed253152f451a877520b3c4a5e41e/mypy-1.19.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:28902ee51f12e0f19e1e16fbe2f8f06b6637f482c459dd393efddd0ec7f82045", size = 13601998, upload-time = "2025-12-15T05:03:13.056Z" }, + { url = "https://files.pythonhosted.org/packages/e3/da/5055c63e377c5c2418760411fd6a63ee2b96cf95397259038756c042574f/mypy-1.19.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:481daf36a4c443332e2ae9c137dfee878fcea781a2e3f895d54bd3002a900957", size = 13807476, upload-time = "2025-12-15T05:03:17.977Z" }, + { url = "https://files.pythonhosted.org/packages/cd/09/4ebd873390a063176f06b0dbf1f7783dd87bd120eae7727fa4ae4179b685/mypy-1.19.1-cp314-cp314-win_amd64.whl", hash = "sha256:8bb5c6f6d043655e055be9b542aa5f3bdd30e4f3589163e85f93f3640060509f", size = 10281872, upload-time = "2025-12-15T05:03:05.549Z" }, + { url = "https://files.pythonhosted.org/packages/8d/f4/4ce9a05ce5ded1de3ec1c1d96cf9f9504a04e54ce0ed55cfa38619a32b8d/mypy-1.19.1-py3-none-any.whl", hash = "sha256:f1235f5ea01b7db5468d53ece6aaddf1ad0b88d9e7462b86ef96fe04995d7247", size = 2471239, upload-time = "2025-12-15T05:03:07.248Z" }, +] + +[[package]] +name = "mypy-extensions" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, +] + +[[package]] +name = "nodeenv" +version = "1.10.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/24/bf/d1bda4f6168e0b2e9e5958945e01910052158313224ada5ce1fb2e1113b8/nodeenv-1.10.0.tar.gz", hash = "sha256:996c191ad80897d076bdfba80a41994c2b47c68e224c542b48feba42ba00f8bb", size = 55611, upload-time = "2025-12-20T14:08:54.006Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/b2/d0896bdcdc8d28a7fc5717c305f1a861c26e18c05047949fb371034d98bd/nodeenv-1.10.0-py2.py3-none-any.whl", hash = "sha256:5bb13e3eed2923615535339b3c620e76779af4cb4c6a90deccc9e36b274d3827", size = 23438, upload-time = "2025-12-20T14:08:52.782Z" }, +] + +[[package]] +name = "numpy" +version = "2.4.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/57/fd/0005efbd0af48e55eb3c7208af93f2862d4b1a56cd78e84309a2d959208d/numpy-2.4.2.tar.gz", hash = "sha256:659a6107e31a83c4e33f763942275fd278b21d095094044eb35569e86a21ddae", size = 20723651, upload-time = "2026-01-31T23:13:10.135Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d3/44/71852273146957899753e69986246d6a176061ea183407e95418c2aa4d9a/numpy-2.4.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:e7e88598032542bd49af7c4747541422884219056c268823ef6e5e89851c8825", size = 16955478, upload-time = "2026-01-31T23:10:25.623Z" }, + { url = "https://files.pythonhosted.org/packages/74/41/5d17d4058bd0cd96bcbd4d9ff0fb2e21f52702aab9a72e4a594efa18692f/numpy-2.4.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7edc794af8b36ca37ef5fcb5e0d128c7e0595c7b96a2318d1badb6fcd8ee86b1", size = 14965467, upload-time = "2026-01-31T23:10:28.186Z" }, + { url = "https://files.pythonhosted.org/packages/49/48/fb1ce8136c19452ed15f033f8aee91d5defe515094e330ce368a0647846f/numpy-2.4.2-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:6e9f61981ace1360e42737e2bae58b27bf28a1b27e781721047d84bd754d32e7", size = 5475172, upload-time = "2026-01-31T23:10:30.848Z" }, + { url = "https://files.pythonhosted.org/packages/40/a9/3feb49f17bbd1300dd2570432961f5c8a4ffeff1db6f02c7273bd020a4c9/numpy-2.4.2-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:cb7bbb88aa74908950d979eeaa24dbdf1a865e3c7e45ff0121d8f70387b55f73", size = 6805145, upload-time = "2026-01-31T23:10:32.352Z" }, + { url = "https://files.pythonhosted.org/packages/3f/39/fdf35cbd6d6e2fcad42fcf85ac04a85a0d0fbfbf34b30721c98d602fd70a/numpy-2.4.2-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4f069069931240b3fc703f1e23df63443dbd6390614c8c44a87d96cd0ec81eb1", size = 15966084, upload-time = "2026-01-31T23:10:34.502Z" }, + { url = "https://files.pythonhosted.org/packages/1b/46/6fa4ea94f1ddf969b2ee941290cca6f1bfac92b53c76ae5f44afe17ceb69/numpy-2.4.2-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c02ef4401a506fb60b411467ad501e1429a3487abca4664871d9ae0b46c8ba32", size = 16899477, upload-time = "2026-01-31T23:10:37.075Z" }, + { url = "https://files.pythonhosted.org/packages/09/a1/2a424e162b1a14a5bd860a464ab4e07513916a64ab1683fae262f735ccd2/numpy-2.4.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:2653de5c24910e49c2b106499803124dde62a5a1fe0eedeaecf4309a5f639390", size = 17323429, upload-time = "2026-01-31T23:10:39.704Z" }, + { url = "https://files.pythonhosted.org/packages/ce/a2/73014149ff250628df72c58204822ac01d768697913881aacf839ff78680/numpy-2.4.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1ae241bbfc6ae276f94a170b14785e561cb5e7f626b6688cf076af4110887413", size = 18635109, upload-time = "2026-01-31T23:10:41.924Z" }, + { url = "https://files.pythonhosted.org/packages/6c/0c/73e8be2f1accd56df74abc1c5e18527822067dced5ec0861b5bb882c2ce0/numpy-2.4.2-cp311-cp311-win32.whl", hash = "sha256:df1b10187212b198dd45fa943d8985a3c8cf854aed4923796e0e019e113a1bda", size = 6237915, upload-time = "2026-01-31T23:10:45.26Z" }, + { url = "https://files.pythonhosted.org/packages/76/ae/e0265e0163cf127c24c3969d29f1c4c64551a1e375d95a13d32eab25d364/numpy-2.4.2-cp311-cp311-win_amd64.whl", hash = "sha256:b9c618d56a29c9cb1c4da979e9899be7578d2e0b3c24d52079c166324c9e8695", size = 12607972, upload-time = "2026-01-31T23:10:47.021Z" }, + { url = "https://files.pythonhosted.org/packages/29/a5/c43029af9b8014d6ea157f192652c50042e8911f4300f8f6ed3336bf437f/numpy-2.4.2-cp311-cp311-win_arm64.whl", hash = "sha256:47c5a6ed21d9452b10227e5e8a0e1c22979811cad7dcc19d8e3e2fb8fa03f1a3", size = 10485763, upload-time = "2026-01-31T23:10:50.087Z" }, + { url = "https://files.pythonhosted.org/packages/51/6e/6f394c9c77668153e14d4da83bcc247beb5952f6ead7699a1a2992613bea/numpy-2.4.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:21982668592194c609de53ba4933a7471880ccbaadcc52352694a59ecc860b3a", size = 16667963, upload-time = "2026-01-31T23:10:52.147Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f8/55483431f2b2fd015ae6ed4fe62288823ce908437ed49db5a03d15151678/numpy-2.4.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40397bda92382fcec844066efb11f13e1c9a3e2a8e8f318fb72ed8b6db9f60f1", size = 14693571, upload-time = "2026-01-31T23:10:54.789Z" }, + { url = "https://files.pythonhosted.org/packages/2f/20/18026832b1845cdc82248208dd929ca14c9d8f2bac391f67440707fff27c/numpy-2.4.2-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:b3a24467af63c67829bfaa61eecf18d5432d4f11992688537be59ecd6ad32f5e", size = 5203469, upload-time = "2026-01-31T23:10:57.343Z" }, + { url = "https://files.pythonhosted.org/packages/7d/33/2eb97c8a77daaba34eaa3fa7241a14ac5f51c46a6bd5911361b644c4a1e2/numpy-2.4.2-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:805cc8de9fd6e7a22da5aed858e0ab16be5a4db6c873dde1d7451c541553aa27", size = 6550820, upload-time = "2026-01-31T23:10:59.429Z" }, + { url = "https://files.pythonhosted.org/packages/b1/91/b97fdfd12dc75b02c44e26c6638241cc004d4079a0321a69c62f51470c4c/numpy-2.4.2-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6d82351358ffbcdcd7b686b90742a9b86632d6c1c051016484fa0b326a0a1548", size = 15663067, upload-time = "2026-01-31T23:11:01.291Z" }, + { url = "https://files.pythonhosted.org/packages/f5/c6/a18e59f3f0b8071cc85cbc8d80cd02d68aa9710170b2553a117203d46936/numpy-2.4.2-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9e35d3e0144137d9fdae62912e869136164534d64a169f86438bc9561b6ad49f", size = 16619782, upload-time = "2026-01-31T23:11:03.669Z" }, + { url = "https://files.pythonhosted.org/packages/b7/83/9751502164601a79e18847309f5ceec0b1446d7b6aa12305759b72cf98b2/numpy-2.4.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:adb6ed2ad29b9e15321d167d152ee909ec73395901b70936f029c3bc6d7f4460", size = 17013128, upload-time = "2026-01-31T23:11:05.913Z" }, + { url = "https://files.pythonhosted.org/packages/61/c4/c4066322256ec740acc1c8923a10047818691d2f8aec254798f3dd90f5f2/numpy-2.4.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:8906e71fd8afcb76580404e2a950caef2685df3d2a57fe82a86ac8d33cc007ba", size = 18345324, upload-time = "2026-01-31T23:11:08.248Z" }, + { url = "https://files.pythonhosted.org/packages/ab/af/6157aa6da728fa4525a755bfad486ae7e3f76d4c1864138003eb84328497/numpy-2.4.2-cp312-cp312-win32.whl", hash = "sha256:ec055f6dae239a6299cace477b479cca2fc125c5675482daf1dd886933a1076f", size = 5960282, upload-time = "2026-01-31T23:11:10.497Z" }, + { url = "https://files.pythonhosted.org/packages/92/0f/7ceaaeaacb40567071e94dbf2c9480c0ae453d5bb4f52bea3892c39dc83c/numpy-2.4.2-cp312-cp312-win_amd64.whl", hash = "sha256:209fae046e62d0ce6435fcfe3b1a10537e858249b3d9b05829e2a05218296a85", size = 12314210, upload-time = "2026-01-31T23:11:12.176Z" }, + { url = "https://files.pythonhosted.org/packages/2f/a3/56c5c604fae6dd40fa2ed3040d005fca97e91bd320d232ac9931d77ba13c/numpy-2.4.2-cp312-cp312-win_arm64.whl", hash = "sha256:fbde1b0c6e81d56f5dccd95dd4a711d9b95df1ae4009a60887e56b27e8d903fa", size = 10220171, upload-time = "2026-01-31T23:11:14.684Z" }, + { url = "https://files.pythonhosted.org/packages/a1/22/815b9fe25d1d7ae7d492152adbc7226d3eff731dffc38fe970589fcaaa38/numpy-2.4.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:25f2059807faea4b077a2b6837391b5d830864b3543627f381821c646f31a63c", size = 16663696, upload-time = "2026-01-31T23:11:17.516Z" }, + { url = "https://files.pythonhosted.org/packages/09/f0/817d03a03f93ba9c6c8993de509277d84e69f9453601915e4a69554102a1/numpy-2.4.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bd3a7a9f5847d2fb8c2c6d1c862fa109c31a9abeca1a3c2bd5a64572955b2979", size = 14688322, upload-time = "2026-01-31T23:11:19.883Z" }, + { url = "https://files.pythonhosted.org/packages/da/b4/f805ab79293c728b9a99438775ce51885fd4f31b76178767cfc718701a39/numpy-2.4.2-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:8e4549f8a3c6d13d55041925e912bfd834285ef1dd64d6bc7d542583355e2e98", size = 5198157, upload-time = "2026-01-31T23:11:22.375Z" }, + { url = "https://files.pythonhosted.org/packages/74/09/826e4289844eccdcd64aac27d13b0fd3f32039915dd5b9ba01baae1f436c/numpy-2.4.2-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:aea4f66ff44dfddf8c2cffd66ba6538c5ec67d389285292fe428cb2c738c8aef", size = 6546330, upload-time = "2026-01-31T23:11:23.958Z" }, + { url = "https://files.pythonhosted.org/packages/19/fb/cbfdbfa3057a10aea5422c558ac57538e6acc87ec1669e666d32ac198da7/numpy-2.4.2-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c3cd545784805de05aafe1dde61752ea49a359ccba9760c1e5d1c88a93bbf2b7", size = 15660968, upload-time = "2026-01-31T23:11:25.713Z" }, + { url = "https://files.pythonhosted.org/packages/04/dc/46066ce18d01645541f0186877377b9371b8fa8017fa8262002b4ef22612/numpy-2.4.2-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d0d9b7c93578baafcbc5f0b83eaf17b79d345c6f36917ba0c67f45226911d499", size = 16607311, upload-time = "2026-01-31T23:11:28.117Z" }, + { url = "https://files.pythonhosted.org/packages/14/d9/4b5adfc39a43fa6bf918c6d544bc60c05236cc2f6339847fc5b35e6cb5b0/numpy-2.4.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f74f0f7779cc7ae07d1810aab8ac6b1464c3eafb9e283a40da7309d5e6e48fbb", size = 17012850, upload-time = "2026-01-31T23:11:30.888Z" }, + { url = "https://files.pythonhosted.org/packages/b7/20/adb6e6adde6d0130046e6fdfb7675cc62bc2f6b7b02239a09eb58435753d/numpy-2.4.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:c7ac672d699bf36275c035e16b65539931347d68b70667d28984c9fb34e07fa7", size = 18334210, upload-time = "2026-01-31T23:11:33.214Z" }, + { url = "https://files.pythonhosted.org/packages/78/0e/0a73b3dff26803a8c02baa76398015ea2a5434d9b8265a7898a6028c1591/numpy-2.4.2-cp313-cp313-win32.whl", hash = "sha256:8e9afaeb0beff068b4d9cd20d322ba0ee1cecfb0b08db145e4ab4dd44a6b5110", size = 5958199, upload-time = "2026-01-31T23:11:35.385Z" }, + { url = "https://files.pythonhosted.org/packages/43/bc/6352f343522fcb2c04dbaf94cb30cca6fd32c1a750c06ad6231b4293708c/numpy-2.4.2-cp313-cp313-win_amd64.whl", hash = "sha256:7df2de1e4fba69a51c06c28f5a3de36731eb9639feb8e1cf7e4a7b0daf4cf622", size = 12310848, upload-time = "2026-01-31T23:11:38.001Z" }, + { url = "https://files.pythonhosted.org/packages/6e/8d/6da186483e308da5da1cc6918ce913dcfe14ffde98e710bfeff2a6158d4e/numpy-2.4.2-cp313-cp313-win_arm64.whl", hash = "sha256:0fece1d1f0a89c16b03442eae5c56dc0be0c7883b5d388e0c03f53019a4bfd71", size = 10221082, upload-time = "2026-01-31T23:11:40.392Z" }, + { url = "https://files.pythonhosted.org/packages/25/a1/9510aa43555b44781968935c7548a8926274f815de42ad3997e9e83680dd/numpy-2.4.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:5633c0da313330fd20c484c78cdd3f9b175b55e1a766c4a174230c6b70ad8262", size = 14815866, upload-time = "2026-01-31T23:11:42.495Z" }, + { url = "https://files.pythonhosted.org/packages/36/30/6bbb5e76631a5ae46e7923dd16ca9d3f1c93cfa8d4ed79a129814a9d8db3/numpy-2.4.2-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:d9f64d786b3b1dd742c946c42d15b07497ed14af1a1f3ce840cce27daa0ce913", size = 5325631, upload-time = "2026-01-31T23:11:44.7Z" }, + { url = "https://files.pythonhosted.org/packages/46/00/3a490938800c1923b567b3a15cd17896e68052e2145d8662aaf3e1ffc58f/numpy-2.4.2-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:b21041e8cb6a1eb5312dd1d2f80a94d91efffb7a06b70597d44f1bd2dfc315ab", size = 6646254, upload-time = "2026-01-31T23:11:46.341Z" }, + { url = "https://files.pythonhosted.org/packages/d3/e9/fac0890149898a9b609caa5af7455a948b544746e4b8fe7c212c8edd71f8/numpy-2.4.2-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:00ab83c56211a1d7c07c25e3217ea6695e50a3e2f255053686b081dc0b091a82", size = 15720138, upload-time = "2026-01-31T23:11:48.082Z" }, + { url = "https://files.pythonhosted.org/packages/ea/5c/08887c54e68e1e28df53709f1893ce92932cc6f01f7c3d4dc952f61ffd4e/numpy-2.4.2-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2fb882da679409066b4603579619341c6d6898fc83a8995199d5249f986e8e8f", size = 16655398, upload-time = "2026-01-31T23:11:50.293Z" }, + { url = "https://files.pythonhosted.org/packages/4d/89/253db0fa0e66e9129c745e4ef25631dc37d5f1314dad2b53e907b8538e6d/numpy-2.4.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:66cb9422236317f9d44b67b4d18f44efe6e9c7f8794ac0462978513359461554", size = 17079064, upload-time = "2026-01-31T23:11:52.927Z" }, + { url = "https://files.pythonhosted.org/packages/2a/d5/cbade46ce97c59c6c3da525e8d95b7abe8a42974a1dc5c1d489c10433e88/numpy-2.4.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:0f01dcf33e73d80bd8dc0f20a71303abbafa26a19e23f6b68d1aa9990af90257", size = 18379680, upload-time = "2026-01-31T23:11:55.22Z" }, + { url = "https://files.pythonhosted.org/packages/40/62/48f99ae172a4b63d981babe683685030e8a3df4f246c893ea5c6ef99f018/numpy-2.4.2-cp313-cp313t-win32.whl", hash = "sha256:52b913ec40ff7ae845687b0b34d8d93b60cb66dcee06996dd5c99f2fc9328657", size = 6082433, upload-time = "2026-01-31T23:11:58.096Z" }, + { url = "https://files.pythonhosted.org/packages/07/38/e054a61cfe48ad9f1ed0d188e78b7e26859d0b60ef21cd9de4897cdb5326/numpy-2.4.2-cp313-cp313t-win_amd64.whl", hash = "sha256:5eea80d908b2c1f91486eb95b3fb6fab187e569ec9752ab7d9333d2e66bf2d6b", size = 12451181, upload-time = "2026-01-31T23:11:59.782Z" }, + { url = "https://files.pythonhosted.org/packages/6e/a4/a05c3a6418575e185dd84d0b9680b6bb2e2dc3e4202f036b7b4e22d6e9dc/numpy-2.4.2-cp313-cp313t-win_arm64.whl", hash = "sha256:fd49860271d52127d61197bb50b64f58454e9f578cb4b2c001a6de8b1f50b0b1", size = 10290756, upload-time = "2026-01-31T23:12:02.438Z" }, + { url = "https://files.pythonhosted.org/packages/18/88/b7df6050bf18fdcfb7046286c6535cabbdd2064a3440fca3f069d319c16e/numpy-2.4.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:444be170853f1f9d528428eceb55f12918e4fda5d8805480f36a002f1415e09b", size = 16663092, upload-time = "2026-01-31T23:12:04.521Z" }, + { url = "https://files.pythonhosted.org/packages/25/7a/1fee4329abc705a469a4afe6e69b1ef7e915117747886327104a8493a955/numpy-2.4.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:d1240d50adff70c2a88217698ca844723068533f3f5c5fa6ee2e3220e3bdb000", size = 14698770, upload-time = "2026-01-31T23:12:06.96Z" }, + { url = "https://files.pythonhosted.org/packages/fb/0b/f9e49ba6c923678ad5bc38181c08ac5e53b7a5754dbca8e581aa1a56b1ff/numpy-2.4.2-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:7cdde6de52fb6664b00b056341265441192d1291c130e99183ec0d4b110ff8b1", size = 5208562, upload-time = "2026-01-31T23:12:09.632Z" }, + { url = "https://files.pythonhosted.org/packages/7d/12/d7de8f6f53f9bb76997e5e4c069eda2051e3fe134e9181671c4391677bb2/numpy-2.4.2-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:cda077c2e5b780200b6b3e09d0b42205a3d1c68f30c6dceb90401c13bff8fe74", size = 6543710, upload-time = "2026-01-31T23:12:11.969Z" }, + { url = "https://files.pythonhosted.org/packages/09/63/c66418c2e0268a31a4cf8a8b512685748200f8e8e8ec6c507ce14e773529/numpy-2.4.2-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d30291931c915b2ab5717c2974bb95ee891a1cf22ebc16a8006bd59cd210d40a", size = 15677205, upload-time = "2026-01-31T23:12:14.33Z" }, + { url = "https://files.pythonhosted.org/packages/5d/6c/7f237821c9642fb2a04d2f1e88b4295677144ca93285fd76eff3bcba858d/numpy-2.4.2-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bba37bc29d4d85761deed3954a1bc62be7cf462b9510b51d367b769a8c8df325", size = 16611738, upload-time = "2026-01-31T23:12:16.525Z" }, + { url = "https://files.pythonhosted.org/packages/c2/a7/39c4cdda9f019b609b5c473899d87abff092fc908cfe4d1ecb2fcff453b0/numpy-2.4.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b2f0073ed0868db1dcd86e052d37279eef185b9c8db5bf61f30f46adac63c909", size = 17028888, upload-time = "2026-01-31T23:12:19.306Z" }, + { url = "https://files.pythonhosted.org/packages/da/b3/e84bb64bdfea967cc10950d71090ec2d84b49bc691df0025dddb7c26e8e3/numpy-2.4.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7f54844851cdb630ceb623dcec4db3240d1ac13d4990532446761baede94996a", size = 18339556, upload-time = "2026-01-31T23:12:21.816Z" }, + { url = "https://files.pythonhosted.org/packages/88/f5/954a291bc1192a27081706862ac62bb5920fbecfbaa302f64682aa90beed/numpy-2.4.2-cp314-cp314-win32.whl", hash = "sha256:12e26134a0331d8dbd9351620f037ec470b7c75929cb8a1537f6bfe411152a1a", size = 6006899, upload-time = "2026-01-31T23:12:24.14Z" }, + { url = "https://files.pythonhosted.org/packages/05/cb/eff72a91b2efdd1bc98b3b8759f6a1654aa87612fc86e3d87d6fe4f948c4/numpy-2.4.2-cp314-cp314-win_amd64.whl", hash = "sha256:068cdb2d0d644cdb45670810894f6a0600797a69c05f1ac478e8d31670b8ee75", size = 12443072, upload-time = "2026-01-31T23:12:26.33Z" }, + { url = "https://files.pythonhosted.org/packages/37/75/62726948db36a56428fce4ba80a115716dc4fad6a3a4352487f8bb950966/numpy-2.4.2-cp314-cp314-win_arm64.whl", hash = "sha256:6ed0be1ee58eef41231a5c943d7d1375f093142702d5723ca2eb07db9b934b05", size = 10494886, upload-time = "2026-01-31T23:12:28.488Z" }, + { url = "https://files.pythonhosted.org/packages/36/2f/ee93744f1e0661dc267e4b21940870cabfae187c092e1433b77b09b50ac4/numpy-2.4.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:98f16a80e917003a12c0580f97b5f875853ebc33e2eaa4bccfc8201ac6869308", size = 14818567, upload-time = "2026-01-31T23:12:30.709Z" }, + { url = "https://files.pythonhosted.org/packages/a7/24/6535212add7d76ff938d8bdc654f53f88d35cddedf807a599e180dcb8e66/numpy-2.4.2-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:20abd069b9cda45874498b245c8015b18ace6de8546bf50dfa8cea1696ed06ef", size = 5328372, upload-time = "2026-01-31T23:12:32.962Z" }, + { url = "https://files.pythonhosted.org/packages/5e/9d/c48f0a035725f925634bf6b8994253b43f2047f6778a54147d7e213bc5a7/numpy-2.4.2-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:e98c97502435b53741540a5717a6749ac2ada901056c7db951d33e11c885cc7d", size = 6649306, upload-time = "2026-01-31T23:12:34.797Z" }, + { url = "https://files.pythonhosted.org/packages/81/05/7c73a9574cd4a53a25907bad38b59ac83919c0ddc8234ec157f344d57d9a/numpy-2.4.2-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:da6cad4e82cb893db4b69105c604d805e0c3ce11501a55b5e9f9083b47d2ffe8", size = 15722394, upload-time = "2026-01-31T23:12:36.565Z" }, + { url = "https://files.pythonhosted.org/packages/35/fa/4de10089f21fc7d18442c4a767ab156b25c2a6eaf187c0db6d9ecdaeb43f/numpy-2.4.2-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9e4424677ce4b47fe73c8b5556d876571f7c6945d264201180db2dc34f676ab5", size = 16653343, upload-time = "2026-01-31T23:12:39.188Z" }, + { url = "https://files.pythonhosted.org/packages/b8/f9/d33e4ffc857f3763a57aa85650f2e82486832d7492280ac21ba9efda80da/numpy-2.4.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:2b8f157c8a6f20eb657e240f8985cc135598b2b46985c5bccbde7616dc9c6b1e", size = 17078045, upload-time = "2026-01-31T23:12:42.041Z" }, + { url = "https://files.pythonhosted.org/packages/c8/b8/54bdb43b6225badbea6389fa038c4ef868c44f5890f95dd530a218706da3/numpy-2.4.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5daf6f3914a733336dab21a05cdec343144600e964d2fcdabaac0c0269874b2a", size = 18380024, upload-time = "2026-01-31T23:12:44.331Z" }, + { url = "https://files.pythonhosted.org/packages/a5/55/6e1a61ded7af8df04016d81b5b02daa59f2ea9252ee0397cb9f631efe9e5/numpy-2.4.2-cp314-cp314t-win32.whl", hash = "sha256:8c50dd1fc8826f5b26a5ee4d77ca55d88a895f4e4819c7ecc2a9f5905047a443", size = 6153937, upload-time = "2026-01-31T23:12:47.229Z" }, + { url = "https://files.pythonhosted.org/packages/45/aa/fa6118d1ed6d776b0983f3ceac9b1a5558e80df9365b1c3aa6d42bf9eee4/numpy-2.4.2-cp314-cp314t-win_amd64.whl", hash = "sha256:fcf92bee92742edd401ba41135185866f7026c502617f422eb432cfeca4fe236", size = 12631844, upload-time = "2026-01-31T23:12:48.997Z" }, + { url = "https://files.pythonhosted.org/packages/32/0a/2ec5deea6dcd158f254a7b372fb09cfba5719419c8d66343bab35237b3fb/numpy-2.4.2-cp314-cp314t-win_arm64.whl", hash = "sha256:1f92f53998a17265194018d1cc321b2e96e900ca52d54c7c77837b71b9465181", size = 10565379, upload-time = "2026-01-31T23:12:51.345Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f8/50e14d36d915ef64d8f8bc4a087fc8264d82c785eda6711f80ab7e620335/numpy-2.4.2-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:89f7268c009bc492f506abd6f5265defa7cb3f7487dc21d357c3d290add45082", size = 16833179, upload-time = "2026-01-31T23:12:53.5Z" }, + { url = "https://files.pythonhosted.org/packages/17/17/809b5cad63812058a8189e91a1e2d55a5a18fd04611dbad244e8aeae465c/numpy-2.4.2-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:e6dee3bb76aa4009d5a912180bf5b2de012532998d094acee25d9cb8dee3e44a", size = 14889755, upload-time = "2026-01-31T23:12:55.933Z" }, + { url = "https://files.pythonhosted.org/packages/3e/ea/181b9bcf7627fc8371720316c24db888dcb9829b1c0270abf3d288b2e29b/numpy-2.4.2-pp311-pypy311_pp73-macosx_14_0_arm64.whl", hash = "sha256:cd2bd2bbed13e213d6b55dc1d035a4f91748a7d3edc9480c13898b0353708920", size = 5399500, upload-time = "2026-01-31T23:12:58.671Z" }, + { url = "https://files.pythonhosted.org/packages/33/9f/413adf3fc955541ff5536b78fcf0754680b3c6d95103230252a2c9408d23/numpy-2.4.2-pp311-pypy311_pp73-macosx_14_0_x86_64.whl", hash = "sha256:cf28c0c1d4c4bf00f509fa7eb02c58d7caf221b50b467bcb0d9bbf1584d5c821", size = 6714252, upload-time = "2026-01-31T23:13:00.518Z" }, + { url = "https://files.pythonhosted.org/packages/91/da/643aad274e29ccbdf42ecd94dafe524b81c87bcb56b83872d54827f10543/numpy-2.4.2-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e04ae107ac591763a47398bb45b568fc38f02dbc4aa44c063f67a131f99346cb", size = 15797142, upload-time = "2026-01-31T23:13:02.219Z" }, + { url = "https://files.pythonhosted.org/packages/66/27/965b8525e9cb5dc16481b30a1b3c21e50c7ebf6e9dbd48d0c4d0d5089c7e/numpy-2.4.2-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:602f65afdef699cda27ec0b9224ae5dc43e328f4c24c689deaf77133dbee74d0", size = 16727979, upload-time = "2026-01-31T23:13:04.62Z" }, + { url = "https://files.pythonhosted.org/packages/de/e5/b7d20451657664b07986c2f6e3be564433f5dcaf3482d68eaecd79afaf03/numpy-2.4.2-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:be71bf1edb48ebbbf7f6337b5bfd2f895d1902f6335a5830b20141fc126ffba0", size = 12502577, upload-time = "2026-01-31T23:13:07.08Z" }, +] + +[[package]] +name = "orjson" +version = "3.11.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/53/45/b268004f745ede84e5798b48ee12b05129d19235d0e15267aa57dcdb400b/orjson-3.11.7.tar.gz", hash = "sha256:9b1a67243945819ce55d24a30b59d6a168e86220452d2c96f4d1f093e71c0c49", size = 6144992, upload-time = "2026-02-02T15:38:49.29Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/37/02/da6cb01fc6087048d7f61522c327edf4250f1683a58a839fdcc435746dd5/orjson-3.11.7-cp311-cp311-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:9487abc2c2086e7c8eb9a211d2ce8855bae0e92586279d0d27b341d5ad76c85c", size = 228664, upload-time = "2026-02-02T15:37:25.542Z" }, + { url = "https://files.pythonhosted.org/packages/c1/c2/5885e7a5881dba9a9af51bc564e8967225a642b3e03d089289a35054e749/orjson-3.11.7-cp311-cp311-macosx_15_0_arm64.whl", hash = "sha256:79cacb0b52f6004caf92405a7e1f11e6e2de8bdf9019e4f76b44ba045125cd6b", size = 125344, upload-time = "2026-02-02T15:37:26.92Z" }, + { url = "https://files.pythonhosted.org/packages/a4/1d/4e7688de0a92d1caf600dfd5fb70b4c5bfff51dfa61ac555072ef2d0d32a/orjson-3.11.7-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c2e85fe4698b6a56d5e2ebf7ae87544d668eb6bde1ad1226c13f44663f20ec9e", size = 128404, upload-time = "2026-02-02T15:37:28.108Z" }, + { url = "https://files.pythonhosted.org/packages/2f/b2/ec04b74ae03a125db7bd69cffd014b227b7f341e3261bf75b5eb88a1aa92/orjson-3.11.7-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b8d14b71c0b12963fe8a62aac87119f1afdf4cb88a400f61ca5ae581449efcb5", size = 123677, upload-time = "2026-02-02T15:37:30.287Z" }, + { url = "https://files.pythonhosted.org/packages/4c/69/f95bdf960605f08f827f6e3291fe243d8aa9c5c9ff017a8d7232209184c3/orjson-3.11.7-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:91c81ef070c8f3220054115e1ef468b1c9ce8497b4e526cb9f68ab4dc0a7ac62", size = 128950, upload-time = "2026-02-02T15:37:31.595Z" }, + { url = "https://files.pythonhosted.org/packages/a4/1b/de59c57bae1d148ef298852abd31909ac3089cff370dfd4cd84cc99cbc42/orjson-3.11.7-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:411ebaf34d735e25e358a6d9e7978954a9c9d58cfb47bc6683cdc3964cd2f910", size = 141756, upload-time = "2026-02-02T15:37:32.985Z" }, + { url = "https://files.pythonhosted.org/packages/ee/9e/9decc59f4499f695f65c650f6cfa6cd4c37a3fbe8fa235a0a3614cb54386/orjson-3.11.7-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a16bcd08ab0bcdfc7e8801d9c4a9cc17e58418e4d48ddc6ded4e9e4b1a94062b", size = 130812, upload-time = "2026-02-02T15:37:34.204Z" }, + { url = "https://files.pythonhosted.org/packages/28/e6/59f932bcabd1eac44e334fe8e3281a92eacfcb450586e1f4bde0423728d8/orjson-3.11.7-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9c0b51672e466fd7e56230ffbae7f1639e18d0ce023351fb75da21b71bc2c960", size = 133444, upload-time = "2026-02-02T15:37:35.446Z" }, + { url = "https://files.pythonhosted.org/packages/f1/36/b0f05c0eaa7ca30bc965e37e6a2956b0d67adb87a9872942d3568da846ae/orjson-3.11.7-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:136dcd6a2e796dfd9ffca9fc027d778567b0b7c9968d092842d3c323cef88aa8", size = 138609, upload-time = "2026-02-02T15:37:36.657Z" }, + { url = "https://files.pythonhosted.org/packages/b8/03/58ec7d302b8d86944c60c7b4b82975d5161fcce4c9bc8c6cb1d6741b6115/orjson-3.11.7-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:7ba61079379b0ae29e117db13bda5f28d939766e410d321ec1624afc6a0b0504", size = 408918, upload-time = "2026-02-02T15:37:38.076Z" }, + { url = "https://files.pythonhosted.org/packages/06/3a/868d65ef9a8b99be723bd510de491349618abd9f62c826cf206d962db295/orjson-3.11.7-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:0527a4510c300e3b406591b0ba69b5dc50031895b0a93743526a3fc45f59d26e", size = 143998, upload-time = "2026-02-02T15:37:39.706Z" }, + { url = "https://files.pythonhosted.org/packages/5b/c7/1e18e1c83afe3349f4f6dc9e14910f0ae5f82eac756d1412ea4018938535/orjson-3.11.7-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:a709e881723c9b18acddcfb8ba357322491ad553e277cf467e1e7e20e2d90561", size = 134802, upload-time = "2026-02-02T15:37:41.002Z" }, + { url = "https://files.pythonhosted.org/packages/d4/0b/ccb7ee1a65b37e8eeb8b267dc953561d72370e85185e459616d4345bab34/orjson-3.11.7-cp311-cp311-win32.whl", hash = "sha256:c43b8b5bab288b6b90dac410cca7e986a4fa747a2e8f94615aea407da706980d", size = 127828, upload-time = "2026-02-02T15:37:42.241Z" }, + { url = "https://files.pythonhosted.org/packages/af/9e/55c776dffda3f381e0f07d010a4f5f3902bf48eaba1bb7684d301acd4924/orjson-3.11.7-cp311-cp311-win_amd64.whl", hash = "sha256:6543001328aa857187f905308a028935864aefe9968af3848401b6fe80dbb471", size = 124941, upload-time = "2026-02-02T15:37:43.444Z" }, + { url = "https://files.pythonhosted.org/packages/aa/8e/424a620fa7d263b880162505fb107ef5e0afaa765b5b06a88312ac291560/orjson-3.11.7-cp311-cp311-win_arm64.whl", hash = "sha256:1ee5cc7160a821dfe14f130bc8e63e7611051f964b463d9e2a3a573204446a4d", size = 126245, upload-time = "2026-02-02T15:37:45.18Z" }, + { url = "https://files.pythonhosted.org/packages/80/bf/76f4f1665f6983385938f0e2a5d7efa12a58171b8456c252f3bae8a4cf75/orjson-3.11.7-cp312-cp312-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:bd03ea7606833655048dab1a00734a2875e3e86c276e1d772b2a02556f0d895f", size = 228545, upload-time = "2026-02-02T15:37:46.376Z" }, + { url = "https://files.pythonhosted.org/packages/79/53/6c72c002cb13b5a978a068add59b25a8bdf2800ac1c9c8ecdb26d6d97064/orjson-3.11.7-cp312-cp312-macosx_15_0_arm64.whl", hash = "sha256:89e440ebc74ce8ab5c7bc4ce6757b4a6b1041becb127df818f6997b5c71aa60b", size = 125224, upload-time = "2026-02-02T15:37:47.697Z" }, + { url = "https://files.pythonhosted.org/packages/2c/83/10e48852865e5dd151bdfe652c06f7da484578ed02c5fca938e3632cb0b8/orjson-3.11.7-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5ede977b5fe5ac91b1dffc0a517ca4542d2ec8a6a4ff7b2652d94f640796342a", size = 128154, upload-time = "2026-02-02T15:37:48.954Z" }, + { url = "https://files.pythonhosted.org/packages/6e/52/a66e22a2b9abaa374b4a081d410edab6d1e30024707b87eab7c734afe28d/orjson-3.11.7-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b7b1dae39230a393df353827c855a5f176271c23434cfd2db74e0e424e693e10", size = 123548, upload-time = "2026-02-02T15:37:50.187Z" }, + { url = "https://files.pythonhosted.org/packages/de/38/605d371417021359f4910c496f764c48ceb8997605f8c25bf1dfe58c0ebe/orjson-3.11.7-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ed46f17096e28fb28d2975834836a639af7278aa87c84f68ab08fbe5b8bd75fa", size = 129000, upload-time = "2026-02-02T15:37:51.426Z" }, + { url = "https://files.pythonhosted.org/packages/44/98/af32e842b0ffd2335c89714d48ca4e3917b42f5d6ee5537832e069a4b3ac/orjson-3.11.7-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3726be79e36e526e3d9c1aceaadbfb4a04ee80a72ab47b3f3c17fefb9812e7b8", size = 141686, upload-time = "2026-02-02T15:37:52.607Z" }, + { url = "https://files.pythonhosted.org/packages/96/0b/fc793858dfa54be6feee940c1463370ece34b3c39c1ca0aa3845f5ba9892/orjson-3.11.7-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0724e265bc548af1dedebd9cb3d24b4e1c1e685a343be43e87ba922a5c5fff2f", size = 130812, upload-time = "2026-02-02T15:37:53.944Z" }, + { url = "https://files.pythonhosted.org/packages/dc/91/98a52415059db3f374757d0b7f0f16e3b5cd5976c90d1c2b56acaea039e6/orjson-3.11.7-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e7745312efa9e11c17fbd3cb3097262d079da26930ae9ae7ba28fb738367cbad", size = 133440, upload-time = "2026-02-02T15:37:55.615Z" }, + { url = "https://files.pythonhosted.org/packages/dc/b6/cb540117bda61791f46381f8c26c8f93e802892830a6055748d3bb1925ab/orjson-3.11.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f904c24bdeabd4298f7a977ef14ca2a022ca921ed670b92ecd16ab6f3d01f867", size = 138386, upload-time = "2026-02-02T15:37:56.814Z" }, + { url = "https://files.pythonhosted.org/packages/63/1a/50a3201c334a7f17c231eee5f841342190723794e3b06293f26e7cf87d31/orjson-3.11.7-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:b9fc4d0f81f394689e0814617aadc4f2ea0e8025f38c226cbf22d3b5ddbf025d", size = 408853, upload-time = "2026-02-02T15:37:58.291Z" }, + { url = "https://files.pythonhosted.org/packages/87/cd/8de1c67d0be44fdc22701e5989c0d015a2adf391498ad42c4dc589cd3013/orjson-3.11.7-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:849e38203e5be40b776ed2718e587faf204d184fc9a008ae441f9442320c0cab", size = 144130, upload-time = "2026-02-02T15:38:00.163Z" }, + { url = "https://files.pythonhosted.org/packages/0f/fe/d605d700c35dd55f51710d159fc54516a280923cd1b7e47508982fbb387d/orjson-3.11.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:4682d1db3bcebd2b64757e0ddf9e87ae5f00d29d16c5cdf3a62f561d08cc3dd2", size = 134818, upload-time = "2026-02-02T15:38:01.507Z" }, + { url = "https://files.pythonhosted.org/packages/e4/e4/15ecc67edb3ddb3e2f46ae04475f2d294e8b60c1825fbe28a428b93b3fbd/orjson-3.11.7-cp312-cp312-win32.whl", hash = "sha256:f4f7c956b5215d949a1f65334cf9d7612dde38f20a95f2315deef167def91a6f", size = 127923, upload-time = "2026-02-02T15:38:02.75Z" }, + { url = "https://files.pythonhosted.org/packages/34/70/2e0855361f76198a3965273048c8e50a9695d88cd75811a5b46444895845/orjson-3.11.7-cp312-cp312-win_amd64.whl", hash = "sha256:bf742e149121dc5648ba0a08ea0871e87b660467ef168a3a5e53bc1fbd64bb74", size = 125007, upload-time = "2026-02-02T15:38:04.032Z" }, + { url = "https://files.pythonhosted.org/packages/68/40/c2051bd19fc467610fed469dc29e43ac65891571138f476834ca192bc290/orjson-3.11.7-cp312-cp312-win_arm64.whl", hash = "sha256:26c3b9132f783b7d7903bf1efb095fed8d4a3a85ec0d334ee8beff3d7a4749d5", size = 126089, upload-time = "2026-02-02T15:38:05.297Z" }, + { url = "https://files.pythonhosted.org/packages/89/25/6e0e52cac5aab51d7b6dcd257e855e1dec1c2060f6b28566c509b4665f62/orjson-3.11.7-cp313-cp313-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:1d98b30cc1313d52d4af17d9c3d307b08389752ec5f2e5febdfada70b0f8c733", size = 228390, upload-time = "2026-02-02T15:38:06.8Z" }, + { url = "https://files.pythonhosted.org/packages/a5/29/a77f48d2fc8a05bbc529e5ff481fb43d914f9e383ea2469d4f3d51df3d00/orjson-3.11.7-cp313-cp313-macosx_15_0_arm64.whl", hash = "sha256:d897e81f8d0cbd2abb82226d1860ad2e1ab3ff16d7b08c96ca00df9d45409ef4", size = 125189, upload-time = "2026-02-02T15:38:08.181Z" }, + { url = "https://files.pythonhosted.org/packages/89/25/0a16e0729a0e6a1504f9d1a13cdd365f030068aab64cec6958396b9969d7/orjson-3.11.7-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:814be4b49b228cfc0b3c565acf642dd7d13538f966e3ccde61f4f55be3e20785", size = 128106, upload-time = "2026-02-02T15:38:09.41Z" }, + { url = "https://files.pythonhosted.org/packages/66/da/a2e505469d60666a05ab373f1a6322eb671cb2ba3a0ccfc7d4bc97196787/orjson-3.11.7-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d06e5c5fed5caedd2e540d62e5b1c25e8c82431b9e577c33537e5fa4aa909539", size = 123363, upload-time = "2026-02-02T15:38:10.73Z" }, + { url = "https://files.pythonhosted.org/packages/23/bf/ed73f88396ea35c71b38961734ea4a4746f7ca0768bf28fd551d37e48dd0/orjson-3.11.7-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:31c80ce534ac4ea3739c5ee751270646cbc46e45aea7576a38ffec040b4029a1", size = 129007, upload-time = "2026-02-02T15:38:12.138Z" }, + { url = "https://files.pythonhosted.org/packages/73/3c/b05d80716f0225fc9008fbf8ab22841dcc268a626aa550561743714ce3bf/orjson-3.11.7-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f50979824bde13d32b4320eedd513431c921102796d86be3eee0b58e58a3ecd1", size = 141667, upload-time = "2026-02-02T15:38:13.398Z" }, + { url = "https://files.pythonhosted.org/packages/61/e8/0be9b0addd9bf86abfc938e97441dcd0375d494594b1c8ad10fe57479617/orjson-3.11.7-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9e54f3808e2b6b945078c41aa8d9b5834b28c50843846e97807e5adb75fa9705", size = 130832, upload-time = "2026-02-02T15:38:14.698Z" }, + { url = "https://files.pythonhosted.org/packages/c9/ec/c68e3b9021a31d9ec15a94931db1410136af862955854ed5dd7e7e4f5bff/orjson-3.11.7-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a12b80df61aab7b98b490fe9e4879925ba666fccdfcd175252ce4d9035865ace", size = 133373, upload-time = "2026-02-02T15:38:16.109Z" }, + { url = "https://files.pythonhosted.org/packages/d2/45/f3466739aaafa570cc8e77c6dbb853c48bf56e3b43738020e2661e08b0ac/orjson-3.11.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:996b65230271f1a97026fd0e6a753f51fbc0c335d2ad0c6201f711b0da32693b", size = 138307, upload-time = "2026-02-02T15:38:17.453Z" }, + { url = "https://files.pythonhosted.org/packages/e1/84/9f7f02288da1ffb31405c1be07657afd1eecbcb4b64ee2817b6fe0f785fa/orjson-3.11.7-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:ab49d4b2a6a1d415ddb9f37a21e02e0d5dbfe10b7870b21bf779fc21e9156157", size = 408695, upload-time = "2026-02-02T15:38:18.831Z" }, + { url = "https://files.pythonhosted.org/packages/18/07/9dd2f0c0104f1a0295ffbe912bc8d63307a539b900dd9e2c48ef7810d971/orjson-3.11.7-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:390a1dce0c055ddf8adb6aa94a73b45a4a7d7177b5c584b8d1c1947f2ba60fb3", size = 144099, upload-time = "2026-02-02T15:38:20.28Z" }, + { url = "https://files.pythonhosted.org/packages/a5/66/857a8e4a3292e1f7b1b202883bcdeb43a91566cf59a93f97c53b44bd6801/orjson-3.11.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1eb80451a9c351a71dfaf5b7ccc13ad065405217726b59fdbeadbcc544f9d223", size = 134806, upload-time = "2026-02-02T15:38:22.186Z" }, + { url = "https://files.pythonhosted.org/packages/0a/5b/6ebcf3defc1aab3a338ca777214966851e92efb1f30dc7fc8285216e6d1b/orjson-3.11.7-cp313-cp313-win32.whl", hash = "sha256:7477aa6a6ec6139c5cb1cc7b214643592169a5494d200397c7fc95d740d5fcf3", size = 127914, upload-time = "2026-02-02T15:38:23.511Z" }, + { url = "https://files.pythonhosted.org/packages/00/04/c6f72daca5092e3117840a1b1e88dfc809cc1470cf0734890d0366b684a1/orjson-3.11.7-cp313-cp313-win_amd64.whl", hash = "sha256:b9f95dcdea9d4f805daa9ddf02617a89e484c6985fa03055459f90e87d7a0757", size = 124986, upload-time = "2026-02-02T15:38:24.836Z" }, + { url = "https://files.pythonhosted.org/packages/03/ba/077a0f6f1085d6b806937246860fafbd5b17f3919c70ee3f3d8d9c713f38/orjson-3.11.7-cp313-cp313-win_arm64.whl", hash = "sha256:800988273a014a0541483dc81021247d7eacb0c845a9d1a34a422bc718f41539", size = 126045, upload-time = "2026-02-02T15:38:26.216Z" }, + { url = "https://files.pythonhosted.org/packages/e9/1e/745565dca749813db9a093c5ebc4bac1a9475c64d54b95654336ac3ed961/orjson-3.11.7-cp314-cp314-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:de0a37f21d0d364954ad5de1970491d7fbd0fb1ef7417d4d56a36dc01ba0c0a0", size = 228391, upload-time = "2026-02-02T15:38:27.757Z" }, + { url = "https://files.pythonhosted.org/packages/46/19/e40f6225da4d3aa0c8dc6e5219c5e87c2063a560fe0d72a88deb59776794/orjson-3.11.7-cp314-cp314-macosx_15_0_arm64.whl", hash = "sha256:c2428d358d85e8da9d37cba18b8c4047c55222007a84f97156a5b22028dfbfc0", size = 125188, upload-time = "2026-02-02T15:38:29.241Z" }, + { url = "https://files.pythonhosted.org/packages/9d/7e/c4de2babef2c0817fd1f048fd176aa48c37bec8aef53d2fa932983032cce/orjson-3.11.7-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3c4bc6c6ac52cdaa267552544c73e486fecbd710b7ac09bc024d5a78555a22f6", size = 128097, upload-time = "2026-02-02T15:38:30.618Z" }, + { url = "https://files.pythonhosted.org/packages/eb/74/233d360632bafd2197f217eee7fb9c9d0229eac0c18128aee5b35b0014fe/orjson-3.11.7-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bd0d68edd7dfca1b2eca9361a44ac9f24b078de3481003159929a0573f21a6bf", size = 123364, upload-time = "2026-02-02T15:38:32.363Z" }, + { url = "https://files.pythonhosted.org/packages/79/51/af79504981dd31efe20a9e360eb49c15f06df2b40e7f25a0a52d9ae888e8/orjson-3.11.7-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:623ad1b9548ef63886319c16fa317848e465a21513b31a6ad7b57443c3e0dcf5", size = 129076, upload-time = "2026-02-02T15:38:33.68Z" }, + { url = "https://files.pythonhosted.org/packages/67/e2/da898eb68b72304f8de05ca6715870d09d603ee98d30a27e8a9629abc64b/orjson-3.11.7-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6e776b998ac37c0396093d10290e60283f59cfe0fc3fccbd0ccc4bd04dd19892", size = 141705, upload-time = "2026-02-02T15:38:34.989Z" }, + { url = "https://files.pythonhosted.org/packages/c5/89/15364d92acb3d903b029e28d834edb8780c2b97404cbf7929aa6b9abdb24/orjson-3.11.7-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:652c6c3af76716f4a9c290371ba2e390ede06f6603edb277b481daf37f6f464e", size = 130855, upload-time = "2026-02-02T15:38:36.379Z" }, + { url = "https://files.pythonhosted.org/packages/c2/8b/ecdad52d0b38d4b8f514be603e69ccd5eacf4e7241f972e37e79792212ec/orjson-3.11.7-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a56df3239294ea5964adf074c54bcc4f0ccd21636049a2cf3ca9cf03b5d03cf1", size = 133386, upload-time = "2026-02-02T15:38:37.704Z" }, + { url = "https://files.pythonhosted.org/packages/b9/0e/45e1dcf10e17d0924b7c9162f87ec7b4ca79e28a0548acf6a71788d3e108/orjson-3.11.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:bda117c4148e81f746655d5a3239ae9bd00cb7bc3ca178b5fc5a5997e9744183", size = 138295, upload-time = "2026-02-02T15:38:39.096Z" }, + { url = "https://files.pythonhosted.org/packages/63/d7/4d2e8b03561257af0450f2845b91fbd111d7e526ccdf737267108075e0ba/orjson-3.11.7-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:23d6c20517a97a9daf1d48b580fcdc6f0516c6f4b5038823426033690b4d2650", size = 408720, upload-time = "2026-02-02T15:38:40.634Z" }, + { url = "https://files.pythonhosted.org/packages/78/cf/d45343518282108b29c12a65892445fc51f9319dc3c552ceb51bb5905ed2/orjson-3.11.7-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:8ff206156006da5b847c9304b6308a01e8cdbc8cce824e2779a5ba71c3def141", size = 144152, upload-time = "2026-02-02T15:38:42.262Z" }, + { url = "https://files.pythonhosted.org/packages/a9/3a/d6001f51a7275aacd342e77b735c71fa04125a3f93c36fee4526bc8c654e/orjson-3.11.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:962d046ee1765f74a1da723f4b33e3b228fe3a48bd307acce5021dfefe0e29b2", size = 134814, upload-time = "2026-02-02T15:38:43.627Z" }, + { url = "https://files.pythonhosted.org/packages/1d/d3/f19b47ce16820cc2c480f7f1723e17f6d411b3a295c60c8ad3aa9ff1c96a/orjson-3.11.7-cp314-cp314-win32.whl", hash = "sha256:89e13dd3f89f1c38a9c9eba5fbf7cdc2d1feca82f5f290864b4b7a6aac704576", size = 127997, upload-time = "2026-02-02T15:38:45.06Z" }, + { url = "https://files.pythonhosted.org/packages/12/df/172771902943af54bf661a8d102bdf2e7f932127968080632bda6054b62c/orjson-3.11.7-cp314-cp314-win_amd64.whl", hash = "sha256:845c3e0d8ded9c9271cd79596b9b552448b885b97110f628fb687aee2eed11c1", size = 124985, upload-time = "2026-02-02T15:38:46.388Z" }, + { url = "https://files.pythonhosted.org/packages/6f/1c/f2a8d8a1b17514660a614ce5f7aac74b934e69f5abc2700cc7ced882a009/orjson-3.11.7-cp314-cp314-win_arm64.whl", hash = "sha256:4a2e9c5be347b937a2e0203866f12bba36082e89b402ddb9e927d5822e43088d", size = 126038, upload-time = "2026-02-02T15:38:47.703Z" }, +] + +[[package]] +name = "outcome" +version = "1.3.0.post0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/98/df/77698abfac98571e65ffeb0c1fba8ffd692ab8458d617a0eed7d9a8d38f2/outcome-1.3.0.post0.tar.gz", hash = "sha256:9dcf02e65f2971b80047b377468e72a268e15c0af3cf1238e6ff14f7f91143b8", size = 21060, upload-time = "2023-10-26T04:26:04.361Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/55/8b/5ab7257531a5d830fc8000c476e63c935488d74609b50f9384a643ec0a62/outcome-1.3.0.post0-py2.py3-none-any.whl", hash = "sha256:e771c5ce06d1415e356078d3bdd68523f284b4ce5419828922b6871e65eda82b", size = 10692, upload-time = "2023-10-26T04:26:02.532Z" }, +] + +[[package]] +name = "packaging" +version = "26.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" }, +] + +[[package]] +name = "pathspec" +version = "1.0.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fa/36/e27608899f9b8d4dff0617b2d9ab17ca5608956ca44461ac14ac48b44015/pathspec-1.0.4.tar.gz", hash = "sha256:0210e2ae8a21a9137c0d470578cb0e595af87edaa6ebf12ff176f14a02e0e645", size = 131200, upload-time = "2026-01-27T03:59:46.938Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/3c/2c197d226f9ea224a9ab8d197933f9da0ae0aac5b6e0f884e2b8d9c8e9f7/pathspec-1.0.4-py3-none-any.whl", hash = "sha256:fb6ae2fd4e7c921a165808a552060e722767cfa526f99ca5156ed2ce45a5c723", size = 55206, upload-time = "2026-01-27T03:59:45.137Z" }, +] + +[[package]] +name = "platformdirs" +version = "4.5.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cf/86/0248f086a84f01b37aaec0fa567b397df1a119f73c16f6c7a9aac73ea309/platformdirs-4.5.1.tar.gz", hash = "sha256:61d5cdcc6065745cdd94f0f878977f8de9437be93de97c1c12f853c9c0cdcbda", size = 21715, upload-time = "2025-12-05T13:52:58.638Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/28/3bfe2fa5a7b9c46fe7e13c97bda14c895fb10fa2ebf1d0abb90e0cea7ee1/platformdirs-4.5.1-py3-none-any.whl", hash = "sha256:d03afa3963c806a9bed9d5125c8f4cb2fdaf74a55ab60e5d59b3fde758104d31", size = 18731, upload-time = "2025-12-05T13:52:56.823Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "pre-commit" +version = "4.5.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cfgv" }, + { name = "identify" }, + { name = "nodeenv" }, + { name = "pyyaml" }, + { name = "virtualenv" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/40/f1/6d86a29246dfd2e9b6237f0b5823717f60cad94d47ddc26afa916d21f525/pre_commit-4.5.1.tar.gz", hash = "sha256:eb545fcff725875197837263e977ea257a402056661f09dae08e4b149b030a61", size = 198232, upload-time = "2025-12-16T21:14:33.552Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5d/19/fd3ef348460c80af7bb4669ea7926651d1f95c23ff2df18b9d24bab4f3fa/pre_commit-4.5.1-py2.py3-none-any.whl", hash = "sha256:3b3afd891e97337708c1674210f8eba659b52a38ea5f822ff142d10786221f77", size = 226437, upload-time = "2025-12-16T21:14:32.409Z" }, +] + +[[package]] +name = "pycparser" +version = "3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, +] + +[[package]] +name = "pygame" +version = "2.6.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/49/cc/08bba60f00541f62aaa252ce0cfbd60aebd04616c0b9574f755b583e45ae/pygame-2.6.1.tar.gz", hash = "sha256:56fb02ead529cee00d415c3e007f75e0780c655909aaa8e8bf616ee09c9feb1f", size = 14808125, upload-time = "2024-09-29T13:41:34.698Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c4/ca/8f367cb9fe734c4f6f6400e045593beea2635cd736158f9fabf58ee14e3c/pygame-2.6.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:20349195326a5e82a16e351ed93465a7845a7e2a9af55b7bc1b2110ea3e344e1", size = 13113753, upload-time = "2024-09-29T14:26:13.751Z" }, + { url = "https://files.pythonhosted.org/packages/83/47/6edf2f890139616b3219be9cfcc8f0cb8f42eb15efd59597927e390538cb/pygame-2.6.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f3935459109da4bb0b3901da9904f0a3e52028a3332a355d298b1673a334cf21", size = 12378146, upload-time = "2024-09-29T14:26:22.456Z" }, + { url = "https://files.pythonhosted.org/packages/00/9e/0d8aa8cf93db2d2ee38ebaf1c7b61d0df36ded27eb726221719c150c673d/pygame-2.6.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c31dbdb5d0217f32764797d21c2752e258e5fb7e895326538d82b5f75a0cd856", size = 13611760, upload-time = "2024-09-29T11:10:47.317Z" }, + { url = "https://files.pythonhosted.org/packages/d7/9e/d06adaa5cc65876bcd7a24f59f67e07f7e4194e6298130024ed3fb22c456/pygame-2.6.1-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:173badf82fa198e6888017bea40f511cb28e69ecdd5a72b214e81e4dcd66c3b1", size = 14298054, upload-time = "2024-09-29T11:39:53.891Z" }, + { url = "https://files.pythonhosted.org/packages/7a/a1/9ae2852ebd3a7cc7d9ae7ff7919ab983e4a5c1b7a14e840732f23b2b48f6/pygame-2.6.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ce8cc108b92de9b149b344ad2e25eedbe773af0dc41dfb24d1f07f679b558c60", size = 13977107, upload-time = "2024-09-29T11:39:56.831Z" }, + { url = "https://files.pythonhosted.org/packages/31/df/6788fd2e9a864d0496a77670e44a7c012184b7a5382866ab0e60c55c0f28/pygame-2.6.1-cp311-cp311-win32.whl", hash = "sha256:811e7b925146d8149d79193652cbb83e0eca0aae66476b1cb310f0f4226b8b5c", size = 10250863, upload-time = "2024-09-29T11:44:48.199Z" }, + { url = "https://files.pythonhosted.org/packages/d2/55/ca3eb851aeef4f6f2e98a360c201f0d00bd1ba2eb98e2c7850d80aabc526/pygame-2.6.1-cp311-cp311-win_amd64.whl", hash = "sha256:91476902426facd4bb0dad4dc3b2573bc82c95c71b135e0daaea072ed528d299", size = 10622016, upload-time = "2024-09-29T12:17:01.545Z" }, + { url = "https://files.pythonhosted.org/packages/92/16/2c602c332f45ff9526d61f6bd764db5096ff9035433e2172e2d2cadae8db/pygame-2.6.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:4ee7f2771f588c966fa2fa8b829be26698c9b4836f82ede5e4edc1a68594942e", size = 13118279, upload-time = "2024-09-29T14:26:30.427Z" }, + { url = "https://files.pythonhosted.org/packages/cd/53/77ccbc384b251c6e34bfd2e734c638233922449a7844e3c7a11ef91cee39/pygame-2.6.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:c8040ea2ab18c6b255af706ec01355c8a6b08dc48d77fd4ee783f8fc46a843bf", size = 12384524, upload-time = "2024-09-29T14:26:49.996Z" }, + { url = "https://files.pythonhosted.org/packages/06/be/3ed337583f010696c3b3435e89a74fb29d0c74d0931e8f33c0a4246307a9/pygame-2.6.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c47a6938de93fa610accd4969e638c2aebcb29b2fca518a84c3a39d91ab47116", size = 13587123, upload-time = "2024-09-29T11:10:50.072Z" }, + { url = "https://files.pythonhosted.org/packages/fd/ca/b015586a450db59313535662991b34d24c1f0c0dc149cc5f496573900f4e/pygame-2.6.1-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:33006f784e1c7d7e466fcb61d5489da59cc5f7eb098712f792a225df1d4e229d", size = 14275532, upload-time = "2024-09-29T11:39:59.356Z" }, + { url = "https://files.pythonhosted.org/packages/b9/f2/d31e6ad42d657af07be2ffd779190353f759a07b51232b9e1d724f2cda46/pygame-2.6.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1206125f14cae22c44565c9d333607f1d9f59487b1f1432945dfc809aeaa3e88", size = 13952653, upload-time = "2024-09-29T11:40:01.781Z" }, + { url = "https://files.pythonhosted.org/packages/f3/42/8ea2a6979e6fa971702fece1747e862e2256d4a8558fe0da6364dd946c53/pygame-2.6.1-cp312-cp312-win32.whl", hash = "sha256:84fc4054e25262140d09d39e094f6880d730199710829902f0d8ceae0213379e", size = 10252421, upload-time = "2024-09-29T11:14:26.877Z" }, + { url = "https://files.pythonhosted.org/packages/5f/90/7d766d54bb95939725e9a9361f9c06b0cfbe3fe100aa35400f0a461a278a/pygame-2.6.1-cp312-cp312-win_amd64.whl", hash = "sha256:3a9e7396be0d9633831c3f8d5d82dd63ba373ad65599628294b7a4f8a5a01a65", size = 10624591, upload-time = "2024-09-29T11:52:54.489Z" }, + { url = "https://files.pythonhosted.org/packages/e1/91/718acf3e2a9d08a6ddcc96bd02a6f63c99ee7ba14afeaff2a51c987df0b9/pygame-2.6.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ae6039f3a55d800db80e8010f387557b528d34d534435e0871326804df2a62f2", size = 13090765, upload-time = "2024-09-29T14:27:02.377Z" }, + { url = "https://files.pythonhosted.org/packages/0e/c6/9cb315de851a7682d9c7568a41ea042ee98d668cb8deadc1dafcab6116f0/pygame-2.6.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2a3a1288e2e9b1e5834e425bedd5ba01a3cd4902b5c2bff8ed4a740ccfe98171", size = 12381704, upload-time = "2024-09-29T14:27:10.228Z" }, + { url = "https://files.pythonhosted.org/packages/9f/8f/617a1196e31ae3b46be6949fbaa95b8c93ce15e0544266198c2266cc1b4d/pygame-2.6.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:27eb17e3dc9640e4b4683074f1890e2e879827447770470c2aba9f125f74510b", size = 13581091, upload-time = "2024-09-29T11:30:27.653Z" }, + { url = "https://files.pythonhosted.org/packages/3b/87/2851a564e40a2dad353f1c6e143465d445dab18a95281f9ea458b94f3608/pygame-2.6.1-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4c1623180e70a03c4a734deb9bac50fc9c82942ae84a3a220779062128e75f3b", size = 14273844, upload-time = "2024-09-29T11:40:04.138Z" }, + { url = "https://files.pythonhosted.org/packages/85/b5/aa23aa2e70bcba42c989c02e7228273c30f3b44b9b264abb93eaeff43ad7/pygame-2.6.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ef07c0103d79492c21fced9ad68c11c32efa6801ca1920ebfd0f15fb46c78b1c", size = 13951197, upload-time = "2024-09-29T11:40:06.785Z" }, + { url = "https://files.pythonhosted.org/packages/a6/06/29e939b34d3f1354738c7d201c51c250ad7abefefaf6f8332d962ff67c4b/pygame-2.6.1-cp313-cp313-win32.whl", hash = "sha256:3acd8c009317190c2bfd81db681ecef47d5eb108c2151d09596d9c7ea9df5c0e", size = 10249309, upload-time = "2024-09-29T11:10:23.329Z" }, + { url = "https://files.pythonhosted.org/packages/7e/11/17f7f319ca91824b86557e9303e3b7a71991ef17fd45286bf47d7f0a38e6/pygame-2.6.1-cp313-cp313-win_amd64.whl", hash = "sha256:813af4fba5d0b2cb8e58f5d95f7910295c34067dcc290d34f1be59c48bd1ea6a", size = 10620084, upload-time = "2024-09-29T11:48:51.587Z" }, +] + +[[package]] +name = "pygments" +version = "2.19.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, +] + +[[package]] +name = "pytest" +version = "9.0.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" }, +] + +[[package]] +name = "pytest-cov" +version = "7.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "coverage", extra = ["toml"] }, + { name = "pluggy" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5e/f7/c933acc76f5208b3b00089573cf6a2bc26dc80a8aece8f52bb7d6b1855ca/pytest_cov-7.0.0.tar.gz", hash = "sha256:33c97eda2e049a0c5298e91f519302a1334c26ac65c1a483d6206fd458361af1", size = 54328, upload-time = "2025-09-09T10:57:02.113Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ee/49/1377b49de7d0c1ce41292161ea0f721913fa8722c19fb9c1e3aa0367eecb/pytest_cov-7.0.0-py3-none-any.whl", hash = "sha256:3b8e9558b16cc1479da72058bdecf8073661c7f57f7d3c5f22a1c23507f2d861", size = 22424, upload-time = "2025-09-09T10:57:00.695Z" }, +] + +[[package]] +name = "pytest-trio" +version = "0.8.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "outcome" }, + { name = "pytest" }, + { name = "trio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/13/08/056279526554c6c6e6ad6d4a479a338d14dc785ac30be8bdc6ca0153c1be/pytest-trio-0.8.0.tar.gz", hash = "sha256:8363db6336a79e6c53375a2123a41ddbeccc4aa93f93788651641789a56fb52e", size = 46525, upload-time = "2022-11-01T17:24:29.352Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e9/22/71953f47e0da5852c899f58cd7a31e6100f37c632b7b9ee52d067613a844/pytest_trio-0.8.0-py3-none-any.whl", hash = "sha256:e6a7e7351ae3e8ec3f4564d30ee77d1ec66e1df611226e5618dbb32f9545c841", size = 27221, upload-time = "2022-11-01T17:24:27.501Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" }, + { url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" }, + { url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" }, + { url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" }, + { url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" }, + { url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" }, + { url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" }, + { url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" }, + { url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" }, + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, +] + +[[package]] +name = "ruff" +version = "0.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c8/39/5cee96809fbca590abea6b46c6d1c586b49663d1d2830a751cc8fc42c666/ruff-0.15.0.tar.gz", hash = "sha256:6bdea47cdbea30d40f8f8d7d69c0854ba7c15420ec75a26f463290949d7f7e9a", size = 4524893, upload-time = "2026-02-03T17:53:35.357Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bc/88/3fd1b0aa4b6330d6aaa63a285bc96c9f71970351579152d231ed90914586/ruff-0.15.0-py3-none-linux_armv6l.whl", hash = "sha256:aac4ebaa612a82b23d45964586f24ae9bc23ca101919f5590bdb368d74ad5455", size = 10354332, upload-time = "2026-02-03T17:52:54.892Z" }, + { url = "https://files.pythonhosted.org/packages/72/f6/62e173fbb7eb75cc29fe2576a1e20f0a46f671a2587b5f604bfb0eaf5f6f/ruff-0.15.0-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:dcd4be7cc75cfbbca24a98d04d0b9b36a270d0833241f776b788d59f4142b14d", size = 10767189, upload-time = "2026-02-03T17:53:19.778Z" }, + { url = "https://files.pythonhosted.org/packages/99/e4/968ae17b676d1d2ff101d56dc69cf333e3a4c985e1ec23803df84fc7bf9e/ruff-0.15.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:d747e3319b2bce179c7c1eaad3d884dc0a199b5f4d5187620530adf9105268ce", size = 10075384, upload-time = "2026-02-03T17:53:29.241Z" }, + { url = "https://files.pythonhosted.org/packages/a2/bf/9843c6044ab9e20af879c751487e61333ca79a2c8c3058b15722386b8cae/ruff-0.15.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:650bd9c56ae03102c51a5e4b554d74d825ff3abe4db22b90fd32d816c2e90621", size = 10481363, upload-time = "2026-02-03T17:52:43.332Z" }, + { url = "https://files.pythonhosted.org/packages/55/d9/4ada5ccf4cd1f532db1c8d44b6f664f2208d3d93acbeec18f82315e15193/ruff-0.15.0-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a6664b7eac559e3048223a2da77769c2f92b43a6dfd4720cef42654299a599c9", size = 10187736, upload-time = "2026-02-03T17:53:00.522Z" }, + { url = "https://files.pythonhosted.org/packages/86/e2/f25eaecd446af7bb132af0a1d5b135a62971a41f5366ff41d06d25e77a91/ruff-0.15.0-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6f811f97b0f092b35320d1556f3353bf238763420ade5d9e62ebd2b73f2ff179", size = 10968415, upload-time = "2026-02-03T17:53:15.705Z" }, + { url = "https://files.pythonhosted.org/packages/e7/dc/f06a8558d06333bf79b497d29a50c3a673d9251214e0d7ec78f90b30aa79/ruff-0.15.0-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:761ec0a66680fab6454236635a39abaf14198818c8cdf691e036f4bc0f406b2d", size = 11809643, upload-time = "2026-02-03T17:53:23.031Z" }, + { url = "https://files.pythonhosted.org/packages/dd/45/0ece8db2c474ad7df13af3a6d50f76e22a09d078af63078f005057ca59eb/ruff-0.15.0-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:940f11c2604d317e797b289f4f9f3fa5555ffe4fb574b55ed006c3d9b6f0eb78", size = 11234787, upload-time = "2026-02-03T17:52:46.432Z" }, + { url = "https://files.pythonhosted.org/packages/8a/d9/0e3a81467a120fd265658d127db648e4d3acfe3e4f6f5d4ea79fac47e587/ruff-0.15.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bcbca3d40558789126da91d7ef9a7c87772ee107033db7191edefa34e2c7f1b4", size = 11112797, upload-time = "2026-02-03T17:52:49.274Z" }, + { url = "https://files.pythonhosted.org/packages/b2/cb/8c0b3b0c692683f8ff31351dfb6241047fa873a4481a76df4335a8bff716/ruff-0.15.0-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:9a121a96db1d75fa3eb39c4539e607f628920dd72ff1f7c5ee4f1b768ac62d6e", size = 11033133, upload-time = "2026-02-03T17:53:33.105Z" }, + { url = "https://files.pythonhosted.org/packages/f8/5e/23b87370cf0f9081a8c89a753e69a4e8778805b8802ccfe175cc410e50b9/ruff-0.15.0-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:5298d518e493061f2eabd4abd067c7e4fb89e2f63291c94332e35631c07c3662", size = 10442646, upload-time = "2026-02-03T17:53:06.278Z" }, + { url = "https://files.pythonhosted.org/packages/e1/9a/3c94de5ce642830167e6d00b5c75aacd73e6347b4c7fc6828699b150a5ee/ruff-0.15.0-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:afb6e603d6375ff0d6b0cee563fa21ab570fd15e65c852cb24922cef25050cf1", size = 10195750, upload-time = "2026-02-03T17:53:26.084Z" }, + { url = "https://files.pythonhosted.org/packages/30/15/e396325080d600b436acc970848d69df9c13977942fb62bb8722d729bee8/ruff-0.15.0-py3-none-musllinux_1_2_i686.whl", hash = "sha256:77e515f6b15f828b94dc17d2b4ace334c9ddb7d9468c54b2f9ed2b9c1593ef16", size = 10676120, upload-time = "2026-02-03T17:53:09.363Z" }, + { url = "https://files.pythonhosted.org/packages/8d/c9/229a23d52a2983de1ad0fb0ee37d36e0257e6f28bfd6b498ee2c76361874/ruff-0.15.0-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:6f6e80850a01eb13b3e42ee0ebdf6e4497151b48c35051aab51c101266d187a3", size = 11201636, upload-time = "2026-02-03T17:52:57.281Z" }, + { url = "https://files.pythonhosted.org/packages/6f/b0/69adf22f4e24f3677208adb715c578266842e6e6a3cc77483f48dd999ede/ruff-0.15.0-py3-none-win32.whl", hash = "sha256:238a717ef803e501b6d51e0bdd0d2c6e8513fe9eec14002445134d3907cd46c3", size = 10465945, upload-time = "2026-02-03T17:53:12.591Z" }, + { url = "https://files.pythonhosted.org/packages/51/ad/f813b6e2c97e9b4598be25e94a9147b9af7e60523b0cb5d94d307c15229d/ruff-0.15.0-py3-none-win_amd64.whl", hash = "sha256:dd5e4d3301dc01de614da3cdffc33d4b1b96fb89e45721f1598e5532ccf78b18", size = 11564657, upload-time = "2026-02-03T17:52:51.893Z" }, + { url = "https://files.pythonhosted.org/packages/f6/b0/2d823f6e77ebe560f4e397d078487e8d52c1516b331e3521bc75db4272ca/ruff-0.15.0-py3-none-win_arm64.whl", hash = "sha256:c480d632cc0ca3f0727acac8b7d053542d9e114a462a145d0b00e7cd658c515a", size = 10865753, upload-time = "2026-02-03T17:53:03.014Z" }, +] + +[[package]] +name = "sniffio" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372, upload-time = "2024-02-25T23:20:04.057Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" }, +] + +[[package]] +name = "sortedcontainers" +version = "2.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e8/c4/ba2f8066cceb6f23394729afe52f3bf7adec04bf9ed2c820b39e19299111/sortedcontainers-2.4.0.tar.gz", hash = "sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88", size = 30594, upload-time = "2021-05-16T22:03:42.897Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/32/46/9cb0e58b2deb7f82b84065f37f3bffeb12413f947f9388e4cac22c4621ce/sortedcontainers-2.4.0-py2.py3-none-any.whl", hash = "sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0", size = 29575, upload-time = "2021-05-16T22:03:41.177Z" }, +] + +[[package]] +name = "tomli" +version = "2.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/30/31573e9457673ab10aa432461bee537ce6cef177667deca369efb79df071/tomli-2.4.0.tar.gz", hash = "sha256:aa89c3f6c277dd275d8e243ad24f3b5e701491a860d5121f2cdd399fbb31fc9c", size = 17477, upload-time = "2026-01-11T11:22:38.165Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3c/d9/3dc2289e1f3b32eb19b9785b6a006b28ee99acb37d1d47f78d4c10e28bf8/tomli-2.4.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b5ef256a3fd497d4973c11bf142e9ed78b150d36f5773f1ca6088c230ffc5867", size = 153663, upload-time = "2026-01-11T11:21:45.27Z" }, + { url = "https://files.pythonhosted.org/packages/51/32/ef9f6845e6b9ca392cd3f64f9ec185cc6f09f0a2df3db08cbe8809d1d435/tomli-2.4.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5572e41282d5268eb09a697c89a7bee84fae66511f87533a6f88bd2f7b652da9", size = 148469, upload-time = "2026-01-11T11:21:46.873Z" }, + { url = "https://files.pythonhosted.org/packages/d6/c2/506e44cce89a8b1b1e047d64bd495c22c9f71f21e05f380f1a950dd9c217/tomli-2.4.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:551e321c6ba03b55676970b47cb1b73f14a0a4dce6a3e1a9458fd6d921d72e95", size = 236039, upload-time = "2026-01-11T11:21:48.503Z" }, + { url = "https://files.pythonhosted.org/packages/b3/40/e1b65986dbc861b7e986e8ec394598187fa8aee85b1650b01dd925ca0be8/tomli-2.4.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5e3f639a7a8f10069d0e15408c0b96a2a828cfdec6fca05296ebcdcc28ca7c76", size = 243007, upload-time = "2026-01-11T11:21:49.456Z" }, + { url = "https://files.pythonhosted.org/packages/9c/6f/6e39ce66b58a5b7ae572a0f4352ff40c71e8573633deda43f6a379d56b3e/tomli-2.4.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1b168f2731796b045128c45982d3a4874057626da0e2ef1fdd722848b741361d", size = 240875, upload-time = "2026-01-11T11:21:50.755Z" }, + { url = "https://files.pythonhosted.org/packages/aa/ad/cb089cb190487caa80204d503c7fd0f4d443f90b95cf4ef5cf5aa0f439b0/tomli-2.4.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:133e93646ec4300d651839d382d63edff11d8978be23da4cc106f5a18b7d0576", size = 246271, upload-time = "2026-01-11T11:21:51.81Z" }, + { url = "https://files.pythonhosted.org/packages/0b/63/69125220e47fd7a3a27fd0de0c6398c89432fec41bc739823bcc66506af6/tomli-2.4.0-cp311-cp311-win32.whl", hash = "sha256:b6c78bdf37764092d369722d9946cb65b8767bfa4110f902a1b2542d8d173c8a", size = 96770, upload-time = "2026-01-11T11:21:52.647Z" }, + { url = "https://files.pythonhosted.org/packages/1e/0d/a22bb6c83f83386b0008425a6cd1fa1c14b5f3dd4bad05e98cf3dbbf4a64/tomli-2.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:d3d1654e11d724760cdb37a3d7691f0be9db5fbdaef59c9f532aabf87006dbaa", size = 107626, upload-time = "2026-01-11T11:21:53.459Z" }, + { url = "https://files.pythonhosted.org/packages/2f/6d/77be674a3485e75cacbf2ddba2b146911477bd887dda9d8c9dfb2f15e871/tomli-2.4.0-cp311-cp311-win_arm64.whl", hash = "sha256:cae9c19ed12d4e8f3ebf46d1a75090e4c0dc16271c5bce1c833ac168f08fb614", size = 94842, upload-time = "2026-01-11T11:21:54.831Z" }, + { url = "https://files.pythonhosted.org/packages/3c/43/7389a1869f2f26dba52404e1ef13b4784b6b37dac93bac53457e3ff24ca3/tomli-2.4.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:920b1de295e72887bafa3ad9f7a792f811847d57ea6b1215154030cf131f16b1", size = 154894, upload-time = "2026-01-11T11:21:56.07Z" }, + { url = "https://files.pythonhosted.org/packages/e9/05/2f9bf110b5294132b2edf13fe6ca6ae456204f3d749f623307cbb7a946f2/tomli-2.4.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7d6d9a4aee98fac3eab4952ad1d73aee87359452d1c086b5ceb43ed02ddb16b8", size = 149053, upload-time = "2026-01-11T11:21:57.467Z" }, + { url = "https://files.pythonhosted.org/packages/e8/41/1eda3ca1abc6f6154a8db4d714a4d35c4ad90adc0bcf700657291593fbf3/tomli-2.4.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:36b9d05b51e65b254ea6c2585b59d2c4cb91c8a3d91d0ed0f17591a29aaea54a", size = 243481, upload-time = "2026-01-11T11:21:58.661Z" }, + { url = "https://files.pythonhosted.org/packages/d2/6d/02ff5ab6c8868b41e7d4b987ce2b5f6a51d3335a70aa144edd999e055a01/tomli-2.4.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1c8a885b370751837c029ef9bc014f27d80840e48bac415f3412e6593bbc18c1", size = 251720, upload-time = "2026-01-11T11:22:00.178Z" }, + { url = "https://files.pythonhosted.org/packages/7b/57/0405c59a909c45d5b6f146107c6d997825aa87568b042042f7a9c0afed34/tomli-2.4.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8768715ffc41f0008abe25d808c20c3d990f42b6e2e58305d5da280ae7d1fa3b", size = 247014, upload-time = "2026-01-11T11:22:01.238Z" }, + { url = "https://files.pythonhosted.org/packages/2c/0e/2e37568edd944b4165735687cbaf2fe3648129e440c26d02223672ee0630/tomli-2.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7b438885858efd5be02a9a133caf5812b8776ee0c969fea02c45e8e3f296ba51", size = 251820, upload-time = "2026-01-11T11:22:02.727Z" }, + { url = "https://files.pythonhosted.org/packages/5a/1c/ee3b707fdac82aeeb92d1a113f803cf6d0f37bdca0849cb489553e1f417a/tomli-2.4.0-cp312-cp312-win32.whl", hash = "sha256:0408e3de5ec77cc7f81960c362543cbbd91ef883e3138e81b729fc3eea5b9729", size = 97712, upload-time = "2026-01-11T11:22:03.777Z" }, + { url = "https://files.pythonhosted.org/packages/69/13/c07a9177d0b3bab7913299b9278845fc6eaaca14a02667c6be0b0a2270c8/tomli-2.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:685306e2cc7da35be4ee914fd34ab801a6acacb061b6a7abca922aaf9ad368da", size = 108296, upload-time = "2026-01-11T11:22:04.86Z" }, + { url = "https://files.pythonhosted.org/packages/18/27/e267a60bbeeee343bcc279bb9e8fbed0cbe224bc7b2a3dc2975f22809a09/tomli-2.4.0-cp312-cp312-win_arm64.whl", hash = "sha256:5aa48d7c2356055feef06a43611fc401a07337d5b006be13a30f6c58f869e3c3", size = 94553, upload-time = "2026-01-11T11:22:05.854Z" }, + { url = "https://files.pythonhosted.org/packages/34/91/7f65f9809f2936e1f4ce6268ae1903074563603b2a2bd969ebbda802744f/tomli-2.4.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:84d081fbc252d1b6a982e1870660e7330fb8f90f676f6e78b052ad4e64714bf0", size = 154915, upload-time = "2026-01-11T11:22:06.703Z" }, + { url = "https://files.pythonhosted.org/packages/20/aa/64dd73a5a849c2e8f216b755599c511badde80e91e9bc2271baa7b2cdbb1/tomli-2.4.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:9a08144fa4cba33db5255f9b74f0b89888622109bd2776148f2597447f92a94e", size = 149038, upload-time = "2026-01-11T11:22:07.56Z" }, + { url = "https://files.pythonhosted.org/packages/9e/8a/6d38870bd3d52c8d1505ce054469a73f73a0fe62c0eaf5dddf61447e32fa/tomli-2.4.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c73add4bb52a206fd0c0723432db123c0c75c280cbd67174dd9d2db228ebb1b4", size = 242245, upload-time = "2026-01-11T11:22:08.344Z" }, + { url = "https://files.pythonhosted.org/packages/59/bb/8002fadefb64ab2669e5b977df3f5e444febea60e717e755b38bb7c41029/tomli-2.4.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1fb2945cbe303b1419e2706e711b7113da57b7db31ee378d08712d678a34e51e", size = 250335, upload-time = "2026-01-11T11:22:09.951Z" }, + { url = "https://files.pythonhosted.org/packages/a5/3d/4cdb6f791682b2ea916af2de96121b3cb1284d7c203d97d92d6003e91c8d/tomli-2.4.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bbb1b10aa643d973366dc2cb1ad94f99c1726a02343d43cbc011edbfac579e7c", size = 245962, upload-time = "2026-01-11T11:22:11.27Z" }, + { url = "https://files.pythonhosted.org/packages/f2/4a/5f25789f9a460bd858ba9756ff52d0830d825b458e13f754952dd15fb7bb/tomli-2.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4cbcb367d44a1f0c2be408758b43e1ffb5308abe0ea222897d6bfc8e8281ef2f", size = 250396, upload-time = "2026-01-11T11:22:12.325Z" }, + { url = "https://files.pythonhosted.org/packages/aa/2f/b73a36fea58dfa08e8b3a268750e6853a6aac2a349241a905ebd86f3047a/tomli-2.4.0-cp313-cp313-win32.whl", hash = "sha256:7d49c66a7d5e56ac959cb6fc583aff0651094ec071ba9ad43df785abc2320d86", size = 97530, upload-time = "2026-01-11T11:22:13.865Z" }, + { url = "https://files.pythonhosted.org/packages/3b/af/ca18c134b5d75de7e8dc551c5234eaba2e8e951f6b30139599b53de9c187/tomli-2.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:3cf226acb51d8f1c394c1b310e0e0e61fecdd7adcb78d01e294ac297dd2e7f87", size = 108227, upload-time = "2026-01-11T11:22:15.224Z" }, + { url = "https://files.pythonhosted.org/packages/22/c3/b386b832f209fee8073c8138ec50f27b4460db2fdae9ffe022df89a57f9b/tomli-2.4.0-cp313-cp313-win_arm64.whl", hash = "sha256:d20b797a5c1ad80c516e41bc1fb0443ddb5006e9aaa7bda2d71978346aeb9132", size = 94748, upload-time = "2026-01-11T11:22:16.009Z" }, + { url = "https://files.pythonhosted.org/packages/f3/c4/84047a97eb1004418bc10bdbcfebda209fca6338002eba2dc27cc6d13563/tomli-2.4.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:26ab906a1eb794cd4e103691daa23d95c6919cc2fa9160000ac02370cc9dd3f6", size = 154725, upload-time = "2026-01-11T11:22:17.269Z" }, + { url = "https://files.pythonhosted.org/packages/a8/5d/d39038e646060b9d76274078cddf146ced86dc2b9e8bbf737ad5983609a0/tomli-2.4.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:20cedb4ee43278bc4f2fee6cb50daec836959aadaf948db5172e776dd3d993fc", size = 148901, upload-time = "2026-01-11T11:22:18.287Z" }, + { url = "https://files.pythonhosted.org/packages/73/e5/383be1724cb30f4ce44983d249645684a48c435e1cd4f8b5cded8a816d3c/tomli-2.4.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:39b0b5d1b6dd03684b3fb276407ebed7090bbec989fa55838c98560c01113b66", size = 243375, upload-time = "2026-01-11T11:22:19.154Z" }, + { url = "https://files.pythonhosted.org/packages/31/f0/bea80c17971c8d16d3cc109dc3585b0f2ce1036b5f4a8a183789023574f2/tomli-2.4.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a26d7ff68dfdb9f87a016ecfd1e1c2bacbe3108f4e0f8bcd2228ef9a766c787d", size = 250639, upload-time = "2026-01-11T11:22:20.168Z" }, + { url = "https://files.pythonhosted.org/packages/2c/8f/2853c36abbb7608e3f945d8a74e32ed3a74ee3a1f468f1ffc7d1cb3abba6/tomli-2.4.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:20ffd184fb1df76a66e34bd1b36b4a4641bd2b82954befa32fe8163e79f1a702", size = 246897, upload-time = "2026-01-11T11:22:21.544Z" }, + { url = "https://files.pythonhosted.org/packages/49/f0/6c05e3196ed5337b9fe7ea003e95fd3819a840b7a0f2bf5a408ef1dad8ed/tomli-2.4.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:75c2f8bbddf170e8effc98f5e9084a8751f8174ea6ccf4fca5398436e0320bc8", size = 254697, upload-time = "2026-01-11T11:22:23.058Z" }, + { url = "https://files.pythonhosted.org/packages/f3/f5/2922ef29c9f2951883525def7429967fc4d8208494e5ab524234f06b688b/tomli-2.4.0-cp314-cp314-win32.whl", hash = "sha256:31d556d079d72db7c584c0627ff3a24c5d3fb4f730221d3444f3efb1b2514776", size = 98567, upload-time = "2026-01-11T11:22:24.033Z" }, + { url = "https://files.pythonhosted.org/packages/7b/31/22b52e2e06dd2a5fdbc3ee73226d763b184ff21fc24e20316a44ccc4d96b/tomli-2.4.0-cp314-cp314-win_amd64.whl", hash = "sha256:43e685b9b2341681907759cf3a04e14d7104b3580f808cfde1dfdb60ada85475", size = 108556, upload-time = "2026-01-11T11:22:25.378Z" }, + { url = "https://files.pythonhosted.org/packages/48/3d/5058dff3255a3d01b705413f64f4306a141a8fd7a251e5a495e3f192a998/tomli-2.4.0-cp314-cp314-win_arm64.whl", hash = "sha256:3d895d56bd3f82ddd6faaff993c275efc2ff38e52322ea264122d72729dca2b2", size = 96014, upload-time = "2026-01-11T11:22:26.138Z" }, + { url = "https://files.pythonhosted.org/packages/b8/4e/75dab8586e268424202d3a1997ef6014919c941b50642a1682df43204c22/tomli-2.4.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:5b5807f3999fb66776dbce568cc9a828544244a8eb84b84b9bafc080c99597b9", size = 163339, upload-time = "2026-01-11T11:22:27.143Z" }, + { url = "https://files.pythonhosted.org/packages/06/e3/b904d9ab1016829a776d97f163f183a48be6a4deb87304d1e0116a349519/tomli-2.4.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c084ad935abe686bd9c898e62a02a19abfc9760b5a79bc29644463eaf2840cb0", size = 159490, upload-time = "2026-01-11T11:22:28.399Z" }, + { url = "https://files.pythonhosted.org/packages/e3/5a/fc3622c8b1ad823e8ea98a35e3c632ee316d48f66f80f9708ceb4f2a0322/tomli-2.4.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0f2e3955efea4d1cfbcb87bc321e00dc08d2bcb737fd1d5e398af111d86db5df", size = 269398, upload-time = "2026-01-11T11:22:29.345Z" }, + { url = "https://files.pythonhosted.org/packages/fd/33/62bd6152c8bdd4c305ad9faca48f51d3acb2df1f8791b1477d46ff86e7f8/tomli-2.4.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0e0fe8a0b8312acf3a88077a0802565cb09ee34107813bba1c7cd591fa6cfc8d", size = 276515, upload-time = "2026-01-11T11:22:30.327Z" }, + { url = "https://files.pythonhosted.org/packages/4b/ff/ae53619499f5235ee4211e62a8d7982ba9e439a0fb4f2f351a93d67c1dd2/tomli-2.4.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:413540dce94673591859c4c6f794dfeaa845e98bf35d72ed59636f869ef9f86f", size = 273806, upload-time = "2026-01-11T11:22:32.56Z" }, + { url = "https://files.pythonhosted.org/packages/47/71/cbca7787fa68d4d0a9f7072821980b39fbb1b6faeb5f5cf02f4a5559fa28/tomli-2.4.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0dc56fef0e2c1c470aeac5b6ca8cc7b640bb93e92d9803ddaf9ea03e198f5b0b", size = 281340, upload-time = "2026-01-11T11:22:33.505Z" }, + { url = "https://files.pythonhosted.org/packages/f5/00/d595c120963ad42474cf6ee7771ad0d0e8a49d0f01e29576ee9195d9ecdf/tomli-2.4.0-cp314-cp314t-win32.whl", hash = "sha256:d878f2a6707cc9d53a1be1414bbb419e629c3d6e67f69230217bb663e76b5087", size = 108106, upload-time = "2026-01-11T11:22:34.451Z" }, + { url = "https://files.pythonhosted.org/packages/de/69/9aa0c6a505c2f80e519b43764f8b4ba93b5a0bbd2d9a9de6e2b24271b9a5/tomli-2.4.0-cp314-cp314t-win_amd64.whl", hash = "sha256:2add28aacc7425117ff6364fe9e06a183bb0251b03f986df0e78e974047571fd", size = 120504, upload-time = "2026-01-11T11:22:35.764Z" }, + { url = "https://files.pythonhosted.org/packages/b3/9f/f1668c281c58cfae01482f7114a4b88d345e4c140386241a1a24dcc9e7bc/tomli-2.4.0-cp314-cp314t-win_arm64.whl", hash = "sha256:2b1e3b80e1d5e52e40e9b924ec43d81570f0e7d09d11081b797bc4692765a3d4", size = 99561, upload-time = "2026-01-11T11:22:36.624Z" }, + { url = "https://files.pythonhosted.org/packages/23/d1/136eb2cb77520a31e1f64cbae9d33ec6df0d78bdf4160398e86eec8a8754/tomli-2.4.0-py3-none-any.whl", hash = "sha256:1f776e7d669ebceb01dee46484485f43a4048746235e683bcdffacdf1fb4785a", size = 14477, upload-time = "2026-01-11T11:22:37.446Z" }, +] + +[[package]] +name = "trio" +version = "0.32.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "cffi", marker = "implementation_name != 'pypy' and os_name == 'nt'" }, + { name = "idna" }, + { name = "outcome" }, + { name = "sniffio" }, + { name = "sortedcontainers" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d8/ce/0041ddd9160aac0031bcf5ab786c7640d795c797e67c438e15cfedf815c8/trio-0.32.0.tar.gz", hash = "sha256:150f29ec923bcd51231e1d4c71c7006e65247d68759dd1c19af4ea815a25806b", size = 605323, upload-time = "2025-10-31T07:18:17.466Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/41/bf/945d527ff706233636c73880b22c7c953f3faeb9d6c7e2e85bfbfd0134a0/trio-0.32.0-py3-none-any.whl", hash = "sha256:4ab65984ef8370b79a76659ec87aa3a30c5c7c83ff250b4de88c29a8ab6123c5", size = 512030, upload-time = "2025-10-31T07:18:15.885Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +] + +[[package]] +name = "uv" +version = "0.10.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/09/36/f7fe4de0ad81234ac43938fe39c6ba84595c6b3a1868d786a4d7ad19e670/uv-0.10.0.tar.gz", hash = "sha256:ad01dd614a4bb8eb732da31ade41447026427397c5ad171cc98bd59579ef57ea", size = 3854103, upload-time = "2026-02-05T20:57:55.248Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/69/33fb64aee6ba138b1aaf957e20778e94a8c23732e41cdf68e6176aa2cf4e/uv-0.10.0-py3-none-linux_armv6l.whl", hash = "sha256:38dc0ccbda6377eb94095688c38e5001b8b40dfce14b9654949c1f0b6aa889df", size = 21984662, upload-time = "2026-02-05T20:57:19.076Z" }, + { url = "https://files.pythonhosted.org/packages/1a/5a/e3ff8a98cfbabc5c2d09bf304d2d9d2d7b2e7d60744241ac5ed762015e5c/uv-0.10.0-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:a165582c1447691109d49d09dccb065d2a23852ff42bf77824ff169909aa85da", size = 21057249, upload-time = "2026-02-05T20:56:48.921Z" }, + { url = "https://files.pythonhosted.org/packages/ee/77/ec8f24f8d0f19c4fda0718d917bb78b9e6f02a4e1963b401f1c4f4614a54/uv-0.10.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:aefea608971f4f23ac3dac2006afb8eb2b2c1a2514f5fee1fac18e6c45fd70c4", size = 19827174, upload-time = "2026-02-05T20:57:10.581Z" }, + { url = "https://files.pythonhosted.org/packages/c6/7e/09b38b93208906728f591f66185a425be3acdb97c448460137d0e6ecb30a/uv-0.10.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:d4b621bcc5d0139502789dc299bae8bf55356d07b95cb4e57e50e2afcc5f43e1", size = 21629522, upload-time = "2026-02-05T20:57:29.959Z" }, + { url = "https://files.pythonhosted.org/packages/89/f3/48d92c90e869331306979efaa29a44c3e7e8376ae343edc729df0d534dfb/uv-0.10.0-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.musllinux_1_1_armv7l.whl", hash = "sha256:b4bea728a6b64826d0091f95f28de06dd2dc786384b3d336a90297f123b4da0e", size = 21614812, upload-time = "2026-02-05T20:56:58.103Z" }, + { url = "https://files.pythonhosted.org/packages/ff/43/d0dedfcd4fe6e36cabdbeeb43425cd788604db9d48425e7b659d0f7ba112/uv-0.10.0-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bc0cc2a4bcf9efbff9a57e2aed21c2d4b5a7ec2cc0096e0c33d7b53da17f6a3b", size = 21577072, upload-time = "2026-02-05T20:57:45.455Z" }, + { url = "https://files.pythonhosted.org/packages/c5/90/b8c9320fd8d86f356e37505a02aa2978ed28f9c63b59f15933e98bce97e5/uv-0.10.0-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:070ca2f0e8c67ca9a8f70ce403c956b7ed9d51e0c2e9dbbcc4efa5e0a2483f79", size = 22829664, upload-time = "2026-02-05T20:57:22.689Z" }, + { url = "https://files.pythonhosted.org/packages/56/9c/2c36b30b05c74b2af0e663e0e68f1d10b91a02a145e19b6774c121120c0b/uv-0.10.0-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8070c66149c06f9b39092a06f593a2241345ea2b1d42badc6f884c2cc089a1b1", size = 23705815, upload-time = "2026-02-05T20:57:37.604Z" }, + { url = "https://files.pythonhosted.org/packages/6c/a1/8c7fdb14ab72e26ca872e07306e496a6b8cf42353f9bf6251b015be7f535/uv-0.10.0-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3db1d5390b3a624de672d7b0f9c9d8197693f3b2d3d9c4d9e34686dcbc34197a", size = 22890313, upload-time = "2026-02-05T20:57:26.35Z" }, + { url = "https://files.pythonhosted.org/packages/f3/f8/5c152350b1a6d0af019801f91a1bdeac854c33deb36275f6c934f0113cb5/uv-0.10.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:82b46db718763bf742e986ebbc7a30ca33648957a0dcad34382970b992f5e900", size = 22769440, upload-time = "2026-02-05T20:56:53.859Z" }, + { url = "https://files.pythonhosted.org/packages/87/44/980e5399c6f4943b81754be9b7deb87bd56430e035c507984e17267d6a97/uv-0.10.0-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:eb95d28590edd73b8fdd80c27d699c45c52f8305170c6a90b830caf7f36670a4", size = 21695296, upload-time = "2026-02-05T20:57:06.732Z" }, + { url = "https://files.pythonhosted.org/packages/ae/e7/f44ad40275be2087b3910df4678ed62cf0c82eeb3375c4a35037a79747db/uv-0.10.0-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:5871eef5046a81df3f1636a3d2b4ccac749c23c7f4d3a4bae5496cb2876a1814", size = 22424291, upload-time = "2026-02-05T20:57:49.067Z" }, + { url = "https://files.pythonhosted.org/packages/c2/81/31c0c0a8673140756e71a1112bf8f0fcbb48a4cf4587a7937f5bd55256b6/uv-0.10.0-py3-none-musllinux_1_1_i686.whl", hash = "sha256:1af0ec125a07edb434dfaa98969f6184c1313dbec2860c3c5ce2d533b257132a", size = 22109479, upload-time = "2026-02-05T20:57:02.258Z" }, + { url = "https://files.pythonhosted.org/packages/d7/d1/2eb51bc233bad3d13ad64a0c280fd4d1ebebf5c2939b3900a46670fa2b91/uv-0.10.0-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:45909b9a734250da05b10101e0a067e01ffa2d94bbb07de4b501e3cee4ae0ff3", size = 22972087, upload-time = "2026-02-05T20:57:52.847Z" }, + { url = "https://files.pythonhosted.org/packages/d2/f7/49987207b87b5c21e1f0e81c52892813e8cdf7e318b6373d6585773ebcdd/uv-0.10.0-py3-none-win32.whl", hash = "sha256:d5498851b1f07aa9c9af75578b2029a11743cb933d741f84dcbb43109a968c29", size = 20896746, upload-time = "2026-02-05T20:57:33.426Z" }, + { url = "https://files.pythonhosted.org/packages/80/b2/1370049596c6ff7fa1fe22fccf86a093982eac81017b8c8aff541d7263b2/uv-0.10.0-py3-none-win_amd64.whl", hash = "sha256:edd469425cd62bcd8c8cc0226c5f9043a94e37ed869da8268c80fdbfd3e5015e", size = 23433041, upload-time = "2026-02-05T20:57:41.41Z" }, + { url = "https://files.pythonhosted.org/packages/e3/76/1034c46244feafec2c274ac52b094f35d47c94cdb11461c24cf4be8a0c0c/uv-0.10.0-py3-none-win_arm64.whl", hash = "sha256:e90c509749b3422eebb54057434b7119892330d133b9690a88f8a6b0f3116be3", size = 21880261, upload-time = "2026-02-05T20:57:14.724Z" }, +] + +[[package]] +name = "virtualenv" +version = "20.36.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "distlib" }, + { name = "filelock" }, + { name = "platformdirs" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/aa/a3/4d310fa5f00863544e1d0f4de93bddec248499ccf97d4791bc3122c9d4f3/virtualenv-20.36.1.tar.gz", hash = "sha256:8befb5c81842c641f8ee658481e42641c68b5eab3521d8e092d18320902466ba", size = 6032239, upload-time = "2026-01-09T18:21:01.296Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6a/2a/dc2228b2888f51192c7dc766106cd475f1b768c10caaf9727659726f7391/virtualenv-20.36.1-py3-none-any.whl", hash = "sha256:575a8d6b124ef88f6f51d56d656132389f961062a9177016a50e4f507bbcc19f", size = 6008258, upload-time = "2026-01-09T18:20:59.425Z" }, +] diff --git a/zizmor.yml b/zizmor.yml new file mode 100644 index 0000000..c359223 --- /dev/null +++ b/zizmor.yml @@ -0,0 +1,6 @@ +rules: + unpinned-uses: + config: + policies: + # TODO: use the default policies + "*": any