├── .editorconfig ├── .github ├── CODEOWNERS ├── ISSUE_TEMPLATE │ ├── bug_report.yml │ ├── config.yml │ └── feature_request.yml ├── dependabot.yml └── workflows │ ├── changelog-fragment.yml │ ├── dependabot-auto-merge.yml │ ├── main.yml │ ├── prepare-release.yml │ ├── publish.yml │ ├── stale.yml │ ├── status_embed.yml │ ├── unit-tests.yml │ └── validation.yml ├── .gitignore ├── .pre-commit-config.yaml ├── .readthedocs.yaml ├── ATTRIBUTION.md ├── CHANGELOG.md ├── CODE-OF-CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE-THIRD-PARTY.txt ├── LICENSE.txt ├── README.md ├── SECURITY.md ├── changes ├── +api_pages.docs.md ├── +loginstart_update.feature.md ├── +server_encryption_fucntions.feature.md ├── .template.rst ├── 131.internal.md ├── 179.docs.md ├── 209.feature.md ├── 257.feature.md ├── 258.internal.md ├── 259.internal.md ├── 274.internal.md ├── 285.internal.1.md ├── 285.internal.2.md ├── 286.internal.md ├── 300.internal.md ├── 323.internal.md ├── 329.internal.md ├── 330.bugfix.md ├── 331.internal.md ├── 332.internal.md ├── 347.internal.md ├── 379.internal.md ├── 395.internal.md ├── 421.breaking.md ├── 421.internal.md ├── 427.bugfix.md └── README.md ├── docs ├── _static │ ├── extra.css │ └── extra.js ├── api │ ├── basic.rst │ ├── internal.rst │ ├── packets.rst │ ├── protocol.rst │ └── types │ │ ├── index.rst │ │ └── nbt.rst ├── conf.py ├── examples │ ├── index.rst │ └── status.rst ├── extensions │ └── attributetable.py ├── index.rst ├── pages │ ├── changelog.rst │ ├── code-of-conduct.rst │ ├── contributing.rst │ ├── faq.rst │ ├── installation.rst │ └── version_guarantees.rst └── usage │ ├── authentication.rst │ └── index.rst ├── mcproto ├── __init__.py ├── auth │ ├── __init__.py │ ├── account.py │ ├── microsoft │ │ ├── __init__.py │ │ ├── oauth.py │ │ └── xbox.py │ ├── msa.py │ └── yggdrasil.py ├── buffer.py ├── connection.py ├── encryption.py ├── multiplayer.py ├── packets │ ├── __init__.py │ ├── handshaking │ │ ├── __init__.py │ │ └── handshake.py │ ├── interactions.py │ ├── login │ │ ├── __init__.py │ │ └── login.py │ ├── packet.py │ ├── packet_map.py │ └── status │ │ ├── __init__.py │ │ ├── ping.py │ │ └── status.py ├── protocol │ ├── __init__.py │ ├── base_io.py │ └── utils.py ├── py.typed ├── types │ ├── __init__.py │ ├── abc.py │ ├── chat.py │ ├── nbt.py │ └── uuid.py └── utils │ ├── __init__.py │ ├── abc.py │ └── deprecation.py ├── poetry.lock ├── pyproject.toml └── tests ├── README.md ├── __init__.py ├── helpers.py └── mcproto ├── __init__.py ├── packets ├── handshaking │ ├── __init__.py │ └── test_handshake.py ├── login │ ├── __init__.py │ └── test_login.py └── status │ ├── __init__.py │ ├── test_ping.py │ └── test_status.py ├── protocol ├── __init__.py ├── helpers.py ├── test_base_io.py └── test_utils.py ├── test_buffer.py ├── test_connection.py ├── test_encryption.py ├── test_multiplayer.py ├── types ├── __init__.py ├── test_chat.py ├── test_nbt.py └── test_uuid.py └── utils ├── __init__.py ├── test_deprecation.py └── test_serializable.py /.editorconfig: -------------------------------------------------------------------------------- 1 | # Check http://editorconfig.org for more information 2 | # This is the main config file for this project: 3 | root = true 4 | 5 | [*] 6 | charset = utf-8 7 | end_of_line = lf 8 | insert_final_newline = true 9 | indent_style = space 10 | indent_size = 2 11 | trim_trailing_whitespace = true 12 | 13 | [*.{py, pyi}] 14 | indent_size = 4 15 | 16 | [*.md] 17 | indent_size = 4 18 | trim_trailing_whitespace = false 19 | max_line_length = 120 20 | -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | # If a PR changes a file that has a code owner specified, this code owner 2 | # is automatically requested a review from 3 | 4 | # GitHub / External CI 5 | .github/dependabot.yml @ItsDrike 6 | .github/workflows/** @ItsDrike 7 | .github/scripts/** @ItsDrike 8 | 9 | # Local CI / tool configurations 10 | .editorconfig @ItsDrike 11 | .gitignore @ItsDrike 12 | .pre-commit-config.yml @ItsDrike 13 | 14 | # Meta (config files for the repo itself) 15 | .github/CODEOWNERS @ItsDrike 16 | .github/ISSUE_TEMPLATE/** @ItsDrike 17 | 18 | # Project's README/documents 19 | README.md @ItsDrike 20 | CODE-OF-CONDUCT.md @ItsDrike 21 | docs/code-of-conduct.rst @ItsDrike 22 | CONTRIBUTING.md @ItsDrike 23 | ATTRIBUTION.md @ItsDrike 24 | LICENSE.txt @ItsDrike 25 | LICENSE-THIRD-PARTY.txt @ItsDrike 26 | SECURITY.md @ItsDrike 27 | tests/README.md @ItsDrike 28 | changes/README.md @ItsDrike 29 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.yml: -------------------------------------------------------------------------------- 1 | name: Bug report 2 | description: Found a bug? Let us know so we can fix it! 3 | labels: ["t: bug"] 4 | 5 | body: 6 | - type: textarea 7 | id: bug-description 8 | attributes: 9 | label: Bug description 10 | description: Describe the bug. What's wrong? 11 | validations: 12 | required: true 13 | 14 | - type: textarea 15 | id: reproduction-steps 16 | attributes: 17 | label: Reproduction 18 | description: Steps to reproduce the bug. This can also be a code snippet. Try to keep things as minimal as you can. 19 | value: | 20 | 1. 21 | 2. 22 | 3. 23 | 4. 24 | validations: 25 | required: true 26 | 27 | - type: input 28 | id: library-version 29 | attributes: 30 | label: Library version 31 | description: mcproto version used when this bug was encountered. (Find out with `pip show mcproto` command) 32 | placeholder: 0.1.0 33 | validations: 34 | required: true 35 | 36 | - type: input 37 | id: python-version 38 | attributes: 39 | label: Python version 40 | description: Version of python interpreter you're using. (Find out with `python -V` or `py -V`) 41 | placeholder: 3.11.1 42 | validations: 43 | required: true 44 | 45 | - type: input 46 | id: operating-system 47 | attributes: 48 | label: Operating system 49 | description: Operating system used when this bug was encountered. 50 | placeholder: Windows 11 / Linux - Ubuntu 22.10 / MacOS / ... 51 | 52 | - type: textarea 53 | id: further-info 54 | attributes: 55 | label: Further info 56 | description: Any further info such as images/videos, exception tracebacks, ... 57 | 58 | - type: checkboxes 59 | id: checklist 60 | attributes: 61 | label: Checklist 62 | description: Make sure to tick all the following boxes. 63 | options: 64 | - label: I have searched the issue tracker and have made sure it's not a duplicate. If it is a follow up of another issue, I have specified it. 65 | required: true 66 | - label: I have made sure to remove ANY sensitive information (passwords, credentials, personal details, etc.). 67 | required: true 68 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: true 2 | contact_links: 3 | - name: Discord server 4 | url: https://discord.gg/C2wX7zduxC 5 | about: Ideal way to ask questions, contact the maintainers, stay up to date with updates, and getting to know other developers. 6 | - name: Discussions 7 | url: https://github.com/py-mine/mcproto/discussions 8 | about: A perfect place to ask questions and get help with issues you are having. 9 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.yml: -------------------------------------------------------------------------------- 1 | name: Feature request 2 | description: Got a cool idea you would like implemented? Share it with us! 3 | labels: ["t: feature"] 4 | 5 | body: 6 | - type: textarea 7 | id: summary 8 | attributes: 9 | label: Summary 10 | description: Small summary of the feature. 11 | validations: 12 | required: true 13 | 14 | - type: textarea 15 | id: problem 16 | attributes: 17 | label: Why is this needed? 18 | description: Why should this feature be implemented? What problem(s) would it solve? 19 | validations: 20 | required: true 21 | 22 | - type: textarea 23 | id: ideal-implementation 24 | attributes: 25 | label: Ideal implementation 26 | description: How should this feature be implemented? 27 | value: To be decided. 28 | 29 | - type: checkboxes 30 | id: checklist 31 | attributes: 32 | label: Checklist 33 | description: Make sure to tick all the following boxes. 34 | options: 35 | - label: I have searched the issue tracker and have made sure it's not a duplicate. If it is a follow up of another issue, I have specified it. 36 | required: true 37 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: "pip" 9 | directory: "/" # Location of package manifests 10 | schedule: 11 | interval: "daily" 12 | labels: 13 | - "a: dependencies" 14 | - "p: 3 - low" 15 | - "t: enhancement" 16 | 17 | - package-ecosystem: "github-actions" 18 | directory: "/" 19 | schedule: 20 | interval: "daily" 21 | labels: 22 | - "a: dependencies" 23 | - "a: CI" 24 | - "p: 3 - low" 25 | - "t: enhancement" 26 | -------------------------------------------------------------------------------- /.github/workflows/changelog-fragment.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Changelog Fragment present 3 | 4 | on: 5 | pull_request: 6 | types: [labeled, unlabeled, opened, reopened, synchronize] 7 | branches: 8 | - main 9 | 10 | concurrency: 11 | group: ${{ github.workflow }}-${{ github.ref }} 12 | cancel-in-progress: true 13 | 14 | jobs: 15 | check-fragment-added: 16 | if: github.event.pull_request.user.type != 'Bot' && !contains(github.event.pull_request.labels.*.name, 'skip-fragment-check') 17 | runs-on: ubuntu-latest 18 | 19 | steps: 20 | - name: Checkout repository 21 | uses: actions/checkout@v4 22 | with: 23 | # `towncrier check` runs `git diff --name-only origin/main...`, which 24 | # needs a non-shallow clone. 25 | fetch-depth: 0 26 | 27 | - name: Setup poetry 28 | uses: ItsDrike/setup-poetry@v1 29 | with: 30 | python-version: 3.11 31 | install-args: "--no-root --only release" 32 | 33 | - name: Check if changelog fragment was added 34 | run: | 35 | if ! towncrier check --compare-with origin/${{ github.base_ref }}; then 36 | echo "----------------------------------------------------" 37 | echo "Please refer to CONTRIBUTING.md/#Changelog and changes/README.md for more information" 38 | exit 1 39 | fi 40 | -------------------------------------------------------------------------------- /.github/workflows/dependabot-auto-merge.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Dependabot auto-merge 3 | on: pull_request_target 4 | 5 | permissions: 6 | contents: write 7 | pull-requests: write 8 | 9 | jobs: 10 | dependabot: 11 | runs-on: ubuntu-latest 12 | if: github.actor == 'dependabot[bot]' 13 | steps: 14 | - name: Generate token 15 | id: app-token 16 | uses: actions/create-github-app-token@v1 17 | with: 18 | app-id: ${{ secrets.APP_ID }} 19 | private-key: ${{ secrets.PRIVATE_KEY }} 20 | 21 | - name: Dependabot metadata 22 | id: metadata 23 | uses: dependabot/fetch-metadata@v2 24 | with: 25 | github-token: "${{ steps.app-token.outputs.token }}" 26 | 27 | - name: Approve a PR 28 | run: gh pr review --approve "$PR_URL" 29 | env: 30 | PR_URL: ${{ github.event.pull_request.html_url }} 31 | GITHUB_TOKEN: ${{ steps.app-token.outputs.token }} 32 | 33 | - name: Enable auto-merge for Dependabot PRs 34 | run: gh pr merge --auto --squash "$PR_URL" 35 | env: 36 | PR_URL: ${{ github.event.pull_request.html_url }} 37 | GITHUB_TOKEN: ${{ steps.app-token.outputs.token }} 38 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: CI 3 | 4 | on: 5 | push: 6 | branches: 7 | - main 8 | pull_request: 9 | workflow_dispatch: 10 | 11 | # Cancel already running workflows if new ones are scheduled 12 | concurrency: 13 | group: ${{ github.workflow }}-${{ github.ref }} 14 | cancel-in-progress: true 15 | 16 | jobs: 17 | validation: 18 | uses: ./.github/workflows/validation.yml 19 | 20 | unit-tests: 21 | uses: ./.github/workflows/unit-tests.yml 22 | secrets: 23 | CC_TEST_REPORTER_ID: ${{ secrets.CC_TEST_REPORTER_ID }} 24 | 25 | # Produce a pull request payload artifact with various data about the 26 | # pull-request event (such as the PR number, title, author, ...). 27 | # This data is then be picked up by status-embed.yml action. 28 | pr_artifact: 29 | name: Produce Pull Request payload artifact 30 | runs-on: ubuntu-latest 31 | 32 | steps: 33 | - name: Prepare Pull Request Payload artifact 34 | id: prepare-artifact 35 | if: always() && github.event_name == 'pull_request' 36 | continue-on-error: true 37 | run: cat $GITHUB_EVENT_PATH | jq '.pull_request' > pull_request_payload.json 38 | 39 | - name: Upload a Build Artifact 40 | if: always() && steps.prepare-artifact.outcome == 'success' 41 | continue-on-error: true 42 | uses: actions/upload-artifact@v4 43 | with: 44 | name: pull-request-payload 45 | path: pull_request_payload.json 46 | -------------------------------------------------------------------------------- /.github/workflows/prepare-release.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Prepare Release 3 | 4 | on: 5 | workflow_dispatch: 6 | inputs: 7 | version: 8 | description: "The version to prepare the release for" 9 | required: true 10 | 11 | jobs: 12 | prepare-release: 13 | if: github.ref == 'refs/heads/main' 14 | runs-on: ubuntu-latest 15 | 16 | steps: 17 | - name: Generate token 18 | id: generate_token 19 | uses: tibdex/github-app-token@v2 20 | with: 21 | app_id: ${{ secrets.APP_ID }} 22 | private_key: ${{ secrets.PRIVATE_KEY }} 23 | 24 | - name: Checkout repository 25 | uses: actions/checkout@v4 26 | with: 27 | token: ${{ steps.generate_token.outputs.token }} 28 | 29 | # Make the github application be the committer 30 | # (see: https://stackoverflow.com/a/74071223 on how to obtain the committer email) 31 | - name: Setup git config 32 | run: | 33 | git config --global user.name "py-mine-ci-bot" 34 | git config --global user.email "121461646+py-mine-ci-bot[bot]@users.noreply.github.com" 35 | 36 | - name: Setup poetry 37 | uses: ItsDrike/setup-poetry@v1 38 | with: 39 | python-version: 3.13 40 | install-args: "--no-root --only release" 41 | 42 | - name: Checkout new branch 43 | run: git checkout -b "prepare-release-${{ github.event.inputs.version }}" 44 | 45 | - name: Run towncrier 46 | run: towncrier build --yes --version "${{ github.event.inputs.version }}" 47 | 48 | - name: Commit changes 49 | run: git commit -am "Prepare for release of version ${{ github.event.inputs.version }}" 50 | 51 | - name: Push changes 52 | run: git push origin "prepare-release-${{ github.event.inputs.version }}" 53 | 54 | - name: Create pull request 55 | uses: repo-sync/pull-request@v2 56 | with: 57 | # We need to use a bot token to be able to trigger workflows that listen to pull_request calls 58 | github_token: ${{ steps.generate_token.outputs.token }} 59 | source_branch: prepare-release-${{ github.event.inputs.version }} 60 | destination_branch: main 61 | pr_assignee: ${{ github.event.sender.login }} 62 | pr_title: Prepare for release of ${{ github.event.inputs.version }} 63 | pr_label: "a: dependencies,t: release" 64 | pr_body: | 65 | Release preparation triggered by @${{ github.event.sender.login }}. 66 | Once the pull request is merged, you can trigger a PyPI release by pushing a \`v${{ github.event.inputs.version }}\` git tag in the repository. 67 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Publish to PyPI / GitHub 3 | 4 | on: 5 | push: 6 | tags: 7 | - "v*" 8 | 9 | permissions: 10 | contents: read 11 | 12 | jobs: 13 | build: 14 | name: "Build the project" 15 | runs-on: ubuntu-latest 16 | 17 | outputs: 18 | prerelease: ${{ steps.check-version.outputs.prerelease }} 19 | 20 | steps: 21 | - name: Checkout repository 22 | uses: actions/checkout@v4 23 | 24 | - name: Setup poetry 25 | uses: ItsDrike/setup-poetry@v1 26 | with: 27 | python-version: 3.13 28 | install-args: "--only release-ci" 29 | 30 | - name: Set version with dynamic versioning 31 | run: poetry run poetry-dynamic-versioning 32 | 33 | - name: Build project for distribution 34 | run: poetry build 35 | 36 | - name: Upload build files 37 | uses: actions/upload-artifact@v4 38 | with: 39 | name: "dist" 40 | path: "dist/" 41 | if-no-files-found: error 42 | retention-days: 5 43 | 44 | - name: Check pre-release status 45 | id: check-version 46 | run: | 47 | if [[ "$(poetry version --short)" =~ "^[0-9]+\.[0-9]+\.[0-9]+$" ]] 48 | then 49 | echo prerelease=true >> $GITHUB_OUTPUT 50 | else 51 | echo prerelease=false >> $GITHUB_OUTPUT 52 | fi 53 | 54 | # Get content of the changelog for the latest release, so that we can use 55 | # it as the body for a GitHub tag 56 | - name: Obtain latest changelog 57 | # Our CHANGELOG.md uses `---` separators between each published 58 | # version. The command below obtains all content until that separator, 59 | # leaving us with just the content for the latest version. We then 60 | # remove first 2 lines, being level 2 header with version and date, 61 | # and a blank line under it, and also the last 2 lines, being the 62 | # separator itself, and a blank line above it. 63 | run: | 64 | awk '1;/---/{exit}' CHANGELOG.md | tail -n +3 | head -n -2 \ 65 | > changelog.txt 66 | 67 | - name: Upload release changelog 68 | uses: actions/upload-artifact@v4 69 | with: 70 | name: "changelog" 71 | path: "changelog.txt" 72 | if-no-files-found: error 73 | retention-days: 5 74 | 75 | publish-github: 76 | name: "Publish a GitHub release" 77 | needs: build 78 | runs-on: ubuntu-latest 79 | environment: release 80 | 81 | steps: 82 | - name: Download the distribution files from PR artifact 83 | uses: actions/download-artifact@v4 84 | with: 85 | name: "dist" 86 | path: "dist/" 87 | 88 | - name: Download the changelog from PR artifact 89 | uses: actions/download-artifact@v4 90 | with: 91 | name: "changelog" 92 | 93 | - name: Generate token 94 | id: generate_token 95 | uses: tibdex/github-app-token@v2 96 | with: 97 | app_id: ${{ secrets.APP_ID }} 98 | private_key: ${{ secrets.PRIVATE_KEY }} 99 | 100 | - name: Create Release 101 | uses: ncipollo/release-action@v1 102 | with: 103 | artifacts: "dist/*" 104 | token: ${{ steps.generate_token.outputs.token }} 105 | bodyFile: changelog.txt 106 | draft: false 107 | prerelease: ${{ needs.build.outputs.prerelease == 'true' }} 108 | 109 | publish-pypi: 110 | name: "Publish to PyPI" 111 | needs: build 112 | runs-on: ubuntu-latest 113 | environment: release 114 | permissions: 115 | # Used to authenticate to PyPI via OIDC. 116 | id-token: write 117 | 118 | steps: 119 | - name: Download the distribution files from PR artifact 120 | uses: actions/download-artifact@v4 121 | with: 122 | name: "dist" 123 | path: "dist/" 124 | 125 | # Upload to Test PyPI first, in case something fails. 126 | - name: Upload to Test PyPI 127 | uses: pypa/gh-action-pypi-publish@release/v1 128 | with: 129 | # the "legacy" in the URL doesn't mean it's deprecated 130 | repository-url: https://test.pypi.org/legacy/ 131 | 132 | # This uses PyPI's trusted publishing, so no token is required 133 | - name: Release to PyPI 134 | uses: pypa/gh-action-pypi-publish@release/v1 135 | -------------------------------------------------------------------------------- /.github/workflows/stale.yml: -------------------------------------------------------------------------------- 1 | name: Mark stale issues and pull requests 2 | 3 | on: 4 | schedule: 5 | - cron: "0 */4 * * *" 6 | workflow_dispatch: 7 | 8 | jobs: 9 | stale: 10 | # Don't run in forks, they probably don't have the same labels set up 11 | if: github.repository == 'py-mine/mcproto' 12 | 13 | runs-on: ubuntu-latest 14 | permissions: 15 | issues: write 16 | pull-requests: write 17 | 18 | steps: 19 | - name: Generate token 20 | id: generate_token 21 | uses: tibdex/github-app-token@v2 22 | with: 23 | app_id: ${{ secrets.APP_ID }} 24 | private_key: ${{ secrets.PRIVATE_KEY }} 25 | 26 | - uses: actions/stale@v9 27 | with: 28 | repo-token: ${{ steps.generate_token.outputs.token }} 29 | stale-issue-label: "s: stale" 30 | stale-pr-label: "s: stale" 31 | exempt-issue-labels: "s: deferred,s: stalled" 32 | exempt-pr-labels: "s: deferred,s: stalled" 33 | days-before-stale: 60 34 | days-before-close: -1 35 | -------------------------------------------------------------------------------- /.github/workflows/status_embed.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Status Embed 3 | 4 | on: 5 | workflow_run: 6 | workflows: 7 | - CI 8 | types: 9 | - completed 10 | 11 | concurrency: 12 | group: ${{ github.workflow }}-${{ github.ref }} 13 | cancel-in-progress: true 14 | 15 | jobs: 16 | status_embed: 17 | name: Send Status Embed to Discord 18 | runs-on: ubuntu-latest 19 | 20 | steps: 21 | # A workflow_run event does not contain all the information 22 | # we need for a PR embed. That's why we upload an artifact 23 | # with that information in the CI workflow. 24 | - name: Get Pull Request Information 25 | id: pr_info 26 | if: github.event.workflow_run.event == 'pull_request' 27 | run: | 28 | curl -s -H "Authorization: token $GITHUB_TOKEN" ${{ github.event.workflow_run.artifacts_url }} > artifacts.json 29 | DOWNLOAD_URL=$(cat artifacts.json | jq -r '.artifacts[] | select(.name == "pull-request-payload") | .archive_download_url') 30 | [ -z "$DOWNLOAD_URL" ] && exit 1 31 | curl -sSL -H "Authorization: token $GITHUB_TOKEN" -o pull_request_payload.zip $DOWNLOAD_URL || exit 2 32 | unzip -p pull_request_payload.zip > pull_request_payload.json 33 | [ -s pull_request_payload.json ] || exit 3 34 | echo "pr_author_login=$(jq -r '.user.login // empty' pull_request_payload.json)" >> $GITHUB_OUTPUT 35 | echo "pr_number=$(jq -r '.number // empty' pull_request_payload.json)" >> $GITHUB_OUTPUT 36 | echo "pr_title=$(jq -r '.title // empty' pull_request_payload.json)" >> $GITHUB_OUTPUT 37 | echo "pr_source=$(jq -r '.head.label // empty' pull_request_payload.json)" >> $GITHUB_OUTPUT 38 | env: 39 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 40 | 41 | # Send an informational status embed to Discord instead of the 42 | # standard embeds that Discord sends. This embed will contain 43 | # more information and we can fine tune when we actually want 44 | # to send an embed. 45 | - name: GitHub Actions Status Embed for Discord 46 | uses: SebastiaanZ/github-status-embed-for-discord@v0.3.0 47 | with: 48 | # Our GitHub Actions webhook 49 | webhook_id: "1051784242318815242" 50 | webhook_token: ${{ secrets.webhook_token }} 51 | 52 | # We need to provide the information of the workflow that 53 | # triggered this workflow instead of this workflow. 54 | workflow_name: ${{ github.event.workflow_run.name }} 55 | run_id: ${{ github.event.workflow_run.id }} 56 | run_number: ${{ github.event.workflow_run.run_number }} 57 | status: ${{ github.event.workflow_run.conclusion }} 58 | sha: ${{ github.event.workflow_run.head_sha }} 59 | 60 | # Now we can use the information extracted in the previous step: 61 | pr_author_login: ${{ steps.pr_info.outputs.pr_author_login }} 62 | pr_number: ${{ steps.pr_info.outputs.pr_number }} 63 | pr_title: ${{ steps.pr_info.outputs.pr_title }} 64 | pr_source: ${{ steps.pr_info.outputs.pr_source }} 65 | -------------------------------------------------------------------------------- /.github/workflows/unit-tests.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Unit-Tests 3 | 4 | on: 5 | workflow_call: 6 | secrets: 7 | CC_TEST_REPORTER_ID: 8 | required: true 9 | 10 | jobs: 11 | unit-tests: 12 | runs-on: ${{ matrix.platform }} 13 | 14 | strategy: 15 | fail-fast: false # Allows for matrix sub-jobs to fail without cancelling the rest 16 | matrix: 17 | platform: [ubuntu-latest, windows-latest] 18 | python-version: ["3.9", "3.13"] 19 | 20 | steps: 21 | - name: Checkout repository 22 | uses: actions/checkout@v4 23 | 24 | - name: Setup poetry 25 | id: poetry_setup 26 | uses: ItsDrike/setup-poetry@v1 27 | with: 28 | python-version: ${{ matrix.python-version }} 29 | install-args: "--without lint --without release" 30 | 31 | - name: Run pytest 32 | shell: bash 33 | run: | 34 | poetry run task test 35 | 36 | tests-done: 37 | needs: [unit-tests] 38 | if: always() && !cancelled() 39 | runs-on: ubuntu-latest 40 | 41 | steps: 42 | - name: Set status based on required jobs 43 | env: 44 | RESULTS: ${{ join(needs.*.result, ' ') }} 45 | run: | 46 | for result in $RESULTS; do 47 | if [ "$result" != "success" ]; then 48 | exit 1 49 | fi 50 | done 51 | -------------------------------------------------------------------------------- /.github/workflows/validation.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Validation 3 | 4 | on: workflow_call 5 | 6 | env: 7 | PRE_COMMIT_HOME: "/home/runner/.cache/pre-commit" 8 | 9 | jobs: 10 | lint: 11 | runs-on: ubuntu-latest 12 | 13 | steps: 14 | - name: Checkout repository 15 | uses: actions/checkout@v4 16 | 17 | - name: Setup poetry 18 | id: poetry_setup 19 | uses: ItsDrike/setup-poetry@v1 20 | with: 21 | python-version: 3.13 22 | install-args: "--without release" 23 | 24 | - name: Pre-commit Environment Caching 25 | uses: actions/cache@v4 26 | with: 27 | path: ${{ env.PRE_COMMIT_HOME }} 28 | key: 29 | "precommit-${{ runner.os }}-${{ steps.poetry_setup.outputs.python-version }}-\ 30 | ${{ hashFiles('./.pre-commit-config.yaml') }}" 31 | # Restore keys allows us to perform a cache restore even if the full cache key wasn't matched. 32 | # That way we still end up saving new cache, but we can still make use of the cache from previous 33 | # version. 34 | restore-keys: "precommit-${{ runner.os }}-${{ steps.poetry_setup.outputs-python-version}}-" 35 | 36 | - name: Run pre-commit hooks 37 | run: SKIP=ruff-linter,ruff-formatter,slotscheck,basedpyright pre-commit run --all-files 38 | 39 | - name: Run ruff linter 40 | run: ruff check --output-format=github --show-fixes --exit-non-zero-on-fix . 41 | 42 | - name: Run ruff formatter 43 | run: ruff format --diff . 44 | 45 | - name: Run slotscheck 46 | run: slotscheck -m mcproto 47 | 48 | - name: Run basedpyright type checker 49 | run: basedpyright --warnings . 50 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Python byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | .pytest_cache/ 6 | .mypy_cache/ 7 | 8 | # Virtual environments 9 | .venv/ 10 | .tox/ 11 | venv/ 12 | env/ 13 | ENV/ 14 | 15 | # Python packaging files 16 | dist/ 17 | 18 | # Pytest coverage reports 19 | htmlcov/ 20 | .coverage* 21 | coverage.xml 22 | 23 | # Sphinx documentation 24 | docs/_build/ 25 | 26 | # Pyenv local version information 27 | .python-version 28 | 29 | # Editor generated files 30 | .idea/ 31 | .vscode/ 32 | .spyproject/ 33 | .spyderproject/ 34 | .replit 35 | 36 | # Auto-generated folder attributes for MacOS 37 | .DS_STORE 38 | 39 | # Local gitignore (symlinked to .git/info/exclude) 40 | .gitignore_local 41 | 42 | # Environmental, backup and personal files 43 | *.env 44 | *.bak 45 | TODO 46 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/pre-commit/pre-commit-hooks 3 | rev: v4.3.0 4 | hooks: 5 | - id: check-merge-conflict 6 | - id: check-toml # For pyproject.toml 7 | - id: check-yaml # For workflows 8 | - id: end-of-file-fixer 9 | - id: trailing-whitespace 10 | args: [--markdown-linebreak-ext=md] 11 | - id: mixed-line-ending 12 | args: [--fix=lf] 13 | 14 | - repo: local 15 | hooks: 16 | - id: ruff-linter 17 | name: Ruff Linter 18 | description: Run ruff checks on the code 19 | entry: poetry run ruff check --force-exclude 20 | language: system 21 | types: [python] 22 | require_serial: true 23 | args: [--fix, --exit-non-zero-on-fix] 24 | 25 | - repo: local 26 | hooks: 27 | - id: ruff-formatter 28 | name: Ruff Formatter 29 | description: Ruf ruff auto-formatter 30 | entry: poetry run ruff format 31 | language: system 32 | types: [python] 33 | require_serial: true 34 | 35 | - repo: local 36 | hooks: 37 | - id: slotscheck 38 | name: Slotscheck 39 | description: "Slotscheck: Ensure your __slots__ are working properly" 40 | entry: poetry run slotscheck -m mcproto 41 | language: system 42 | pass_filenames: false # running slotscheck for single files doesn't respect ignored files, run for entire project 43 | types: [python] 44 | 45 | - repo: local 46 | hooks: 47 | - id: basedpyright 48 | name: Based Pyright 49 | description: Run basedpyright type checker 50 | entry: poetry run basedpyright --warnings 51 | language: system 52 | types: [python] 53 | pass_filenames: false # pyright runs for the entire project, it can't run for single files 54 | -------------------------------------------------------------------------------- /.readthedocs.yaml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | build: 4 | os: ubuntu-22.04 5 | tools: 6 | python: "3.12" 7 | jobs: 8 | post_create_environment: 9 | - python -m pip install poetry 10 | post_install: 11 | - VIRTUAL_ENV=$READTHEDOCS_VIRTUALENV_PATH poetry install --only main,docs,docs-ci 12 | - poetry run poetry-dynamic-versioning 13 | 14 | sphinx: 15 | builder: dirhtml 16 | configuration: "docs/conf.py" 17 | fail_on_warning: true 18 | -------------------------------------------------------------------------------- /ATTRIBUTION.md: -------------------------------------------------------------------------------- 1 | This file serves as a way to explicitly give credit to projects which made mcproto possible. 2 | 3 | Note that as with any other project, if there was some code that was directly utilized from these projects, it will be 4 | mentioned in `LICENSE-THIRD-PARTY.txt`, not in here. This file isn't meant to serve as a place to disclose used code 5 | and it's licenses, but rather to give proper credit where it is due, and to shout out a few amazing projects that 6 | allowed mcproto to exist in the first place. 7 | 8 | - **wiki.vg** (): An absolutely amazing community driven wiki that documents how the minecraft protocol is 9 | structured and the changes that occur between the protocol versions. 10 | - **PyMine-Net**: The project that was the main inspiration to this project, being a separation of the minecraft 11 | networking tooling used in PyMine-Server, which is an attempt at implementing a fully working minecraft server purely 12 | in python. However, this project is no longer maintained, and so mcproto was created to be it's replacement. 13 | - **Mcstatus**: A library that allows for easy fetching of status/query data from minecraft servers, including parsers and 14 | structures that meaningfully represent the obtained data, but also the logic on how it's actually obtained, some of 15 | which this project took heavy inspiration from. 16 | - **pyCraft**: A long abandoned project similar to mcproto, which served as an inspiration for various functionalities in 17 | the library. 18 | - **quarry**: A library providing support for basic interactions with the minecraft protocol, though only up to packet reading. 19 | Implementation for reading data from specific packets is not included. 20 | 21 | To all of these projects, they deserve a massive thank you, for keeping their code/information open-sourced and 22 | available as a source of information, and inspiration freely to anyone. 23 | -------------------------------------------------------------------------------- /CODE-OF-CONDUCT.md: -------------------------------------------------------------------------------- 1 | You can find our Code of Conduct in the project's documentation 2 | [here](https://mcproto.readthedocs.io/en/latest/pages/code-of-conduct/) 3 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | GNU LESSER GENERAL PUBLIC LICENSE 2 | Version 3, 29 June 2007 3 | 4 | Copyright (C) 2007 Free Software Foundation, Inc. 5 | Everyone is permitted to copy and distribute verbatim copies 6 | of this license document, but changing it is not allowed. 7 | 8 | 9 | This version of the GNU Lesser General Public License incorporates 10 | the terms and conditions of version 3 of the GNU General Public 11 | License, supplemented by the additional permissions listed below. 12 | 13 | 0. Additional Definitions. 14 | 15 | As used herein, "this License" refers to version 3 of the GNU Lesser 16 | General Public License, and the "GNU GPL" refers to version 3 of the GNU 17 | General Public License. 18 | 19 | "The Library" refers to a covered work governed by this License, 20 | other than an Application or a Combined Work as defined below. 21 | 22 | An "Application" is any work that makes use of an interface provided 23 | by the Library, but which is not otherwise based on the Library. 24 | Defining a subclass of a class defined by the Library is deemed a mode 25 | of using an interface provided by the Library. 26 | 27 | A "Combined Work" is a work produced by combining or linking an 28 | Application with the Library. The particular version of the Library 29 | with which the Combined Work was made is also called the "Linked 30 | Version". 31 | 32 | The "Minimal Corresponding Source" for a Combined Work means the 33 | Corresponding Source for the Combined Work, excluding any source code 34 | for portions of the Combined Work that, considered in isolation, are 35 | based on the Application, and not on the Linked Version. 36 | 37 | The "Corresponding Application Code" for a Combined Work means the 38 | object code and/or source code for the Application, including any data 39 | and utility programs needed for reproducing the Combined Work from the 40 | Application, but excluding the System Libraries of the Combined Work. 41 | 42 | 1. Exception to Section 3 of the GNU GPL. 43 | 44 | You may convey a covered work under sections 3 and 4 of this License 45 | without being bound by section 3 of the GNU GPL. 46 | 47 | 2. Conveying Modified Versions. 48 | 49 | If you modify a copy of the Library, and, in your modifications, a 50 | facility refers to a function or data to be supplied by an Application 51 | that uses the facility (other than as an argument passed when the 52 | facility is invoked), then you may convey a copy of the modified 53 | version: 54 | 55 | a) under this License, provided that you make a good faith effort to 56 | ensure that, in the event an Application does not supply the 57 | function or data, the facility still operates, and performs 58 | whatever part of its purpose remains meaningful, or 59 | 60 | b) under the GNU GPL, with none of the additional permissions of 61 | this License applicable to that copy. 62 | 63 | 3. Object Code Incorporating Material from Library Header Files. 64 | 65 | The object code form of an Application may incorporate material from 66 | a header file that is part of the Library. You may convey such object 67 | code under terms of your choice, provided that, if the incorporated 68 | material is not limited to numerical parameters, data structure 69 | layouts and accessors, or small macros, inline functions and templates 70 | (ten or fewer lines in length), you do both of the following: 71 | 72 | a) Give prominent notice with each copy of the object code that the 73 | Library is used in it and that the Library and its use are 74 | covered by this License. 75 | 76 | b) Accompany the object code with a copy of the GNU GPL and this license 77 | document. 78 | 79 | 4. Combined Works. 80 | 81 | You may convey a Combined Work under terms of your choice that, 82 | taken together, effectively do not restrict modification of the 83 | portions of the Library contained in the Combined Work and reverse 84 | engineering for debugging such modifications, if you also do each of 85 | the following: 86 | 87 | a) Give prominent notice with each copy of the Combined Work that 88 | the Library is used in it and that the Library and its use are 89 | covered by this License. 90 | 91 | b) Accompany the Combined Work with a copy of the GNU GPL and this license 92 | document. 93 | 94 | c) For a Combined Work that displays copyright notices during 95 | execution, include the copyright notice for the Library among 96 | these notices, as well as a reference directing the user to the 97 | copies of the GNU GPL and this license document. 98 | 99 | d) Do one of the following: 100 | 101 | 0) Convey the Minimal Corresponding Source under the terms of this 102 | License, and the Corresponding Application Code in a form 103 | suitable for, and under terms that permit, the user to 104 | recombine or relink the Application with a modified version of 105 | the Linked Version to produce a modified Combined Work, in the 106 | manner specified by section 6 of the GNU GPL for conveying 107 | Corresponding Source. 108 | 109 | 1) Use a suitable shared library mechanism for linking with the 110 | Library. A suitable mechanism is one that (a) uses at run time 111 | a copy of the Library already present on the user's computer 112 | system, and (b) will operate properly with a modified version 113 | of the Library that is interface-compatible with the Linked 114 | Version. 115 | 116 | e) Provide Installation Information, but only if you would otherwise 117 | be required to provide such information under section 6 of the 118 | GNU GPL, and only to the extent that such information is 119 | necessary to install and execute a modified version of the 120 | Combined Work produced by recombining or relinking the 121 | Application with a modified version of the Linked Version. (If 122 | you use option 4d0, the Installation Information must accompany 123 | the Minimal Corresponding Source and Corresponding Application 124 | Code. If you use option 4d1, you must provide the Installation 125 | Information in the manner specified by section 6 of the GNU GPL 126 | for conveying Corresponding Source.) 127 | 128 | 5. Combined Libraries. 129 | 130 | You may place library facilities that are a work based on the 131 | Library side by side in a single library together with other library 132 | facilities that are not Applications and are not covered by this 133 | License, and convey such a combined library under terms of your 134 | choice, if you do both of the following: 135 | 136 | a) Accompany the combined library with a copy of the same work based 137 | on the Library, uncombined with any other library facilities, 138 | conveyed under the terms of this License. 139 | 140 | b) Give prominent notice with the combined library that part of it 141 | is a work based on the Library, and explaining where to find the 142 | accompanying uncombined form of the same work. 143 | 144 | 6. Revised Versions of the GNU Lesser General Public License. 145 | 146 | The Free Software Foundation may publish revised and/or new versions 147 | of the GNU Lesser General Public License from time to time. Such new 148 | versions will be similar in spirit to the present version, but may 149 | differ in detail to address new problems or concerns. 150 | 151 | Each version is given a distinguishing version number. If the 152 | Library as you received it specifies that a certain numbered version 153 | of the GNU Lesser General Public License "or any later version" 154 | applies to it, you have the option of following the terms and 155 | conditions either of that published version or of any later version 156 | published by the Free Software Foundation. If the Library as you 157 | received it does not specify a version number of the GNU Lesser 158 | General Public License, you may choose any version of the GNU Lesser 159 | General Public License ever published by the Free Software Foundation. 160 | 161 | If the Library as you received it specifies that a proxy can decide 162 | whether future versions of the GNU Lesser General Public License shall 163 | apply, that proxy's public statement of acceptance of any version is 164 | permanent authorization for you to choose that version for the 165 | Library. 166 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | ## Reporting Security Vulnerabilities 4 | 5 | **We urge you not to file a bug report in the GitHub issue tracker, since they are open for anyone to see** 6 | 7 | Instead, we encourage you to reach out to the maintainer team so we can assess the problem and later disclose it 8 | responsibly. 9 | 10 | To do so, you can use the `Security` tab and file a bug report there 11 | ![image](https://user-images.githubusercontent.com/20902250/209860003-573a5219-5e71-4f27-91ec-7ad6c0516749.png) 12 | 13 | Alternatively, you can also reach out to the maintainer team directly. If you prefer this approach, you can contact one 14 | of the people below: 15 | 16 | - **ItsDrike** (project maintainer and owner) 17 | - **Email:** `itsdrike@protonmail.com` 18 | - **Discord:** `ItsDrike#5359` (however you will need to join the [py-mine discord](https://discord.gg/C2wX7zduxC) too, 19 | as I might not answer to message requests from people I don't share a server with.) 20 | -------------------------------------------------------------------------------- /changes/+api_pages.docs.md: -------------------------------------------------------------------------------- 1 | Add protocol and protocol pages (API reference docs) 2 | -------------------------------------------------------------------------------- /changes/+loginstart_update.feature.md: -------------------------------------------------------------------------------- 1 | Update `LoginStart` packet to latest protocol version (`uuid` no longer optional) 2 | -------------------------------------------------------------------------------- /changes/+server_encryption_fucntions.feature.md: -------------------------------------------------------------------------------- 1 | Added further encryption related fucntions used by servers. 2 | -------------------------------------------------------------------------------- /changes/.template.rst: -------------------------------------------------------------------------------- 1 | ## Version {{ versiondata.version }} ({{ versiondata.date }}) 2 | {% for section, _ in sections.items() %} 3 | {% set anchor = "#" * underlines[0] %}{% if section %}{{ anchor }} {{section}} 4 | {% set anchor = "#" * underlines[1] %} 5 | 6 | {% endif %} 7 | 8 | {% if sections[section] %} 9 | {% for category, val in definitions.items() if category in sections[section]%} 10 | {{ anchor }} {{ definitions[category]['name'] }} 11 | 12 | {% if definitions[category]['showcontent'] %} 13 | {% for text, values in sections[section][category].items() %} 14 | {% set version = values|join(",") %} 15 | {% if version %} 16 | - {{ version }}: {{ text }} 17 | {% else %} 18 | - {{ text }} 19 | {% endif %} 20 | {% endfor %} 21 | 22 | {% else %} 23 | - {{ sections[section][category]['']|join(', ') }} 24 | 25 | {% endif %} 26 | {% if sections[section][category]|length == 0 %} 27 | No significant changes. 28 | 29 | {% else %} 30 | {% endif %} 31 | {% endfor %} 32 | {% else %} 33 | No significant changes. 34 | 35 | {% endif %} 36 | {% endfor %} 37 | --- 38 | -------------------------------------------------------------------------------- /changes/131.internal.md: -------------------------------------------------------------------------------- 1 | Any overridden methods in any classes now have to explicitly use the `typing.override` decorator (see [PEP 698](https://peps.python.org/pep-0698/)) 2 | -------------------------------------------------------------------------------- /changes/179.docs.md: -------------------------------------------------------------------------------- 1 | Enforce presence of docstrings everywhere with pydocstyle. This also adds docstring to all functions and classes that didn't already have one. Minor improvements for consistency were also made to some existing docstrings. 2 | -------------------------------------------------------------------------------- /changes/209.feature.md: -------------------------------------------------------------------------------- 1 | - Added `InvalidPacketContentError` exception, raised when deserializing of a specific packet fails. This error inherits from `IOError`, making it backwards compatible with the original implementation. 2 | -------------------------------------------------------------------------------- /changes/257.feature.md: -------------------------------------------------------------------------------- 1 | - Added the `NBTag` to deal with NBT data: 2 | - The `NBTag` class is the base class for all NBT tags and provides the basic functionality to serialize and deserialize NBT data from and to a `Buffer` object. 3 | - The classes `EndNBT`, `ByteNBT`, `ShortNBT`, `IntNBT`, `LongNBT`, `FloatNBT`, `DoubleNBT`, `ByteArrayNBT`, `StringNBT`, `ListNBT`, `CompoundNBT`, `IntArrayNBT`and `LongArrayNBT` were added and correspond to the NBT types described in the [NBT specification](https://wiki.vg/NBT#Specification). 4 | - NBT tags can be created using the `NBTag.from_object()` method and a schema that describes the NBT tag structure. 5 | Compound tags are represented as dictionaries, list tags as lists, and primitive tags as their respective Python types. 6 | The implementation allows to add custom classes to the schema to handle custom NBT tags if they inherit the `:class: NBTagConvertible` class. 7 | - The `NBTag.to_object()` method can be used to convert an NBT tag back to a Python object. Use include_schema=True to include the schema in the output, and `include_name=True` to include the name of the tag in the output. In that case the output will be a dictionary with a single key that is the name of the tag and the value is the object representation of the tag. 8 | - The `NBTag.serialize()` can be used to serialize an NBT tag to a new `Buffer` object. 9 | - The `NBTag.deserialize(buffer)` can be used to deserialize an NBT tag from a `Buffer` object. 10 | - If the buffer already exists, the `NBTag.write_to(buffer, with_type=True, with_name=True)` method can be used to write the NBT tag to the buffer (and in that case with the type and name in the right format). 11 | - The `NBTag.read_from(buffer, with_type=True, with_name=True)` method can be used to read an NBT tag from the buffer (and in that case with the type and name in the right format). 12 | - The `NBTag.value` property can be used to get the value of the NBT tag as a Python object. 13 | -------------------------------------------------------------------------------- /changes/258.internal.md: -------------------------------------------------------------------------------- 1 | Fix readthedocs CI 2 | -------------------------------------------------------------------------------- /changes/259.internal.md: -------------------------------------------------------------------------------- 1 | Merge dependabot PRs automatically, if they pass all CI checks. 2 | -------------------------------------------------------------------------------- /changes/274.internal.md: -------------------------------------------------------------------------------- 1 | - Update ruff version (the version we used was very outdated) 2 | - Drop isort in favor of ruff's built-in isort module in the linter 3 | - Drop black in favor of ruff's new built-in formatter 4 | - Update ruff settings, including adding/enabling some new rule-sets 5 | -------------------------------------------------------------------------------- /changes/285.internal.1.md: -------------------------------------------------------------------------------- 1 | - **Function**: `gen_serializable_test` 2 | - Generates tests for serializable classes, covering serialization, deserialization, validation, and error handling. 3 | - **Parameters**: 4 | - `context` (dict): Context to add the test functions to (usually `globals()`). 5 | - `cls` (type): The serializable class to test. 6 | - `fields` (list): Tuples of field names and types of the serializable class. 7 | - `serialize_deserialize` (list, optional): Tuples for testing successful serialization/deserialization. 8 | - `validation_fail` (list, optional): Tuples for testing validation failures with expected exceptions. 9 | - `deserialization_fail` (list, optional): Tuples for testing deserialization failures with expected exceptions. 10 | - **Note**: Implement `__eq__` in the class for accurate comparison. 11 | 12 | - The `gen_serializable_test` function generates a test class with the following tests: 13 | 14 | .. literalinclude:: /../tests/mcproto/utils/test_serializable.py 15 | :language: python 16 | :start-after: # region Test ToyClass 17 | :end-before: # endregion Test ToyClass 18 | 19 | - The generated test class will have the following tests: 20 | 21 | ```python 22 | class TestGenToyClass: 23 | def test_serialization(self): 24 | # 3 subtests for the cases 1, 2, 3 (serialize_deserialize) 25 | 26 | def test_deserialization(self): 27 | # 3 subtests for the cases 1, 2, 3 (serialize_deserialize) 28 | 29 | def test_validation(self): 30 | # 3 subtests for the cases 4, 5, 6 (validation_fail) 31 | 32 | def test_exceptions(self): 33 | # 3 subtests for the cases 7, 8, 9 (deserialization_fail) 34 | ``` 35 | -------------------------------------------------------------------------------- /changes/285.internal.2.md: -------------------------------------------------------------------------------- 1 | - **Class**: `Serializable` 2 | - Base class for types that should be (de)serializable into/from `mcproto.Buffer` data. 3 | - **Methods**: 4 | - `__attrs_post_init__()`: Runs validation after object initialization, override to define custom behavior. 5 | - `serialize() -> Buffer`: Returns the object as a `Buffer`. 6 | - `serialize_to(buf: Buffer)`: Abstract method to write the object to a `Buffer`. 7 | - `validate()`: Validates the object's attributes; can be overridden for custom validation. 8 | - `deserialize(cls, buf: Buffer) -> Self`: Abstract method to construct the object from a `Buffer`. 9 | - **Note**: Use the `dataclass` decorator when adding parameters to subclasses. 10 | 11 | - Exemple: 12 | 13 | .. literalinclude:: /../tests/mcproto/utils/test_serializable.py 14 | :language: python 15 | :start-after: # region ToyClass 16 | :end-before: # endregion ToyClass 17 | -------------------------------------------------------------------------------- /changes/286.internal.md: -------------------------------------------------------------------------------- 1 | Update the docstring formatting directive in CONTRIBUTING.md to reflect the formatting practices currently in place. 2 | -------------------------------------------------------------------------------- /changes/300.internal.md: -------------------------------------------------------------------------------- 1 | - Fix CI not running unit tests on python 3.8 (only 3.11) 2 | - Update to use python 3.12 (in validation and as one of the matrix versions in unit-tests workflow) 3 | - Trigger and run lint and unit-tests workflows form a single main CI workflow. 4 | - Only send status embed after the main CI workflow finishes (not for both unit-tests and validation) 5 | - Use `--output-format=github` for `ruff check` in the validation workflow 6 | - Fix the status-embed workflow 7 | -------------------------------------------------------------------------------- /changes/323.internal.md: -------------------------------------------------------------------------------- 1 | Enable various other ruff rules as a part of switching to blacklist model, where we explicitly disable the rules we don't want, rather than enabling dozens of rule groups individually. 2 | -------------------------------------------------------------------------------- /changes/329.internal.md: -------------------------------------------------------------------------------- 1 | - Change the type-checker from `pyright` to `basedpyright` 2 | - BasedPyright is a fork of pyright, which provides some additional typing features and re-implements various proprietary features from the closed-source Pylance vscode extension. 3 | - Overall, it is very similar to pyright with some bonus stuff on top. However, it does mean all contributors who want proper editor support for the project will need to update their editor settings and add basedpyright. The instructions on how to do this are described in the updated `CONTRIBUTING.md`. 4 | -------------------------------------------------------------------------------- /changes/330.bugfix.md: -------------------------------------------------------------------------------- 1 | Fix behavior of the `mcproto.utils.deprecation` module, which was incorrectly always using a fallback version, assuming mcproto is at version 0.0.0. This then could've meant that using a deprecated feature that is past the specified deprecation (removal) version still only resulted in a deprecation warning, as opposed to a full runtime error. 2 | -------------------------------------------------------------------------------- /changes/331.internal.md: -------------------------------------------------------------------------------- 1 | Add `.editorconfig` file, defining some basic configuration for the project, which editors can automatically pick up on (i.e. indent size). 2 | -------------------------------------------------------------------------------- /changes/332.internal.md: -------------------------------------------------------------------------------- 1 | Enable various other (based)pyright rules (in fact, switch to a black-list, having all rules enabled except those explicitly disabled). This introduces a much stricter type checking behavior into the code-base. 2 | -------------------------------------------------------------------------------- /changes/347.internal.md: -------------------------------------------------------------------------------- 1 | Fix towncrier after an update (template file isn't ignored by default, so ignore it manually) 2 | -------------------------------------------------------------------------------- /changes/379.internal.md: -------------------------------------------------------------------------------- 1 | Remove codeclimate 2 | -------------------------------------------------------------------------------- /changes/395.internal.md: -------------------------------------------------------------------------------- 1 | Add CI workflow for marking inactive issues (>60 days) with the stale label 2 | -------------------------------------------------------------------------------- /changes/421.breaking.md: -------------------------------------------------------------------------------- 1 | Drop support for Python 3.8 ([EOL since 2024-09-06](https://peps.python.org/pep-0569/)) 2 | -------------------------------------------------------------------------------- /changes/421.internal.md: -------------------------------------------------------------------------------- 1 | Add support for python 3.13, moving the CI to test against it. 2 | -------------------------------------------------------------------------------- /changes/427.bugfix.md: -------------------------------------------------------------------------------- 1 | Fix version comparisons in deprecated functions for PEP440, non-semver compatible versions 2 | -------------------------------------------------------------------------------- /changes/README.md: -------------------------------------------------------------------------------- 1 | # Changelog fragments 2 | 3 | This folder holds fragments of the changelog to be used in the next release, when the final changelog will be 4 | generated. 5 | 6 | For every pull request made to this project, the contributor is responsible for creating a file (fragment), with 7 | a short description of what that PR changes. 8 | 9 | These fragment files use the following format: `{pull_request_number}.{type}.md`, 10 | 11 | Possible types are: 12 | - **`feature`**: New feature that affects the public API. 13 | - **`bugfix`**: A bugfix, which was affecting the public API. 14 | - **`docs`**: Change to the documentation, or updates to public facing docstrings 15 | - **`breaking`**: Signifying a breaking change of some part of the project's public API, which could cause issues for 16 | end-users updating to this version. (Includes deprecation removals.) 17 | - **`deprecation`**: Signifying a newly deprecated feature, scheduled for eventual removal. 18 | - **`internal`** Fully internal change that doesn't affect the public API, but is significant enough to be mentioned, 19 | likely because it affects project contributors. (Such as a new linter rule, change in code style, significant change 20 | in internal API, ...) 21 | 22 | For changes that do not fall under any of the above cases, please specify the lack of the changelog in the pull request 23 | description, so that a maintainer can skip the job that checks for presence of this fragment file. 24 | 25 | ## Create fragments with commands 26 | 27 | While you absolutely can simply create these files manually, it's a much better idea to use the `towncrier` library, 28 | which can create the file for you in the proper place. With it, you can simply run `towncrier create 29 | {pull_request}.{type}.md` after creating the pull request, edit the created file and commit the changes. 30 | 31 | If the change is simple enough, you can even use the `-c`/`--content` flag and specify it directly, like: `towncrier 32 | create 12.feature.md -c "Add dinosaurs!"`, or if you're used to terminal editors, there's also the `--edit` flag, which 33 | opens the file with your `$EDITOR`. 34 | 35 | ## Preview changelog 36 | 37 | To preview the latest changelog, run `towncrier build --draft --version [version number]`. (For version number, you can 38 | pretty much enter anything as this is just for a draft version. For true builds, this would be the next version number, 39 | so for example, if the current version is 1.0.2, next one will be one either 1.0.3, or 1.1.0, or 2.0.0. But for drafts, 40 | you can also just enter something like `next` for the version, as it's just for your own private preview.) 41 | 42 | To make this a bit easier, there is a taskipy task running the command above, so you can just use `poetry run task 43 | changelog-preview` to see the changelog, if you don't like remembering new commands. 44 | 45 | ## Multiple fragments in single PR 46 | 47 | If necessary, multiple fragment files can be created per pull-request, with different change types, if the PR covers 48 | multiple areas. For example for PR #13 that both introduces a feature, and changes the documentation, can add 49 | 2 fragment files: `13.feature.md` and `13.docs.md`. 50 | 51 | Additionally, if a single PR is addressing multiple unrelated topics in the same category, and needs to make multiple 52 | distinct changelog entries, an optional counter value can be added at the end of the file name (needs to be an 53 | integer). So for example PR #25 which makes 2 distinct internal changes can add these fragment files: 54 | `25.internal.1.md` and `25.internal.2.md`. (The numbers in this counter position will not be shown in the final 55 | changelog and are merely here for separation of the individual fragments.) 56 | 57 | However if the changes are related to some bigger overarching goal, you can also just use a single fragment file with 58 | the following format: 59 | 60 | ```markdown 61 | Update changelog 62 | - Rename `documentation` category to shorter: `docs` 63 | - Add `internal` category for changes unrelated to public API, but potentially relevant to contributors 64 | - Add github workflow enforcing presence of a new changelog fragment file for each PR 65 | - For insignificant PRs which don't require any changelog entry, a maintainer can add `skip-fragment-check` label. 66 | ``` 67 | 68 | That said, if you end up making multiple distinct changelog fragments like this, it's a sign that your PR is probably 69 | too big, and you should split it up into multiple PRs instead. Making huge PRs that address several unrelated topics at 70 | once is generally a bad practice, and should be avoided. If you go overboard, your PR might even end up getting closed 71 | for being too big, and you'll be required to split it up. 72 | 73 | ## Footnotes 74 | 75 | - See for more info about why and how to properly maintain a changelog 76 | - For more info about `towncrier`, check out it's [documentation](https://towncrier.readthedocs.io/en/latest/tutorial.html) 77 | -------------------------------------------------------------------------------- /docs/_static/extra.css: -------------------------------------------------------------------------------- 1 | html { 2 | word-wrap: anywhere; 3 | } 4 | 5 | body { 6 | --toc-item-spacing-horizontal: 0.5rem; 7 | --admonition-font-size: 0.8em; 8 | 9 | --attribute-table-title: var(--color-content-foreground); 10 | --attribute-table-entry-border: var(--color-foreground-border); 11 | --attribute-table-entry-text: var(--color-api-name); 12 | --attribute-table-entry-hover-border: var(--color-content-foreground); 13 | --attribute-table-entry-hover-background: var(--color-api-background-hover); 14 | --attribute-table-entry-hover-text: var(--color-content-foreground); 15 | --attribute-table-badge: var(--color-api-keyword); 16 | } 17 | 18 | .icon { 19 | user-select: none; 20 | } 21 | 22 | .viewcode-back { 23 | position: absolute; 24 | right: 1em; 25 | background-color: var(--color-code-background); 26 | width: auto; 27 | } 28 | 29 | .toc-drawer { 30 | width: initial; 31 | max-width: 20em; 32 | right: -20em; 33 | } 34 | 35 | .toc-tree ul ul ul ul { 36 | border-left: 1px solid var(--color-background-border); 37 | } 38 | 39 | @media (max-width: 82em) { 40 | body { 41 | font-size: 0.7em; 42 | } 43 | 44 | .toc-tree { 45 | padding-left: 0; 46 | } 47 | 48 | .sidebar-brand-text { 49 | font-size: 1rem; 50 | } 51 | 52 | .sidebar-tree .reference { 53 | padding: 0.5em 1em; 54 | } 55 | } 56 | 57 | /* attribute tables */ 58 | .py-attribute-table { 59 | display: flex; 60 | flex-wrap: wrap; 61 | flex-direction: row; 62 | margin: 0 2em; 63 | padding-top: 16px; 64 | } 65 | 66 | .py-attribute-table-column { 67 | flex: 1 1 auto; 68 | } 69 | 70 | .py-attribute-table-column:not(:first-child) { 71 | margin-top: 1em; 72 | } 73 | 74 | .py-attribute-table-column > span { 75 | color: var(--attribute-table-title); 76 | } 77 | 78 | main .py-attribute-table-column > ul { 79 | list-style: none; 80 | margin: 4px 0px; 81 | padding-left: 0; 82 | font-size: 0.95em; 83 | } 84 | 85 | .py-attribute-table-entry { 86 | margin: 0; 87 | padding: 2px 0; 88 | padding-left: 0.2em; 89 | border-left: 2px solid var(--attribute-table-entry-border); 90 | display: flex; 91 | line-height: 1.2em; 92 | } 93 | 94 | .py-attribute-table-entry > a { 95 | padding-left: 0.5em; 96 | color: var(--attribute-table-entry-text); 97 | flex-grow: 1; 98 | } 99 | 100 | .py-attribute-table-entry > a:hover { 101 | color: var(--attribute-table-entry-hover-text); 102 | text-decoration: none; 103 | } 104 | 105 | .py-attribute-table-entry:hover { 106 | background-color: var(--attribute-table-entry-hover-background); 107 | border-left: 2px solid var(--attribute-table-entry-hover-border); 108 | text-decoration: none; 109 | } 110 | 111 | .py-attribute-table-badge { 112 | flex-basis: 3em; 113 | text-align: right; 114 | font-size: 0.9em; 115 | color: var(--attribute-table-badge); 116 | -moz-user-select: none; 117 | -webkit-user-select: none; 118 | user-select: none; 119 | } 120 | -------------------------------------------------------------------------------- /docs/_static/extra.js: -------------------------------------------------------------------------------- 1 | document.addEventListener("DOMContentLoaded", () => { 2 | const tables = document.querySelectorAll( 3 | ".py-attribute-table[data-move-to-id]" 4 | ); 5 | tables.forEach((table) => { 6 | let element = document.getElementById( 7 | table.getAttribute("data-move-to-id") 8 | ); 9 | let parent = element.parentNode; 10 | // insert ourselves after the element 11 | parent.insertBefore(table, element.nextSibling); 12 | }); 13 | }); 14 | -------------------------------------------------------------------------------- /docs/api/basic.rst: -------------------------------------------------------------------------------- 1 | Basic Usage 2 | =========== 3 | 4 | .. 5 | TODO: Write this 6 | -------------------------------------------------------------------------------- /docs/api/internal.rst: -------------------------------------------------------------------------------- 1 | Internal API 2 | ============ 3 | 4 | Everything listed on this page is considered internal, and is only present to provide linkable references, and 5 | as an easy quick reference for contributors. These components **are not a part of the public API** and **they 6 | should not be used externally**, as we do not guarantee their backwards compatibility, which means breaking changes 7 | may be introduced between patch versions without any warnings. 8 | 9 | .. automodule:: mcproto.utils.abc 10 | :exclude-members: define 11 | 12 | .. autofunction:: tests.helpers.gen_serializable_test 13 | .. 14 | TODO: Write this 15 | -------------------------------------------------------------------------------- /docs/api/packets.rst: -------------------------------------------------------------------------------- 1 | Packets documentation 2 | ===================== 3 | 4 | Base classes and interaction functions 5 | -------------------------------------- 6 | 7 | .. automodule:: mcproto.packets 8 | :members: 9 | :undoc-members: 10 | :show-inheritance: 11 | 12 | 13 | Handshaking gamestate 14 | --------------------- 15 | 16 | .. automodule:: mcproto.packets.handshaking.handshake 17 | :members: 18 | :undoc-members: 19 | :show-inheritance: 20 | 21 | Status gamestate 22 | ---------------- 23 | 24 | .. automodule:: mcproto.packets.status.ping 25 | :members: 26 | :undoc-members: 27 | :show-inheritance: 28 | 29 | .. automodule:: mcproto.packets.status.status 30 | :members: 31 | :undoc-members: 32 | :show-inheritance: 33 | 34 | Login gamestate 35 | --------------- 36 | 37 | .. automodule:: mcproto.packets.login.login 38 | :members: 39 | :undoc-members: 40 | :show-inheritance: 41 | 42 | Play gamestate 43 | -------------- 44 | 45 | Not yet implemented 46 | -------------------------------------------------------------------------------- /docs/api/protocol.rst: -------------------------------------------------------------------------------- 1 | Protocol documentation 2 | ====================== 3 | 4 | This is the documentation for methods minecraft protocol interactions, connection and buffer. 5 | 6 | 7 | .. attributetable:: mcproto.protocol.base_io.BaseAsyncReader 8 | 9 | .. attributetable:: mcproto.protocol.base_io.BaseSyncReader 10 | 11 | .. automodule:: mcproto.protocol.base_io 12 | :members: 13 | :undoc-members: 14 | :show-inheritance: 15 | 16 | .. autoclass:: mcproto.buffer.Buffer 17 | :members: 18 | :undoc-members: 19 | :show-inheritance: 20 | 21 | .. automodule:: mcproto.connection 22 | :members: 23 | :undoc-members: 24 | :show-inheritance: 25 | -------------------------------------------------------------------------------- /docs/api/types/index.rst: -------------------------------------------------------------------------------- 1 | .. api/types documentation master file 2 | 3 | ======================= 4 | API Types Documentation 5 | ======================= 6 | 7 | Welcome to the API Types documentation! This documentation provides information about the various types used in the API. 8 | 9 | .. toctree:: 10 | :maxdepth: 2 11 | 12 | nbt.rst 13 | -------------------------------------------------------------------------------- /docs/api/types/nbt.rst: -------------------------------------------------------------------------------- 1 | NBT Format 2 | ========== 3 | 4 | .. automodule:: mcproto.types.nbt 5 | :members: 6 | :show-inheritance: 7 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | """Configuration file for the Sphinx documentation builder. 2 | 3 | For the full list of built-in configuration values, see the documentation: 4 | https://www.sphinx-doc.org/en/master/usage/configuration.html 5 | 6 | # -- Project information ----------------------------------------------------- 7 | # https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information 8 | """ 9 | 10 | from __future__ import annotations 11 | 12 | import datetime 13 | import sys 14 | from pathlib import Path 15 | 16 | from packaging.version import parse as parse_version 17 | from typing_extensions import override 18 | 19 | if sys.version_info >= (3, 11): 20 | from tomllib import load as toml_parse 21 | else: 22 | from tomli import load as toml_parse 23 | 24 | 25 | # -- Basic project information ----------------------------------------------- 26 | 27 | with Path("../pyproject.toml").open("rb") as f: 28 | pkg_meta: dict[str, str] = toml_parse(f)["tool"]["poetry"] 29 | 30 | project = str(pkg_meta["name"]) 31 | copyright = f"{datetime.datetime.now(tz=datetime.timezone.utc).date().year}, ItsDrike" # noqa: A001 32 | author = "ItsDrike" 33 | 34 | parsed_version = parse_version(pkg_meta["version"]) 35 | release = str(parsed_version) 36 | 37 | # -- General configuration --------------------------------------------------- 38 | # https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration 39 | 40 | # Add docs/extensions into python path, allowing custom internal sphinx extensions 41 | # these will now be essentially considered as regualar packages 42 | sys.path.append(str(Path(__file__).parent.joinpath("extensions").absolute())) 43 | 44 | extensions = [ 45 | # official extensions 46 | "sphinx.ext.autodoc", # Automatic documentation generation 47 | "sphinx.ext.autosectionlabel", # Allows referring to sections by their title 48 | "sphinx.ext.extlinks", # Shorten common link patterns 49 | "sphinx.ext.intersphinx", # Used to reference for third party projects: 50 | "sphinx.ext.todo", # Adds todo directive 51 | "sphinx.ext.viewcode", # Links to source files for the documented functions 52 | # external 53 | "sphinxcontrib.towncrier.ext", # Towncrier changelog 54 | "m2r2", # Used to include .md files: 55 | "sphinx_copybutton", # Copyable codeblocks 56 | # internal 57 | "attributetable", # adds attributetable directive, for producing list of methods and attributes of class 58 | ] 59 | 60 | # The suffix(es) of source filenames. 61 | source_suffix = [".rst", ".md"] 62 | 63 | # The master toctree document. 64 | master_doc = "index" 65 | 66 | # The language for content autogenerated by Sphinx. Refer to documentation 67 | # for a list of supported languages. 68 | # 69 | # This is also used if you do content translation via gettext catalogs. 70 | # Usually you set "language" from the command line for these cases. 71 | language = "en" 72 | 73 | # List of patterns, relative to source directory, that match files and 74 | # directories to ignore when looking for source files. 75 | exclude_patterns = ["_build"] 76 | 77 | # If true, '()' will be appended to :func: etc. cross-reference text. 78 | add_function_parentheses = True 79 | 80 | # If true, the current module name will be prepended to all description 81 | # unit titles (such as .. function::). 82 | add_module_names = True 83 | 84 | # If true, sectionauthor and moduleauthor directives will be shown in the 85 | # output. They are ignored by default. 86 | show_authors = False 87 | 88 | # The name of the Pygments (syntax highlighting) style to use. 89 | pygments_style = "sphinx" 90 | 91 | # -- Options for HTML output ------------------------------------------------- 92 | # https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output 93 | 94 | html_theme = "furo" 95 | html_favicon = "https://i.imgur.com/nPCcxts.png" 96 | 97 | html_static_path = ["_static"] 98 | html_css_files = ["extra.css"] 99 | html_js_files = ["extra.js"] 100 | 101 | # -- Extension configuration ------------------------------------------------- 102 | 103 | # -- sphinx.ext.autodoc ------------------------ 104 | 105 | # What docstring to insert into main body of autoclass 106 | # "class" / "init" / "both" 107 | autoclass_content = "both" 108 | 109 | # Sort order of the automatically documented members 110 | autodoc_member_order = "bysource" 111 | 112 | # Default options for all autodoc directives 113 | autodoc_default_options = { 114 | "members": True, 115 | "undoc-members": True, 116 | "show-inheritance": True, 117 | "exclude-members": "__dict__,__weakref__", 118 | } 119 | 120 | # -- sphinx.ext.autosectionlabel --------------- 121 | 122 | # Automatically generate section labels: 123 | autosectionlabel_prefix_document = True 124 | 125 | # -- sphinx.ext.extlinks ----------------------- 126 | 127 | # will create new role, allowing for example :issue:`123` 128 | extlinks = { 129 | # role: (URL with %s, caption or None) 130 | "issue": ("https://github.com/py-mine/mcproto/issues/%s", "GH-%s"), 131 | } 132 | 133 | # -- sphinx.ext.intersphinx -------------------- 134 | 135 | # Third-party projects documentation references: 136 | intersphinx_mapping = { 137 | "python": ("https://docs.python.org/3", None), 138 | } 139 | 140 | # -- sphinx.ext.todo --------------------------- 141 | 142 | # If true, `todo` and `todoList` produce output, else they produce nothing. 143 | todo_include_todos = True 144 | 145 | # -- sphinxcontrib.towncrier.ext --------------- 146 | 147 | towncrier_draft_autoversion_mode = "draft" 148 | towncrier_draft_include_empty = True 149 | towncrier_draft_working_directory = Path(__file__).parents[1].resolve() 150 | 151 | # -- m2r2 -------------------------------------- 152 | 153 | # Enable multiple references to the same URL for m2r2 154 | m2r_anonymous_references = True 155 | 156 | # Changelog contains a lot of duplicate labels, since every subheading holds a category 157 | # and these repeat a lot. Currently, m2r2 doesn't handle this properly, and so these 158 | # labels end up duplicated. See: https://github.com/CrossNox/m2r2/issues/59 159 | suppress_warnings = [ 160 | "autosectionlabel.pages/changelog", 161 | "autosectionlabel.pages/code-of-conduct", 162 | "autosectionlabel.pages/contributing", 163 | ] 164 | 165 | # -- Other options ----------------------------------------------------------- 166 | 167 | 168 | def mock_autodoc() -> None: 169 | """Mock autodoc to not add ``Bases: object`` to the classes, that do not have super classes. 170 | 171 | See also https://stackoverflow.com/a/75041544/20952782. 172 | """ 173 | from sphinx.ext import autodoc 174 | 175 | class MockedClassDocumenter(autodoc.ClassDocumenter): 176 | @override 177 | def add_line(self, line: str, source: str, *lineno: int) -> None: 178 | if line == " Bases: :py:class:`object`": 179 | return 180 | super().add_line(line, source, *lineno) 181 | 182 | autodoc.ClassDocumenter = MockedClassDocumenter 183 | 184 | 185 | def override_towncrier_draft_format() -> None: 186 | """Monkeypatch sphinxcontrib.towncrier.ext to first convert the draft text from md to rst. 187 | 188 | We can use ``m2r2`` for this, as it's an already installed extension with goal 189 | of including markdown documents into rst documents, so we simply run it's converter 190 | somewhere within sphinxcontrib.towncrier.ext and include this conversion. 191 | 192 | Additionally, the current changelog format always starts the version with "Version {}", 193 | this doesn't look well with the version set to "Unreleased changes", so this function 194 | also removes this "Version " prefix. 195 | """ 196 | import m2r2 197 | import sphinxcontrib.towncrier.ext 198 | from docutils import statemachine 199 | from sphinx.util.nodes import nodes 200 | 201 | orig_f = sphinxcontrib.towncrier.ext._nodes_from_document_markup_source # pyright: ignore[reportPrivateUsage] 202 | 203 | def override_f( 204 | state: statemachine.State, # pyright: ignore[reportMissingTypeArgument] # arg not specified in orig_f either 205 | markup_source: str, 206 | ) -> list[nodes.Node]: 207 | markup_source = markup_source.replace("## Version Unreleased changes", "## Unreleased changes") 208 | markup_source = markup_source.rstrip(" \n") 209 | 210 | # Alternative to 3.9+ str.removesuffix 211 | if markup_source.endswith("---"): 212 | markup_source = markup_source[:-3] 213 | 214 | markup_source = markup_source.rstrip(" \n") 215 | markup_source = m2r2.M2R()(markup_source) 216 | 217 | return orig_f(state, markup_source) 218 | 219 | sphinxcontrib.towncrier.ext._nodes_from_document_markup_source = override_f # pyright: ignore[reportPrivateUsage] 220 | 221 | 222 | mock_autodoc() 223 | override_towncrier_draft_format() 224 | -------------------------------------------------------------------------------- /docs/examples/index.rst: -------------------------------------------------------------------------------- 1 | Examples 2 | ======== 3 | 4 | Here are some examples of using the project in practice. 5 | 6 | 7 | .. toctree:: 8 | :maxdepth: 1 9 | :caption: Examples 10 | 11 | status.rst 12 | 13 | Feel free to propose any further examples, we'll be happy to add them to the list! 14 | -------------------------------------------------------------------------------- /docs/examples/status.rst: -------------------------------------------------------------------------------- 1 | Obtaining status data from a server 2 | =================================== 3 | 4 | .. 5 | TODO: Write this 6 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | .. mdinclude:: ../README.md 2 | 3 | Content 4 | ------- 5 | 6 | .. toctree:: 7 | :maxdepth: 1 8 | :caption: Pages 9 | 10 | pages/installation.rst 11 | usage/index.rst 12 | examples/index.rst 13 | pages/faq.rst 14 | pages/changelog.rst 15 | pages/version_guarantees.rst 16 | pages/contributing.rst 17 | pages/code-of-conduct.rst 18 | 19 | .. toctree:: 20 | :maxdepth: 1 21 | :caption: API Documentation 22 | 23 | api/basic.rst 24 | api/packets.rst 25 | api/protocol.rst 26 | api/internal.rst 27 | api/types/index.rst 28 | 29 | 30 | Indices and tables 31 | ------------------ 32 | 33 | * :ref:`genindex` 34 | * :ref:`modindex` 35 | * :ref:`search` 36 | -------------------------------------------------------------------------------- /docs/pages/changelog.rst: -------------------------------------------------------------------------------- 1 | Changelog 2 | ========= 3 | 4 | .. seealso:: 5 | Check out what can and can't change between the library versions. :doc:`version_guarantees` 6 | 7 | .. attention:: 8 | Major and minor releases also include the changes specified in prior development releases. 9 | 10 | .. towncrier-draft-entries:: Unreleased changes 11 | 12 | .. mdinclude:: ../../CHANGELOG.md 13 | -------------------------------------------------------------------------------- /docs/pages/code-of-conduct.rst: -------------------------------------------------------------------------------- 1 | Code of Conduct 2 | =============== 3 | 4 | This code of conduct outlines our expectations for the people involved with this project. We, as members, contributors 5 | and leaders are committed to providing a welcoming and inspiring project that anyone can easily join, expecting 6 | a harassment-free experience, as described in this code of conduct. 7 | 8 | This code of conduct is here to ensure we provide a welcoming and inspiring project that anyone can easily join, 9 | expecting a harassment-free experience, as described in this code of conduct. 10 | 11 | The goal of this document is to set the overall tone for our community. It is here to outline some of the things you 12 | can and can't do if you wish to participate in our community. However it is not here to serve as a rule-book with 13 | a complete set of things you can't do, social conduct differs from situation to situation, and person to person, but we 14 | should do our best to try and provide a good experience to everyone, in every situation. 15 | 16 | We value many things beyond just technical expertise, including collaboration and supporting others within our 17 | community. Providing a positive experience for others can have a much more significant impact than simply providing the 18 | correct answer. 19 | 20 | Harassment 21 | ---------- 22 | 23 | We share a common understanding of what constitutes harassment as it applies to a professional setting. Although this 24 | list cannot be exhaustive, we explicitly honor diversity in age, gender, culture, ethnicity, language, national origin, 25 | political beliefs, profession, race, religion, sexual orientation, socioeconomic status, disability and personal 26 | appearance. We will not tolerate discrimination based on any of the protected characteristics above, including some 27 | that may not have been explicitly mentioned here. We consider discrimination of any kind to be unacceptable and 28 | immoral. 29 | 30 | Harassment includes, but is not limited to: 31 | 32 | * Offensive comments (or "jokes") related to any of the above mentioned attributes. 33 | * Deliberate "outing"/"doxing" of any aspect of a person's identity, such as physical or electronic address, without 34 | their explicit consent, except as necessary to protect others from intentional abuse. 35 | * Unwelcome comments regarding a person's lifestyle choices and practices, including those related to food, health, 36 | parenting, drugs and employment. 37 | * Deliberate misgendering. This includes deadnaming or persistently using a pronoun that does not correctly reflect a 38 | person's gender identity. You must address people by the name they give you when not addressing them by their 39 | username or handle. 40 | * Threats of violence, both physical and psychological. 41 | * Incitement of violence towards any individual, including encouraging a person to engage in self-harm. 42 | * Publication of non-harassing private communication. 43 | * Pattern of inappropriate social conduct, such as requesting/assuming inappropriate levels of intimacy with others, or 44 | excessive teasing after a request to stop. 45 | * Continued one-on-one communication after requests to cease. 46 | * Sabotage of someone else's work or intentionally hindering someone else's performance. 47 | 48 | Plagiarism 49 | ---------- 50 | 51 | Plagiarism is the re-use of someone else's work (eg: binary content such as images, textual content such as an article, 52 | but also source code, or any other copyrightable resources) without the permission or license right from the author. 53 | Claiming someone else's work as your own is not just immoral and disrespectful to the author, but also illegal in most 54 | countries. You should always follow the authors wishes, and give credit where credit is due. 55 | 56 | If we found that you've **intentionally** attempted to add plagiarized content to our code-base, you will likely end up 57 | being permanently banned from any future contributions to this project's repository. We will of course also do our best 58 | to remove, or properly attribute this plagiarized content as quickly as possible. 59 | 60 | An unintentional attempt at plagiarism will not be punished as harshly, but nevertheless, it is your responsibility as 61 | a contributor to check where the code you're submitting comes from, and so repeated submission of such content, even 62 | after you were warned might still get you banned. 63 | 64 | Please note that an online repository that has no license is presumed to only be source-available, NOT open-source. 65 | Meaning that this work is protected by author's copyright, automatically imposed over it, and without any license 66 | extending that copyright, you have no rights to use such code. So know that you can't simply take some source-code, 67 | even though it's published publicly. This code may be available to be seen by anyone, but that does not mean it's also 68 | available to be used by anyone in other projects. 69 | 70 | Another important note to keep in mind is that even if some project has an open-source license, that license may have 71 | conditions which are incompatible with our codebase (such as requiring all of the code that links to this new part to 72 | also be licensed under the same license, which our code-base is not currently under). That is why it's necessary to 73 | understand a license before using code available under it. Simple attribution often isn't everything that the license 74 | requires. 75 | 76 | Generally inappropriate behavior 77 | -------------------------------- 78 | 79 | Outside of just harassment and plagiarism, there are countless other behaviors which we consider unacceptable, as they 80 | may be offensive, and discourage people from engaging with our community. 81 | 82 | **Examples of generally inappropriate behavior:** 83 | 84 | * The use of sexualized language or imagery of any kind 85 | * The use of inappropriate images, including in an account's avatar 86 | * The use of inappropriate language, including in an account's nickname 87 | * Any spamming, flamming, baiting or other attention-stealing behavior 88 | * Discussing topics that are overly polarizing, sensitive, or incite arguments. 89 | * Responding with "RTFM", "just google it" or similar response to help requests 90 | * Other conduct which could be reasonably considered inappropriate 91 | 92 | **Examples of generally appropriate behavior:** 93 | 94 | * Being kind and courteous to others 95 | * Collaborating with other community members 96 | * Gracefully accepting constructive criticism 97 | * Using welcoming and inclusive language 98 | * Showing empathy towards other community members 99 | 100 | Scope 101 | ----- 102 | 103 | This Code of Conduct applies within all community spaces, including this repository itself, conversations on any 104 | platforms officially connected to this project (such as in GitHub issues, through official emails or applications like 105 | discord). It also applies when an individual is officially representing the community in public spaces. Examples of 106 | representing our community include using an official social media account, or acting as an appointed representative at 107 | an online or offline event. 108 | 109 | All members involved with the project are expected to follow this Code of Conduct, no matter their position in the 110 | project's hierarchy, this Code of Conduct applies equally to contributors, maintainers, people seeking help/reporting 111 | bugs, etc. 112 | 113 | Enforcement Responsibilities 114 | ---------------------------- 115 | 116 | Whenever a participant has made a mistake, we expect them to take responsibility for their actions. If someone has been 117 | harmed or offended, it is our responsibility to listen carefully and respectfully, and to do our best to right the 118 | wrong. 119 | 120 | Community leaders are responsible for clarifying and enforcing our standards of acceptable behavior and will take 121 | appropriate and fair corrective action in response to any behavior that they deem inappropriate, threatening, 122 | offensive, harmful, or otherwise undesirable. 123 | 124 | Community leaders have the right and responsibility to remove, edit or reject comments, commits, code, wiki edits, 125 | issues and other contributions within the enforcement scope that are not aligned to this Code of Conduct, and will 126 | communicate reasons for moderation decisions when appropriate. 127 | 128 | If you have experienced or witnessed unacceptable behavior constituting a code of conduct violation or have any other 129 | code of conduct concerns, please let us know and we will do our best to resolve this issue. 130 | 131 | Reporting a Code of Conduct violation 132 | ------------------------------------- 133 | 134 | If you saw someone violating the Code of Conduct in some way, you can report it to any repository maintainer, either by 135 | email or through a Discord DM. You should avoid using public channels for reporting these, and instead do so in private 136 | discussion with a maintainer. 137 | 138 | Sources 139 | ------- 140 | 141 | The open-source community has an incredible amount of resources that people have freely provided to others and we all 142 | depend on these projects in many ways. This code of conduct article is no exception and there were many open source 143 | projects that has helped bring this code of conduct to existence. For that reason, we'd like to thank all of these 144 | communities and projects for keeping their content open and available to everyone, but most notably we'd like to thank 145 | the projects with established codes of conduct and diversity statements that we used as our inspiration. Below is the 146 | list these projects: 147 | 148 | * `Python `_ 149 | * `Contributor Covenant `_ 150 | * `Rust-lang `_ 151 | * `Code Fellows `_ 152 | * `Python Discord `_ 153 | 154 | License 155 | ------- 156 | 157 | All content of this page is licensed under a Creative Commons Attributions license. 158 | 159 | For more information about this license, see: 160 | -------------------------------------------------------------------------------- /docs/pages/contributing.rst: -------------------------------------------------------------------------------- 1 | Contributing Guidelines 2 | ======================= 3 | 4 | .. mdinclude:: ../../CONTRIBUTING.md 5 | :start-line: 2 6 | 7 | .. 8 | TODO: Rewrite CONTRIBUTING.md here directly, rather than including it 9 | like this, and just include a link to the docs in CONTRIBUTING.md 10 | -------------------------------------------------------------------------------- /docs/pages/faq.rst: -------------------------------------------------------------------------------- 1 | Frequently Asked Questions 2 | ========================== 3 | 4 | .. note:: 5 | This page is still being worked on, if you have any suggestions for a question, feel free to create an issue on 6 | GitHub, or let us know on the development discord server. 7 | 8 | Missing synchronous alternatives for some functions 9 | --------------------------------------------------- 10 | 11 | While mcproto does provide synchronous functionalities for the general protocol interactions (reading/writing packets 12 | and lower level structures), any unrelated functionalities (such as HTTP interactions with the Minecraft API) will only 13 | provide asynchronous versions. 14 | 15 | This was done to reduce the burden of maintaining 2 versions of the same code. The only reason protocol intercation 16 | even have synchronous support is because it's needed in the :class:`~mcproto.buffer.Buffer` class. (See `Issue #128 17 | `_ for more details on this decision.) 18 | 19 | Generally, we recommend that you just stick to using the asynchronous alternatives though, both since some functions 20 | only support async, and because async will generally provide you with a more scalable codebase, making it much easier 21 | to handle multiple things concurrently. 22 | -------------------------------------------------------------------------------- /docs/pages/installation.rst: -------------------------------------------------------------------------------- 1 | Installation 2 | ============ 3 | 4 | PyPI (stable) version 5 | --------------------- 6 | 7 | Mcproto is available on `PyPI `_, and can be installed trivially with: 8 | 9 | .. code-block:: bash 10 | 11 | python3 -m pip install mcproto 12 | 13 | This will install the latest stable (released) version. This is generally what you'll want to do. 14 | 15 | Latest (git) version 16 | -------------------- 17 | 18 | Alternatively, you may want to install the latest available version, which is what you currently see in the ``main`` 19 | git branch. Although this method will actually work for any branch with a pretty straightforward change. This kind of 20 | installation should only be done when testing new feautes, and it's likely you'll encounter bugs. 21 | 22 | That said, since mcproto is still in development, changes can often be made pretty quickly, and it can sometimes take a 23 | while for these changes to carry over to PyPI. So if you really want to try out that latest feature, this is the method 24 | you'll want. 25 | 26 | .. code-block:: bash 27 | 28 | python3 -m pip install 'mcproto@git+https://github.com/py-mine/mcproto@main' 29 | -------------------------------------------------------------------------------- /docs/pages/version_guarantees.rst: -------------------------------------------------------------------------------- 1 | Version Guarantees 2 | ================== 3 | 4 | .. attention:: 5 | Mcproto is currently in the pre-release phase (pre v1.0.0). During this phase, these guarantees will NOT be 6 | followed! This means that **breaking changes can occur in minor version bumps**, though micro version bumps are 7 | still strictly for bugfixes, and will not include any features or breaking changes. 8 | 9 | This library follows `semantic versioning model `_, which means the major version is updated every 10 | time there is an incompatible (breaking) change made to the public API. However due to the fairly dynamic nature of 11 | Python, it can be hard to discern what can be considered a breaking change, and what isn't. 12 | 13 | First thing to keep in mind is that breaking changes only apply to **publicly documented functions and classes**. If 14 | it's not listed in the documentation here, it's an internal feature, that isn't considered a part of the public API, 15 | and thus is bound to change. This includes documented attributes that start with an underscore. 16 | 17 | .. note:: 18 | The examples below are non-exhaustive. 19 | 20 | Examples of Breaking Changes 21 | ---------------------------- 22 | 23 | * Changing the default parameter value of a function to something else. 24 | * Renaming (or removing) a function without an alias to the old function. 25 | * Adding or removing parameters of a function. 26 | * Removing deprecated alias to a renamed function 27 | 28 | Examples of Non-Breaking Changes 29 | -------------------------------- 30 | 31 | * Changing function's name, while providing a deprecated alias. 32 | * Renaming (or removing) private underscored attributes. 33 | * Adding an element into `__slots__` of a data class. 34 | * Changing the behavior of a function to fix a bug. 35 | * Changes in the typing behavior of the library. 36 | * Changes in the documentation. 37 | * Modifying the internal protocol connection handling. 38 | * Updating the dependencies to a newer version, major or otherwise. 39 | -------------------------------------------------------------------------------- /docs/usage/index.rst: -------------------------------------------------------------------------------- 1 | Usage guides 2 | ============ 3 | 4 | Here are some guides and explanations on how to use the various different parts of mcproto. 5 | 6 | 7 | .. toctree:: 8 | :maxdepth: 1 9 | :caption: Guides 10 | 11 | authentication.rst 12 | 13 | Feel free to propose any further guides, we'll be happy to add them to the list! 14 | -------------------------------------------------------------------------------- /mcproto/__init__.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | -------------------------------------------------------------------------------- /mcproto/auth/__init__.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | -------------------------------------------------------------------------------- /mcproto/auth/account.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import httpx 4 | from typing_extensions import override 5 | 6 | from mcproto.types.uuid import UUID as McUUID # noqa: N811 7 | 8 | MINECRAFT_API_URL = "https://api.minecraftservices.com" 9 | 10 | __all__ = [ 11 | "Account", 12 | "InvalidAccountAccessTokenError", 13 | "MismatchedAccountInfoError", 14 | ] 15 | 16 | 17 | class MismatchedAccountInfoError(Exception): 18 | """Exception raised when info stored in the account instance doesn't match one from API.""" 19 | 20 | def __init__(self, mismatched_variable: str, current: object, expected: object) -> None: 21 | self.missmatched_variable = mismatched_variable 22 | self.current = current 23 | self.expected = expected 24 | super().__init__(repr(self)) 25 | 26 | @override 27 | def __repr__(self) -> str: 28 | msg = f"Account has mismatched {self.missmatched_variable}: " 29 | msg += f"current={self.current!r}, expected={self.expected!r}." 30 | 31 | return f"{self.__class__.__name__}({msg!r})" 32 | 33 | 34 | class InvalidAccountAccessTokenError(Exception): 35 | """Exception raised when the access token of the account was reported as invalid.""" 36 | 37 | def __init__(self, access_token: str, status_error: httpx.HTTPStatusError) -> None: 38 | self.access_token = access_token 39 | self.status_error = status_error 40 | super().__init__("The account access token used is not valid (key expired?)") 41 | 42 | 43 | class Account: 44 | """Base class for an authenticated Minecraft account.""" 45 | 46 | __slots__ = ("access_token", "username", "uuid") 47 | 48 | def __init__(self, username: str, uuid: McUUID, access_token: str) -> None: 49 | self.username = username 50 | self.uuid = uuid 51 | self.access_token = access_token 52 | 53 | async def check(self, client: httpx.AsyncClient) -> None: 54 | """Check with minecraft API whether the account information stored is valid. 55 | 56 | :raises MismatchedAccountInfoError: 57 | If the information received from the minecraft API didn't match the information currently 58 | stored in the account instance. 59 | :raises InvalidAccountAccessTokenError: If the access token is not valid. 60 | """ 61 | res = await client.get( 62 | f"{MINECRAFT_API_URL}/minecraft/profile", 63 | headers={"Authorization": f"Bearer {self.access_token}"}, 64 | ) 65 | 66 | try: 67 | res.raise_for_status() 68 | except httpx.HTTPStatusError as exc: 69 | if exc.response.status_code == 401: 70 | raise InvalidAccountAccessTokenError(self.access_token, exc) from exc 71 | raise 72 | data = res.json() 73 | 74 | if self.uuid != McUUID(data["id"]): 75 | raise MismatchedAccountInfoError("uuid", self.uuid, data["id"]) 76 | if self.username != data["name"]: 77 | raise MismatchedAccountInfoError("username", self.username, data["name"]) 78 | -------------------------------------------------------------------------------- /mcproto/auth/microsoft/__init__.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | -------------------------------------------------------------------------------- /mcproto/auth/microsoft/oauth.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import asyncio 4 | from enum import Enum 5 | from typing import TypedDict 6 | 7 | import httpx 8 | from typing_extensions import override 9 | 10 | __all__ = [ 11 | "MicrosoftOauthRequestData", 12 | "MicrosoftOauthResponseData", 13 | "MicrosoftOauthResponseError", 14 | "MicrosoftOauthResponseErrorType", 15 | "full_microsoft_oauth", 16 | "microsoft_oauth_authenticate", 17 | "microsoft_oauth_request", 18 | ] 19 | 20 | MICROSOFT_OAUTH_URL = "https://login.microsoftonline.com/consumers/oauth2/v2.0" 21 | 22 | 23 | class MicrosoftOauthResponseErrorType(str, Enum): 24 | """Enum for various different kinds of exceptions that the Microsoft OAuth2 API can report.""" 25 | 26 | AUTHORIZATION_PENDING = "The user hasn't finished authenticating, but hasn't canceled the flow." 27 | AUTHORIZATION_DECLINED = "The end user denied the authorization request." 28 | BAD_VERIFICATION_CODE = "The device_code sent to the /token endpoint wasn't recognized." 29 | EXPIRED_TOKEN = ( 30 | "Value of expires_in has been exceeded and authentication is no longer possible" # noqa: S105 31 | " with device_code." 32 | ) 33 | INVALID_GRANT = "The provided value for the input parameter 'device_code' is not valid." 34 | UNKNOWN = "This is an unknown error" 35 | 36 | @classmethod 37 | def from_status_error(cls, error: str) -> MicrosoftOauthResponseErrorType: 38 | """Determine the error kind based on the error data.""" 39 | if error == "expired_token": 40 | return cls.EXPIRED_TOKEN 41 | if error == "authorization_pending": 42 | return cls.AUTHORIZATION_PENDING 43 | if error == "authorization_declined": 44 | return cls.AUTHORIZATION_DECLINED 45 | if error == "bad_verification_code": 46 | return cls.BAD_VERIFICATION_CODE 47 | if error == "invald_grant": 48 | return cls.INVALID_GRANT 49 | return cls.UNKNOWN 50 | 51 | 52 | class MicrosoftOauthResponseError(Exception): 53 | """Exception raised on a failure from the Microsoft OAuth2 API.""" 54 | 55 | def __init__(self, exc: httpx.HTTPStatusError): 56 | self.status_error = exc 57 | 58 | data = exc.response.json() 59 | self.error = data["error"] 60 | self.err_type = MicrosoftOauthResponseErrorType.from_status_error(self.error) 61 | 62 | super().__init__(self.err_type.value) 63 | 64 | @property 65 | def msg(self) -> str: 66 | """Produce a message for this error.""" 67 | if self.err_type is MicrosoftOauthResponseErrorType.UNKNOWN: 68 | return f"Unknown error: {self.error!r}" 69 | return f"Error {self.err_type.name}: {self.err_type.value!r}" 70 | 71 | @override 72 | def __repr__(self) -> str: 73 | return f"{self.__class__.__name__}({self.msg!r})" 74 | 75 | 76 | class MicrosoftOauthRequestData(TypedDict): 77 | """Data obtained from Microsoft OAuth2 API after making a new authentication request. 78 | 79 | This data specifies where (URL) we can check with the Microsoft OAuth2 servers for a client 80 | confirmation of this authentication request, how often we should check with this server, and 81 | when this request expires. 82 | """ 83 | 84 | user_code: str 85 | device_code: str 86 | verification_url: str 87 | expires_in: int 88 | interval: int 89 | message: str 90 | 91 | 92 | class MicrosoftOauthResponseData(TypedDict): 93 | """Data obtained from Microsoft OAuth2 API after a successful authentication. 94 | 95 | This data contains the access and refresh tokens, giving us the requested account access 96 | and the expiry information. 97 | """ 98 | 99 | token_type: str 100 | scope: str 101 | expires_in: int 102 | access_token: str 103 | refresh_token: str 104 | id_token: str 105 | 106 | 107 | async def microsoft_oauth_request(client: httpx.AsyncClient, client_id: str) -> MicrosoftOauthRequestData: 108 | """Initiate Microsoft Oauth2 flow. 109 | 110 | This requires a ``client_id``, which can be obtained by creating an application on 111 | `Microsoft Azure `_, 112 | with 'Allow public client flows' set to 'Yes' (can be set from the 'Authentication' tab). 113 | 114 | This will create a device id, used to identify our request and a user code, which the user can manually enter to 115 | https://www.microsoft.com/link and confirm, after that, :func:`microsoft_oauth_authenticate` should be called, 116 | with the returend device id as an argument. 117 | """ 118 | data = {"client_id": client_id, "scope": "XboxLive.signin offline_access"} 119 | res = await client.post(f"{MICROSOFT_OAUTH_URL}/devicecode", data=data) 120 | res.raise_for_status() 121 | 122 | return res.json() 123 | 124 | 125 | async def microsoft_oauth_authenticate( 126 | client: httpx.AsyncClient, 127 | client_id: str, 128 | device_code: str, 129 | ) -> MicrosoftOauthResponseData: 130 | """Complete Microsoft Oauth2 flow and authenticate. 131 | 132 | This functon should be called after :func:`microsoft_oauth_request`. If the user has authorized the request, 133 | we will get an access token back, allowing us to perform certain actions on behaf of the microsoft user that 134 | has authorized this request. Alternatively, this function will fal with :exc:`MicrosoftOauthResponseError`. 135 | """ 136 | data = { 137 | "grant_type": "urn:ietf:params:oauth:grant-type:device_code", 138 | "client_id": client_id, 139 | "device_code": device_code, 140 | } 141 | res = await client.post(f"{MICROSOFT_OAUTH_URL}/token", data=data) 142 | 143 | try: 144 | res.raise_for_status() 145 | except httpx.HTTPStatusError as exc: 146 | if exc.response.status_code == 400: 147 | raise MicrosoftOauthResponseError(exc) from exc 148 | raise 149 | 150 | return res.json() 151 | 152 | 153 | async def full_microsoft_oauth(client: httpx.AsyncClient, client_id: str) -> MicrosoftOauthResponseData: 154 | """Perform full Microsoft Oauth2 sequence, waiting for user to authenticated (from the browser). 155 | 156 | See :func:`microsoft_oauth_request` (OAuth2 start) and :func:`microsoft_oauth_authenticate` (OAuth2 end). 157 | """ 158 | request_data = await microsoft_oauth_request(client, client_id) 159 | 160 | # Contains instructions for the user (user code and verification url) 161 | print(request_data["message"]) # noqa: T201 162 | 163 | while True: 164 | await asyncio.sleep(request_data["interval"]) 165 | try: 166 | return await microsoft_oauth_authenticate(client, client_id, request_data["device_code"]) 167 | except MicrosoftOauthResponseError as exc: 168 | if exc.err_type is MicrosoftOauthResponseErrorType.AUTHORIZATION_PENDING: 169 | continue 170 | raise 171 | -------------------------------------------------------------------------------- /mcproto/auth/microsoft/xbox.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from enum import Enum 4 | from typing import NamedTuple 5 | 6 | import httpx 7 | from typing_extensions import override 8 | 9 | __all__ = [ 10 | "XSTSErrorType", 11 | "XSTSRequestError", 12 | "XboxData", 13 | "xbox_auth", 14 | ] 15 | 16 | XBOX_LIVE_AUTH_URL = "https://user.auth.xboxlive.com/user/authenticate" 17 | XBOX_SECURE_TOKEN_SERVER_AUTH_URL = "https://xsts.auth.xboxlive.com/xsts/authorize" # noqa: S105 18 | 19 | 20 | class XSTSErrorType(str, Enum): 21 | """Enum for various different kinds of exceptions that the Xbox Secure Token Server (XSTS) API can report.""" 22 | 23 | NO_XBOX_ACCOUNT = ( 24 | "The account doesn't have an Xbox account. Once they sign up for one (or login through minecraft.net to create" 25 | " one) then they can proceed with the login. This shouldn't happen with accounts that have purchased Minecraft" 26 | " with a Microsoft account, as they would've already gone through that Xbox signup process." 27 | ) 28 | XBOX_LIVE_NOT_IN_COUNTRY = "The account is from a country where Xbox Live is not available/banned" 29 | ADULT_VERIFICATION_NEEDED = "The account needs adult verification on Xbox page. (South Korea)" 30 | UNDERAGE_ACCOUNT = ( 31 | "The account is a child (under 18) and cannot proceed unless the account is added to a Family by an adult." 32 | " This only seems to occur when using a custom Microsoft Azure application. When using the Minecraft launchers" 33 | " client id, this doesn't trigger." 34 | ) 35 | UNKNOWN = "This is an unknown error." 36 | 37 | @classmethod 38 | def from_status_error(cls, xerr_no: int) -> XSTSErrorType: 39 | """Determine the error kind based on the error data.""" 40 | if xerr_no == 2148916233: 41 | return cls.NO_XBOX_ACCOUNT 42 | if xerr_no == 2148916235: 43 | return cls.XBOX_LIVE_NOT_IN_COUNTRY 44 | if xerr_no in (2148916236, 2148916237): 45 | return cls.ADULT_VERIFICATION_NEEDED 46 | if xerr_no == 2148916238: 47 | return cls.UNDERAGE_ACCOUNT 48 | 49 | return cls.UNKNOWN 50 | 51 | 52 | class XSTSRequestError(Exception): 53 | """Exception raised on a failure from the Xbox Secure Token Server (XSTS) API.""" 54 | 55 | def __init__(self, exc: httpx.HTTPStatusError): 56 | self.status_error = exc 57 | 58 | data = exc.response.json() 59 | self.identity: str = data["Identity"] 60 | self.xerr: int = data["XErr"] 61 | self.message: str = data["Message"] 62 | self.redirect_url: str = data["Redirect"] 63 | self.err_type = XSTSErrorType.from_status_error(self.xerr) 64 | 65 | super().__init__(self.msg) 66 | 67 | @property 68 | def msg(self) -> str: 69 | """Produce a message for this error.""" 70 | msg_parts = [] 71 | if self.err_type is not XSTSErrorType.UNKNOWN: 72 | msg_parts.append(f"{self.err_type.name}: {self.err_type.value!r}") 73 | else: 74 | msg_parts.append(f"identity={self.identity!r}") 75 | msg_parts.append(f"xerr-{self.xerr}") 76 | msg_parts.append(f"message={self.message!r}") 77 | msg_parts.append(f"redirect_url={self.redirect_url!r}") 78 | 79 | return " ".join(msg_parts) 80 | 81 | @override 82 | def __repr__(self) -> str: 83 | return f"{self.__class__.__name__}({self.msg})" 84 | 85 | 86 | class XboxData(NamedTuple): 87 | """Xbox authentication data.""" 88 | 89 | user_hash: str 90 | xsts_token: str 91 | 92 | 93 | async def xbox_auth(client: httpx.AsyncClient, microsoft_access_token: str, bedrock: bool = False) -> XboxData: 94 | """Authenticate into Xbox Live account and obtain user hash and XSTS token. 95 | 96 | See :func:`~mcproto.auth.microsoft.oauth.full_microsoft_oauth` for info on ``microsoft_access_token``. 97 | """ 98 | # Obtain XBL token 99 | payload = { 100 | "Properties": { 101 | "AuthMethod": "RPS", 102 | "SiteName": "user.auth.xboxlive.com", 103 | "RpsTicket": f"d={microsoft_access_token}", 104 | }, 105 | "RelyingParty": "http://auth.xboxlive.com", 106 | "TokenType": "JWT", 107 | } 108 | res = await client.post(XBOX_LIVE_AUTH_URL, json=payload) 109 | res.raise_for_status() 110 | data = res.json() 111 | 112 | xbl_token = data["Token"] 113 | user_hash = data["DisplayClaims"]["xui"][0]["uhs"] 114 | 115 | # Obtain XSTS token 116 | payload = { 117 | "Properties": { 118 | "SandboxId": "RETAIL", 119 | "UserTokens": [xbl_token], 120 | }, 121 | "RelyingParty": "https://pocket.realms.minecraft.net" if bedrock else "rp://api.minecraftservices.com/", 122 | "TokenType": "JWT", 123 | } 124 | res = await client.post(XBOX_SECURE_TOKEN_SERVER_AUTH_URL, json=payload) 125 | 126 | try: 127 | res.raise_for_status() 128 | except httpx.HTTPStatusError as exc: 129 | if exc.response.status_code == 401: 130 | raise XSTSRequestError(exc) from exc 131 | raise 132 | data = res.json() 133 | 134 | xsts_token = data["Token"] 135 | 136 | return XboxData(user_hash=user_hash, xsts_token=xsts_token) 137 | -------------------------------------------------------------------------------- /mcproto/auth/msa.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from enum import Enum 4 | 5 | import httpx 6 | from typing_extensions import Self, override 7 | 8 | from mcproto.auth.account import Account 9 | from mcproto.types.uuid import UUID as McUUID # noqa: N811 10 | 11 | __all__ = [ 12 | "MSAAccount", 13 | "ServicesAPIError", 14 | "ServicesAPIErrorType", 15 | ] 16 | 17 | MC_SERVICES_API_URL = "https://api.minecraftservices.com" 18 | 19 | 20 | class ServicesAPIErrorType(str, Enum): 21 | """Enum for various different kinds of exceptions that the Minecraft services API can report.""" 22 | 23 | INVALID_REGISTRATION = "Invalid app registration, see https://aka.ms/AppRegInfo for more information" 24 | UNKNOWN = "This is an unknown error." 25 | 26 | @classmethod 27 | def from_status_error(cls, code: int, err_msg: str | None) -> ServicesAPIErrorType: 28 | """Determine the error kind based on the error data.""" 29 | if code == 401 and err_msg == "Invalid app registration, see https://aka.ms/AppRegInfo for more information": 30 | return cls.INVALID_REGISTRATION 31 | return cls.UNKNOWN 32 | 33 | 34 | class ServicesAPIError(Exception): 35 | """Exception raised on a failure from the Minecraft services API.""" 36 | 37 | def __init__(self, exc: httpx.HTTPStatusError): 38 | self.status_error = exc 39 | self.code = exc.response.status_code 40 | self.url = exc.request.url 41 | 42 | data = exc.response.json() 43 | self.err_msg: str | None = data.get("errorMessage") 44 | self.err_type = ServicesAPIErrorType.from_status_error(self.code, self.err_msg) 45 | 46 | super().__init__(self.msg) 47 | 48 | @property 49 | def msg(self) -> str: 50 | """Produce a message for this error.""" 51 | msg_parts = [] 52 | msg_parts.append(f"HTTP {self.code} from {self.url}:") 53 | msg_parts.append(f"type={self.err_type.name!r}") 54 | 55 | if self.err_type is not ServicesAPIErrorType.UNKNOWN: 56 | msg_parts.append(f"details={self.err_type.value!r}") 57 | elif self.err_msg is not None: 58 | msg_parts.append(f"msg={self.err_msg!r}") 59 | 60 | return " ".join(msg_parts) 61 | 62 | @override 63 | def __repr__(self) -> str: 64 | return f"{self.__class__.__name__}({self.msg})" 65 | 66 | 67 | class MSAAccount(Account): 68 | """Minecraft account logged into using Microsoft OAUth2 auth system.""" 69 | 70 | __slots__ = () 71 | 72 | @staticmethod 73 | async def _get_access_token_from_xbox(client: httpx.AsyncClient, user_hash: str, xsts_token: str) -> str: 74 | """Obtain access token from an XSTS token from Xbox Live auth (for Microsoft accounts).""" 75 | payload = {"identityToken": f"XBL3.0 x={user_hash};{xsts_token}"} 76 | res = await client.post(f"{MC_SERVICES_API_URL}/authentication/login_with_xbox", json=payload) 77 | 78 | try: 79 | res.raise_for_status() 80 | except httpx.HTTPStatusError as exc: 81 | raise ServicesAPIError(exc) from exc 82 | 83 | data = res.json() 84 | return data["access_token"] 85 | 86 | @classmethod 87 | async def from_xbox_access_token(cls, client: httpx.AsyncClient, access_token: str) -> Self: 88 | """Construct the account from the xbox access token, using it to get the rest of the profile information. 89 | 90 | See :meth:`_get_access_token_from_xbox` for how to obtain the ``access_token``. Note that 91 | in most cases, you'll want to use :meth:`xbox_auth` rather than this method directly. 92 | """ 93 | res = await client.get( 94 | f"{MC_SERVICES_API_URL}/minecraft/profile", headers={"Authorization": f"Bearer {access_token}"} 95 | ) 96 | res.raise_for_status() 97 | data = res.json() 98 | 99 | return cls(data["name"], McUUID(data["id"]), access_token) 100 | 101 | @classmethod 102 | async def xbox_auth(cls, client: httpx.AsyncClient, user_hash: str, xsts_token: str) -> Self: 103 | """Authenticate using an XSTS token from Xbox Live auth (for Microsoft accounts). 104 | 105 | See :func:`mcproto.auth.microsoft.xbox.xbox_auth` for how to obtain the ``user_hash`` and ``xsts_token``. 106 | """ 107 | access_token = await cls._get_access_token_from_xbox(client, user_hash, xsts_token) 108 | return await cls.from_xbox_access_token(client, access_token) 109 | -------------------------------------------------------------------------------- /mcproto/auth/yggdrasil.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import warnings 4 | from enum import Enum 5 | from uuid import uuid4 6 | 7 | import httpx 8 | from typing_extensions import Self, override 9 | 10 | from mcproto.auth.account import Account 11 | from mcproto.types.uuid import UUID as McUUID # noqa: N811 12 | 13 | __all__ = [ 14 | "AuthServerApiError", 15 | "AuthServerApiErrorType", 16 | "YggdrasilAccount", 17 | ] 18 | 19 | AUTHSERVER_API_URL = "https://authserver.mojang.com" 20 | 21 | 22 | class AuthServerApiErrorType(str, Enum): 23 | """Enum for various different kinds of exceptions that the authserver API can report.""" 24 | 25 | MICROSOFT_MIGRATED = "This Minecraft account was migrated, use Microsoft OAuth2 login instaed." 26 | FORBIDDEN = "An attempt to sign in using empty or insufficiently short credentials." 27 | INVALID_CREDENTIALS = ( 28 | "Either a successful attempt to sign in using an account with excessive login attempts" 29 | " or an unsuccessful attempt to sign in using a non-existent account." 30 | ) 31 | MOJANG_MIGRATED = ( 32 | "Attempted to login with username (Mojang accounts only), howver this account was" 33 | " already migrated into a Minecraft account. Use E-Mail to login instead of username." 34 | ) 35 | INVALID_TOKEN_REFRESH = ( 36 | "An attempt to refresh an access token that has been invalidated, " # noqa: S105 37 | "no longer exists or has been ereased." 38 | ) 39 | INVALID_TOKEN_VALIDATE = ( 40 | "An attempt to validate an access token obtained from /authenticate endpoint that has" # noqa: S105 41 | " expired or become invalid while under rate-limiting conditions." 42 | ) 43 | UNKNOWN = "This is an unknown error." 44 | 45 | @classmethod 46 | def from_status_error( 47 | cls, 48 | code: int, 49 | short_msg: str, 50 | full_msg: str, 51 | cause_msg: str | None, 52 | ) -> AuthServerApiErrorType: 53 | """Determine the error kind based on the error data.""" 54 | if code == 410: 55 | return cls.MICROSOFT_MIGRATED 56 | if code == 403: 57 | if full_msg == "Forbidden": 58 | return cls.FORBIDDEN 59 | if full_msg == "Invalid credentials. Invalid username or password.": 60 | return cls.INVALID_CREDENTIALS 61 | if full_msg == "Invalid credentials. Account migrated, use email as username.": 62 | return cls.MOJANG_MIGRATED 63 | if full_msg == "Token does not exist": 64 | return cls.INVALID_TOKEN_REFRESH 65 | if code == 429 and full_msg == "Invalid token.": 66 | return cls.INVALID_TOKEN_VALIDATE 67 | 68 | return cls.UNKNOWN 69 | 70 | 71 | class AuthServerApiError(Exception): 72 | """Exception raised on a failure from the authserver API.""" 73 | 74 | def __init__(self, exc: httpx.HTTPStatusError): 75 | self.status_error = exc 76 | self.code = exc.response.status_code 77 | self.url = exc.request.url 78 | 79 | data = exc.response.json() 80 | self.short_msg: str = data["error"] 81 | self.full_msg: str = data["errorMessage"] 82 | self.cause_msg: str = data.get("cause") 83 | self.err_type = AuthServerApiErrorType.from_status_error( 84 | self.code, self.short_msg, self.cause_msg, self.full_msg 85 | ) 86 | 87 | super().__init__(self.msg) 88 | 89 | @property 90 | def msg(self) -> str: 91 | """Produce a message for this error.""" 92 | msg_parts = [] 93 | msg_parts.append(f"HTTP {self.code} from {self.url}:") 94 | msg_parts.append(f"type={self.err_type.name!r}") 95 | 96 | if self.err_type is not AuthServerApiErrorType.UNKNOWN: 97 | msg_parts.append(f"msg={self.err_type.value!r}") 98 | else: 99 | msg_parts.append(f"short_msg={self.short_msg!r}") 100 | msg_parts.append(f"full_msg={self.full_msg!r}") 101 | msg_parts.append(f"cause_msg={self.cause_msg!r}") 102 | 103 | return " ".join(msg_parts) 104 | 105 | @override 106 | def __repr__(self) -> str: 107 | return f"{self.__class__.__name__}({self.msg})" 108 | 109 | 110 | class YggdrasilAccount(Account): 111 | """Minecraft account logged into using Yggdrasil (legacy/unmigrated) auth system.""" 112 | 113 | __slots__ = ("client_token",) 114 | 115 | def __init__(self, username: str, uuid: McUUID, access_token: str, client_token: str | None) -> None: 116 | super().__init__(username, uuid, access_token) 117 | 118 | if client_token is None: 119 | client_token = str(uuid4()) 120 | self.client_token = client_token 121 | 122 | async def refresh(self, client: httpx.AsyncClient) -> None: 123 | """Refresh the Yggdrasil access token. 124 | 125 | This method can be called when the access token expires, to obtain a new one without 126 | having to go through a complete re-login. This can happen after some time period, or 127 | for example when someone else logs in to this minecraft account elsewhere. 128 | """ 129 | payload = { 130 | "accessToken": self.access_token, 131 | "clientToken": self.client_token, 132 | "selectedProfile": {"id": str(self.uuid), "name": self.username}, 133 | } 134 | res = await client.post( 135 | f"{AUTHSERVER_API_URL}/refresh", 136 | headers={"content-type": "application/json"}, 137 | json=payload, 138 | ) 139 | 140 | try: 141 | res.raise_for_status() 142 | except httpx.HTTPStatusError as exc: 143 | raise AuthServerApiError(exc) from exc 144 | 145 | data = res.json() 146 | 147 | if (recv_client_token := data["clientToken"]) != self.client_token: 148 | raise ValueError(f"Missmatched client tokens! {recv_client_token!r} != {self.client_token!r}") 149 | 150 | if (recv_uuid := McUUID(data["selectedProfile"]["uuid"])) != self.uuid: 151 | # The UUID really shouldn't be different here, but if it is, update it, as it's more recent. 152 | # However it's incredibly weird if this really would happen, so a warning is emitted. 153 | warnings.warn( 154 | f"Player UUID changed after refresh ({self.uuid!r} -> {recv_uuid!r})", 155 | UserWarning, 156 | stacklevel=2, 157 | ) 158 | self.uuid = recv_uuid 159 | 160 | # in case username changed 161 | self.username = data["selectedProfile"]["name"] 162 | 163 | # new (refreshed) access token 164 | self.access_token = data["accessToken"] 165 | 166 | async def validate(self, client: httpx.AsyncClient) -> bool: 167 | """Check if the access token is (still) usable for authentication with a Minecraft server. 168 | 169 | If this method fails, the stored access token is no longer usable for for authentcation 170 | with a Minecraft server, but should still be good enough for :meth:`refresh`. 171 | 172 | This mainly happens when one has used another client (e.g. another launcher). 173 | """ 174 | # The payload can also include a client token (same as the one used in auth), but it's 175 | # not necessary, and the official launcher doesn't send it, so we won't either 176 | payload = {"accessToken": self.access_token} 177 | res = await client.post(f"{AUTHSERVER_API_URL}/validate", json=payload) 178 | 179 | if res.status_code == 204: 180 | return True 181 | 182 | try: 183 | res.raise_for_status() 184 | except httpx.HTTPStatusError as exc: 185 | raise AuthServerApiError(exc) from exc 186 | 187 | raise ValueError(f"Received unexpected 2XX response: {res.status_code} from /validate, but not 204") 188 | 189 | @classmethod 190 | async def authenticate(cls, client: httpx.AsyncClient, login: str, password: str) -> Self: 191 | """Authenticate using the Yggdrasil system (for non-Microsoft accounts). 192 | 193 | :param login: E-Mail of your Minecraft account, or username for (really old) Mojang accounts. 194 | :param password: Plaintext account password. 195 | """ 196 | # Any random string, we use a random v4 uuid, needs to remain same in further communications 197 | client_token = str(uuid4()) 198 | 199 | payload = { 200 | "agent": { 201 | "name": "Minecraft", 202 | "version": 1, 203 | }, 204 | "username": login, 205 | "password": password, 206 | "clientToken": client_token, 207 | "requestUser": False, 208 | } 209 | res = await client.post(f"{AUTHSERVER_API_URL}/authenticate", json=payload) 210 | 211 | try: 212 | res.raise_for_status() 213 | except httpx.HTTPStatusError as exc: 214 | raise AuthServerApiError(exc) from exc 215 | 216 | data = res.json() 217 | 218 | if (recv_client_token := data["clientToken"]) != client_token: 219 | raise ValueError(f"Missmatched client tokens! {recv_client_token!r} != {client_token!r}") 220 | 221 | username = data["selectedProfile"]["name"] 222 | uuid = McUUID(data["selectedProfile"]["uuid"]) 223 | access_token = data["accessToken"] 224 | 225 | return cls(username, uuid, access_token, client_token) 226 | 227 | async def signout(self, client: httpx.AsyncClient, username: str, password: str) -> None: 228 | """Sign out using the Yggdrasil system (for non-Microsoft accounts). 229 | 230 | :param login: E-Mail of your Minecraft account, or username for (really old) Mojang accounts. 231 | :param password: Plaintext account password. 232 | """ 233 | payload = { 234 | "username": username, 235 | "password": password, 236 | } 237 | res = await client.post(f"{AUTHSERVER_API_URL}/signout", json=payload) 238 | 239 | try: 240 | res.raise_for_status() 241 | except httpx.HTTPStatusError as exc: 242 | raise AuthServerApiError(exc) from exc 243 | 244 | # Status code is 2XX, meaning we succeeded (response doesn't contain any payload) 245 | -------------------------------------------------------------------------------- /mcproto/buffer.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing_extensions import override 4 | 5 | from mcproto.protocol.base_io import BaseSyncReader, BaseSyncWriter 6 | 7 | __all__ = ["Buffer"] 8 | 9 | 10 | class Buffer(BaseSyncWriter, BaseSyncReader, bytearray): 11 | """In-memory bytearray-like buffer supporting the common read/write operations.""" 12 | 13 | __slots__ = ("pos",) 14 | 15 | def __init__(self, *args, **kwargs): 16 | super().__init__(*args, **kwargs) 17 | self.pos = 0 18 | 19 | @override 20 | def write(self, data: bytes | bytearray) -> None: 21 | """Write/Store given ``data`` into the buffer.""" 22 | self.extend(data) 23 | 24 | @override 25 | def read(self, length: int) -> bytes: 26 | """Read data stored in the buffer. 27 | 28 | Reading data doesn't remove that data, rather that data is treated as already read, and 29 | next read will start from the first unread byte. If freeing the data is necessary, check 30 | the :meth:`.clear` function. 31 | 32 | :param length: 33 | Amount of bytes to be read. 34 | 35 | If the requested amount can't be read (buffer doesn't contain that much data/buffer 36 | doesn't contain any data), an :exc:`IOError` will be reaised. 37 | 38 | If there were some data in the buffer, but it was less than requested, this remaining 39 | data will still be depleted and the partial data that was read will be a part of the 40 | error message in the :exc:`IOError`. This behavior is here to mimic reading from a real 41 | socket connection. 42 | """ 43 | end = self.pos + length 44 | 45 | if end > len(self): 46 | data = self[self.pos : len(self)] 47 | bytes_read = len(self) - self.pos 48 | self.pos = len(self) 49 | raise IOError( 50 | "Requested to read more data than available." 51 | f" Read {bytes_read} bytes: {data}, out of {length} requested bytes." 52 | ) 53 | 54 | try: 55 | return bytes(self[self.pos : end]) 56 | finally: 57 | self.pos = end 58 | 59 | @override 60 | def clear(self, only_already_read: bool = False) -> None: 61 | """Clear out the stored data and reset position. 62 | 63 | :param only_already_read: 64 | When set to ``True``, only the data that was already marked as read will be cleared, 65 | and the position will be reset (to start at the remaining data). This can be useful 66 | for avoiding needlessly storing large amounts of data in memory, if this data is no 67 | longer useful. 68 | 69 | Otherwise, if set to ``False``, all of the data is cleared, and the position is reset, 70 | essentially resulting in a blank buffer. 71 | """ 72 | if only_already_read: 73 | del self[: self.pos] 74 | else: 75 | super().clear() 76 | self.pos = 0 77 | 78 | def reset(self) -> None: 79 | """Reset the position in the buffer. 80 | 81 | Since the buffer doesn't automatically clear the already read data, it is possible to simply 82 | reset the position and read the data it contains again. 83 | """ 84 | self.pos = 0 85 | 86 | def flush(self) -> bytes: 87 | """Read all of the remaining data in the buffer and clear it out.""" 88 | data = self[self.pos : len(self)] 89 | self.clear() 90 | return bytes(data) 91 | 92 | @property 93 | def remaining(self) -> int: 94 | """Get the amount of bytes that's still remaining in the buffer to be read.""" 95 | return len(self) - self.pos 96 | -------------------------------------------------------------------------------- /mcproto/encryption.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import os 4 | 5 | from cryptography.hazmat.backends import default_backend 6 | from cryptography.hazmat.primitives.asymmetric.padding import PKCS1v15 7 | from cryptography.hazmat.primitives.asymmetric.rsa import RSAPrivateKey, RSAPublicKey, generate_private_key 8 | 9 | 10 | def generate_shared_secret() -> bytes: # pragma: no cover 11 | """Generate a random shared secret for client. 12 | 13 | This secret will be sent to the server in :class:`~mcproto.packets.login.login.LoginEncryptionResponse` packet, 14 | and used to encrypt all future communication afterwards. 15 | 16 | This will be symetric encryption using AES/CFB8 stream cipher. And this shared secret will be 16-bytes long. 17 | """ 18 | return os.urandom(16) 19 | 20 | 21 | def generate_verify_token() -> bytes: # pragma: no cover 22 | """Generate a random verify token. 23 | 24 | This token will be sent by the server in :class:`~mcproto.packets.login.login.LoginEncryptionRequest`, to be 25 | encrypted by the client as a form of verification. 26 | 27 | This token doesn't need to be cryptographically secure, it's just a sanity check that 28 | the client has encrypted the data correctly. 29 | """ 30 | return os.urandom(4) 31 | 32 | 33 | def generate_rsa_key() -> RSAPrivateKey: # pragma: no cover 34 | """Generate a random RSA key pair for server. 35 | 36 | This key pair will be used for :class:`~mcproto.packets.login.login.LoginEncryptionRequest` packet, 37 | where the client will be sent the public part of this key pair, which will be used to encrypt the 38 | shared secret (and verification token) sent in :class:`~mcproto.packets.login.login.LoginEncryptionResponse` 39 | packet. The server will then use the private part of this key pair to decrypt that. 40 | 41 | This will be a 1024-bit RSA key pair. 42 | """ 43 | return generate_private_key( 44 | public_exponent=65537, 45 | key_size=1024, # noqa: S505 # 1024-bit keys are not secure, but well, the mc protocol uses them 46 | backend=default_backend(), 47 | ) 48 | 49 | 50 | def encrypt_token_and_secret( 51 | public_key: RSAPublicKey, 52 | verification_token: bytes, 53 | shared_secret: bytes, 54 | ) -> tuple[bytes, bytes]: 55 | """Encrypts the verification token and shared secret with the server's public key. 56 | 57 | :param public_key: The RSA public key provided by the server 58 | :param verification_token: The verification token provided by the server 59 | :param shared_secret: The generated shared secret 60 | :return: A tuple containing (encrypted token, encrypted secret) 61 | """ 62 | # Ensure both the `shared_secret` and `verification_token` are instances 63 | # of the bytes class, not any subclass. This is needed since the cryptography 64 | # library calls some C code in the back, which relies on this being bytes. If 65 | # it's not a bytes instance, convert it. 66 | if type(verification_token) is not bytes: 67 | verification_token = bytes(verification_token) 68 | if type(shared_secret) is not bytes: 69 | shared_secret = bytes(shared_secret) 70 | 71 | encrypted_token = public_key.encrypt(verification_token, PKCS1v15()) 72 | encrypted_secret = public_key.encrypt(shared_secret, PKCS1v15()) 73 | return encrypted_token, encrypted_secret 74 | 75 | 76 | def decrypt_token_and_secret( 77 | private_key: RSAPrivateKey, 78 | verification_token: bytes, 79 | shared_secret: bytes, 80 | ) -> tuple[bytes, bytes]: 81 | """Decrypts the verification token and shared secret with the server's private key. 82 | 83 | :param private_key: The RSA private key generated by the server 84 | :param verification_token: The verification token encrypted and sent by the client 85 | :param shared_secret: The shared secret encrypted and sent by the client 86 | :return: A tuple containing (decrypted token, decrypted secret) 87 | """ 88 | # Ensure both the `shared_secret` and `verification_token` are instances 89 | # of the bytes class, not any subclass. This is needed since the cryptography 90 | # library calls some C code in the back, which relies on this being bytes. If 91 | # it's not a bytes instance, convert it. 92 | if type(verification_token) is not bytes: # we don't want isinstance 93 | verification_token = bytes(verification_token) 94 | if type(shared_secret) is not bytes: # we don't want isinstance 95 | shared_secret = bytes(shared_secret) 96 | 97 | decrypted_token = private_key.decrypt(verification_token, PKCS1v15()) 98 | decrypted_secret = private_key.decrypt(shared_secret, PKCS1v15()) 99 | return decrypted_token, decrypted_secret 100 | -------------------------------------------------------------------------------- /mcproto/packets/__init__.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from mcproto.packets.interactions import async_read_packet, async_write_packet, sync_read_packet, sync_write_packet 4 | from mcproto.packets.packet import ClientBoundPacket, GameState, Packet, PacketDirection, ServerBoundPacket 5 | from mcproto.packets.packet_map import generate_packet_map 6 | 7 | __all__ = [ 8 | "ClientBoundPacket", 9 | "GameState", 10 | "Packet", 11 | "PacketDirection", 12 | "ServerBoundPacket", 13 | "async_read_packet", 14 | "async_write_packet", 15 | "generate_packet_map", 16 | "sync_read_packet", 17 | "sync_write_packet", 18 | ] 19 | -------------------------------------------------------------------------------- /mcproto/packets/handshaking/__init__.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from mcproto.packets.handshaking.handshake import Handshake, NextState 4 | 5 | __all__ = [ 6 | "Handshake", 7 | "NextState", 8 | ] 9 | -------------------------------------------------------------------------------- /mcproto/packets/handshaking/handshake.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from enum import IntEnum 4 | from typing import ClassVar, cast, final 5 | 6 | from attrs import define 7 | from typing_extensions import Self, override 8 | 9 | from mcproto.buffer import Buffer 10 | from mcproto.packets.packet import GameState, ServerBoundPacket 11 | from mcproto.protocol.base_io import StructFormat 12 | 13 | __all__ = [ 14 | "Handshake", 15 | "NextState", 16 | ] 17 | 18 | 19 | class NextState(IntEnum): 20 | """Enum of all possible next game states we can transition to from the :class:`Handshake` packet.""" 21 | 22 | STATUS = 1 23 | LOGIN = 2 24 | 25 | 26 | @final 27 | @define 28 | class Handshake(ServerBoundPacket): 29 | """Initializes connection between server and client. (Client -> Server). 30 | 31 | Initialize the Handshake packet. 32 | 33 | :param protocol_version: Protocol version number to be used. 34 | :param server_address: The host/address the client is connecting to. 35 | :param server_port: The port the client is connecting to. 36 | :param next_state: The next state for the server to move into. 37 | """ 38 | 39 | PACKET_ID: ClassVar[int] = 0x00 40 | GAME_STATE: ClassVar[GameState] = GameState.HANDSHAKING 41 | 42 | protocol_version: int 43 | server_address: str 44 | server_port: int 45 | next_state: NextState | int 46 | 47 | @override 48 | def __attrs_post_init__(self) -> None: 49 | if not isinstance(self.next_state, NextState): 50 | self.next_state = NextState(self.next_state) 51 | 52 | super().__attrs_post_init__() 53 | 54 | @override 55 | def serialize_to(self, buf: Buffer) -> None: 56 | """Serialize the packet.""" 57 | self.next_state = cast(NextState, self.next_state) # Handled by the __attrs_post_init__ method 58 | buf.write_varint(self.protocol_version) 59 | buf.write_utf(self.server_address) 60 | buf.write_value(StructFormat.USHORT, self.server_port) 61 | buf.write_varint(self.next_state.value) 62 | 63 | @override 64 | @classmethod 65 | def _deserialize(cls, buf: Buffer, /) -> Self: 66 | return cls( 67 | protocol_version=buf.read_varint(), 68 | server_address=buf.read_utf(), 69 | server_port=buf.read_value(StructFormat.USHORT), 70 | next_state=buf.read_varint(), 71 | ) 72 | 73 | @override 74 | def validate(self) -> None: 75 | if not isinstance(self.next_state, NextState): 76 | rev_lookup = {x.value: x for x in NextState.__members__.values()} 77 | if self.next_state not in rev_lookup: 78 | raise ValueError("No such next_state.") 79 | -------------------------------------------------------------------------------- /mcproto/packets/interactions.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import zlib 4 | from collections.abc import Mapping 5 | from typing import TypeVar 6 | 7 | from mcproto.buffer import Buffer 8 | from mcproto.packets.packet import Packet 9 | from mcproto.protocol.base_io import BaseAsyncReader, BaseAsyncWriter, BaseSyncReader, BaseSyncWriter 10 | 11 | __all__ = ["async_read_packet", "async_write_packet", "sync_read_packet", "sync_write_packet"] 12 | 13 | T_Packet = TypeVar("T_Packet", bound=Packet) 14 | 15 | # PACKET FORMAT: 16 | # | Field name | Field type | Notes | 17 | # |-------------|---------------|---------------------------------------| 18 | # | Length | 32-bit varint | Length (in bytes) of PacketID + Data | 19 | # | Packet ID | 32-bit varint | | 20 | # | Data | byte array | Internal data to packet of given id | 21 | 22 | # COMPRESSED PACKET FORMAT: 23 | # | Compressed? | Field name | Field type | Notes | 24 | # | ------------|---------------|---------------|-------------------------------------------------------------------| 25 | # | No | Packet Length | 32-bit varint | Length of (Data Length) + Compressed length of (Packet ID + Data) | 26 | # | No | Data Length | 32-bit varint | Length of uncompressed (PacketID + Data) | 27 | # | Yes | Packet ID | 32-bit varint | Zlib compressed packet ID | 28 | # | Yes | Data | byte array | Zlib compressed packet data | 29 | # 30 | # Compression should only be used when LoginSetCompression packet is received. 31 | # In this packet, a compression threshold will be sent by the server. This is 32 | # a number which specifies how large a packet can be at most (it's Data Length), 33 | # before enabling compression. If a packet is smaller, compression will not be 34 | # enabled. 35 | # 36 | # However since compression changes how the packet format looks, we need to inform 37 | # the reader whether or not compression was used, so when disabled, we just set 38 | # Data Length to 0, which will mean compression is disabled, and Packet ID and Data 39 | # fields will be sent uncompressed. 40 | 41 | 42 | # Since the read functions here require PACKET_MAP, we can't move these functions 43 | # directly into BaseWriter/BaseReader classes, as that would be a circular import 44 | 45 | 46 | def _serialize_packet(packet: Packet, *, compression_threshold: int = -1) -> Buffer: 47 | """Serialize the internal packet data, along with it's packet id. 48 | 49 | :param packet: The packet to serialize. 50 | :param compression_threshold: 51 | A threshold for the packet length (in bytes), which if surpassed compression should 52 | be enabeld. To disable compression, set this to -1. Note that when enabled, even if 53 | the threshold isn't crossed, the packet format will be different than with compression 54 | disabled. 55 | """ 56 | packet_data = packet.serialize() 57 | 58 | # Base packet buffer should only contain packet id and internal packet data 59 | packet_buf = Buffer() 60 | packet_buf.write_varint(packet.PACKET_ID) 61 | packet_buf.write(packet_data) 62 | 63 | # Compression is enabled 64 | if compression_threshold >= 0: 65 | # Only run the actual compression step if we cross the threshold, otherwise 66 | # send uncompressed data with an extra 0 for data length 67 | if len(packet_buf) > compression_threshold: 68 | data_length = len(packet_buf) 69 | packet_buf = Buffer(zlib.compress(packet_buf)) 70 | else: 71 | data_length = 0 72 | 73 | data_buf = Buffer() 74 | data_buf.write_varint(data_length) 75 | data_buf.write(packet_buf) 76 | return data_buf 77 | return packet_buf 78 | 79 | 80 | def _deserialize_packet( 81 | buf: Buffer, 82 | packet_map: Mapping[int, type[T_Packet]], 83 | *, 84 | compressed: bool = False, 85 | ) -> T_Packet: 86 | """Deserialize the packet id and it's internal data. 87 | 88 | :param packet_map: 89 | A mapping of packet id (int) -> packet. Should hold all possible packets for the 90 | current gamestate and direction. See :func:`~mcproto.packets.packet_map.generate_packet_map` 91 | :param compressed: 92 | Boolean flag, if compression is enabled, it should be set to ``True``, ``False`` otherwise. 93 | 94 | You can get this based on :class:`~mcproto.packets.login.login.LoginSetCompression` packet, 95 | which will contain a compression threshold value. This threshold is only useful when writing 96 | the packets, for reading, we don't care about the specific threshold, we only need to know 97 | whether compression is enabled or not. That is, if the threshold is set to a non-negative 98 | number, this should be ``True``. 99 | """ 100 | if compressed: 101 | data_length = buf.read_varint() 102 | packet_data = buf.read(buf.remaining) 103 | # Only run decompression if the threshold was crosed, otherwise the data_length will be 104 | # set to 0, indicating no compression was done, read the data normally if that's the case 105 | buf = Buffer(zlib.decompress(packet_data)) if data_length != 0 else Buffer(packet_data) 106 | 107 | packet_id = buf.read_varint() 108 | packet_data = buf.read(buf.remaining) 109 | 110 | return packet_map[packet_id].deserialize(Buffer(packet_data)) 111 | 112 | 113 | def sync_write_packet( 114 | writer: BaseSyncWriter, 115 | packet: Packet, 116 | *, 117 | compression_threshold: int = -1, 118 | ) -> None: 119 | """Write given ``packet``. 120 | 121 | :param writer: The connection/writer to send this packet to. 122 | :param packet: The packet to be sent. 123 | :param compression_threshold: 124 | A threshold packet length, whcih if crossed compression should be enabled. 125 | 126 | You can get this number from :class:`~mcproto.packets.login.login.LoginSetCompression` packet. 127 | If this packet wasn't sent by the server, set this to -1 (default). 128 | """ 129 | data_buf = _serialize_packet(packet, compression_threshold=compression_threshold) 130 | writer.write_bytearray(data_buf) 131 | 132 | 133 | async def async_write_packet( 134 | writer: BaseAsyncWriter, 135 | packet: Packet, 136 | *, 137 | compression_threshold: int = -1, 138 | ) -> None: 139 | """Write given ``packet``. 140 | 141 | :param writer: The connection/writer to send this packet to. 142 | :param packet: The packet to be sent. 143 | :param compression_threshold: 144 | A threshold packet length, whcih if crossed compression should be enabled. 145 | 146 | You can get this number from :class:`~mcproto.packets.login.login.LoginSetCompression` packet. 147 | If this packet wasn't sent by the server, set this to -1 (default). 148 | """ 149 | data_buf = _serialize_packet(packet, compression_threshold=compression_threshold) 150 | await writer.write_bytearray(data_buf) 151 | 152 | 153 | def sync_read_packet( 154 | reader: BaseSyncReader, 155 | packet_map: Mapping[int, type[T_Packet]], 156 | *, 157 | compression_threshold: int = -1, 158 | ) -> T_Packet: 159 | """Read a packet. 160 | 161 | :param reader: The connection/reader to receive this packet from. 162 | :param packet_map: 163 | A mapping of packet id (number) -> Packet (class). 164 | 165 | This mapping should contain all of the packets for the current gamestate and direction. 166 | See :func:`~mcproto.packets.packet_map.generate_packet_map` 167 | :param compression_threshold: 168 | A threshold packet length, whcih if crossed compression should be enabled. 169 | 170 | You can get this number from :class:`~mcproto.packets.login.login.LoginSetCompression` packet. 171 | If this packet wasn't sent by the server, set this to -1 (default). 172 | 173 | Note that during reading, we don't actually need to know the specific threshold, just 174 | whether or not is is non-negative (whether compression is enabled), as the packet format 175 | fundamentally changes when it is. That means you can pass any positive number here to 176 | enable compresison, regardess of what it actually is. 177 | """ 178 | # The packet format fundamentally changes when compression_threshold is non-negative (enabeld) 179 | # We only care about the sepcific threshold when writing though, for reading (deserialization), 180 | # we just need to know whether or not compression is enabled 181 | compressed = compression_threshold >= 0 182 | 183 | data_buf = Buffer(reader.read_bytearray()) 184 | return _deserialize_packet(data_buf, packet_map, compressed=compressed) 185 | 186 | 187 | async def async_read_packet( 188 | reader: BaseAsyncReader, 189 | packet_map: Mapping[int, type[T_Packet]], 190 | *, 191 | compression_threshold: int = -1, 192 | ) -> T_Packet: 193 | """Read a packet. 194 | 195 | :param reader: The connection/reader to receive this packet from. 196 | :param packet_map: 197 | A mapping of packet id (number) -> Packet (class). 198 | 199 | This mapping should contain all of the packets for the current gamestate and direction. 200 | See :func:`~mcproto.packets.packet_map.generate_packet_map` 201 | :param compression_threshold: 202 | A threshold packet length, whcih if crossed compression should be enabled. 203 | 204 | You can get this number from :class:`~mcproto.packets.login.login.LoginSetCompression` packet. 205 | If this packet wasn't sent by the server, set this to -1 (default). 206 | 207 | Note that during reading, we don't actually need to know the specific threshold, just 208 | whether or not is is non-negative (whether compression is enabled), as the packet format 209 | fundamentally changes when it is. That means you can pass any positive number here to 210 | enable compresison, regardess of what it actually is. 211 | """ 212 | # The packet format fundamentally changes when compression_threshold is non-negative (enabeld) 213 | # We only care about the sepcific threshold when writing though, for reading (deserialization), 214 | # we just need to know whether or not compression is enabled 215 | compressed = compression_threshold >= 0 216 | 217 | data_buf = Buffer(await reader.read_bytearray()) 218 | return _deserialize_packet(data_buf, packet_map, compressed=compressed) 219 | -------------------------------------------------------------------------------- /mcproto/packets/login/__init__.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from mcproto.packets.login.login import ( 4 | LoginDisconnect, 5 | LoginEncryptionRequest, 6 | LoginEncryptionResponse, 7 | LoginPluginRequest, 8 | LoginPluginResponse, 9 | LoginSetCompression, 10 | LoginStart, 11 | LoginSuccess, 12 | ) 13 | 14 | __all__ = [ 15 | "LoginDisconnect", 16 | "LoginEncryptionRequest", 17 | "LoginEncryptionResponse", 18 | "LoginPluginRequest", 19 | "LoginPluginResponse", 20 | "LoginSetCompression", 21 | "LoginStart", 22 | "LoginSuccess", 23 | ] 24 | -------------------------------------------------------------------------------- /mcproto/packets/login/login.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import ClassVar, cast, final 4 | 5 | from attrs import define 6 | from cryptography.hazmat.backends import default_backend 7 | from cryptography.hazmat.primitives.asymmetric.rsa import RSAPublicKey 8 | from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat, load_der_public_key 9 | from typing_extensions import Self, override 10 | 11 | from mcproto.buffer import Buffer 12 | from mcproto.packets.packet import ClientBoundPacket, GameState, ServerBoundPacket 13 | from mcproto.types.chat import ChatMessage 14 | from mcproto.types.uuid import UUID 15 | 16 | __all__ = [ 17 | "LoginDisconnect", 18 | "LoginEncryptionRequest", 19 | "LoginEncryptionResponse", 20 | "LoginPluginRequest", 21 | "LoginPluginResponse", 22 | "LoginSetCompression", 23 | "LoginStart", 24 | "LoginSuccess", 25 | ] 26 | 27 | 28 | @final 29 | @define 30 | class LoginStart(ServerBoundPacket): 31 | """Packet from client asking to start login process. (Client -> Server). 32 | 33 | Initialize the LoginStart packet. 34 | 35 | :param username: Username of the client who sent the request. 36 | :param uuid: UUID of the player logging in (if the player doesn't have a UUID, this can be ``None``) 37 | """ 38 | 39 | PACKET_ID: ClassVar[int] = 0x00 40 | GAME_STATE: ClassVar[GameState] = GameState.LOGIN 41 | 42 | username: str 43 | uuid: UUID 44 | 45 | @override 46 | def serialize_to(self, buf: Buffer) -> None: 47 | buf.write_utf(self.username) 48 | self.uuid.serialize_to(buf) 49 | 50 | @override 51 | @classmethod 52 | def _deserialize(cls, buf: Buffer, /) -> Self: 53 | username = buf.read_utf() 54 | uuid = UUID.deserialize(buf) 55 | return cls(username=username, uuid=uuid) 56 | 57 | 58 | @final 59 | @define 60 | class LoginEncryptionRequest(ClientBoundPacket): 61 | """Used by the server to ask the client to encrypt the login process. (Server -> Client). 62 | 63 | Initialize the LoginEncryptionRequest packet. 64 | 65 | :param public_key: Server's public key. 66 | :param verify_token: Sequence of random bytes generated by server for verification. 67 | :param server_id: Empty on minecraft versions 1.7.X and higher (20 random chars pre 1.7). 68 | """ 69 | 70 | PACKET_ID: ClassVar[int] = 0x01 71 | GAME_STATE: ClassVar[GameState] = GameState.LOGIN 72 | 73 | public_key: RSAPublicKey 74 | verify_token: bytes 75 | server_id: str | None = None 76 | 77 | @override 78 | def __attrs_post_init__(self) -> None: 79 | if self.server_id is None: 80 | self.server_id = " " * 20 81 | 82 | super().__attrs_post_init__() 83 | 84 | @override 85 | def serialize_to(self, buf: Buffer) -> None: 86 | self.server_id = cast(str, self.server_id) 87 | 88 | public_key_raw = self.public_key.public_bytes(encoding=Encoding.DER, format=PublicFormat.SubjectPublicKeyInfo) 89 | buf.write_utf(self.server_id) 90 | buf.write_bytearray(public_key_raw) 91 | buf.write_bytearray(self.verify_token) 92 | 93 | @override 94 | @classmethod 95 | def _deserialize(cls, buf: Buffer, /) -> Self: 96 | server_id = buf.read_utf() 97 | public_key_raw = bytes(buf.read_bytearray()) 98 | verify_token = bytes(buf.read_bytearray()) 99 | 100 | # Key type is determined by the passed key itself, we know in our case, it will 101 | # be an RSA public key, so we explicitly type-cast here. 102 | public_key = cast(RSAPublicKey, load_der_public_key(public_key_raw, default_backend())) 103 | 104 | return cls(server_id=server_id, public_key=public_key, verify_token=verify_token) 105 | 106 | 107 | @final 108 | @define 109 | class LoginEncryptionResponse(ServerBoundPacket): 110 | """Response from the client to :class:`LoginEncryptionRequest` packet. (Client -> Server). 111 | 112 | Initialize the LoginEncryptionResponse packet. 113 | 114 | :param shared_secret: Shared secret value, encrypted with server's public key. 115 | :param verify_token: Verify token value, encrypted with same public key. 116 | """ 117 | 118 | PACKET_ID: ClassVar[int] = 0x01 119 | GAME_STATE: ClassVar[GameState] = GameState.LOGIN 120 | 121 | shared_secret: bytes 122 | verify_token: bytes 123 | 124 | @override 125 | def serialize_to(self, buf: Buffer) -> None: 126 | """Serialize the packet.""" 127 | buf.write_bytearray(self.shared_secret) 128 | buf.write_bytearray(self.verify_token) 129 | 130 | @override 131 | @classmethod 132 | def _deserialize(cls, buf: Buffer, /) -> Self: 133 | shared_secret = bytes(buf.read_bytearray()) 134 | verify_token = bytes(buf.read_bytearray()) 135 | return cls(shared_secret=shared_secret, verify_token=verify_token) 136 | 137 | 138 | @final 139 | @define 140 | class LoginSuccess(ClientBoundPacket): 141 | """Sent by the server to denote a successful login. (Server -> Client). 142 | 143 | Initialize the LoginSuccess packet. 144 | 145 | :param uuid: The UUID of the connecting player/client. 146 | :param username: The username of the connecting player/client. 147 | """ 148 | 149 | PACKET_ID: ClassVar[int] = 0x02 150 | GAME_STATE: ClassVar[GameState] = GameState.LOGIN 151 | 152 | uuid: UUID 153 | username: str 154 | 155 | @override 156 | def serialize_to(self, buf: Buffer) -> None: 157 | self.uuid.serialize_to(buf) 158 | buf.write_utf(self.username) 159 | 160 | @override 161 | @classmethod 162 | def _deserialize(cls, buf: Buffer, /) -> Self: 163 | uuid = UUID.deserialize(buf) 164 | username = buf.read_utf() 165 | return cls(uuid, username) 166 | 167 | 168 | @final 169 | @define 170 | class LoginDisconnect(ClientBoundPacket): 171 | """Sent by the server to kick a player while in the login state. (Server -> Client). 172 | 173 | Initialize the LoginDisconnect packet. 174 | 175 | :param reason: The reason for disconnection (kick). 176 | """ 177 | 178 | PACKET_ID: ClassVar[int] = 0x00 179 | GAME_STATE: ClassVar[GameState] = GameState.LOGIN 180 | 181 | reason: ChatMessage 182 | 183 | @override 184 | def serialize_to(self, buf: Buffer) -> None: 185 | self.reason.serialize_to(buf) 186 | 187 | @override 188 | @classmethod 189 | def _deserialize(cls, buf: Buffer, /) -> Self: 190 | reason = ChatMessage.deserialize(buf) 191 | return cls(reason) 192 | 193 | 194 | @final 195 | @define 196 | class LoginPluginRequest(ClientBoundPacket): 197 | """Sent by the server to implement a custom handshaking flow. (Server -> Client). 198 | 199 | Initialize the LoginPluginRequest. 200 | 201 | :param message_id: Message id, generated by the server, should be unique to the connection. 202 | :param channel: Channel identifier, name of the plugin channel used to send data. 203 | :param data: Data that is to be sent. 204 | """ 205 | 206 | PACKET_ID: ClassVar[int] = 0x04 207 | GAME_STATE: ClassVar[GameState] = GameState.LOGIN 208 | 209 | message_id: int 210 | channel: str 211 | data: bytes 212 | 213 | @override 214 | def serialize_to(self, buf: Buffer) -> None: 215 | buf.write_varint(self.message_id) 216 | buf.write_utf(self.channel) 217 | buf.write(self.data) 218 | 219 | @override 220 | @classmethod 221 | def _deserialize(cls, buf: Buffer, /) -> Self: 222 | message_id = buf.read_varint() 223 | channel = buf.read_utf() 224 | data = bytes(buf.read(buf.remaining)) # All of the remaining data in the buffer 225 | return cls(message_id, channel, data) 226 | 227 | 228 | @final 229 | @define 230 | class LoginPluginResponse(ServerBoundPacket): 231 | """Response to LoginPluginRequest from client. (Client -> Server). 232 | 233 | Initialize the LoginPluginRequest packet. 234 | 235 | :param message_id: Message id, generated by the server, should be unique to the connection. 236 | :param data: Optional response data, present if client understood request. 237 | """ 238 | 239 | PACKET_ID: ClassVar[int] = 0x02 240 | GAME_STATE: ClassVar[GameState] = GameState.LOGIN 241 | 242 | message_id: int 243 | data: bytes | None 244 | 245 | @override 246 | def serialize_to(self, buf: Buffer) -> None: 247 | buf.write_varint(self.message_id) 248 | buf.write_optional(self.data, buf.write) 249 | 250 | @override 251 | @classmethod 252 | def _deserialize(cls, buf: Buffer, /) -> Self: 253 | message_id = buf.read_varint() 254 | data = buf.read_optional(lambda: bytes(buf.read(buf.remaining))) 255 | return cls(message_id, data) 256 | 257 | 258 | @final 259 | @define 260 | class LoginSetCompression(ClientBoundPacket): 261 | """Sent by the server to specify whether to use compression on future packets or not (Server -> Client). 262 | 263 | Initialize the LoginSetCompression packet. 264 | 265 | 266 | :param threshold: 267 | Maximum size of a packet before it is compressed. All packets smaller than this will remain uncompressed. 268 | To disable compression completely, threshold can be set to -1. 269 | 270 | .. note:: This packet is optional, and if not set, the compression will not be enabled at all. 271 | """ 272 | 273 | PACKET_ID: ClassVar[int] = 0x03 274 | GAME_STATE: ClassVar[GameState] = GameState.LOGIN 275 | 276 | threshold: int 277 | 278 | @override 279 | def serialize_to(self, buf: Buffer) -> None: 280 | buf.write_varint(self.threshold) 281 | 282 | @override 283 | @classmethod 284 | def _deserialize(cls, buf: Buffer, /) -> Self: 285 | threshold = buf.read_varint() 286 | return cls(threshold) 287 | -------------------------------------------------------------------------------- /mcproto/packets/packet.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from abc import ABC, abstractmethod 4 | from collections.abc import Sequence 5 | from enum import IntEnum 6 | from typing import ClassVar 7 | 8 | from typing_extensions import Self, override 9 | 10 | from mcproto.buffer import Buffer 11 | from mcproto.utils.abc import RequiredParamsABCMixin, Serializable 12 | 13 | __all__ = [ 14 | "ClientBoundPacket", 15 | "GameState", 16 | "Packet", 17 | "PacketDirection", 18 | "ServerBoundPacket", 19 | ] 20 | 21 | 22 | class GameState(IntEnum): 23 | """All possible game states in minecraft.""" 24 | 25 | HANDSHAKING = 0 26 | STATUS = 1 27 | LOGIN = 2 28 | PLAY = 3 29 | 30 | 31 | class PacketDirection(IntEnum): 32 | """Represents whether a packet targets (is bound to) a client or server.""" 33 | 34 | SERVERBOUND = 0 35 | CLIENTBOUND = 1 36 | 37 | 38 | class Packet(Serializable, RequiredParamsABCMixin, ABC): 39 | """Base class for all packets.""" 40 | 41 | _REQUIRED_CLASS_VARS: ClassVar[Sequence[str]] = ["PACKET_ID", "GAME_STATE"] 42 | _REQUIRED_CLASS_VARS_NO_MRO: ClassVar[Sequence[str]] = ["__slots__"] 43 | 44 | __slots__ = () 45 | 46 | PACKET_ID: ClassVar[int] 47 | GAME_STATE: ClassVar[GameState] 48 | 49 | @override 50 | @classmethod 51 | def deserialize(cls, buf: Buffer, /) -> Self: 52 | try: 53 | return cls._deserialize(buf) 54 | except IOError as exc: 55 | raise InvalidPacketContentError.from_packet_class(cls, buf, repr(exc)) from exc 56 | 57 | @classmethod 58 | @abstractmethod 59 | def _deserialize(cls, buf: Buffer, /) -> Self: 60 | raise NotImplementedError 61 | 62 | 63 | class ServerBoundPacket(Packet, ABC): 64 | """Packet bound to a server (Client -> Server).""" 65 | 66 | __slots__ = () 67 | 68 | 69 | class ClientBoundPacket(Packet, ABC): 70 | """Packet bound to a client (Server -> Client).""" 71 | 72 | __slots__ = () 73 | 74 | 75 | class InvalidPacketContentError(IOError): 76 | """Unable to deserialize given packet, as it didn't match the expected content. 77 | 78 | This error can occur during deserialization of a specific packet (after the 79 | packet class was already identified), but the deserialization process for this 80 | packet type failed. 81 | 82 | This can happen if the server sent a known packet, but it's content didn't match 83 | the expected content for this packet kind. 84 | """ 85 | 86 | def __init__( 87 | self, 88 | packet_id: int, 89 | game_state: GameState, 90 | direction: PacketDirection, 91 | buffer: Buffer, 92 | message: str, 93 | ) -> None: 94 | """Initialize the error class. 95 | 96 | :param packet_id: Identified packet ID. 97 | :param game_state: Game state of the identified packet. 98 | :param direction: Packet direction of the identified packet. 99 | :param buffer: Buffer received for deserialization, that failed to parse. 100 | :param message: Reason for the failure. 101 | """ 102 | self.packet_id = packet_id 103 | self.game_state = game_state 104 | self.direction = direction 105 | self.buffer = buffer 106 | self.message = message 107 | super().__init__(self.msg) 108 | 109 | @classmethod 110 | def from_packet_class(cls, packet_class: type[Packet], buffer: Buffer, message: str) -> Self: 111 | """Construct the error from packet class. 112 | 113 | This is a convenience constructor, picking up the necessary parameters about the identified packet 114 | from the packet class automatically (packet id, game state, ...). 115 | """ 116 | if issubclass(packet_class, ServerBoundPacket): 117 | direction = PacketDirection.SERVERBOUND 118 | elif issubclass(packet_class, ClientBoundPacket): 119 | direction = PacketDirection.CLIENTBOUND 120 | else: 121 | raise TypeError( 122 | "Unable to determine the packet direction. Got a packet class which doesn't " 123 | "inherit from ServerBoundPacket nor ClientBoundPacket class." 124 | ) 125 | 126 | return cls(packet_class.PACKET_ID, packet_class.GAME_STATE, direction, buffer, message) 127 | 128 | @property 129 | def msg(self) -> str: 130 | """Produce a message for this error.""" 131 | msg_parts = [] 132 | 133 | if self.direction is PacketDirection.CLIENTBOUND: 134 | msg_parts.append("Clientbound") 135 | else: 136 | msg_parts.append("Serverbound") 137 | 138 | msg_parts.append("packet in") 139 | 140 | if self.game_state is GameState.HANDSHAKING: 141 | msg_parts.append("handshaking") 142 | elif self.game_state is GameState.STATUS: 143 | msg_parts.append("status") 144 | elif self.game_state is GameState.LOGIN: 145 | msg_parts.append("login") 146 | else: 147 | msg_parts.append("play") 148 | 149 | msg_parts.append("game state") 150 | msg_parts.append(f"with ID: 0x{self.packet_id:02x}") 151 | msg_parts.append(f"failed to deserialize: {self.message}") 152 | 153 | return " ".join(msg_parts) 154 | 155 | @override 156 | def __repr__(self) -> str: 157 | return f"{self.__class__.__name__}({self.msg})" 158 | -------------------------------------------------------------------------------- /mcproto/packets/packet_map.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import importlib 4 | import pkgutil 5 | from collections.abc import Iterator, Mapping, Sequence 6 | from types import MappingProxyType, ModuleType 7 | from typing import Literal, NamedTuple, NoReturn, TYPE_CHECKING, cast, overload 8 | 9 | from mcproto.packets.packet import ClientBoundPacket, GameState, Packet, PacketDirection, ServerBoundPacket 10 | 11 | # lru_cache doesn't forward the call parameters in _lru_cache_wrapper type, only the return type, 12 | # this fixes the issue, though it means losing the type info about the function being cached, 13 | # that's annoying, but preserving the parameters is much more important to us, so this is the lesser 14 | # evil from the two 15 | if TYPE_CHECKING: 16 | from typing import TypeVar 17 | 18 | T = TypeVar("T") 19 | 20 | def lru_cache(func: T, /) -> T: ... 21 | 22 | else: 23 | from functools import lru_cache 24 | 25 | 26 | __all__ = ["generate_packet_map"] 27 | 28 | MODULE_PATHS = { 29 | GameState.HANDSHAKING: "mcproto.packets.handshaking", 30 | GameState.STATUS: "mcproto.packets.status", 31 | GameState.LOGIN: "mcproto.packets.login", 32 | GameState.PLAY: "mcproto.packets.play", 33 | } 34 | 35 | 36 | class WalkableModuleData(NamedTuple): 37 | module: ModuleType 38 | info: pkgutil.ModuleInfo 39 | member_names: Sequence[str] 40 | 41 | 42 | def _walk_submodules(module: ModuleType) -> Iterator[WalkableModuleData]: 43 | """Find all submodules of given module, that specify ``__all__``. 44 | 45 | If a submodule that doesn't define ``__all__`` is found, it will be skipped, as we don't 46 | consider it walkable. (This is important, as we'll later need to go over all variables in 47 | these modules, and without ``__all__`` we wouldn't know what to go over. Simply using all 48 | defined variables isn't viable, as that would also include imported things, potentially 49 | causing the same object to appear more than once. This makes ``__all__`` a requirement.) 50 | """ 51 | 52 | def on_error(name: str) -> NoReturn: 53 | raise ImportError(name=name) 54 | 55 | for module_info in pkgutil.walk_packages(module.__path__, f"{module.__name__}.", onerror=on_error): 56 | imported_module = importlib.import_module(module_info.name) 57 | 58 | if not hasattr(imported_module, "__all__"): 59 | continue 60 | member_names = imported_module.__all__ 61 | 62 | if not isinstance(member_names, Sequence): 63 | raise TypeError(f"Module {module_info.name!r}'s __all__ isn't defined as a sequence.") 64 | 65 | for member_name in member_names: 66 | if not isinstance(member_name, str): 67 | raise TypeError(f"Module {module_info.name!r}'s __all__ contains non-string item.") 68 | member_names = cast(Sequence[str], member_names) 69 | 70 | yield WalkableModuleData(imported_module, module_info, member_names) 71 | 72 | 73 | def _walk_module_packets(module_data: WalkableModuleData) -> Iterator[type[Packet]]: 74 | """Find all packet classes specified in module's ``__all__``. 75 | 76 | :return: 77 | Iterator yielding every packet class defined in ``__all__`` of given module. 78 | These objects are obtained directly using ``getattr`` from the imported module. 79 | 80 | :raises TypeError: 81 | Raised when an attribute defined in ``__all__`` can't be obtained using ``getattr``. 82 | This would suggest the module has incorrectly defined ``__all__``, as it includes values 83 | that aren't actually defined in the module. 84 | """ 85 | for member_name in module_data.member_names: 86 | try: 87 | member = getattr(module_data.module, member_name) 88 | except AttributeError as exc: 89 | module_name = module_data.info.name 90 | raise TypeError(f"Member {member_name!r} of {module_name!r} module isn't defined.") from exc 91 | 92 | if issubclass(member, Packet): 93 | yield member 94 | 95 | 96 | @overload 97 | def generate_packet_map( 98 | direction: Literal[PacketDirection.SERVERBOUND], 99 | state: GameState, 100 | ) -> Mapping[int, type[ServerBoundPacket]]: ... 101 | 102 | 103 | @overload 104 | def generate_packet_map( 105 | direction: Literal[PacketDirection.CLIENTBOUND], 106 | state: GameState, 107 | ) -> Mapping[int, type[ClientBoundPacket]]: ... 108 | 109 | 110 | @lru_cache 111 | def generate_packet_map(direction: PacketDirection, state: GameState) -> Mapping[int, type[Packet]]: 112 | """Dynamically generated a packet map for given ``direction`` and ``state``. 113 | 114 | This generation is done by dynamically importing all of the modules containing these packets, 115 | filtering them to only contain those pacekts with the specified parameters, and storing those 116 | into a dictionary, using the packet id as key, and the packet class itself being the value. 117 | 118 | As this fucntion is likely to be called quite often, and it uses dynamic importing to obtain 119 | the packet classes, this function is cached, which means the logic only actually runs once, 120 | after which, for the same arguments, the same dict will be returned. 121 | """ 122 | module = importlib.import_module(MODULE_PATHS[state]) 123 | 124 | if direction is PacketDirection.SERVERBOUND: 125 | direction_class = ServerBoundPacket 126 | elif direction is PacketDirection.CLIENTBOUND: 127 | direction_class = ClientBoundPacket 128 | else: 129 | raise ValueError("Unrecognized packet direction") 130 | 131 | packet_map: dict[int, type[Packet]] = {} 132 | 133 | for submodule in _walk_submodules(module): 134 | for packet_class in _walk_module_packets(submodule): 135 | if issubclass(packet_class, direction_class): 136 | packet_map[packet_class.PACKET_ID] = packet_class 137 | 138 | # Return an immutable mapping proxy, rather than the actual (mutable) dict 139 | # This allows us to safely cache the function returns, without worrying that 140 | # when the user mutates the dict, next function run would return that same 141 | # mutated dict, as it was cached 142 | return MappingProxyType(packet_map) 143 | -------------------------------------------------------------------------------- /mcproto/packets/status/__init__.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from mcproto.packets.status.ping import PingPong 4 | from mcproto.packets.status.status import StatusRequest, StatusResponse 5 | 6 | __all__ = [ 7 | "PingPong", 8 | "StatusRequest", 9 | "StatusResponse", 10 | ] 11 | -------------------------------------------------------------------------------- /mcproto/packets/status/ping.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import ClassVar, final 4 | 5 | from attrs import define 6 | from typing_extensions import Self, override 7 | 8 | from mcproto.buffer import Buffer 9 | from mcproto.packets.packet import ClientBoundPacket, GameState, ServerBoundPacket 10 | from mcproto.protocol.base_io import StructFormat 11 | 12 | __all__ = ["PingPong"] 13 | 14 | 15 | @final 16 | @define 17 | class PingPong(ClientBoundPacket, ServerBoundPacket): 18 | """Ping request/Pong response (Server <-> Client). 19 | 20 | Initialize the PingPong packet. 21 | 22 | :param payload: 23 | Random number to test out the connection. Ideally, this number should be quite big, 24 | however it does need to fit within the limit of a signed long long (-2 ** 63 to 2 ** 63 - 1). 25 | """ 26 | 27 | PACKET_ID: ClassVar[int] = 0x01 28 | GAME_STATE: ClassVar[GameState] = GameState.STATUS 29 | 30 | payload: int 31 | 32 | @override 33 | def serialize_to(self, buf: Buffer) -> None: 34 | buf.write_value(StructFormat.LONGLONG, self.payload) 35 | 36 | @override 37 | @classmethod 38 | def _deserialize(cls, buf: Buffer, /) -> Self: 39 | payload = buf.read_value(StructFormat.LONGLONG) 40 | return cls(payload) 41 | -------------------------------------------------------------------------------- /mcproto/packets/status/status.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import json 4 | from typing import Any, ClassVar, final 5 | 6 | from attrs import define 7 | from typing_extensions import Self, override 8 | 9 | from mcproto.buffer import Buffer 10 | from mcproto.packets.packet import ClientBoundPacket, GameState, ServerBoundPacket 11 | 12 | __all__ = ["StatusRequest", "StatusResponse"] 13 | 14 | 15 | @final 16 | @define 17 | class StatusRequest(ServerBoundPacket): 18 | """Request from the client to get information on the server. (Client -> Server).""" 19 | 20 | PACKET_ID: ClassVar[int] = 0x00 21 | GAME_STATE: ClassVar[GameState] = GameState.STATUS 22 | 23 | @override 24 | def serialize_to(self, buf: Buffer) -> None: 25 | return # pragma: no cover, nothing to test here. 26 | 27 | @override 28 | @classmethod 29 | def _deserialize(cls, buf: Buffer, /) -> Self: # pragma: no cover, nothing to test here. 30 | return cls() 31 | 32 | 33 | @final 34 | @define 35 | class StatusResponse(ClientBoundPacket): 36 | """Response from the server to requesting client with status data information. (Server -> Client). 37 | 38 | Initialize the StatusResponse packet. 39 | 40 | :param data: JSON response data sent back to the client. 41 | """ 42 | 43 | PACKET_ID: ClassVar[int] = 0x00 44 | GAME_STATE: ClassVar[GameState] = GameState.STATUS 45 | 46 | data: dict[str, Any] # JSON response data sent back to the client. 47 | 48 | @override 49 | def serialize_to(self, buf: Buffer) -> None: 50 | s = json.dumps(self.data, separators=(",", ":")) 51 | buf.write_utf(s) 52 | 53 | @override 54 | @classmethod 55 | def _deserialize(cls, buf: Buffer, /) -> Self: 56 | s = buf.read_utf() 57 | data_ = json.loads(s) 58 | return cls(data_) 59 | 60 | @override 61 | def validate(self) -> None: 62 | # Ensure the data is serializable to JSON 63 | try: 64 | _ = json.dumps(self.data) 65 | except TypeError as exc: 66 | raise ValueError("Data is not serializable to JSON.") from exc 67 | -------------------------------------------------------------------------------- /mcproto/protocol/__init__.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from mcproto.protocol.base_io import BaseAsyncReader, BaseAsyncWriter, BaseSyncReader, BaseSyncWriter, StructFormat 4 | 5 | __all__ = [ 6 | "BaseAsyncReader", 7 | "BaseAsyncWriter", 8 | "BaseSyncReader", 9 | "BaseSyncWriter", 10 | "StructFormat", 11 | ] 12 | -------------------------------------------------------------------------------- /mcproto/protocol/utils.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | __all__ = ["from_twos_complement", "to_twos_complement"] 4 | 5 | 6 | def to_twos_complement(number: int, bits: int) -> int: 7 | """Convert a given ``number`` into twos complement format of given amount of ``bits``. 8 | 9 | :raises ValueError: 10 | Given ``number`` is out of range, and can't be converted into twos complement format, since 11 | it wouldn't fit into the given amount of ``bits``. 12 | """ 13 | value_max = 1 << (bits - 1) 14 | value_min = value_max * -1 15 | # With two's complement, we have one more negative number than positive 16 | # this means we can't be exactly at value_max, but we can be at exactly value_min 17 | if number >= value_max or number < value_min: 18 | raise ValueError(f"Can't convert number {number} into {bits}-bit twos complement format - out of range") 19 | 20 | return number + (1 << bits) if number < 0 else number 21 | 22 | 23 | def from_twos_complement(number: int, bits: int) -> int: 24 | """Convert a given ``number`` from twos complement format of given amount of ``bits``. 25 | 26 | :raises ValueError: 27 | Given ``number`` doesn't fit into given amount of ``bits``. This likely means that you're using 28 | the wrong number, or that the number was converted into twos complement with higher amount of ``bits``. 29 | """ 30 | value_max = (1 << bits) - 1 31 | if number < 0 or number > value_max: 32 | raise ValueError(f"Can't convert number {number} from {bits}-bit twos complement format - out of range") 33 | 34 | if number & (1 << (bits - 1)) != 0: 35 | number -= 1 << bits 36 | 37 | return number 38 | -------------------------------------------------------------------------------- /mcproto/py.typed: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/py-mine/mcproto/8b6335d8ef2abdd0a9add4456c593327b8a8cdf7/mcproto/py.typed -------------------------------------------------------------------------------- /mcproto/types/__init__.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | -------------------------------------------------------------------------------- /mcproto/types/abc.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from abc import ABC 4 | 5 | from mcproto.utils.abc import Serializable 6 | 7 | __all__ = ["MCType"] 8 | 9 | 10 | class MCType(Serializable, ABC): 11 | """Base class for a minecraft type structure.""" 12 | 13 | __slots__ = () 14 | -------------------------------------------------------------------------------- /mcproto/types/chat.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import json 4 | from typing import TypedDict, Union, final 5 | 6 | from attrs import define 7 | from typing_extensions import Self, TypeAlias, override 8 | 9 | from mcproto.buffer import Buffer 10 | from mcproto.types.abc import MCType 11 | 12 | __all__ = [ 13 | "ChatMessage", 14 | "RawChatMessage", 15 | "RawChatMessageDict", 16 | ] 17 | 18 | 19 | class RawChatMessageDict(TypedDict, total=False): 20 | """Dictionary structure of JSON chat messages when serialized.""" 21 | 22 | text: str 23 | translation: str 24 | extra: list[RawChatMessageDict] 25 | 26 | color: str 27 | bold: bool 28 | strikethrough: bool 29 | italic: bool 30 | underlined: bool 31 | obfuscated: bool 32 | 33 | 34 | RawChatMessage: TypeAlias = Union[RawChatMessageDict, "list[RawChatMessageDict]", str] 35 | 36 | 37 | @final 38 | @define 39 | class ChatMessage(MCType): 40 | """Minecraft chat message representation.""" 41 | 42 | raw: RawChatMessage 43 | 44 | __slots__ = ("raw",) 45 | 46 | def as_dict(self) -> RawChatMessageDict: 47 | """Convert received ``raw`` into a stadard :class:`dict` form.""" 48 | if isinstance(self.raw, list): 49 | return RawChatMessageDict(extra=self.raw) 50 | if isinstance(self.raw, str): 51 | return RawChatMessageDict(text=self.raw) 52 | if isinstance(self.raw, dict): # pyright: ignore[reportUnnecessaryIsInstance] 53 | return self.raw 54 | 55 | raise TypeError( # pragma: no cover 56 | f"Found unexpected type ({self.raw.__class__!r}) ({self.raw!r}) in `raw` attribute" 57 | ) 58 | 59 | @override 60 | def __eq__(self, other: object) -> bool: 61 | """Check equality between two chat messages. 62 | 63 | ..warning: This is purely using the `raw` field, which means it's possible that 64 | a chat message that appears the same, but was representing in a different way 65 | will fail this equality check. 66 | """ 67 | if not isinstance(other, ChatMessage): 68 | return NotImplemented 69 | 70 | return self.raw == other.raw 71 | 72 | @override 73 | def serialize_to(self, buf: Buffer) -> None: 74 | txt = json.dumps(self.raw) 75 | buf.write_utf(txt) 76 | 77 | @override 78 | @classmethod 79 | def deserialize(cls, buf: Buffer, /) -> Self: 80 | txt = buf.read_utf() 81 | dct = json.loads(txt) 82 | return cls(dct) 83 | 84 | @override 85 | def validate(self) -> None: 86 | if not isinstance(self.raw, (dict, list, str)): # pyright: ignore[reportUnnecessaryIsInstance] 87 | raise TypeError(f"Expected `raw` to be a dict, list or str, got {self.raw!r} instead") 88 | 89 | if isinstance(self.raw, dict): # We want to keep it this way for readability 90 | if "text" not in self.raw and "extra" not in self.raw: 91 | raise AttributeError("Expected `raw` to have either 'text' or 'extra' key, got neither") 92 | 93 | if isinstance(self.raw, list): 94 | for elem in self.raw: 95 | if not isinstance(elem, dict): # pyright: ignore[reportUnnecessaryIsInstance] 96 | raise TypeError(f"Expected `raw` to be a list of dicts, got {elem!r} instead") 97 | if "text" not in elem and "extra" not in elem: 98 | raise AttributeError( 99 | "Expected each element in `raw` to have either 'text' or 'extra' key, got neither" 100 | ) 101 | -------------------------------------------------------------------------------- /mcproto/types/uuid.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import uuid 4 | from typing import final 5 | 6 | from typing_extensions import Self, override 7 | 8 | from mcproto.buffer import Buffer 9 | from mcproto.types.abc import MCType 10 | 11 | __all__ = ["UUID"] 12 | 13 | 14 | @final 15 | class UUID(uuid.UUID, MCType): 16 | """Minecraft UUID type. 17 | 18 | In order to support potential future changes in protocol version, and implement McType, 19 | this is a custom subclass, however it is currently compatible with the stdlib's `uuid.UUID`. 20 | """ 21 | 22 | __slots__ = () 23 | 24 | @override 25 | def serialize_to(self, buf: Buffer) -> None: 26 | buf.write(self.bytes) 27 | 28 | @override 29 | @classmethod 30 | def deserialize(cls, buf: Buffer, /) -> Self: 31 | data = bytes(buf.read(16)) 32 | return cls(bytes=data) 33 | -------------------------------------------------------------------------------- /mcproto/utils/__init__.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | -------------------------------------------------------------------------------- /mcproto/utils/abc.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from abc import ABC, abstractmethod 4 | from collections.abc import Sequence 5 | from typing import Any, ClassVar 6 | 7 | from typing_extensions import Self 8 | 9 | from mcproto.buffer import Buffer 10 | 11 | __all__ = ["RequiredParamsABCMixin", "Serializable"] 12 | 13 | 14 | class RequiredParamsABCMixin: 15 | """Mixin class to ABCs that require certain attributes to be set in order to allow initialization. 16 | 17 | This class performs a similar check to what :class:`~abc.ABC` already does with abstractmethods, 18 | but for class variables. The required class variable names are set with :attr:`._REQUIRED_CLASS_VARS` 19 | class variable, which itself is automatically required. 20 | 21 | Just like with ABCs, this doesn't prevent creation of classes without these required class vars 22 | defined, only initialization is prevented. This is done to allow creation of a more specific, but 23 | still abstract class. 24 | 25 | Additionally, you can also define :attr:`._REQUIRED_CLASS_VARS_NO_MRO` class var, holding names of 26 | class vars which should be defined on given class directly. That means inheritance will be ignored 27 | so even if a subclass defines the required class var, unless the latest class also defines it, this 28 | check will fail. 29 | 30 | This is often useful for classes that are expected to be slotted, as each subclass will need to define 31 | ``__slots__``, otherwise a ``__dict__`` will automatically be made for it. However this is entirely 32 | optional, and if :attr:`._REQUIRED_CLASS_VARS_NO_MRO` isn't set, this check is skipped. 33 | """ 34 | 35 | __slots__ = () 36 | 37 | _REQUIRED_CLASS_VARS: ClassVar[Sequence[str]] 38 | _REQUIRED_CLASS_VARS_NO_MRO: ClassVar[Sequence[str]] 39 | 40 | def __new__(cls: type[Self], *a: Any, **kw: Any) -> Self: 41 | """Enforce required parameters being set for each instance of the concrete classes.""" 42 | _err_msg = f"Can't instantiate abstract {cls.__name__} class without defining " + "{!r} classvar" 43 | 44 | _required_class_vars = getattr(cls, "_REQUIRED_CLASS_VARS", None) 45 | if _required_class_vars is None: 46 | raise TypeError(_err_msg.format("_REQUIRED_CLASS_VARS")) 47 | 48 | for req_attr in _required_class_vars: 49 | if not hasattr(cls, req_attr): 50 | raise TypeError(_err_msg.format(req_attr)) 51 | 52 | _required_class_vars_no_mro = getattr(cls, "_REQUIRED_CLASS_VARS_NO_MRO", None) 53 | if _required_class_vars_no_mro is None: 54 | return super().__new__(cls) 55 | 56 | for req_no_mro_attr in _required_class_vars_no_mro: 57 | if req_no_mro_attr not in vars(cls): 58 | emsg = _err_msg.format(req_no_mro_attr) + " explicitly" 59 | if hasattr(cls, req_no_mro_attr): 60 | emsg += f" ({req_no_mro_attr} found in a subclass, but not explicitly in {cls.__name__})" 61 | raise TypeError(emsg) 62 | 63 | return super().__new__(cls) 64 | 65 | 66 | class Serializable(ABC): 67 | """Base class for any type that should be (de)serializable into/from :class:`~mcproto.Buffer` data. 68 | 69 | Any class that inherits from this class and adds parameters should use the :func:`~mcproto.utils.abc.define` 70 | decorator. 71 | """ 72 | 73 | __slots__ = () 74 | 75 | def __attrs_post_init__(self) -> None: 76 | """Run the validation method after the object is initialized. 77 | 78 | This function is responsible for conversion/transformation of given values right after initialization (often 79 | for example to convert an int initialization param into a specific enum variant) 80 | 81 | .. note:: 82 | If you override this method, make sure to call the superclass method at some point to ensure that the 83 | validation is run. 84 | """ 85 | self.validate() 86 | 87 | def serialize(self) -> Buffer: 88 | """Represent the object as a :class:`~mcproto.Buffer` (transmittable sequence of bytes).""" 89 | buf = Buffer() 90 | self.serialize_to(buf) 91 | return buf 92 | 93 | @abstractmethod 94 | def serialize_to(self, buf: Buffer, /) -> None: 95 | """Write the object to a :class:`~mcproto.Buffer`.""" 96 | raise NotImplementedError 97 | 98 | def validate(self) -> None: 99 | """Validate the object's attributes, raising an exception if they are invalid. 100 | 101 | By default, this method does nothing. Override it in your subclass to add validation logic. 102 | 103 | .. note:: 104 | This method is called by :meth:`~mcproto.utils.abc.Serializable.__attrs_post_init__` 105 | """ 106 | return 107 | 108 | @classmethod 109 | @abstractmethod 110 | def deserialize(cls, buf: Buffer, /) -> Self: 111 | """Construct the object from a :class:`~mcproto.Buffer` (transmittable sequence of bytes).""" 112 | raise NotImplementedError 113 | -------------------------------------------------------------------------------- /mcproto/utils/deprecation.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import importlib.metadata 4 | import warnings 5 | from collections.abc import Callable 6 | from functools import wraps 7 | from typing import TypeVar 8 | 9 | from packaging.version import Version 10 | from typing_extensions import ParamSpec, Protocol 11 | 12 | __all__ = ["deprecated", "deprecation_warn"] 13 | 14 | R = TypeVar("R") 15 | P = ParamSpec("P") 16 | 17 | 18 | def deprecation_warn( 19 | *, 20 | obj_name: str, 21 | removal_version: str | Version, 22 | replacement: str | None = None, 23 | extra_msg: str | None = None, 24 | stack_level: int = 3, 25 | ) -> None: 26 | """Produce an appropriate deprecation warning given the parameters. 27 | 28 | If the currently installed project version is already past the specified deprecation version, 29 | a :exc:`DeprecationWarning` will be raised as a full exception. Otherwise it will just get emitted 30 | as a warning. 31 | 32 | The deprecation message used will be constructed based on the input parameters. 33 | 34 | :param obj_name: Name of the object that got deprecated (such as ``my_function``). 35 | :param removal_version: 36 | Version at which this object should be considered as deprecated and should no longer be used. 37 | :param replacement: A new alternative to this (now deprecated) object. 38 | :param extra_msg: Additional message included in the deprecation warning/exception at the end. 39 | :param stack_level: Stack level at which the warning is emitted. 40 | """ 41 | if isinstance(removal_version, str): 42 | removal_version = Version(removal_version) 43 | 44 | try: 45 | _project_version = importlib.metadata.version("mcproto") 46 | except importlib.metadata.PackageNotFoundError: 47 | # v0.0.0 will never mark things as already deprecated (removal_version will always be newer) 48 | warnings.warn("Failed to get mcproto project version, assuming v0.0.0", category=RuntimeWarning, stacklevel=1) 49 | project_version = Version("0.0.0") 50 | else: 51 | project_version = Version(_project_version) 52 | 53 | already_deprecated = project_version >= removal_version 54 | 55 | msg = f"{obj_name!r}" 56 | if already_deprecated: 57 | msg += f" is passed its removal version ({removal_version})" 58 | else: 59 | msg += f" is deprecated and scheduled for removal in {removal_version}" 60 | 61 | if replacement is not None: 62 | msg += f", use '{replacement}' instead" 63 | 64 | msg += "." 65 | if extra_msg is not None: 66 | msg += f" ({extra_msg})" 67 | 68 | if already_deprecated: 69 | raise DeprecationWarning(msg) 70 | 71 | warnings.warn(msg, category=DeprecationWarning, stacklevel=stack_level) 72 | 73 | 74 | class DecoratorFunction(Protocol): 75 | def __call__(self, /, func: Callable[P, R]) -> Callable[P, R]: ... 76 | 77 | 78 | def deprecated( 79 | removal_version: str | Version, 80 | display_name: str | None = None, 81 | replacement: str | None = None, 82 | extra_msg: str | None = None, 83 | ) -> DecoratorFunction: 84 | """Mark an object as deprecated. 85 | 86 | Decorator version of :func:`.deprecation_warn` function. 87 | 88 | If the currently installed project version is already past the specified deprecation version, 89 | a :exc:`DeprecationWarning` will be raised as a full exception. Otherwise it will just get emitted 90 | as a warning. 91 | 92 | The deprecation message used will be constructed based on the input parameters. 93 | 94 | :param display_name: 95 | Name of the object that got deprecated (such as ``my_function``). 96 | 97 | By default, the object name is obtained automatically from ``__qualname__`` (falling back 98 | to ``__name__``) of the decorated object. Setting this explicitly will override this obtained 99 | name and the ``display_name`` will be used instead. 100 | :param removal_version: 101 | Version at which this object should be considered as deprecated and should no longer be used. 102 | :param replacement: A new alternative to this (now deprecated) object. 103 | :param extra_msg: Additional message included in the deprecation warning/exception at the end. 104 | """ 105 | 106 | def inner(func: Callable[P, R]) -> Callable[P, R]: 107 | obj_name = getattr(func, "__qualname__", func.__name__) if display_name is None else display_name 108 | 109 | @wraps(func) 110 | def wrapper(*args: P.args, **kwargs: P.kwargs) -> R: 111 | deprecation_warn( 112 | obj_name=obj_name, 113 | removal_version=removal_version, 114 | replacement=replacement, 115 | extra_msg=extra_msg, 116 | stack_level=3, 117 | ) 118 | return func(*args, **kwargs) 119 | 120 | return wrapper 121 | 122 | return inner 123 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | -------------------------------------------------------------------------------- /tests/mcproto/__init__.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | -------------------------------------------------------------------------------- /tests/mcproto/packets/handshaking/__init__.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | -------------------------------------------------------------------------------- /tests/mcproto/packets/handshaking/test_handshake.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from mcproto.packets.handshaking.handshake import Handshake, NextState 4 | from tests.helpers import gen_serializable_test 5 | 6 | gen_serializable_test( 7 | context=globals(), 8 | cls=Handshake, 9 | fields=[ 10 | ("protocol_version", int), 11 | ("server_address", str), 12 | ("server_port", int), 13 | ("next_state", NextState), 14 | ], 15 | serialize_deserialize=[ 16 | ( 17 | (757, "mc.aircs.racing", 25565, NextState.LOGIN), 18 | bytes.fromhex("f5050f6d632e61697263732e726163696e6763dd02"), 19 | ), 20 | ( 21 | (757, "mc.aircs.racing", 25565, NextState.STATUS), 22 | bytes.fromhex("f5050f6d632e61697263732e726163696e6763dd01"), 23 | ), 24 | ( 25 | (757, "hypixel.net", 25565, NextState.LOGIN), 26 | bytes.fromhex("f5050b6879706978656c2e6e657463dd02"), 27 | ), 28 | ( 29 | (757, "hypixel.net", 25565, NextState.STATUS), 30 | bytes.fromhex("f5050b6879706978656c2e6e657463dd01"), 31 | ), 32 | ], 33 | validation_fail=[ 34 | # Invalid next state 35 | ((757, "localhost", 25565, 3), ValueError), 36 | ], 37 | ) 38 | -------------------------------------------------------------------------------- /tests/mcproto/packets/login/__init__.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | -------------------------------------------------------------------------------- /tests/mcproto/packets/login/test_login.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from mcproto.packets.login.login import ( 4 | LoginDisconnect, 5 | LoginEncryptionRequest, 6 | LoginEncryptionResponse, 7 | LoginPluginRequest, 8 | LoginPluginResponse, 9 | LoginSetCompression, 10 | LoginStart, 11 | LoginSuccess, 12 | ) 13 | from mcproto.packets.packet import InvalidPacketContentError 14 | from mcproto.types.chat import ChatMessage 15 | from mcproto.types.uuid import UUID 16 | from tests.helpers import gen_serializable_test 17 | from tests.mcproto.test_encryption import RSA_PUBLIC_KEY 18 | 19 | # LoginStart 20 | gen_serializable_test( 21 | context=globals(), 22 | cls=LoginStart, 23 | fields=[("username", str), ("uuid", UUID)], 24 | serialize_deserialize=[ 25 | ( 26 | ("ItsDrike", UUID("f70b4a42c9a04ffb92a31390c128a1b2")), 27 | bytes.fromhex("084974734472696b65f70b4a42c9a04ffb92a31390c128a1b2"), 28 | ), 29 | ( 30 | ("foobar1", UUID("7a82476416fc4e8b8686a99c775db7d3")), 31 | bytes.fromhex("07666f6f626172317a82476416fc4e8b8686a99c775db7d3"), 32 | ), 33 | ], 34 | ) 35 | 36 | # LoginEncryptionRequest 37 | gen_serializable_test( 38 | context=globals(), 39 | cls=LoginEncryptionRequest, 40 | fields=[("server_id", str), ("public_key", bytes), ("verify_token", bytes)], 41 | serialize_deserialize=[ 42 | ( 43 | ("a" * 20, RSA_PUBLIC_KEY, bytes.fromhex("9bd416ef")), 44 | bytes.fromhex( 45 | "146161616161616161616161616161616161616161a20130819f300d06092a864886f70d010101050003818d003081890" 46 | "2818100cb515109911ea3e4740d8a17a7ccd9cf226c83c7615e4a5505cd124571ee210a4ba26c7c42e15f51fcb7fa90dc" 47 | "e6f83ebe0e163817c7d9fb1af7d981e90da2cc06ea59d01ff9fbb76b1803a0fe5af4a2c75145d89eb03e6a4aae21d2e7d" 48 | "4c3938a298da575e12e0ae178d61a69bc0ea0b381790f182d9dba715bfb503c99d92b0203010001049bd416ef" 49 | ), 50 | ), 51 | ], 52 | deserialization_fail=[ 53 | (bytes.fromhex("14"), InvalidPacketContentError), 54 | ], 55 | ) 56 | 57 | 58 | def test_login_encryption_request_noid(): 59 | """Test LoginEncryptionRequest without server_id.""" 60 | packet = LoginEncryptionRequest(server_id=None, public_key=RSA_PUBLIC_KEY, verify_token=bytes.fromhex("9bd416ef")) 61 | assert packet.server_id == " " * 20 # None is converted to an empty server id 62 | 63 | 64 | # TestLoginEncryptionResponse 65 | gen_serializable_test( 66 | context=globals(), 67 | cls=LoginEncryptionResponse, 68 | fields=[("shared_secret", bytes), ("verify_token", bytes)], 69 | serialize_deserialize=[ 70 | ( 71 | (b"I'm shared", b"Token"), 72 | bytes.fromhex("0a49276d2073686172656405546f6b656e"), 73 | ), 74 | ], 75 | ) 76 | 77 | 78 | # LoginSuccess 79 | gen_serializable_test( 80 | context=globals(), 81 | cls=LoginSuccess, 82 | fields=[("uuid", UUID), ("username", str)], 83 | serialize_deserialize=[ 84 | ( 85 | (UUID("f70b4a42c9a04ffb92a31390c128a1b2"), "Mario"), 86 | bytes.fromhex("f70b4a42c9a04ffb92a31390c128a1b2054d6172696f"), 87 | ), 88 | ], 89 | ) 90 | 91 | # LoginDisconnect 92 | gen_serializable_test( 93 | context=globals(), 94 | cls=LoginDisconnect, 95 | fields=[("reason", ChatMessage)], 96 | serialize_deserialize=[ 97 | ( 98 | (ChatMessage("You are banned."),), 99 | bytes.fromhex("1122596f75206172652062616e6e65642e22"), 100 | ), 101 | ], 102 | ) 103 | 104 | 105 | # LoginPluginRequest 106 | gen_serializable_test( 107 | context=globals(), 108 | cls=LoginPluginRequest, 109 | fields=[("message_id", int), ("channel", str), ("data", bytes)], 110 | serialize_deserialize=[ 111 | ( 112 | (0, "xyz", b"Hello"), 113 | bytes.fromhex("000378797a48656c6c6f"), 114 | ), 115 | ], 116 | ) 117 | 118 | 119 | # LoginPluginResponse 120 | gen_serializable_test( 121 | context=globals(), 122 | cls=LoginPluginResponse, 123 | fields=[("message_id", int), ("data", bytes)], 124 | serialize_deserialize=[ 125 | ( 126 | (0, b"Hello"), 127 | bytes.fromhex("000148656c6c6f"), 128 | ), 129 | ], 130 | ) 131 | 132 | # LoginSetCompression 133 | gen_serializable_test( 134 | context=globals(), 135 | cls=LoginSetCompression, 136 | fields=[("threshold", int)], 137 | serialize_deserialize=[ 138 | ( 139 | (2,), 140 | bytes.fromhex("02"), 141 | ), 142 | ], 143 | ) 144 | -------------------------------------------------------------------------------- /tests/mcproto/packets/status/__init__.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | -------------------------------------------------------------------------------- /tests/mcproto/packets/status/test_ping.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from mcproto.packets.status.ping import PingPong 4 | from tests.helpers import gen_serializable_test 5 | 6 | gen_serializable_test( 7 | context=globals(), 8 | cls=PingPong, 9 | fields=[("payload", int)], 10 | serialize_deserialize=[ 11 | ( 12 | (2806088,), 13 | bytes.fromhex("00000000002ad148"), 14 | ), 15 | ( 16 | (123456,), 17 | bytes.fromhex("000000000001e240"), 18 | ), 19 | ], 20 | ) 21 | -------------------------------------------------------------------------------- /tests/mcproto/packets/status/test_status.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from mcproto.packets.status.status import StatusResponse 4 | from tests.helpers import gen_serializable_test 5 | 6 | gen_serializable_test( 7 | context=globals(), 8 | cls=StatusResponse, 9 | fields=[("data", "dict[str, Any]")], 10 | serialize_deserialize=[ 11 | ( 12 | ( 13 | { 14 | "description": {"text": "A Minecraft Server"}, 15 | "players": {"max": 20, "online": 0}, 16 | "version": {"name": "1.18.1", "protocol": 757}, 17 | }, 18 | ), 19 | bytes.fromhex( 20 | "787b226465736372697074696f6e223a7b2274657874223a2241204d696e6" 21 | "5637261667420536572766572227d2c22706c6179657273223a7b226d6178" 22 | "223a32302c226f6e6c696e65223a307d2c2276657273696f6e223a7b226e6" 23 | "16d65223a22312e31382e31222c2270726f746f636f6c223a3735377d7d" 24 | ), 25 | ), 26 | ], 27 | validation_fail=[ 28 | # Unserializable data for JSON 29 | (({"data": object()},), ValueError), 30 | ], 31 | ) 32 | -------------------------------------------------------------------------------- /tests/mcproto/protocol/__init__.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | -------------------------------------------------------------------------------- /tests/mcproto/protocol/helpers.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from unittest.mock import AsyncMock, Mock 4 | 5 | from typing_extensions import override 6 | 7 | 8 | class WriteFunctionMock(Mock): 9 | """Mock write function, storing the written data.""" 10 | 11 | def __init__(self, *a, **kw): 12 | super().__init__(*a, **kw) 13 | self.combined_data = bytearray() 14 | 15 | @override 16 | def __call__(self, data: bytes) -> None: 17 | """Override mock's ``__call__`` to extend our :attr:`.combined_data` bytearray. 18 | 19 | This allows us to keep track of exactly what data was written by the mocked write function 20 | in total, rather than only having tools like :meth:`.assert_called_with`, which might let us 21 | get the data from individual calls, but not the combined data, which is what we'll need. 22 | """ 23 | self.combined_data.extend(data) 24 | return super().__call__(data) 25 | 26 | @override 27 | def assert_has_data(self, data: bytearray, ensure_called: bool = True) -> None: 28 | """Ensure that the combined write data by the mocked function matches expected ``data``.""" 29 | if ensure_called: 30 | self.assert_called() 31 | 32 | if self.combined_data != data: 33 | raise AssertionError(f"Write function mock expected data {data!r}, but was {self.call_data!r}") 34 | 35 | 36 | class WriteFunctionAsyncMock(WriteFunctionMock, AsyncMock): # pyright: ignore[reportUnsafeMultipleInheritance] 37 | """Asynchronous mock write function, storing the written data.""" 38 | 39 | 40 | class ReadFunctionMock(Mock): 41 | """Mock read function, giving pre-defined data.""" 42 | 43 | def __init__(self, *a, combined_data: bytearray | None = None, **kw): 44 | super().__init__(*a, **kw) 45 | if combined_data is None: 46 | combined_data = bytearray() 47 | self.combined_data = combined_data 48 | 49 | @override 50 | def __call__(self, length: int) -> bytearray: 51 | """Override mock's __call__ to make it return part of our :attr:`.combined_data` bytearray. 52 | 53 | This allows us to make the return value always be the next requested part (length) of 54 | the :attr:`.combined_data`. It would be difficult to replicate this with regular mocks, 55 | because some functions can end up making multiple read calls, and each time the result 56 | needs to be different (the next part). 57 | """ 58 | self.return_value = self.combined_data[:length] 59 | del self.combined_data[:length] 60 | return super().__call__(length) 61 | 62 | @override 63 | def assert_read_everything(self, ensure_called: bool = True) -> None: 64 | """Ensure that the passed :attr:`.combined_data` was fully read and depleted.""" 65 | if ensure_called: 66 | self.assert_called() 67 | 68 | if len(self.combined_data) != 0: 69 | raise AssertionError( 70 | f"Read function didn't deplete all of it's data, remaining data: {self.combined_data!r}" 71 | ) 72 | 73 | 74 | class ReadFunctionAsyncMock(ReadFunctionMock, AsyncMock): # pyright: ignore[reportUnsafeMultipleInheritance] 75 | """Asynchronous mock read function, giving pre-defined data.""" 76 | -------------------------------------------------------------------------------- /tests/mcproto/protocol/test_utils.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import pytest 4 | 5 | from mcproto.protocol.utils import from_twos_complement, to_twos_complement 6 | 7 | # TODO: Consider adding tests for enforce_range 8 | 9 | 10 | @pytest.mark.parametrize( 11 | ("number", "bits", "expected_out"), 12 | [ 13 | (0, 8, 0), 14 | (1, 8, 1), 15 | (10, 8, 10), 16 | (127, 8, 127), 17 | ], 18 | ) 19 | def test_to_twos_complement_positive(number: int, bits: int, expected_out: int): 20 | """Test conversion to two's complement format from positive numbers gives expected result.""" 21 | assert to_twos_complement(number, bits) == expected_out 22 | 23 | 24 | @pytest.mark.parametrize( 25 | ("number", "bits", "expected_out"), 26 | [ 27 | (-1, 8, 255), 28 | (-10, 8, 246), 29 | (-128, 8, 128), 30 | ], 31 | ) 32 | def test_to_twos_complement_negative(number: int, bits: int, expected_out: int): 33 | """Test conversion to two's complement format of negative numbers gives expected result.""" 34 | assert to_twos_complement(number, bits) == expected_out 35 | 36 | 37 | @pytest.mark.parametrize( 38 | ("number", "bits"), 39 | [ 40 | (128, 8), 41 | (-129, 8), 42 | (32768, 16), 43 | (-32769, 16), 44 | (2147483648, 32), 45 | (-2147483649, 32), 46 | (9223372036854775808, 64), 47 | (-9223372036854775809, 64), 48 | ], 49 | ) 50 | def test_to_twos_complement_range(number: int, bits: int): 51 | """Test conversion to two's complement format for out of range numbers raises :exc:`ValueError`.""" 52 | with pytest.raises(ValueError, match="out of range"): 53 | _ = to_twos_complement(number, bits) 54 | 55 | 56 | @pytest.mark.parametrize( 57 | ("number", "bits", "expected_out"), 58 | [ 59 | (0, 8, 0), 60 | (1, 8, 1), 61 | (10, 8, 10), 62 | (127, 8, 127), 63 | ], 64 | ) 65 | def test_from_twos_complement_positive(number: int, bits: int, expected_out: int): 66 | """Test conversion from two's complement format of positive numbers give expected result.""" 67 | assert from_twos_complement(number, bits) == expected_out 68 | 69 | 70 | @pytest.mark.parametrize( 71 | ("number", "bits", "expected_out"), 72 | [ 73 | (255, 8, -1), 74 | (246, 8, -10), 75 | (128, 8, -128), 76 | ], 77 | ) 78 | def test_from_twos_complement_negative(number: int, bits: int, expected_out: int): 79 | """Test conversion from two's complement format of negative numbers give expected result.""" 80 | assert from_twos_complement(number, bits) == expected_out 81 | 82 | 83 | @pytest.mark.parametrize( 84 | ("number", "bits"), 85 | [ 86 | (256, 8), 87 | (-1, 8), 88 | (65536, 16), 89 | (-1, 16), 90 | (4294967296, 32), 91 | (-1, 32), 92 | (18446744073709551616, 64), 93 | (-1, 64), 94 | ], 95 | ) 96 | def test_from_twos_complement_range(number: int, bits: int): 97 | """Test conversion from two's complement format for out of range numbers raises :exc:`ValueError`.""" 98 | with pytest.raises(ValueError, match="out of range"): 99 | _ = from_twos_complement(number, bits) 100 | -------------------------------------------------------------------------------- /tests/mcproto/test_buffer.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import pytest 4 | 5 | from mcproto.buffer import Buffer 6 | 7 | 8 | def test_write(): 9 | """Writing into the buffer should store data.""" 10 | buf = Buffer() 11 | buf.write(b"Hello") 12 | assert buf, bytearray(b"Hello") 13 | 14 | 15 | def test_read(): 16 | """Reading from buffer should return stored data.""" 17 | buf = Buffer(b"Reading is cool") 18 | data = buf.read(len(buf)) 19 | assert data == b"Reading is cool" 20 | 21 | 22 | def test_read_multiple(): 23 | """Multiple reads should deplete the data.""" 24 | buf = Buffer(b"Something random") 25 | data = buf.read(9) 26 | assert data == b"Something" 27 | data = buf.read(7) 28 | assert data == b" random" 29 | 30 | 31 | def test_no_data_read(): 32 | """Reading more data than available should raise IOError.""" 33 | buf = Buffer(b"Blip") 34 | with pytest.raises(IOError): 35 | _ = buf.read(len(buf) + 1) 36 | 37 | 38 | def test_reset(): 39 | """Resetting should treat already read data as new unread data.""" 40 | buf = Buffer(b"Will it reset?") 41 | data = buf.read(len(buf)) 42 | buf.reset() 43 | data2 = buf.read(len(buf)) 44 | assert data == data2 45 | assert data == b"Will it reset?" 46 | 47 | 48 | def test_clear(): 49 | """Clearing should remove all stored data from buffer.""" 50 | buf = Buffer(b"Will it clear?") 51 | buf.clear() 52 | assert buf == bytearray() 53 | 54 | 55 | def test_clear_resets_position(): 56 | """Clearing should reset reading position for new data to be read.""" 57 | buf = Buffer(b"abcdef") 58 | _ = buf.read(3) 59 | buf.clear() 60 | buf.write(b"012345") 61 | data = buf.read(3) 62 | assert data == b"012" 63 | 64 | 65 | def test_clear_read_only(): 66 | """Clearing should allow just removing the already read data.""" 67 | buf = Buffer(b"0123456789") 68 | _ = buf.read(5) 69 | buf.clear(only_already_read=True) 70 | assert buf == bytearray(b"56789") 71 | 72 | 73 | def test_flush(): 74 | """Flushing should read all available data and clear out the buffer.""" 75 | buf = Buffer(b"Foobar") 76 | data = buf.flush() 77 | assert data == b"Foobar" 78 | assert buf == bytearray() 79 | 80 | 81 | def test_remainig(): 82 | """Buffer should report correct amount of remaining bytes to be read.""" 83 | buf = Buffer(b"012345") # 6 bytes to be read 84 | assert buf.remaining == 6 85 | _ = buf.read(2) 86 | assert buf.remaining == 4 87 | buf.clear() 88 | assert buf.remaining == 0 89 | -------------------------------------------------------------------------------- /tests/mcproto/test_encryption.py: -------------------------------------------------------------------------------- 1 | from typing import cast 2 | 3 | from cryptography.hazmat.primitives.asymmetric.padding import PKCS1v15 4 | from cryptography.hazmat.primitives.asymmetric.rsa import RSAPrivateKey 5 | from cryptography.hazmat.primitives.serialization import load_pem_private_key 6 | 7 | from mcproto.encryption import encrypt_token_and_secret 8 | 9 | _SERIALIZED_RSA_PRIVATE_KEY = b""" 10 | -----BEGIN PRIVATE KEY----- 11 | MIICdgIBADANBgkqhkiG9w0BAQEFAASCAmAwggJcAgEAAoGBAMtRUQmRHqPkdA2K 12 | F6fM2c8ibIPHYV5KVQXNEkVx7iEKS6JsfELhX1H8t/qQ3Ob4Pr4OFjgXx9n7GvfZ 13 | gekNoswG6lnQH/n7t2sYA 6D+WvSix1FF2J6wPmpKriHS59TDk4opjaV14S4K4XjW 14 | Gmm8DqCzgXkPGC2dunFb+1A8mdkrAgMBAAECgYAWj2dWkGu989OMzQ3i6LAic8dm 15 | t/Dt7YGRqzejzQiHUgUieLcxFKDnEAu6GejpGBKeNCHzB3B9l4deiRwJKCIwHqMN 16 | LKMKoayinA8mj/Y/ O/ELDofkEyeXOhFyM642sPpaxQJoNWc9QEsYbxpG2zeB3sPf 17 | l3eIhkYTKVdxB+o8AQJBAPiddMjU8fuHyjKT6VCL2ZQbwnrRe1AaLLE6VLwEZuZC 18 | wlbx5Lcszi77PkMRTvltQW39VN6MEjiYFSPtRJleA+sCQQDRW2e3BX6uiil2IZ08 19 | tPFMnltFJpa 8YvW50N6mySd8Zg1oQJpzP2fC0n0+K4j3EiA/Zli8jBt45cJ4dMGX 20 | km/BAkEAtkYy5j+BvolbDGP3Ti+KcRU9K/DD+QGHvNRoZYTQsIdHlpk4t7eo3zci 21 | +ecJwMOCkhKHE7cccNPHxBRkFBGiywJAJBt2pMsu0R2FDxm3C6xNXaCGL0P7hVwv 22 | 8y9B51 QUGlFjiJJz0OKjm6c/8IQDqFEY/LZDIamsZ0qBItNIPEMGQQJALZV0GD5Y 23 | zmnkw1hek/JcfQBlVYo3gFmWBh6Hl1Lb7p3TKUViJCA1k2f0aGv7+d9aFS0fRq6u 24 | /sETkem8Jc1s3g== 25 | -----END PRIVATE KEY----- 26 | """ 27 | RSA_PRIVATE_KEY = cast(RSAPrivateKey, load_pem_private_key(_SERIALIZED_RSA_PRIVATE_KEY, password=None)) 28 | RSA_PUBLIC_KEY = RSA_PRIVATE_KEY.public_key() 29 | 30 | 31 | def test_encrypt_token_and_secret(): 32 | """Test encryption returns properly encrypted (decryptable) values.""" 33 | verification_token = bytes.fromhex("9bd416ef") 34 | shared_secret = bytes.fromhex("f71e3033d4c0fc6aadee4417831b5c3e") 35 | 36 | encrypted_token, encrypted_secret = encrypt_token_and_secret(RSA_PUBLIC_KEY, verification_token, shared_secret) 37 | 38 | assert RSA_PRIVATE_KEY.decrypt(encrypted_token, PKCS1v15()) == verification_token 39 | assert RSA_PRIVATE_KEY.decrypt(encrypted_secret, PKCS1v15()) == shared_secret 40 | -------------------------------------------------------------------------------- /tests/mcproto/test_multiplayer.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import sys 4 | from typing import TYPE_CHECKING 5 | from unittest.mock import Mock 6 | 7 | import httpx 8 | import pytest 9 | 10 | if sys.version_info > (3, 9) or TYPE_CHECKING: 11 | from pytest_httpx import HTTPXMock 12 | 13 | from mcproto.multiplayer import ( 14 | JoinAcknowledgeData, 15 | JoinAcknowledgeProperty, 16 | SESSION_SERVER_URL, 17 | UserJoinCheckFailedError, 18 | UserJoinRequestErrorKind, 19 | UserJoinRequestFailedError, 20 | compute_server_hash, 21 | join_check, 22 | join_request, 23 | ) 24 | from tests.mcproto.test_encryption import RSA_PUBLIC_KEY 25 | 26 | 27 | @pytest.mark.skipif(sys.version_info < (3, 9), reason="requires 3.9+ for pytest-httpx dependency") 28 | async def test_join_request_valid(httpx_mock: HTTPXMock) -> None: 29 | """Test making a join request, getting back a valid response.""" 30 | httpx_mock.add_response( 31 | url=f"{SESSION_SERVER_URL}/session/minecraft/join", 32 | method="POST", 33 | status_code=204, 34 | ) 35 | 36 | account = Mock() 37 | account.access_token = "foobar" # noqa: S105 # hard-coded password 38 | account.uuid.hex = "97e071429b5e49b19c15d16232a93746" 39 | server_hash = "-745fc7fdb2d6ae7c4b20e2987770def8f3dd1105" 40 | 41 | async with httpx.AsyncClient() as client: 42 | await join_request(client, account, server_hash) 43 | 44 | 45 | @pytest.mark.skipif(sys.version_info < (3, 9), reason="requires 3.9+ for pytest-httpx dependency") 46 | @pytest.mark.parametrize( 47 | ("status_code", "err_msg", "err_type"), 48 | [ 49 | (403, "InsufficientPrivilegesException", UserJoinRequestErrorKind.XBOX_MULTIPLAYER_DISABLED), 50 | (403, "UserBannedException", UserJoinRequestErrorKind.BANNED_FROM_MULTIPLAYER), 51 | (403, "ForbiddenOperationException", UserJoinRequestErrorKind.UNKNOWN), 52 | ], 53 | ) 54 | async def test_join_request_invalid( 55 | status_code: int, 56 | err_msg: str, 57 | err_type: UserJoinRequestErrorKind, 58 | httpx_mock: HTTPXMock, 59 | ) -> None: 60 | """Test making a join request, getting back an invalid response.""" 61 | httpx_mock.add_response( 62 | url=f"{SESSION_SERVER_URL}/session/minecraft/join", 63 | method="POST", 64 | status_code=status_code, 65 | json={"error": err_msg, "path": "/session/minecraft/join"}, 66 | ) 67 | 68 | account = Mock() 69 | account.access_token = "foobar" # noqa: S105 # hard-coded password 70 | account.uuid.hex = "97e071429b5e49b19c15d16232a93746" 71 | server_hash = "-745fc7fdb2d6ae7c4b20e2987770def8f3dd1105" 72 | 73 | async with httpx.AsyncClient() as client: 74 | with pytest.raises(UserJoinRequestFailedError) as exc_info: 75 | await join_request(client, account, server_hash) 76 | 77 | exc = exc_info.value 78 | assert exc.code == status_code 79 | assert exc.url == f"{SESSION_SERVER_URL}/session/minecraft/join" 80 | assert exc.err_msg == err_msg 81 | assert exc.err_type is err_type 82 | 83 | 84 | @pytest.mark.skipif(sys.version_info < (3, 9), reason="requires 3.9+ for pytest-httpx dependency") 85 | @pytest.mark.parametrize( 86 | ("client_ip"), 87 | [ 88 | (None), 89 | ("172.17.0.1"), 90 | ], 91 | ) 92 | async def test_join_check_valid(client_ip, httpx_mock: HTTPXMock): 93 | """Test making a join check, getting back a valid response.""" 94 | client_username = "ItsDrike" 95 | server_hash = "-745fc7fdb2d6ae7c4b20e2987770def8f3dd1105" 96 | ack_data = JoinAcknowledgeData( 97 | { 98 | "id": "1759517ef05a4bcd8c8b116fa31c1bbd", 99 | "name": "ItsDrike", 100 | "properties": [ 101 | JoinAcknowledgeProperty( 102 | { 103 | "name": "textures", 104 | "value": "", 105 | "signature": "", 106 | } 107 | ) 108 | ], 109 | } 110 | ) 111 | 112 | params = {"username": client_username, "serverId": server_hash} 113 | if client_ip is not None: 114 | params["ip"] = client_ip 115 | url = httpx.URL(f"{SESSION_SERVER_URL}/session/minecraft/hasJoined", params=params) 116 | 117 | httpx_mock.add_response( 118 | url=str(url), 119 | method="GET", 120 | status_code=200, 121 | json=ack_data, 122 | ) 123 | 124 | async with httpx.AsyncClient() as client: 125 | ret_data = await join_check(client, client_username, server_hash, client_ip) 126 | 127 | assert ret_data == ack_data 128 | 129 | 130 | @pytest.mark.skipif(sys.version_info < (3, 9), reason="requires 3.9+ for pytest-httpx dependency") 131 | async def test_join_check_invalid(httpx_mock: HTTPXMock): 132 | """Test making a join check, getting back an invalid response.""" 133 | client_username = "ItsDrike" 134 | server_hash = "-745fc7fdb2d6ae7c4b20e2987770def8f3dd1105" 135 | client_ip = None 136 | 137 | params = {"username": client_username, "serverId": server_hash} 138 | if client_ip is not None: 139 | params["ip"] = client_ip 140 | url = httpx.URL(f"{SESSION_SERVER_URL}/session/minecraft/hasJoined", params=params) 141 | 142 | httpx_mock.add_response( 143 | url=str(url), 144 | method="GET", 145 | status_code=204, 146 | ) 147 | 148 | async with httpx.AsyncClient() as client: 149 | with pytest.raises(UserJoinCheckFailedError) as exc_info: 150 | _ = await join_check(client, client_username, server_hash, client_ip) 151 | 152 | exc = exc_info.value 153 | assert exc.client_username == client_username 154 | assert exc.server_hash == server_hash 155 | assert exc.client_ip == client_ip 156 | 157 | 158 | def test_compute_server_hash(): 159 | """Test hash value computing returns expected hash.""" 160 | server_id = "" 161 | shared_secret = bytes.fromhex("f71e3033d4c0fc6aadee4417831b5c3e") 162 | server_public_key = RSA_PUBLIC_KEY 163 | expected_server_hash = "-745fc7fdb2d6ae7c4b20e2987770def8f3dd1105" 164 | 165 | assert compute_server_hash(server_id, shared_secret, server_public_key) == expected_server_hash 166 | -------------------------------------------------------------------------------- /tests/mcproto/types/__init__.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | -------------------------------------------------------------------------------- /tests/mcproto/types/test_chat.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import pytest 4 | 5 | from mcproto.types.chat import ChatMessage, RawChatMessage, RawChatMessageDict 6 | from tests.helpers import gen_serializable_test 7 | 8 | 9 | @pytest.mark.parametrize( 10 | ("raw", "expected_dict"), 11 | [ 12 | ( 13 | {"text": "A Minecraft Server"}, 14 | {"text": "A Minecraft Server"}, 15 | ), 16 | ( 17 | "A Minecraft Server", 18 | {"text": "A Minecraft Server"}, 19 | ), 20 | ( 21 | [{"text": "hello", "bold": True}, {"text": "there"}], 22 | {"extra": [{"text": "hello", "bold": True}, {"text": "there"}]}, 23 | ), 24 | ], 25 | ) 26 | def test_as_dict(raw: RawChatMessage, expected_dict: RawChatMessageDict): 27 | """Test converting raw ChatMessage input into dict produces expected dict.""" 28 | chat = ChatMessage(raw) 29 | assert chat.as_dict() == expected_dict 30 | 31 | 32 | @pytest.mark.parametrize( 33 | ("raw1", "raw2", "expected_result"), 34 | [ 35 | ( 36 | {"text": "A Minecraft Server"}, 37 | {"text": "A Minecraft Server"}, 38 | True, 39 | ), 40 | ( 41 | {"text": "Not a Minecraft Server"}, 42 | {"text": "A Minecraft Server"}, 43 | False, 44 | ), 45 | ], 46 | ) 47 | def test_equality(raw1: RawChatMessage, raw2: RawChatMessage, expected_result: bool): 48 | """Test comparing ChatMessage instances produces expected equality result.""" 49 | assert (ChatMessage(raw1) == ChatMessage(raw2)) is expected_result 50 | 51 | 52 | gen_serializable_test( 53 | context=globals(), 54 | cls=ChatMessage, 55 | fields=[("raw", RawChatMessage)], 56 | serialize_deserialize=[ 57 | ( 58 | ("A Minecraft Server",), 59 | bytes.fromhex("142241204d696e6563726166742053657276657222"), 60 | ), 61 | ( 62 | ({"text": "abc"},), 63 | bytes.fromhex("0f7b2274657874223a2022616263227d"), 64 | ), 65 | ( 66 | ([{"text": "abc"}, {"text": "def"}],), 67 | bytes.fromhex("225b7b2274657874223a2022616263227d2c207b2274657874223a2022646566227d5d"), 68 | ), 69 | ], 70 | validation_fail=[ 71 | # Wrong type for raw 72 | ((b"invalid",), TypeError), 73 | (({"no_extra_or_text": "invalid"},), AttributeError), 74 | (([{"no_text": "invalid"}, {"text": "Hello"}, {"extra": "World"}],), AttributeError), 75 | # Expects a list of dicts if raw is a list 76 | (([[]],), TypeError), 77 | ], 78 | ) 79 | -------------------------------------------------------------------------------- /tests/mcproto/types/test_uuid.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from mcproto.types.uuid import UUID 4 | from tests.helpers import gen_serializable_test 5 | 6 | gen_serializable_test( 7 | context=globals(), 8 | cls=UUID, 9 | fields=[("hex", str)], 10 | serialize_deserialize=[ 11 | (("12345678-1234-5678-1234-567812345678",), bytes.fromhex("12345678123456781234567812345678")), 12 | ], 13 | validation_fail=[ 14 | # Too short or too long 15 | (("12345678-1234-5678-1234-56781234567",), ValueError), 16 | (("12345678-1234-5678-1234-5678123456789",), ValueError), 17 | ], 18 | deserialization_fail=[ 19 | # Not enough data in the buffer (needs 16 bytes) 20 | (b"", IOError), 21 | (b"\x01", IOError), 22 | (b"\x01\x02\x03\x04\x05\x06\x07\x08\x09\x0a\x0b\x0c\x0d\x0e", IOError), 23 | ], 24 | ) 25 | -------------------------------------------------------------------------------- /tests/mcproto/utils/__init__.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | -------------------------------------------------------------------------------- /tests/mcproto/utils/test_deprecation.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import importlib.metadata 4 | import warnings 5 | from functools import wraps 6 | 7 | import pytest 8 | 9 | from mcproto.utils.deprecation import deprecated, deprecation_warn 10 | 11 | 12 | def _patch_project_version(monkeypatch: pytest.MonkeyPatch, version: str | None): 13 | """Patch the project version reported by importlib.metadata.version. 14 | 15 | This is used to simulate different project versions for testing purposes. 16 | If ``version`` is ``None``, a :exc:`~importlib.metadata.PackageNotFoundError` will be raised 17 | when trying to get the project version. 18 | """ 19 | orig_version_func = importlib.metadata.version 20 | 21 | @wraps(orig_version_func) 22 | def patched_version_func(distribution_name: str) -> str: 23 | if distribution_name == "mcproto": 24 | if version is None: 25 | raise importlib.metadata.PackageNotFoundError 26 | return version 27 | return orig_version_func(distribution_name) 28 | 29 | monkeypatch.setattr(importlib.metadata, "version", patched_version_func) 30 | 31 | 32 | def test_deprecation_warn_produces_error(monkeypatch: pytest.MonkeyPatch): 33 | """Test deprecation_warn with older removal_version than current version produces exception.""" 34 | _patch_project_version(monkeypatch, "1.0.0") 35 | 36 | with pytest.raises(DeprecationWarning, match="test"): 37 | deprecation_warn(obj_name="test", removal_version="0.9.0") 38 | 39 | 40 | def test_deprecation_warn_produces_warning(monkeypatch: pytest.MonkeyPatch): 41 | """Test deprecation_warn with newer removal_version than current version produces warning.""" 42 | _patch_project_version(monkeypatch, "1.0.0") 43 | 44 | with warnings.catch_warnings(record=True) as w: 45 | deprecation_warn(obj_name="test", removal_version="1.0.1") 46 | 47 | assert len(w) == 1 48 | assert issubclass(w[0].category, DeprecationWarning) 49 | assert "test" in str(w[0].message) 50 | 51 | 52 | def test_deprecation_warn_unknown_version(monkeypatch: pytest.MonkeyPatch): 53 | """Test deprecation_warn with unknown mcproto version. 54 | 55 | This could occur if mcproto wasn't installed as a package. (e.g. when running directly from source, 56 | like via a git submodule.) 57 | """ 58 | _patch_project_version(monkeypatch, None) 59 | 60 | with warnings.catch_warnings(record=True) as w: 61 | deprecation_warn(obj_name="test", removal_version="1.0.0") 62 | 63 | assert len(w) == 2 64 | assert issubclass(w[0].category, RuntimeWarning) 65 | assert "Failed to get mcproto project version" in str(w[0].message) 66 | assert issubclass(w[1].category, DeprecationWarning) 67 | assert "test" in str(w[1].message) 68 | 69 | 70 | def test_deprecation_decorator(monkeypatch: pytest.MonkeyPatch): 71 | """Check deprecated decorator with newer removal_version than current version produces warning.""" 72 | _patch_project_version(monkeypatch, "1.0.0") 73 | 74 | @deprecated(removal_version="1.0.1") 75 | def func(x: object) -> object: 76 | return x 77 | 78 | with warnings.catch_warnings(record=True) as w: 79 | assert func(5) == 5 80 | 81 | assert len(w) == 1 82 | assert issubclass(w[0].category, DeprecationWarning) 83 | assert "func" in str(w[0].message) 84 | -------------------------------------------------------------------------------- /tests/mcproto/utils/test_serializable.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import Any, cast, final 4 | 5 | from attrs import define 6 | from typing_extensions import override 7 | 8 | from mcproto.buffer import Buffer 9 | from mcproto.utils.abc import Serializable 10 | from tests.helpers import TestExc, gen_serializable_test 11 | 12 | 13 | class CustomError(Exception): 14 | """Custom exception for testing.""" 15 | 16 | additional_data: Any 17 | 18 | def __init__(self, message: str, additional_data: Any): 19 | super().__init__(message) 20 | self.additional_data = additional_data 21 | 22 | 23 | # region ToyClass 24 | @final 25 | @define(init=True) 26 | class ToyClass(Serializable): 27 | """Toy class for testing demonstrating the use of gen_serializable_test on `Serializable`.""" 28 | 29 | a: int 30 | b: str | int 31 | 32 | @override 33 | def __attrs_post_init__(self) -> None: 34 | if isinstance(self.b, int): 35 | self.b = str(self.b) 36 | 37 | return super().__attrs_post_init__() 38 | 39 | @override 40 | def serialize_to(self, buf: Buffer): 41 | """Write the object to a buffer.""" 42 | self.b = cast(str, self.b) # Handled by the __attrs_post_init__ method 43 | buf.write_varint(self.a) 44 | buf.write_utf(self.b) 45 | 46 | @classmethod 47 | @override 48 | def deserialize(cls, buf: Buffer) -> ToyClass: 49 | """Deserialize the object from a buffer.""" 50 | a = buf.read_varint() 51 | if a == 0: 52 | raise CustomError("a must be non-zero", additional_data=a) 53 | b = buf.read_utf() 54 | return cls(a, b) 55 | 56 | @override 57 | def validate(self) -> None: 58 | """Validate the object's attributes.""" 59 | if self.a == 0: 60 | raise ZeroDivisionError("a must be non-zero") 61 | self.b = cast(str, self.b) # Handled by the __attrs_post_init__ method 62 | if len(self.b) > 10: 63 | raise ValueError("b must be less than 10 characters") 64 | 65 | 66 | # endregion ToyClass 67 | 68 | # region Test ToyClass 69 | gen_serializable_test( 70 | context=globals(), 71 | cls=ToyClass, 72 | fields=[("a", int), ("b", str)], 73 | serialize_deserialize=[ 74 | ((1, "hello"), b"\x01\x05hello"), 75 | ((2, "world"), b"\x02\x05world"), 76 | ((3, 1234567890), b"\x03\x0a1234567890"), 77 | ], 78 | validation_fail=[ 79 | ((0, "hello"), TestExc(ZeroDivisionError, "a must be non-zero")), # Message specified 80 | ((1, "hello world"), ValueError), # No message specified 81 | ((1, 12345678900), TestExc(ValueError, "b must be less than .*")), # Message regex 82 | ], 83 | deserialization_fail=[ 84 | (b"\x00", CustomError), # No message specified 85 | (b"\x00\x05hello", TestExc(CustomError, "a must be non-zero", {"additional_data": 0})), # Check fields 86 | (b"\x01", TestExc(IOError)), # No message specified 87 | ], 88 | ) 89 | # endregion Test ToyClass 90 | 91 | 92 | if __name__ == "__main__": 93 | # TODO: What's the point of this? 94 | # Do we really expect this file to be ran directly? 95 | _ = ToyClass(1, "hello").serialize() 96 | --------------------------------------------------------------------------------