From 81a41666dfe1aae537c6483f6ec7b238db510920 Mon Sep 17 00:00:00 2001 From: Mark Elliot <123787712+mark-thm@users.noreply.github.com> Date: Fri, 26 Jul 2024 22:44:39 -0400 Subject: [PATCH] Create rules_mypy --- .bazelignore | 1 + .bazelrc | 5 + .bazelversion | 1 + .bcr/README.md | 9 + .bcr/config.yml | 3 + .bcr/metadata.template.json | 18 ++ .bcr/presubmit.yml | 15 ++ .bcr/source.template.json | 5 + .gitattributes | 10 + .github/CODEOWNERS | 4 + .github/renovate.json | 16 ++ .github/reviewers.json | 14 ++ .github/workflows/automation-autorelease.yml | 24 +++ .github/workflows/automation-autosquash.yml | 39 ++++ .github/workflows/automation-reviewbot.yml | 15 ++ .github/workflows/ci.bazelrc | 18 ++ .github/workflows/ci.yml | 28 +++ .../workflows/periodic-update-multitool.yml | 54 +++++ .github/workflows/release.yml | 14 ++ .github/workflows/release_prep.sh | 30 +++ .gitignore | 7 + BUILD.bazel | 22 ++ LICENSE | 201 ++++++++++++++++++ MODULE.bazel | 33 +++ WORKSPACE.bazel | 0 examples/demo/.bazelrc | 5 + examples/demo/.bazelversion | 1 + examples/demo/BUILD.bazel | 13 ++ examples/demo/MODULE.bazel | 47 ++++ examples/demo/py.bzl | 10 + examples/demo/requirements.in | 3 + examples/demo/requirements.txt | 49 +++++ examples/demo/thm/BUILD.bazel | 7 + examples/demo/thm/__init__.py | 0 examples/demo/thm/a/BUILD.bazel | 8 + examples/demo/thm/a/__init__.py | 1 + examples/demo/thm/b/BUILD.bazel | 13 ++ examples/demo/thm/b/__init__.py | 8 + mypy/BUILD.bazel | 19 ++ mypy/mypy.bzl | 66 ++++++ mypy/private/BUILD.bazel | 64 ++++++ mypy/private/mypy.bzl | 135 ++++++++++++ mypy/private/mypy.py | 70 ++++++ mypy/private/py_type_library.bzl | 42 ++++ mypy/private/py_type_library.py | 34 +++ mypy/private/requirements.in | 2 + mypy/private/requirements.txt | 45 ++++ mypy/private/types.bzl | 70 ++++++ mypy/py_type_library.bzl | 5 + mypy/types.bzl | 27 +++ readme.md | 135 ++++++++++++ 51 files changed, 1465 insertions(+) create mode 100644 .bazelignore create mode 100644 .bazelrc create mode 100644 .bazelversion create mode 100644 .bcr/README.md create mode 100644 .bcr/config.yml create mode 100644 .bcr/metadata.template.json create mode 100644 .bcr/presubmit.yml create mode 100644 .bcr/source.template.json create mode 100644 .gitattributes create mode 100644 .github/CODEOWNERS create mode 100644 .github/renovate.json create mode 100644 .github/reviewers.json create mode 100644 .github/workflows/automation-autorelease.yml create mode 100644 .github/workflows/automation-autosquash.yml create mode 100644 .github/workflows/automation-reviewbot.yml create mode 100644 .github/workflows/ci.bazelrc create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/periodic-update-multitool.yml create mode 100644 .github/workflows/release.yml create mode 100755 .github/workflows/release_prep.sh create mode 100644 .gitignore create mode 100644 BUILD.bazel create mode 100644 LICENSE create mode 100644 MODULE.bazel create mode 100644 WORKSPACE.bazel create mode 100644 examples/demo/.bazelrc create mode 100644 examples/demo/.bazelversion create mode 100644 examples/demo/BUILD.bazel create mode 100644 examples/demo/MODULE.bazel create mode 100644 examples/demo/py.bzl create mode 100644 examples/demo/requirements.in create mode 100644 examples/demo/requirements.txt create mode 100644 examples/demo/thm/BUILD.bazel create mode 100644 examples/demo/thm/__init__.py create mode 100644 examples/demo/thm/a/BUILD.bazel create mode 100644 examples/demo/thm/a/__init__.py create mode 100644 examples/demo/thm/b/BUILD.bazel create mode 100644 examples/demo/thm/b/__init__.py create mode 100644 mypy/BUILD.bazel create mode 100644 mypy/mypy.bzl create mode 100644 mypy/private/BUILD.bazel create mode 100644 mypy/private/mypy.bzl create mode 100644 mypy/private/mypy.py create mode 100644 mypy/private/py_type_library.bzl create mode 100644 mypy/private/py_type_library.py create mode 100644 mypy/private/requirements.in create mode 100644 mypy/private/requirements.txt create mode 100644 mypy/private/types.bzl create mode 100644 mypy/py_type_library.bzl create mode 100644 mypy/types.bzl create mode 100644 readme.md diff --git a/.bazelignore b/.bazelignore new file mode 100644 index 0000000..56bef8c --- /dev/null +++ b/.bazelignore @@ -0,0 +1 @@ +examples/ \ No newline at end of file diff --git a/.bazelrc b/.bazelrc new file mode 100644 index 0000000..acb122c --- /dev/null +++ b/.bazelrc @@ -0,0 +1,5 @@ +common --enable_bzlmod + +common --lockfile_mode=off + +test --test_output=errors diff --git a/.bazelversion b/.bazelversion new file mode 100644 index 0000000..66ce77b --- /dev/null +++ b/.bazelversion @@ -0,0 +1 @@ +7.0.0 diff --git a/.bcr/README.md b/.bcr/README.md new file mode 100644 index 0000000..44ae7fe --- /dev/null +++ b/.bcr/README.md @@ -0,0 +1,9 @@ +# Bazel Central Registry + +When the ruleset is released, we want it to be published to the +Bazel Central Registry automatically: + + +This folder contains configuration files to automate the publish step. +See +for authoritative documentation about these files. diff --git a/.bcr/config.yml b/.bcr/config.yml new file mode 100644 index 0000000..894ade6 --- /dev/null +++ b/.bcr/config.yml @@ -0,0 +1,3 @@ +fixedReleaser: + login: mark-thm + email: 123787712+mark-thm@users.noreply.github.com diff --git a/.bcr/metadata.template.json b/.bcr/metadata.template.json new file mode 100644 index 0000000..d6dffb9 --- /dev/null +++ b/.bcr/metadata.template.json @@ -0,0 +1,18 @@ +{ + "homepage": "https://github.com/theoremlp/rules_mypy", + "maintainers": [ + { + "email": "123787712+mark-thm@users.noreply.github.com", + "github": "mark-thm", + "name": "Mark Elliot" + }, + { + "email": "bazel-maintainers@theoremlp.com", + "github": "theoremlp", + "name": "Theorem Bazel Maintainers" + } + ], + "repository": ["github:theoremlp/rules_myp"], + "versions": [], + "yanked_versions": {} +} diff --git a/.bcr/presubmit.yml b/.bcr/presubmit.yml new file mode 100644 index 0000000..1746784 --- /dev/null +++ b/.bcr/presubmit.yml @@ -0,0 +1,15 @@ +matrix: + platform: + - debian10 + - ubuntu2004 + - macos + - macos_arm64 + bazel: + - 7.x +tasks: + verify_targets: + name: Verify build targets + platform: ${{ platform }} + bazel: ${{ bazel }} + build_targets: + - "@rules_mypy//..." diff --git a/.bcr/source.template.json b/.bcr/source.template.json new file mode 100644 index 0000000..d25b066 --- /dev/null +++ b/.bcr/source.template.json @@ -0,0 +1,5 @@ +{ + "integrity": "**leave this alone**", + "strip_prefix": "{REPO}-{VERSION}", + "url": "https://github.com/{OWNER}/{REPO}/releases/download/{TAG}/{REPO}-{VERSION}.tar.gz" +} diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..e8f86e7 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,10 @@ +# In code review, collapse generated files +docs/*.md linguist-generated=true + +################################# +# Configuration for 'git archive' +# See https://git-scm.com/docs/git-archive#ATTRIBUTES + +# Don't include examples in the distribution artifact, to reduce size. +# You may want to add additional exclusions for folders or files that users don't need. +examples export-ignore diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..e626341 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,4 @@ +* @theoremlp/eng + +.github/workflows/*.yml @theoremlp/eng @reviewbot-theorem +uv/private/uv.lock.json @theoremlp/eng @reviewbot-theorem diff --git a/.github/renovate.json b/.github/renovate.json new file mode 100644 index 0000000..d9bdee6 --- /dev/null +++ b/.github/renovate.json @@ -0,0 +1,16 @@ +{ + "$schema": "https://docs.renovatebot.com/renovate-schema.json", + "extends": [":semanticCommitsDisabled"], + "enabledManagers": ["github-actions"], + "timezone": "America/New_York", + "schedule": ["every weekday after 9am before 5pm"], + "branchConcurrentLimit": 10, + "labels": ["automerge"], + "dependencyDashboard": true, + "packageRules": [ + { + "matchFiles": ["MODULE.bazel"], + "enabled": false + } + ] +} diff --git a/.github/reviewers.json b/.github/reviewers.json new file mode 100644 index 0000000..d4db7a2 --- /dev/null +++ b/.github/reviewers.json @@ -0,0 +1,14 @@ +{ + "teams": {}, + "reviewers": {}, + "overrides": [ + { + "description": "Auto-approve automated PRs", + "onlyModifiedByUsers": ["thm-automation[bot]", "renovate-thm[bot]"], + "onlyModifiedFileRegExs": [ + "^.github/workflows/.*", + "^uv/private/uv.lock.json$" + ] + } + ] +} diff --git a/.github/workflows/automation-autorelease.yml b/.github/workflows/automation-autorelease.yml new file mode 100644 index 0000000..f255e7c --- /dev/null +++ b/.github/workflows/automation-autorelease.yml @@ -0,0 +1,24 @@ +name: autorelease +on: + workflow_dispatch: {} + schedule: + # check at 11am every day + - cron: "0 11 * * *" +jobs: + autorelease: + name: autorelease + runs-on: ubuntu-latest + if: ${{ github.ref == 'refs/heads/main' }} + steps: + - name: Get Token + id: app-token + uses: actions/create-github-app-token@v1 + with: + app-id: ${{ secrets.THM_AUTOMATION_APP_ID }} + private-key: ${{ secrets.THM_AUTOMATION_PRIVATE_KEY }} + - name: autorelease + uses: markelliot/autorelease@v2 + with: + github-token: ${{ steps.app-token.outputs.token }} + max-days: 7 + tag-only: true diff --git a/.github/workflows/automation-autosquash.yml b/.github/workflows/automation-autosquash.yml new file mode 100644 index 0000000..e9b7555 --- /dev/null +++ b/.github/workflows/automation-autosquash.yml @@ -0,0 +1,39 @@ +name: autosquash +on: + pull_request: + types: + # omit "opened" as labeling happens after opening + # and when we include both the two events end up + # canceling each other's runs. + - reopened + - edited + - labeled + - synchronize + - unlabeled + - ready_for_review +concurrency: + # only one autosquash at a time per PR + group: ${{ github.workflow }}-${{ github.event.pull_request.number }} + cancel-in-progress: true +jobs: + autosquash: + runs-on: ubuntu-latest + steps: + - name: Get Token + if: github.event.pull_request.head.repo.full_name == 'theoremlp/rules_mypy' + id: app-token + uses: actions/create-github-app-token@v1 + with: + app-id: ${{ secrets.THM_AUTOMATION_APP_ID }} + private-key: ${{ secrets.THM_AUTOMATION_PRIVATE_KEY }} + - if: github.event.pull_request.head.repo.full_name == 'theoremlp/rules_mypy' + uses: actions/checkout@v4 + - if: github.event.pull_request.head.repo.full_name == 'theoremlp/rules_mypy' + uses: theoremlp/autosquash@v1 + with: + github-token: ${{ steps.app-token.outputs.token }} + pull-request-number: ${{ github.event.pull_request.number }} + squash-commit-title: "${{ github.event.pull_request.title }} (#${{ github.event.pull_request.number }})" + squash-commit-message: "${{ github.event.pull_request.body }}" + do-not-merge-label: "do not merge" + required-label: "automerge" diff --git a/.github/workflows/automation-reviewbot.yml b/.github/workflows/automation-reviewbot.yml new file mode 100644 index 0000000..fa521f1 --- /dev/null +++ b/.github/workflows/automation-reviewbot.yml @@ -0,0 +1,15 @@ +name: reviewbot +on: + pull_request: {} + pull_request_review: {} +jobs: + required-reviewers: + name: reviewbot + runs-on: ubuntu-latest + if: github.event.pull_request.head.repo.full_name == 'theoremlp/rules_mypy' + steps: + - name: required-reviewers + uses: theoremlp/required-reviews@v2 + with: + github-token: ${{ secrets.REVIEW_TOKEN_PUB }} + post-review: true diff --git a/.github/workflows/ci.bazelrc b/.github/workflows/ci.bazelrc new file mode 100644 index 0000000..cb2a9b3 --- /dev/null +++ b/.github/workflows/ci.bazelrc @@ -0,0 +1,18 @@ +# This file contains Bazel settings to apply on CI only. +# It is referenced with a --bazelrc option in the call to bazel in ci.yaml + +# Debug where options came from +build --announce_rc + +# This directory is configured in GitHub actions to be persisted between runs. +# We do not enable the repository cache to cache downloaded external artifacts +# as these are generally faster to download again than to fetch them from the +# GitHub actions cache. +build --disk_cache=~/.cache/bazel + +# Don't rely on test logs being easily accessible from the test runner, +# though it makes the log noisier. +test --test_output=errors + +# Allows tests to run bazelisk-in-bazel, since this is the cache folder used +test --test_env=XDG_CACHE_HOME diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..a888012 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,28 @@ +name: CI +on: + push: + branches: [main] + pull_request: + branches: [main] + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true +jobs: + test: + uses: bazel-contrib/.github/.github/workflows/bazel.yaml@v6 + with: + folders: | + [ + ".", + "examples/demo", + ] + # we only support Bazel 7, and only with bzlmod enabled + exclude: | + [ + {"bzlmodEnabled": false}, + {"bazelversion": "5.4.0"}, + {"bazelversion": "6.4.0"}, + ] + # this ruleset only supports linux and macos + exclude_windows: true diff --git a/.github/workflows/periodic-update-multitool.yml b/.github/workflows/periodic-update-multitool.yml new file mode 100644 index 0000000..c03804c --- /dev/null +++ b/.github/workflows/periodic-update-multitool.yml @@ -0,0 +1,54 @@ +name: Periodic - Update Multitool Versions +on: + workflow_dispatch: {} + schedule: + # run every hour on the 5 between 9am and 5pm (4am and 12pm UTC), M-F + - cron: "5 14-22 * * 1-5" +jobs: + update-requirement: + name: Update Multitool Versions + runs-on: ubuntu-latest + permissions: + id-token: write + contents: read + env: + LOCKFILE: uv/private/uv.lock.json + # disable running on anything but main + if: ${{ github.ref == 'refs/heads/main' }} + steps: + - name: Get Token + id: app-token + uses: actions/create-github-app-token@v1 + with: + app-id: ${{ secrets.THM_AUTOMATION_APP_ID }} + private-key: ${{ secrets.THM_AUTOMATION_PRIVATE_KEY }} + - uses: actions/checkout@v4 + with: + token: ${{ steps.app-token.outputs.token }} + - name: Download and Extract Latest Multitool + run: | + latest="$(curl https://api.github.com/repos/theoremlp/multitool/releases/latest | jq -r '.assets[].browser_download_url | select(. | test("x86_64-unknown-linux-gnu.tar.xz$"))')" + wget -O multitool.tar.xz "$latest" + tar --strip-components=1 -xf multitool.tar.xz + - name: Find Updates and Render Lockfile + env: + GITHUB_TOKEN: ${{ steps.app-token.outputs.token }} + run: ./multitool --lockfile "$LOCKFILE" update + - name: Commit Changes + env: + GH_TOKEN: ${{ steps.app-token.outputs.token }} + BRANCH_NAME: "automation/update-multitool-lockfile" + run: | + if [[ -n "$(git diff "$LOCKFILE")" ]] + then + git config --local user.name 'Theorem Automation' + git config --local user.email 'thm-automation[bot]@users.noreply.github.com' + git checkout -b "${BRANCH_NAME}" + git add "$LOCKFILE" + git commit -m "Update Multitool Versions + + Updated with [update-multitool](${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}) by *${GITHUB_ACTOR}* + " + git push origin "${BRANCH_NAME}" -f + gh pr create --fill --label "automerge" >> "$GITHUB_STEP_SUMMARY" + fi diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..f6b53ba --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,14 @@ +name: Release +on: + push: + tags: + - "v*.*.*" + +jobs: + release: + uses: bazel-contrib/.github/.github/workflows/release_ruleset.yaml@v6 + permissions: + contents: write + with: + release_files: rules_mypy-*.tar.gz + prerelease: false diff --git a/.github/workflows/release_prep.sh b/.github/workflows/release_prep.sh new file mode 100755 index 0000000..24013c9 --- /dev/null +++ b/.github/workflows/release_prep.sh @@ -0,0 +1,30 @@ +#!/usr/bin/env bash + +# invoked by release workflow +# (via https://github.com/bazel-contrib/.github/blob/master/.github/workflows/release_ruleset.yaml) + +set -o errexit -o nounset -o pipefail + +RULES_NAME="rules_mypy" +TAG="${GITHUB_REF_NAME}" +PREFIX="${RULES_NAME}-${TAG:1}" +ARCHIVE="${RULES_NAME}-${TAG:1}.tar.gz" + +# embed version in MODULE.bazel +perl -pi -e "s/version = \"0\.0\.0\",/version = \"${TAG:1}\",/g" MODULE.bazel + +stash_name=`git stash create`; +git archive --format=tar --prefix=${PREFIX}/ "${stash_name}" | gzip > $ARCHIVE + +SHA=$(shasum -a 256 $ARCHIVE | awk '{print $1}') + +cat << EOF +## Using Bzlmod with Bazel 7 + +1. Enable with \`common --enable_bzlmod\` in \`.bazelrc\`. +2. Add to your \`MODULE.bazel\` file: + +\`\`\`starlark +bazel_dep(name = "${RULES_NAME}", version = "${TAG:1}") +\`\`\` +EOF diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..85f9442 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +bazel-bin/ +bazel-out/ +bazel-testlogs/ +bazel-* + +venv/ +.venv/ diff --git a/BUILD.bazel b/BUILD.bazel new file mode 100644 index 0000000..f5bf95d --- /dev/null +++ b/BUILD.bazel @@ -0,0 +1,22 @@ +load("@buildifier_prebuilt//:rules.bzl", "buildifier", "buildifier_test") + +exports_files([ + "MODULE.bazel", +]) + +buildifier( + name = "buildifier.fix", + exclude_patterns = ["./.git/*"], + lint_mode = "fix", + mode = "fix", + visibility = ["//thm/buildtools/fix:__pkg__"], +) + +buildifier_test( + name = "buildifier.test", + exclude_patterns = ["./.git/*"], + lint_mode = "warn", + mode = "diff", + no_sandbox = True, + workspace = "//:MODULE.bazel", +) diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..261eeb9 --- /dev/null +++ b/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/MODULE.bazel b/MODULE.bazel new file mode 100644 index 0000000..8309b32 --- /dev/null +++ b/MODULE.bazel @@ -0,0 +1,33 @@ +"rules_mypy" + +module( + name = "rules_mypy", + version = "0.0.0", +) + +bazel_dep(name = "bazel_skylib", version = "1.4.1") +bazel_dep(name = "buildifier_prebuilt", version = "6.1.2") +bazel_dep(name = "platforms", version = "0.0.8") +bazel_dep(name = "rules_python", version = "0.34.0") +bazel_dep(name = "rules_uv", version = "0.21.0") + +# configuration +PYTHON_VERSION = "3.12" + +PYTHON_VERSION_SNAKE = PYTHON_VERSION.replace(".", "_") + +python = use_extension("@rules_python//python/extensions:python.bzl", "python") +python.toolchain(python_version = PYTHON_VERSION) +use_repo(python, "python_" + PYTHON_VERSION_SNAKE, "python_versions") + +pip = use_extension("@rules_python//python/extensions:pip.bzl", "pip") +pip.parse( + enable_implicit_namespace_pkgs = True, + experimental_index_url = "https://pypi.org/simple", # use Bazel downloader + hub_name = "rules_mypy_pip", + python_version = PYTHON_VERSION, + requirements_by_platform = { + "//mypy/private:requirements.txt": "*", + }, +) +use_repo(pip, "rules_mypy_pip") diff --git a/WORKSPACE.bazel b/WORKSPACE.bazel new file mode 100644 index 0000000..e69de29 diff --git a/examples/demo/.bazelrc b/examples/demo/.bazelrc new file mode 100644 index 0000000..acb122c --- /dev/null +++ b/examples/demo/.bazelrc @@ -0,0 +1,5 @@ +common --enable_bzlmod + +common --lockfile_mode=off + +test --test_output=errors diff --git a/examples/demo/.bazelversion b/examples/demo/.bazelversion new file mode 100644 index 0000000..66ce77b --- /dev/null +++ b/examples/demo/.bazelversion @@ -0,0 +1 @@ +7.0.0 diff --git a/examples/demo/BUILD.bazel b/examples/demo/BUILD.bazel new file mode 100644 index 0000000..fd30437 --- /dev/null +++ b/examples/demo/BUILD.bazel @@ -0,0 +1,13 @@ +load("@rules_uv//uv:pip.bzl", "pip_compile") +load("@rules_uv//uv:venv.bzl", "create_venv") + +pip_compile( + name = "generate_requirements_lock", + requirements_in = "requirements.in", + requirements_txt = "requirements.txt", +) + +create_venv( + name = "venv", + requirements_txt = "requirements.txt", +) diff --git a/examples/demo/MODULE.bazel b/examples/demo/MODULE.bazel new file mode 100644 index 0000000..4259d14 --- /dev/null +++ b/examples/demo/MODULE.bazel @@ -0,0 +1,47 @@ +"rules_mypy demo" + +module( + name = "rules_mypy_examples__demo", + version = "0.0.0", +) + +bazel_dep(name = "bazel_skylib", version = "1.4.1") +bazel_dep(name = "buildifier_prebuilt", version = "6.1.2") +bazel_dep(name = "platforms", version = "0.0.8") +bazel_dep(name = "rules_mypy", version = "0.0.0") +bazel_dep(name = "rules_python", version = "0.34.0") +bazel_dep(name = "rules_uv", version = "0.21.0") + +local_path_override( + module_name = "rules_mypy", + path = "../..", +) + +# configuration +PYTHON_VERSION = "3.12" + +PYTHON_VERSION_SNAKE = PYTHON_VERSION.replace(".", "_") + +python = use_extension("@rules_python//python/extensions:python.bzl", "python") +python.toolchain(python_version = PYTHON_VERSION) +use_repo(python, "python_" + PYTHON_VERSION_SNAKE, "python_versions") + +pip = use_extension("@rules_python//python/extensions:pip.bzl", "pip") +pip.parse( + enable_implicit_namespace_pkgs = True, + experimental_index_url = "https://pypi.org/simple", # use Bazel downloader + hub_name = "pip", + python_version = PYTHON_VERSION, + requirements_by_platform = { + "//:requirements.txt": "*", + }, +) +use_repo(pip, "pip") + +types = use_extension("@rules_mypy//mypy:types.bzl", "types") +types.requirements( + name = "pip_types", + pip_requirements = "@pip//:requirements.bzl", + requirements_txt = "//:requirements.txt", +) +use_repo(types, "pip_types") diff --git a/examples/demo/py.bzl b/examples/demo/py.bzl new file mode 100644 index 0000000..039ea95 --- /dev/null +++ b/examples/demo/py.bzl @@ -0,0 +1,10 @@ +"Custom py_library rule that also runs mypy." + +load("@pip_types//:types.bzl", "types") +load("@rules_mypy//mypy:mypy.bzl", "decorate") +load("@rules_python//python:py_library.bzl", rules_python_py_library = "py_library") + +py_library = decorate( + py_target = rules_python_py_library, + types = types, +) diff --git a/examples/demo/requirements.in b/examples/demo/requirements.in new file mode 100644 index 0000000..f3944ca --- /dev/null +++ b/examples/demo/requirements.in @@ -0,0 +1,3 @@ +cachetools~=5.4.0 +types-cachetools~=5.4.0.20240717 +mypy~=1.11.0 \ No newline at end of file diff --git a/examples/demo/requirements.txt b/examples/demo/requirements.txt new file mode 100644 index 0000000..a9c3a4c --- /dev/null +++ b/examples/demo/requirements.txt @@ -0,0 +1,49 @@ +# This file was autogenerated by uv via the following command: +# bazel run @@//:generate_requirements_lock +--index-url https://pypi.org/simple + +cachetools==5.4.0 \ + --hash=sha256:3ae3b49a3d5e28a77a0be2b37dbcb89005058959cb2323858c2657c4a8cab474 \ + --hash=sha256:b8adc2e7c07f105ced7bc56dbb6dfbe7c4a00acce20e2227b3f355be89bc6827 + # via -r requirements.in +mypy==1.11.0 \ + --hash=sha256:0bea2a0e71c2a375c9fa0ede3d98324214d67b3cbbfcbd55ac8f750f85a414e3 \ + --hash=sha256:104e9c1620c2675420abd1f6c44bab7dd33cc85aea751c985006e83dcd001095 \ + --hash=sha256:14f9294528b5f5cf96c721f231c9f5b2733164e02c1c018ed1a0eff8a18005ac \ + --hash=sha256:1a5d8d8dd8613a3e2be3eae829ee891b6b2de6302f24766ff06cb2875f5be9c6 \ + --hash=sha256:1d44c1e44a8be986b54b09f15f2c1a66368eb43861b4e82573026e04c48a9e20 \ + --hash=sha256:25bcfa75b9b5a5f8d67147a54ea97ed63a653995a82798221cca2a315c0238c1 \ + --hash=sha256:35ce88b8ed3a759634cb4eb646d002c4cef0a38f20565ee82b5023558eb90c00 \ + --hash=sha256:56913ec8c7638b0091ef4da6fcc9136896914a9d60d54670a75880c3e5b99ace \ + --hash=sha256:65f190a6349dec29c8d1a1cd4aa71284177aee5949e0502e6379b42873eddbe7 \ + --hash=sha256:6801319fe76c3f3a3833f2b5af7bd2c17bb93c00026a2a1b924e6762f5b19e13 \ + --hash=sha256:72596a79bbfb195fd41405cffa18210af3811beb91ff946dbcb7368240eed6be \ + --hash=sha256:93743608c7348772fdc717af4aeee1997293a1ad04bc0ea6efa15bf65385c538 \ + --hash=sha256:940bfff7283c267ae6522ef926a7887305945f716a7704d3344d6d07f02df850 \ + --hash=sha256:96f8dbc2c85046c81bcddc246232d500ad729cb720da4e20fce3b542cab91287 \ + --hash=sha256:98790025861cb2c3db8c2f5ad10fc8c336ed2a55f4daf1b8b3f877826b6ff2eb \ + --hash=sha256:a3824187c99b893f90c845bab405a585d1ced4ff55421fdf5c84cb7710995229 \ + --hash=sha256:a83ec98ae12d51c252be61521aa5731f5512231d0b738b4cb2498344f0b840cd \ + --hash=sha256:becc9111ca572b04e7e77131bc708480cc88a911adf3d0239f974c034b78085c \ + --hash=sha256:c1a184c64521dc549324ec6ef7cbaa6b351912be9cb5edb803c2808a0d7e85ac \ + --hash=sha256:c7b73a856522417beb78e0fb6d33ef89474e7a622db2653bc1285af36e2e3e3d \ + --hash=sha256:cea3d0fb69637944dd321f41bc896e11d0fb0b0aa531d887a6da70f6e7473aba \ + --hash=sha256:d2b3d36baac48e40e3064d2901f2fbd2a2d6880ec6ce6358825c85031d7c0d4d \ + --hash=sha256:d7b54c27783991399046837df5c7c9d325d921394757d09dbcbf96aee4649fe9 \ + --hash=sha256:d8e2e43977f0e09f149ea69fd0556623919f816764e26d74da0c8a7b48f3e18a \ + --hash=sha256:dbe286303241fea8c2ea5466f6e0e6a046a135a7e7609167b07fd4e7baf151bf \ + --hash=sha256:f006e955718ecd8d159cee9932b64fba8f86ee6f7728ca3ac66c3a54b0062abe \ + --hash=sha256:f2268d9fcd9686b61ab64f077be7ffbc6fbcdfb4103e5dd0cc5eaab53a8886c2 + # via -r requirements.in +mypy-extensions==1.0.0 \ + --hash=sha256:4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d \ + --hash=sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782 + # via mypy +types-cachetools==5.4.0.20240717 \ + --hash=sha256:1eae90c48760bac44ab89108be938e8ce1d740910f2d4b68446dcdc82763f186 \ + --hash=sha256:67c84c26df988039be68344b162afd2dd7cd3741dc08e7d67aa1954782fd2d2a + # via -r requirements.in +typing-extensions==4.12.2 \ + --hash=sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d \ + --hash=sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8 + # via mypy diff --git a/examples/demo/thm/BUILD.bazel b/examples/demo/thm/BUILD.bazel new file mode 100644 index 0000000..d3fc7e0 --- /dev/null +++ b/examples/demo/thm/BUILD.bazel @@ -0,0 +1,7 @@ +load("//:py.bzl", "py_library") + +py_library( + name = "thm", + srcs = ["__init__.py"], + visibility = ["//visibility:public"], +) diff --git a/examples/demo/thm/__init__.py b/examples/demo/thm/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/examples/demo/thm/a/BUILD.bazel b/examples/demo/thm/a/BUILD.bazel new file mode 100644 index 0000000..61109fb --- /dev/null +++ b/examples/demo/thm/a/BUILD.bazel @@ -0,0 +1,8 @@ +load("//:py.bzl", "py_library") + +py_library( + name = "a", + srcs = ["__init__.py"], + visibility = ["//visibility:public"], + deps = ["//thm"], +) diff --git a/examples/demo/thm/a/__init__.py b/examples/demo/thm/a/__init__.py new file mode 100644 index 0000000..c840fb2 --- /dev/null +++ b/examples/demo/thm/a/__init__.py @@ -0,0 +1 @@ +i: int = 0 diff --git a/examples/demo/thm/b/BUILD.bazel b/examples/demo/thm/b/BUILD.bazel new file mode 100644 index 0000000..8b3c7da --- /dev/null +++ b/examples/demo/thm/b/BUILD.bazel @@ -0,0 +1,13 @@ +load("@pip//:requirements.bzl", "requirement") +load("//:py.bzl", "py_library") + +py_library( + name = "b", + srcs = ["__init__.py"], + visibility = ["//visibility:public"], + deps = [ + "//thm", + "//thm/a", + requirement("cachetools"), + ], +) diff --git a/examples/demo/thm/b/__init__.py b/examples/demo/thm/b/__init__.py new file mode 100644 index 0000000..bfce069 --- /dev/null +++ b/examples/demo/thm/b/__init__.py @@ -0,0 +1,8 @@ +import cachetools + +cache: cachetools.Cache[str, str] = cachetools.LRUCache(maxsize=100) + + +def demo() -> str | None: + value: str | None = cache.get("test") + return value diff --git a/mypy/BUILD.bazel b/mypy/BUILD.bazel new file mode 100644 index 0000000..07c658a --- /dev/null +++ b/mypy/BUILD.bazel @@ -0,0 +1,19 @@ +load("@bazel_skylib//:bzl_library.bzl", "bzl_library") + +bzl_library( + name = "mypy", + srcs = ["mypy.bzl"], + visibility = ["//visibility:public"], +) + +bzl_library( + name = "py_type_library", + srcs = ["py_type_library.bzl"], + visibility = ["//visibility:public"], +) + +bzl_library( + name = "types", + srcs = ["types.bzl"], + visibility = ["//visibility:public"], +) diff --git a/mypy/mypy.bzl b/mypy/mypy.bzl new file mode 100644 index 0000000..c6ac51c --- /dev/null +++ b/mypy/mypy.bzl @@ -0,0 +1,66 @@ +"Public API for interacting with the mypy rule." + +load("//mypy/private:mypy.bzl", _mypy = "mypy", _mypy_cli = "mypy_cli") + +# re-export mypy macro +mypy = _mypy + +# export custom mypy_cli producer +mypy_cli = _mypy_cli + +def _expand(deps, types): + deps = deps or [] + types = types or {} + + return deps + [ + types[dep] + for dep in deps + if dep in types + ] + +def decorate(py_target, mypy_ini = None, mypy_cli = None, types = None): # buildifier: disable=unnamed-macro + """ + Decorate a py_binary, py_library or py_test rule/macro by adding an additional mypy target. + + Typical usage: + ``` + load("@rules_python//python:py_library.bzl", rules_python_py_library = "py_library") + py_library = decorate(rules_python_py_library) + ``` + + Args: + py_target: a py_library or py_binary rule/macro to decorate with mypy + mypy_ini: (optional) label of a mypy.ini file to use to configure mypy + mypy_cli: (optional) a replacement mypy_cli to use (recommended to produce + with mypy_cli macro) + types: (optional) a dict of dependency label to types dependency label + example: + ``` + { + requirement("cachetools"): requirement("types-cachetools"), + } + ``` + Use the types extension to create this map for a requirements.in + or requirements.txt file. + + Returns: a decorated py_target. + """ + + def decorated_py_target(name, srcs = None, deps = None, **kwargs): + py_target( + name = name, + srcs = srcs, + deps = deps, + **kwargs + ) + + mypy( + name = name + ".mypy", + srcs = srcs, + deps = _expand(deps, types), + mypy_ini = mypy_ini, + mypy_cli = mypy_cli, + visibility = kwargs.get("visibility"), + ) + + return decorated_py_target diff --git a/mypy/private/BUILD.bazel b/mypy/private/BUILD.bazel new file mode 100644 index 0000000..2bb426b --- /dev/null +++ b/mypy/private/BUILD.bazel @@ -0,0 +1,64 @@ +load("@bazel_skylib//:bzl_library.bzl", "bzl_library") +load("@rules_mypy_pip//:requirements.bzl", "requirement") +load("@rules_python//python:py_binary.bzl", "py_binary") +load("@rules_uv//uv:pip.bzl", "pip_compile") +load("@rules_uv//uv:venv.bzl", "create_venv") +load(":mypy.bzl", "mypy_cli", _mypy = "mypy") + +bzl_library( + name = "mypy_rules", + srcs = ["mypy.bzl"], + visibility = ["//mypy:__subpackages__"], +) + +bzl_library( + name = "py_type_library_rules", + srcs = ["py_type_library.bzl"], + visibility = ["//mypy:__subpackages__"], +) + +bzl_library( + name = "types_rules", + srcs = ["types.bzl"], + visibility = ["//mypy:__subpackages__"], +) + +pip_compile( + name = "generate_requirements_lock", + requirements_in = "requirements.in", + requirements_txt = "requirements.txt", +) + +create_venv( + name = "venv", + requirements_txt = "requirements.txt", +) + +mypy_cli(name = "mypy") + +_mypy( + name = "mypy.mypy", + srcs = ["mypy.py"], + deps = [ + requirement("click"), + requirement("mypy"), + ], +) + +py_binary( + name = "py_type_library", + srcs = ["py_type_library.py"], + main = "py_type_library.py", + visibility = ["//visibility:public"], + deps = [ + requirement("click"), + ], +) + +_mypy( + name = "py_type_library.mypy", + srcs = ["py_type_library.py"], + deps = [ + requirement("click"), + ], +) diff --git a/mypy/private/mypy.bzl b/mypy/private/mypy.bzl new file mode 100644 index 0000000..19f6437 --- /dev/null +++ b/mypy/private/mypy.bzl @@ -0,0 +1,135 @@ +""" +mypy build rule. + +The mypy build rule runs mypy, succeeding if mypy is error free and failing if mypy produces errors. The +result of the build is a mypy cache directory, located at [name].mypy_cache. When provided input cache +directories (the results of other mypy builds), the build rule first attempts to merge the cache directories. +""" + +load("@rules_mypy_pip//:requirements.bzl", "requirement") +load("@rules_python//python:py_binary.bzl", "py_binary") +load("//mypy/private:py_type_library.bzl", "PyTypeLibraryInfo") + +MypyCacheInfo = provider( + doc = "Output details of the mypy build rule.", + fields = { + "cache_directory": "Location of the mypy cache produced by this target.", + }, +) + +def _mypy_impl(ctx): + # we need to help mypy map the location of external deps by setting + # MYPYPATH to include the site-packages directories. + external_deps = [] + + types = [] + + for dep in ctx.attr.deps: + if PyTypeLibraryInfo in dep: + types.append(dep[PyTypeLibraryInfo].directory + "/site-packages") + elif dep.label.workspace_root.startswith("external/"): + external_deps.append(dep.label.workspace_root + "/site-packages") + + # types need to appear first in the mypy path since the module directories + # are the same and mypy resolves the first ones, first. + mypy_path = ":".join(types + external_deps) + + cache_directory = ctx.actions.declare_directory(ctx.attr.name + ".mypy_cache") + + args = ctx.actions.args() + args.add("--cache-dir", cache_directory.path) + + args.add_all([ + cache[MypyCacheInfo].cache_directory.path + for cache in ctx.attr.caches + ], before_each = "--upstream-cache") + args.add_all(ctx.files.srcs) + + ctx.actions.run( + mnemonic = "mypy", + inputs = depset(direct = ctx.files.srcs + ctx.files.deps + ctx.files.caches), + outputs = [cache_directory], + executable = ctx.executable.mypy_cli, + arguments = [args], + env = { + "MYPYPATH": mypy_path, + # force color on + "MYPY_FORCE_COLOR": "1", + # force color on only works if TERM is set to something that supports color + "TERM": "xterm-256color", + } | ctx.configuration.default_shell_env, + ) + + return [ + DefaultInfo( + files = depset([cache_directory]), + runfiles = ctx.runfiles(files = [cache_directory]), + ), + MypyCacheInfo(cache_directory = cache_directory), + ] + +_mypy = rule( + implementation = _mypy_impl, + attrs = { + "srcs": attr.label_list(allow_files = True), + "deps": attr.label_list(), + "mypy_ini": attr.label(allow_single_file = True), + "mypy_cli": attr.label(cfg = "exec", default = "//mypy/private:mypy", executable = True), + "caches": attr.label_list(), + }, +) + +def mypy(name, srcs = None, deps = None, mypy_ini = None, mypy_cli = None, visibility = None): + """ + Create a mypy target inferring upstream caches from deps. + + Args: + name: name of the target to produce + srcs: (optional) srcs to type-check + deps: (optional) deps used as input to type-checking + mypy_ini: (optional) mypy_ini file to use + mypy_cli: (optional) a replacement mypy_cli to use (recommended to produce + with mypy_cli macro) + visibility: (optional) visibility of this target (recommended to set to same + as the py_* target it inherits) + """ + + upstream_caches = [] + if deps: + for dep in deps: + lab = native.package_relative_label(dep) + if lab.workspace_root == "": + upstream_caches.append(str(lab) + ".mypy") + + _mypy( + name = name, + srcs = srcs, + deps = deps, + mypy_ini = mypy_ini, + mypy_cli = mypy_cli, + visibility = visibility, + ) + +def mypy_cli(name, deps = None, mypy_requirement = None): + """ + Produce a custom mypy executable for use with the mypy build rule. + + Args: + name: name of the binary target this macro produces + deps: (optional) additional dependencies to include (e.g. mypy plugins) + mypy_requirement: (optional) a replacement mypy requirement + """ + + deps = deps or [] + mypy_requirement = mypy_requirement or requirement("mypy") + + py_binary( + name = name, + srcs = ["//mypy/private:mypy.py"], + main = "//mypy/private:mypy.py", + visibility = ["//visibility:public"], + deps = [ + mypy_requirement, + requirement("click"), + ] + deps, + ) diff --git a/mypy/private/mypy.py b/mypy/private/mypy.py new file mode 100644 index 0000000..7df6d30 --- /dev/null +++ b/mypy/private/mypy.py @@ -0,0 +1,70 @@ +import pathlib +import shutil +import sys +import click +import mypy.api + + +def _merge_upstream_caches(cache_dir: str, upstream_caches: list[str]) -> None: + current = pathlib.Path(cache_dir) + current.mkdir(parents=True, exist_ok=True) + + for upstream_dir in upstream_caches: + upstream = pathlib.Path(upstream_dir) + + # TODO(mark): maybe there's a more efficient way to synchronize the cache dirs? + for dirpath, _, filenames in upstream.walk(): + relative_dir = dirpath.relative_to(upstream) + for file in filenames: + upstream_path = dirpath / file + target_path = current / relative_dir / file + if not target_path.parent.exists(): + target_path.parent.mkdir(parents=True) + if not target_path.exists(): + shutil.copy(upstream_path, target_path) + + +@click.command() +@click.option("--cache-dir", required=False, type=click.Path()) +@click.option( + "--upstream-cache", + "upstream_caches", + multiple=True, + required=False, + type=click.Path(exists=True), +) +@click.option("--mypy-ini", required=False, type=click.Path(exists=True)) +@click.argument("srcs", nargs=-1, type=click.Path(exists=True)) +def main( + cache_dir: str | None, + upstream_caches: tuple[str, ...], + mypy_ini: str | None, + srcs: tuple[str, ...], +) -> None: + cache_dir = cache_dir or ".mypy_cache" + _merge_upstream_caches(cache_dir, list(upstream_caches)) + + maybe_config = ["--config-file", mypy_ini] if mypy_ini else [] + + report, errors, status = mypy.api.run( + maybe_config + + [ + # use a known cache-dir + f"--cache-dir={cache_dir}", + # use current dir + MYPYPATH to resolve deps + "--explicit-package-bases", + # do not type-check dependencies, only use deps for type-checking srcs + "--follow-imports=silent", + ] + + list(srcs) + ) + + if status: + sys.stderr.write(errors) + sys.stderr.write(report) + + sys.exit(status) + + +if __name__ == "__main__": + main() diff --git a/mypy/private/py_type_library.bzl b/mypy/private/py_type_library.bzl new file mode 100644 index 0000000..93ed190 --- /dev/null +++ b/mypy/private/py_type_library.bzl @@ -0,0 +1,42 @@ +"Convert pip typings packages for use with mypy." + +PyTypeLibraryInfo = provider( + doc = "Information about the content of a py_type_library.", + fields = { + "directory": "Directory containing site-packages.", + }, +) + +def _py_type_library_impl(ctx): + directory = ctx.actions.declare_directory(ctx.attr.name) + + args = ctx.actions.args() + args.add("--input-dir", ctx.attr.typing.label.workspace_root) + args.add("--output-dir", directory.path) + + ctx.actions.run( + mnemonic = "BuildPyTypeLibrary", + inputs = depset(transitive = [ctx.attr.typing.default_runfiles.files]), + outputs = [directory], + executable = ctx.executable._exec, + arguments = [args], + env = ctx.configuration.default_shell_env, + ) + + return [ + DefaultInfo( + files = depset([directory]), + runfiles = ctx.runfiles(files = [directory]), + ), + PyTypeLibraryInfo( + directory = directory.path, + ), + ] + +py_type_library = rule( + implementation = _py_type_library_impl, + attrs = { + "typing": attr.label(), + "_exec": attr.label(cfg = "exec", default = "//mypy/private:py_type_library", executable = True), + }, +) diff --git a/mypy/private/py_type_library.py b/mypy/private/py_type_library.py new file mode 100644 index 0000000..99268d1 --- /dev/null +++ b/mypy/private/py_type_library.py @@ -0,0 +1,34 @@ +""" +py_type_library CLI. + +Many of the typings/stubs published to pypi for package [x] show up use +[x]-stubs as their base-package. mypy expects the typings/stubs to exist +in the as-used-in-Python package [x] when placed on the path. This CLI +creates a copy of the input directory's site-packages content dropping +`-stubs` from any directory in the site-packages dir while copying. +""" + +import pathlib +import shutil +import click + + +def _clean(package: str) -> str: + return package.removesuffix("-stubs") + + +@click.command() +@click.option("--input-dir", required=True, type=click.Path(exists=True)) +@click.option("--output-dir", required=True, type=click.Path()) +def main(input_dir: str, output_dir: str) -> None: + input = pathlib.Path(input_dir) / "site-packages" + + output = pathlib.Path(output_dir) / "site-packages" + output.mkdir(parents=True, exist_ok=True) + + for package in input.iterdir(): + shutil.copytree(input / package.name, output / _clean(package.name)) + + +if __name__ == "__main__": + main() diff --git a/mypy/private/requirements.in b/mypy/private/requirements.in new file mode 100644 index 0000000..d963161 --- /dev/null +++ b/mypy/private/requirements.in @@ -0,0 +1,2 @@ +click~=8.1.7 +mypy~=1.11.0 \ No newline at end of file diff --git a/mypy/private/requirements.txt b/mypy/private/requirements.txt new file mode 100644 index 0000000..46c15d4 --- /dev/null +++ b/mypy/private/requirements.txt @@ -0,0 +1,45 @@ +# This file was autogenerated by uv via the following command: +# bazel run @@//mypy/private:generate_requirements_lock +--index-url https://pypi.org/simple + +click==8.1.7 \ + --hash=sha256:ae74fb96c20a0277a1d615f1e4d73c8414f5a98db8b799a7931d1582f3390c28 \ + --hash=sha256:ca9853ad459e787e2192211578cc907e7594e294c7ccc834310722b41b9ca6de + # via -r mypy/private/requirements.in +mypy==1.11.0 \ + --hash=sha256:0bea2a0e71c2a375c9fa0ede3d98324214d67b3cbbfcbd55ac8f750f85a414e3 \ + --hash=sha256:104e9c1620c2675420abd1f6c44bab7dd33cc85aea751c985006e83dcd001095 \ + --hash=sha256:14f9294528b5f5cf96c721f231c9f5b2733164e02c1c018ed1a0eff8a18005ac \ + --hash=sha256:1a5d8d8dd8613a3e2be3eae829ee891b6b2de6302f24766ff06cb2875f5be9c6 \ + --hash=sha256:1d44c1e44a8be986b54b09f15f2c1a66368eb43861b4e82573026e04c48a9e20 \ + --hash=sha256:25bcfa75b9b5a5f8d67147a54ea97ed63a653995a82798221cca2a315c0238c1 \ + --hash=sha256:35ce88b8ed3a759634cb4eb646d002c4cef0a38f20565ee82b5023558eb90c00 \ + --hash=sha256:56913ec8c7638b0091ef4da6fcc9136896914a9d60d54670a75880c3e5b99ace \ + --hash=sha256:65f190a6349dec29c8d1a1cd4aa71284177aee5949e0502e6379b42873eddbe7 \ + --hash=sha256:6801319fe76c3f3a3833f2b5af7bd2c17bb93c00026a2a1b924e6762f5b19e13 \ + --hash=sha256:72596a79bbfb195fd41405cffa18210af3811beb91ff946dbcb7368240eed6be \ + --hash=sha256:93743608c7348772fdc717af4aeee1997293a1ad04bc0ea6efa15bf65385c538 \ + --hash=sha256:940bfff7283c267ae6522ef926a7887305945f716a7704d3344d6d07f02df850 \ + --hash=sha256:96f8dbc2c85046c81bcddc246232d500ad729cb720da4e20fce3b542cab91287 \ + --hash=sha256:98790025861cb2c3db8c2f5ad10fc8c336ed2a55f4daf1b8b3f877826b6ff2eb \ + --hash=sha256:a3824187c99b893f90c845bab405a585d1ced4ff55421fdf5c84cb7710995229 \ + --hash=sha256:a83ec98ae12d51c252be61521aa5731f5512231d0b738b4cb2498344f0b840cd \ + --hash=sha256:becc9111ca572b04e7e77131bc708480cc88a911adf3d0239f974c034b78085c \ + --hash=sha256:c1a184c64521dc549324ec6ef7cbaa6b351912be9cb5edb803c2808a0d7e85ac \ + --hash=sha256:c7b73a856522417beb78e0fb6d33ef89474e7a622db2653bc1285af36e2e3e3d \ + --hash=sha256:cea3d0fb69637944dd321f41bc896e11d0fb0b0aa531d887a6da70f6e7473aba \ + --hash=sha256:d2b3d36baac48e40e3064d2901f2fbd2a2d6880ec6ce6358825c85031d7c0d4d \ + --hash=sha256:d7b54c27783991399046837df5c7c9d325d921394757d09dbcbf96aee4649fe9 \ + --hash=sha256:d8e2e43977f0e09f149ea69fd0556623919f816764e26d74da0c8a7b48f3e18a \ + --hash=sha256:dbe286303241fea8c2ea5466f6e0e6a046a135a7e7609167b07fd4e7baf151bf \ + --hash=sha256:f006e955718ecd8d159cee9932b64fba8f86ee6f7728ca3ac66c3a54b0062abe \ + --hash=sha256:f2268d9fcd9686b61ab64f077be7ffbc6fbcdfb4103e5dd0cc5eaab53a8886c2 + # via -r mypy/private/requirements.in +mypy-extensions==1.0.0 \ + --hash=sha256:4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d \ + --hash=sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782 + # via mypy +typing-extensions==4.12.2 \ + --hash=sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d \ + --hash=sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8 + # via mypy diff --git a/mypy/private/types.bzl b/mypy/private/types.bzl new file mode 100644 index 0000000..7cd8ce9 --- /dev/null +++ b/mypy/private/types.bzl @@ -0,0 +1,70 @@ +"Repository rule to generate `py_type_library` from input typings/stubs requirements." + +_PY_TYPE_LIBRARY_TEMPLATE = """ +py_type_library( + name = "{requirement}", + typing = requirement("{requirement}"), + visibility = ["//visibility:public"], +) +""" + +def _render_build(rctx, types): + content = "" + content += """load("{pip_requirements}", "requirement")\n""".format( + pip_requirements = rctx.attr.pip_requirements, + ) + content += """load("@rules_mypy//mypy:py_type_library.bzl", "py_type_library")\n""" + for requirement in types: + content += _PY_TYPE_LIBRARY_TEMPLATE.format( + requirement = requirement, + ) + "\n" + return content + +def _render_types_bzl(rctx, types): + content = "" + content += """load("{pip_requirements}", "requirement")\n""".format( + pip_requirements = rctx.attr.pip_requirements, + ) + content += "types = {\n" + for requirement in types: + content += """ requirement("{raw}"): "@@{name}//:{requirement}",\n""".format( + raw = requirement.removeprefix("types-").removesuffix("-stubs"), + name = str(rctx.attr.name), + requirement = requirement, + ) + content += "}\n" + return content + +def _generate_impl(rctx): + contents = rctx.read(rctx.attr.requirements_txt) + + types = [] + + # this is a very, very naive parser + for line in contents.splitlines(): + if line.startswith("#") or line == "": + continue + + if "~=" in line: + req, _ = line.split("~=") + elif "==" in line: + req, _ = line.split("==") + elif "<=" in line: + req, _ = line.split("<=") + else: + continue + + req = req.strip() + if req.endswith("-stubs") or req.startswith("types-"): + types.append(req) + + rctx.file("BUILD.bazel", content = _render_build(rctx, types)) + rctx.file("types.bzl", content = _render_types_bzl(rctx, types)) + +generate = repository_rule( + implementation = _generate_impl, + attrs = { + "pip_requirements": attr.label(), + "requirements_txt": attr.label(allow_single_file = True), + }, +) diff --git a/mypy/py_type_library.bzl b/mypy/py_type_library.bzl new file mode 100644 index 0000000..7fce65e --- /dev/null +++ b/mypy/py_type_library.bzl @@ -0,0 +1,5 @@ +"Public API for py_type_library." + +load("//mypy/private:py_type_library.bzl", _py_type_library = "py_type_library") + +py_type_library = _py_type_library diff --git a/mypy/types.bzl b/mypy/types.bzl new file mode 100644 index 0000000..4a905dc --- /dev/null +++ b/mypy/types.bzl @@ -0,0 +1,27 @@ +"Extension for generating a types repository containing py_type_librarys for requirements." + +load("//mypy/private:types.bzl", "generate") + +requirements = tag_class( + attrs = { + "name": attr.string(), + "pip_requirements": attr.label(), + "requirements_txt": attr.label(mandatory = True, allow_single_file = True), + }, +) + +def _extension(module_ctx): + for mod in module_ctx.modules: + for tag in mod.tags.requirements: + generate( + name = tag.name, + pip_requirements = tag.pip_requirements, + requirements_txt = tag.requirements_txt, + ) + +types = module_extension( + implementation = _extension, + tag_classes = { + "requirements": requirements, + }, +) diff --git a/readme.md b/readme.md new file mode 100644 index 0000000..c56c1aa --- /dev/null +++ b/readme.md @@ -0,0 +1,135 @@ +# rules_mypy + +Bazel rules to decorate `py_*` targets with mypy type-checking. + +Compared to [bazel-mypy-integration](https://github.com/bazel-contrib/bazel-mypy-integration), this ruleset aims to make a couple of improvements: + +- Propagation of the mypy cache between dependencies within a repository to avoid exponential type-checking work +- Robust (and automated) support for including 3rd party types/stubs packages + +To propagate the mypy cache between targets, this ruleset uses build actions, which comes with a couple of trade-offs compared to bazel-mypy-integration: + +- Compared to running as an aspect, the targets produced by these rules will not run automatically when building the primary target, which may create usability trouble in some developer cycles +- Compared to running as a test, the targets produced by these rules can fail a broad build phase, which may be undesirable in some setups + +We should note that the community might prefer to treat mypy semantically as a test rather than a build action, and these rules do not enable that. + +Instead, we take the opinion that type-checking is a build-time action, and the actions that are executed here take as input source files and as output produce mypy caches. + +> [!WARNING] +> rules_mypy's build actions produce mypy caches as outputs, and these may contain large file counts and that will only grow as a dependency chain grows. This may have an impact on the size and usage of build and/or remote caches. + +## Usage + +Whenever you define a `py_binary`, `py_library` or `py_test` using the rules_mypy decorated forms, rules_mypy defines a sibling target `[name].mypy`. Building this target will type-check the sources in `[name]` and leverage the mypy cache from upstream internal dependencies. + +Setup is significantly easier with bzlmod, we recommend and predominantly support bzlmod, though these rules should work without issue in non-bzlmod setups, albeit with more work to configure. + +### Bzlmod Setup + +**Add rules_mypy to your MODULE.bazel:** + +```starlark +bazel_dep(name = "rules_mypy", version = "0.0.0") +``` + +**Optionally, configure a types repository:** + +Many Python packages have separately published types/stubs packages. While mypy (and these rules) will work without including these types, this ruleset provides some utilities for leveraging these types to improve mypy's type checking. + +```starlark +types = use_extension("@rules_mypy//mypy:types.bzl", "types") +types.requirements( + name = "pip_types", + # `@pip` in the next line corresponds to the `hub_name` when using + # rules_python's `pip.parse(...)`. + pip_requirements = "@pip//:requirements.bzl", + # also legal to pass a `requirements.in` here + requirements_txt = "//:requirements.txt", +) +use_repo(types, "pip_types") +``` + +**Wrap `py_*` rules/macros.** + +If you do not already wrap `py_*` rules with a macro, create a `.bzl` file to wrap these rules: + +```starlark +"Custom py_* macros that also run mypy." + +load("@pip_types//:types.bzl", "types") +load("@rules_mypy//mypy:mypy.bzl", "decorate") +load("@rules_python//python:py_binary.bzl", rules_python_py_binary = "py_binary") +load("@rules_python//python:py_library.bzl", rules_python_py_library = "py_library") +load("@rules_python//python:py_test.bzl", rules_python_py_test = "py_test") + +py_binary = decorate( + py_target = rules_python_py_binary, + types = types, +) + +py_library = decorate( + py_target = rules_python_py_library, + types = types, +) + +py_test = decorate( + py_target = rules_python_py_test, + types = types, +) +``` + +Or, if you do already wrap `py_*` rules with a macro, wrap your customized rules/macros or the input `py_*` rules as illustrated above. + +If you're using Gazelle, you may need to adjust the imports Gazelle uses for `py_*` targets, refer to the `rules_python` docs for how to do this. + +## Customizing mypy + +mypy's behavior may be customized using a [mypy config file](https://mypy.readthedocs.io/en/stable/config_file.html) file. To use a mypy config file, pass a label for a valid config file to the `decorate` method: + +```starlark +py_library = decorate( + py_target = rules_python_py_library, + mypy_ini = "//:mypy.ini", + types = types, +) +``` + +To customize the version of mypy, use rules_python's requirements resolution and construct a custom mypy CLI: + +```starlark +load("@pip//:requirements.bzl", "requirements") # '@pip' must match configured pip hub_name +load("@rules_mypy//mypy:mypy.bzl", "decorate", "mypy_cli") + +mypy_cli( + name = "mypy_cli", + mypy_requirement = requirement("mypy"), +) + +py_library = decorate( + py_target = rules_python_py_library, + mypy_cli = ":mypy_cli", + types = types, +) +``` + +Further, to use mypy plugins referenced in any config file, use the `deps` attribute of `mypy_cli`: + +```starlark +load("@pip//:requirements.bzl", "requirements") # '@pip' must match configured pip hub_name +load("@rules_mypy//mypy:mypy.bzl", "decorate", "mypy_cli") + +mypy_cli( + name = "mypy_cli", + mypy_requirement = requirement("mypy"), + deps = [ + requirement("pydantic"), + ], +) + +py_library = decorate( + py_target = rules_python_py_library, + mypy_cli = ":mypy_cli", + types = types, +) +```